internal/web: merge with go.dev/cmd/internal/site

internal/web was the framework left serving golang.org.
go.dev/cmd/internal/site was the framework serving go.dev.
This CL merges the two into a coherent, simple site serving
framework that works for both sites, a step toward merging
the sites themselves.

The CL is difficult to break up, so it's a bit larger than would be ideal.
The best place to start is the doc comment in internal/web/site.go
and then the other changes in that directory.
The rest of the CL is just minor adjustments to the repo to match.

Change-Id: I927dea29396104a817bd81b6bf25fa43f996968f
Reviewed-on: https://go-review.googlesource.com/c/website/+/339403
Trust: Russ Cox <rsc@golang.org>
Website-Publish: Russ Cox <rsc@golang.org>
Reviewed-by: Jamal Carvalho <jamal@golang.org>
diff --git a/_content/lib/godoc/codewalk.html b/_content/codewalk.tmpl
similarity index 97%
rename from _content/lib/godoc/codewalk.html
rename to _content/codewalk.tmpl
index 89360bf..511a095 100644
--- a/_content/lib/godoc/codewalk.html
+++ b/_content/codewalk.tmpl
@@ -4,7 +4,8 @@
 	license that can be found in the LICENSE file.
 -->
 
-{{with .Data}}
+{{define "layout"}}
+{{with .codewalk}}
 <style type='text/css'>@import "/doc/codewalk/codewalk.css";</style>
 <script type="text/javascript" src="/doc/codewalk/codewalk.js"></script>
 
@@ -56,3 +57,4 @@
   </div>
 </div>
 {{end}}
+{{end}}
diff --git a/_content/lib/godoc/codewalkdir.html b/_content/codewalkdir.tmpl
similarity index 86%
rename from _content/lib/godoc/codewalkdir.html
rename to _content/codewalkdir.tmpl
index 5479429..5912e19 100644
--- a/_content/lib/godoc/codewalkdir.html
+++ b/_content/codewalkdir.tmpl
@@ -4,8 +4,9 @@
 	license that can be found in the LICENSE file.
 -->
 
+{{define "layout"}}
 <table class="layout">
-{{range .Data}}
+{{range .dirs}}
 <tr>
 	<td><a href="{{.Name}}">{{.Name}}</a></td>
 	<td width="25">&nbsp;</td>
@@ -13,3 +14,4 @@
 </tr>
 {{end}}
 </table>
+{{end}}
diff --git a/_content/lib/godoc/dirlist.html b/_content/dir.tmpl
similarity index 86%
rename from _content/lib/godoc/dirlist.html
rename to _content/dir.tmpl
index 6f32403..51db1c5 100644
--- a/_content/lib/godoc/dirlist.html
+++ b/_content/dir.tmpl
@@ -4,7 +4,7 @@
 	license that can be found in the LICENSE file.
 -->
 
-{{with .Data}}
+{{define "layout"}}
 <p>
 <table class="layout">
 <tr>
@@ -15,10 +15,11 @@
 <tr>
 	<td><a href="../">../</a></td>
 </tr>
-{{range .}}{{if .IsDir}}
+
+{{range .dir}}{{if .IsDir}}
 	<tr><td align="left"><a href="{{.Name}}/">{{.Name}}/</a><td></tr>
 {{end}}{{end}}
-{{range .}}{{if not .IsDir}}
+{{range .dir}}{{if not .IsDir}}
 	<tr><td align="left"><a href="{{.Name}}">{{.Name}}</a><td align="right">{{.Size}}</tr>
 {{end}}{{end}}
 
diff --git a/_content/lib/godoc/dl.html b/_content/dl.tmpl
similarity index 98%
rename from _content/lib/godoc/dl.html
rename to _content/dl.tmpl
index 11a893a..4cbc922 100644
--- a/_content/lib/godoc/dl.html
+++ b/_content/dl.tmpl
@@ -1,4 +1,5 @@
-{{with .Data}}
+{{define "layout"}}
+{{with .dl}}
 <p>
 After downloading a binary release suitable for your system,
 please follow the <a href="/doc/install">installation instructions</a>.
@@ -157,3 +158,4 @@
 </div>
 </a>
 {{end}}
+{{end}}
diff --git a/_content/doc/code.html b/_content/doc/code.html
index 1f020c9..ffa9b08 100644
--- a/_content/doc/code.html
+++ b/_content/doc/code.html
@@ -438,7 +438,7 @@
 </p>
 
 <p>
-Take {{if $.GoogleCN}}
+Take {{if googleCN}}
 A Tour of Go
 {{else}}
 <a href="//tour.golang.org/">A Tour of Go</a>
diff --git a/_content/doc/index.html b/_content/doc/index.html
index a55c828..7daf45b 100644
--- a/_content/doc/index.html
+++ b/_content/doc/index.html
@@ -58,7 +58,7 @@
 <img class="gopher" src="/doc/gopher/doc.png" alt=""/>
 
 <h3 id="go_tour">
-	{{if $.GoogleCN}}
+	{{if googleCN}}
 	  A Tour of Go
 	{{else}}
 	  <a href="//tour.golang.org/">A Tour of Go</a>
@@ -69,7 +69,7 @@
 The first section covers basic syntax and data structures; the second discusses
 methods and interfaces; and the third introduces Go's concurrency primitives.
 Each section concludes with a few exercises so you can practice what you've
-learned. You can {{if not $.GoogleCN}}<a href="//tour.golang.org/">take the tour
+learned. You can {{if not googleCN}}<a href="//tour.golang.org/">take the tour
 online</a> or{{end}} install it locally with:
 </p>
 <pre>
@@ -254,7 +254,7 @@
 <li><a href="/doc/codewalk/sharemem">Share Memory by Communicating</a></li>
 </ul>
 
-{{if not $.GoogleCN}}
+{{if not googleCN}}
 <h2 id="blog">From the Go Blog</h2>
 <p>The <a href="//blog.golang.org/">official blog of the Go project</a>, featuring news and in-depth articles by
 the Go team and guests.</p>
@@ -296,7 +296,7 @@
 <li><a href="/doc/gdb">Debugging Go Code with GDB</a></li>
 <li><a href="/doc/articles/race_detector.html">Data Race Detector</a> - a manual for the data race detector.</li>
 <li><a href="/doc/asm">A Quick Guide to Go's Assembler</a> - an introduction to the assembler used by Go.</li>
-{{if not $.GoogleCN}}
+{{if not googleCN}}
 <li><a href="/blog/c-go-cgo">C? Go? Cgo!</a> - linking against C code with <a href="/cmd/cgo/">cgo</a>.</li>
 <li><a href="/blog/godoc-documenting-go-code">Godoc: documenting Go code</a> - writing good documentation for <a href="/cmd/godoc/">godoc</a>.</li>
 <li><a href="/blog/profiling-go-programs">Profiling Go Programs</a></li>
@@ -314,7 +314,7 @@
 for more Go learning resources.
 </p>
 
-{{if not $.GoogleCN}}
+{{if not googleCN}}
 <h2 id="talks">Talks</h2>
 
 <img class="gopher" src="/doc/gopher/talks.png" alt=""/>
diff --git a/_content/lib/godoc/error.html b/_content/error.tmpl
similarity index 68%
rename from _content/lib/godoc/error.html
rename to _content/error.tmpl
index cb5325e..30fe79d 100644
--- a/_content/lib/godoc/error.html
+++ b/_content/error.tmpl
@@ -4,8 +4,8 @@
 	license that can be found in the LICENSE file.
 -->
 
-{{with .Data}}
+{{define "layout"}}
 <p>
-<span class="alert" style="font-size:120%">{{.}}</span>
+<span class="alert" style="font-size:120%">{{.error}}</span>
 </p>
 {{end}}
diff --git a/_content/help.html b/_content/help.html
index e6da3ba..2b53e44 100644
--- a/_content/help.html
+++ b/_content/help.html
@@ -9,7 +9,7 @@
 
 <img class="gopher" src="/doc/gopher/help.png" alt=""/>
 
-{{if not $.GoogleCN}}
+{{if not googleCN}}
 <h3 id="mailinglist"><a href="https://groups.google.com/group/golang-nuts">Go Nuts Mailing List</a></h3>
 <p>
 Get help from Go users, and share your work on the official mailing list.
@@ -42,7 +42,7 @@
 <h3 id="faq"><a href="/doc/faq">Frequently Asked Questions (FAQ)</a></h3>
 <p>Answers to common questions about Go.</p>
 
-{{if not $.GoogleCN}}
+{{if not googleCN}}
 <h2 id="inform">Stay informed</h2>
 
 <h3 id="announce"><a href="https://groups.google.com/group/golang-announce">Go Announcements Mailing List</a></h3>
@@ -79,7 +79,7 @@
 meet to talk about Go. Find a chapter near you.
 </p>
 
-{{if not $.GoogleCN}}
+{{if not googleCN}}
 <h3 id="playground"><a href="/play">Go Playground</a></h3>
 <p>A place to write, run, and share Go code.</p>
 
diff --git a/_content/index.html b/_content/index.html
index 51cb389..ae08edd 100644
--- a/_content/index.html
+++ b/_content/index.html
@@ -22,7 +22,7 @@
   <section class="HomeSection Playground">
     <div class="Playground-headerContainer">
       <h2 class="HomeSection-header">Try Go</h2>
-      {{if not $.GoogleCN}}
+      {{if not googleCN}}
         <a class="Playground-popout js-playgroundShareEl">Open in Playground</a>
       {{end}}
     </div>
@@ -55,7 +55,7 @@
       <div class="Playground-buttons">
         <button class="Button Button--primary js-playgroundRunEl" title="Run this code [shift-enter]">Run</button>
         <div class="Playground-secondaryButtons">
-          {{if not $.GoogleCN}}
+          {{if not googleCN}}
             <button class="Button js-playgroundShareEl" title="Share this code">Share</button>
             <a class="Button tour" href="https://tour.golang.org/" title="Playground Go from your browser">Tour</a>
           {{end}}
@@ -64,7 +64,7 @@
     </div>
   </section>
 
-  {{if not $.GoogleCN}}
+  {{if not googleCN}}
     <section class="HomeSection Blog js-blogContainerEl">
       <h2 class="HomeSection-header">Featured articles</h2>
       <div class="Blog-footer js-blogFooterEl"><a class="Button Button--primary" href="https://blog.golang.org/">Read more &gt;</a></div>
@@ -101,7 +101,7 @@
     }
   });
 
-  {{if not $.GoogleCN}}
+  {{if not googleCN}}
     function readableTime(t) {
       var m = ["January", "February", "March", "April", "May", "June", "July",
         "August", "September", "October", "November", "December"];
@@ -175,6 +175,6 @@
       var v = videos[Math.floor(Math.random()*videos.length)];
       $(".js-videoContainer iframe").attr("src", v.s).attr("title", v.title);
     });
-  {{end}} {{/* if not .GoogleCN */}}
+  {{end}} {{/* if not googleCN */}}
 })();
 </script>
diff --git a/_content/lib/godoc/package.html b/_content/pkg.tmpl
similarity index 92%
rename from _content/lib/godoc/package.html
rename to _content/pkg.tmpl
index e2cbd4d..9ffbb0a 100644
--- a/_content/lib/godoc/package.html
+++ b/_content/pkg.tmpl
@@ -9,7 +9,9 @@
 	them to conflict with generated attributes (some of which
 	correspond to Go identifiers).
 -->
-{{$pkg := .Data}}
+{{define "layout"}}
+{{$canShare := not googleCN}}
+{{$pkg := .pkg}}
 {{with $pkg.PDoc}}
 	{{if $pkg.IsMain}}
 		{{/* command documentation */}}
@@ -39,7 +41,7 @@
 			<div class="expanded">
 				<h2 class="toggleButton" title="Click to hide Overview section">Overview ▾</h2>
 				{{$pkg.Comment .Doc}}
-				{{range $pkg.FmtExamples ""}}{{template "example" .}}{{end}}
+				{{range $pkg.FmtExamples ""}}{{example . $canShare}}{{end}}
 			</div>
 		</div>
 
@@ -95,7 +97,7 @@
 			<p>
 			<span style="font-size:90%">
 			{{range .}}
-				<a href="/{{.}}">{{basename .}}</a>
+				<a href="/{{.}}">{{path.Base .}}</a>
 			{{end}}
 			</span>
 			</p>
@@ -126,7 +128,7 @@
 			</h2>
 			<pre>{{$pkg.Node .Decl}}</pre>
 			{{$pkg.Comment .Doc}}
-			{{range $pkg.FmtExamples .Name}}{{template "example" .}}{{end}}
+			{{range $pkg.FmtExamples .Name}}{{example . $canShare}}{{end}}
 		{{end}}
 		{{range .Types}}
 			{{$typeName := .Name}}
@@ -148,7 +150,7 @@
 				<pre>{{$pkg.Node .Decl}}</pre>
 			{{end}}
 
-			{{range $pkg.FmtExamples .Name}}{{template "example" .}}{{end}}
+			{{range $pkg.FmtExamples .Name}}{{example . $canShare}}{{end}}
 
 			{{range .Funcs}}
 				<h3 id="{{.Name}}">func <a href="{{$pkg.SrcPosLink .Decl}}">{{.Name}}</a>
@@ -158,7 +160,7 @@
 				</h3>
 				<pre>{{$pkg.Node .Decl}}</pre>
 				{{$pkg.Comment .Doc}}
-				{{range $pkg.FmtExamples .Name}}{{template "example" .}}{{end}}
+				{{range $pkg.FmtExamples .Name}}{{example . $canShare}}{{end}}
 			{{end}}
 
 			{{range .Methods}}
@@ -169,7 +171,7 @@
 				</h3>
 				<pre>{{$pkg.Node .Decl}}</pre>
 				{{$pkg.Comment .Doc}}
-				{{range $pkg.FmtExamples (printf "%s_%s" $typeName .Name)}}{{template "example" .}}{{end}}
+				{{range $pkg.FmtExamples (printf "%s_%s" $typeName .Name)}}{{example . $canShare}}{{end}}
 			{{end}}
 		{{end}}
 	{{end}}
@@ -223,8 +225,11 @@
 		</table>
 	</div>
 {{end}}
+{{end}}
 
-{{define "example"}}
+{{define "example ex canShare"}}
+{{$canShare := .canShare}}
+{{with .ex}}
 <div id="example_{{.Name}}" class="toggle">
   <div class="collapsed">
     <p class="exampleHeading toggleButton">▹ <span class="text">Example{{.Page.ExampleSuffix .Name}}</span></p>
@@ -240,7 +245,7 @@
         <div class="buttons">
           <button class="Button Button--primary run" title="Run this code [shift-enter]">Run</button>
           <button class="Button fmt" title="Format this code">Format</button>
-          {{if not $.Page.Web.GoogleCN}}
+          {{if $canShare}}
             <button class="Button share" title="Share this code">Share</button>
           {{end}}
         </div>
@@ -256,3 +261,4 @@
   </div>
 </div>
 {{end}}
+{{end}}
diff --git a/_content/lib/godoc/packageroot.html b/_content/pkgroot.tmpl
similarity index 98%
rename from _content/lib/godoc/packageroot.html
rename to _content/pkgroot.tmpl
index aaa2c53..b972cd9 100644
--- a/_content/lib/godoc/packageroot.html
+++ b/_content/pkgroot.tmpl
@@ -9,7 +9,8 @@
 	them to conflict with generated attributes (some of which
 	correspond to Go identifiers).
 -->
-{{$pkg := .Data}}
+{{define "layout"}}
+{{$pkg := .pkg}}
 
 {{with $pkg.Dirs}}
 	{{/* DirList entries are numbers and strings - no need for FSet */}}
@@ -99,3 +100,4 @@
 		<li><a href="/wiki/Projects">Projects at the Go Wiki</a> - a curated list of Go projects.</li>
 	</ul>
 {{end}}
+{{end}}
diff --git a/_content/lib/godoc/site.html b/_content/site.tmpl
similarity index 72%
rename from _content/lib/godoc/site.html
rename to _content/site.tmpl
index 09624be..129d5b7 100644
--- a/_content/lib/godoc/site.html
+++ b/_content/site.tmpl
@@ -4,7 +4,7 @@
 <meta name="description" content="Go is an open source programming language that makes it easy to build simple, reliable, and efficient software.">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <meta name="theme-color" content="#00ADD8">
-{{with .TabTitle}}
+{{with (or .tabTitle .title (strings.Trim .URL "/"))}}
   <title>{{.}} - The Go Programming Language</title>
 {{else}}
   <title>The Go Programming Language</title>
@@ -13,7 +13,7 @@
 <link href="https://fonts.googleapis.com/css?family=Product+Sans&text=Supported%20by%20Google&display=swap" rel="stylesheet">
 <link type="text/css" rel="stylesheet" href="/lib/godoc/style.css">
 <script>window.initFuncs = [];</script>
-{{with .GoogleAnalytics}}
+{{with googleAnalytics}}
 <script>
 var _gaq = _gaq || [];
 _gaq.push(["_setAccount", "{{.}}"]);
@@ -29,7 +29,7 @@
 <script src="/lib/godoc/jquery.js" defer></script>
 
 <script src="/lib/godoc/playground.js" defer></script>
-{{with .Version}}<script>var goVersion = {{printf "%q" .}};</script>{{end}}
+{{with version}}<script>var goVersion = {{printf "%q" .}};</script>{{end}}
 <script src="/lib/godoc/godocs.js" defer></script>
 
 <body class="Site">
@@ -40,7 +40,7 @@
        target="_blank"
        rel="noopener">Support the Equal Justice Initiative.</a>
   </div>
-  <nav class="Header-nav {{if .Title}}Header-nav--wide{{end}}">
+  <nav class="Header-nav {{if .title}}Header-nav--wide{{end}}">
     <a href="/"><img class="Header-logo" src="/lib/godoc/images/go-logo-blue.svg" alt="Go"></a>
     <button class="Header-menuButton js-headerMenuButton" aria-label="Main menu" aria-expanded="false">
       <div class="Header-menuButtonInner"></div>
@@ -50,7 +50,7 @@
       <li class="Header-menuItem"><a href="/pkg/">Packages</a></li>
       <li class="Header-menuItem"><a href="/project/">The Project</a></li>
       <li class="Header-menuItem"><a href="/help/">Help</a></li>
-      {{if not .GoogleCN}}
+      {{if not googleCN}}
         <li class="Header-menuItem"><a href="/blog/">Blog</a></li>
         <li class="Header-menuItem"><a href="https://play.golang.org/">Play</a></li>
       {{end}}
@@ -58,38 +58,42 @@
   </nav>
 </header>
 
-<main id="page" class="Site-content{{if .Title}} wide{{end}}">
+<main id="page" class="Site-content{{if .title}} wide{{end}}">
 <div class="container">
 
-{{define "srcBreadcrumb"}}
-	{{$elems := split . "/"}}
-	{{$prefix := slice $elems 0 (sub (len $elems) 1)}}
-	{{if hasSuffix . "/"}}
-		{{$prefix = slice $elems 0 (sub (len $elems) 2)}}
-	{{end}}
-	{{range $i, $elem := $prefix -}}
-		<a href="/{{join (slice $prefix 0 (add $i 1)) "/"}}">{{$elem}}</a>/
-	{{- end -}}
-	<span class="text-muted">{{join (slice $elems (len $prefix) (len $elems)) "/"}} {{len $prefix}} {{len $elems}}</span>
+{{define "breadcrumb"}}
+  {{$elems := strings.Split (strings.Trim . "/") "/"}}
+  {{$prefix := slice $elems 0 (sub (len $elems) 1)}}
+  {{if strings.HasSuffix . "/"}}
+    {{$prefix = slice $elems 0 (sub (len $elems) 2)}}
+  {{end}}
+  {{range $i, $elem := $prefix -}}
+    <a href="/{{strings.Join (slice $prefix 0 (add $i 1)) "/"}}">{{$elem}}</a>/
+  {{- end -}}
+  <span class="text-muted">{{strings.Join (slice $elems (len $prefix) (len $elems)) "/"}}</span>
 {{end}}
 
-{{if or .Title .SrcPath}}
-  <h1>
-    {{.Title}}
-    {{template "srcBreadcrumb" .SrcPath}}
-  </h1>
+{{if .title}}
+  <h1>{{.title}}</h1>
+{{else if eq .layout "error"}}
+  <h1>Error</h1>
+{{else if eq .layout "dir"}}
+  <h1>Directory {{breadcrumb .URL}}</h1>
+{{else if and (eq .layout "texthtml") (strings.HasSuffix .URL ".go")}}
+  <h1>Source file {{breadcrumb .URL}}</h1>
+{{else if eq .layout "texthtml"}}
+  <h1>Text file {{breadcrumb .URL}}</h1>
 {{end}}
 
-{{with .Subtitle}}
+{{with .subtitle}}
   <h2>{{.}}</h2>
 {{end}}
 
-{{if hasPrefix .SrcPath "src/"}}
+{{if strings.HasPrefix .URL "/src/"}}
   <h2>
     Documentation:
-    {{$path := trimPrefix .SrcPath "src/"}}
-    {{if $path}}
-      <a href="/pkg/{{$path}}">{{$path}}</a>
+    {{with strings.TrimPrefix .URL "/src/"}}
+      <a href="/pkg/{{.}}">{{.}}</a>
     {{else}}
       <a href="/pkg">Index</a>
     {{end}}
@@ -100,12 +104,12 @@
      Do not delete this <div>. */}}
 <div id="nav"></div>
 
-{{.HTML}}
+{{block "layout" .}}{{.Content}}{{end}}
 
 </div><!-- .container -->
 </main><!-- #page -->
 <footer>
-  <div class="Footer {{if .Title}}Footer--wide{{end}}">
+  <div class="Footer {{if .title}}Footer--wide{{end}}">
     <img class="Footer-gopher" src="/lib/godoc/images/footer-gopher.jpg" alt="The Go Gopher">
     <ul class="Footer-links">
       <li class="Footer-link"><a href="/doc/copyright.html">Copyright</a></li>
@@ -116,7 +120,7 @@
     <a class="Footer-supportedBy" href="https://google.com">Supported by Google</a>
   </div>
 </footer>
-{{if .GoogleAnalytics}}
+{{if googleAnalytics}}
 <script>
 (function() {
   var ga = document.createElement("script"); ga.type = "text/javascript"; ga.async = true;
diff --git a/go.dev/_content/default.tmpl b/_content/texthtml.tmpl
similarity index 66%
rename from go.dev/_content/default.tmpl
rename to _content/texthtml.tmpl
index ae82f43..c7cd1af 100644
--- a/go.dev/_content/default.tmpl
+++ b/_content/texthtml.tmpl
@@ -1,3 +1,3 @@
 {{define "layout"}}
-{{.Content}}
+{{.texthtml}}
 {{end}}
diff --git a/internal/web/googlecn.go b/cmd/golangorg/googlecn.go
similarity index 71%
rename from internal/web/googlecn.go
rename to cmd/golangorg/googlecn.go
index 82d658b..d1dbb8c 100644
--- a/internal/web/googlecn.go
+++ b/cmd/golangorg/googlecn.go
@@ -2,18 +2,18 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package web
+package main
 
 import (
 	"net/http"
 	"strings"
 )
 
-// GoogleCN reports whether request r is considered to be arriving from China.
+// googleCN reports whether request r is considered to be arriving from China.
 // Typically that means the request is for host golang.google.cn,
 // but we also report true for requests that set googlecn=1 as a query parameter
-// and requests that App Engine geolocates in China or in “unknown country.”
-func GoogleCN(r *http.Request) bool {
+// and for requests that App Engine geolocates in China.
+func googleCN(r *http.Request) bool {
 	if r.FormValue("googlecn") != "" {
 		return true
 	}
@@ -21,7 +21,7 @@
 		return true
 	}
 	switch r.Header.Get("X-Appengine-Country") {
-	case "ZZ", "CN":
+	case "CN":
 		return true
 	}
 	return false
diff --git a/cmd/golangorg/server.go b/cmd/golangorg/server.go
index 8924abe..a707371 100644
--- a/cmd/golangorg/server.go
+++ b/cmd/golangorg/server.go
@@ -17,6 +17,7 @@
 	"log"
 	"net/http"
 	"os"
+	"path"
 	"path/filepath"
 	"runtime"
 	"runtime/debug"
@@ -36,6 +37,7 @@
 	"golang.org/x/website/internal/dl"
 	"golang.org/x/website/internal/env"
 	"golang.org/x/website/internal/gitfs"
+	"golang.org/x/website/internal/history"
 	"golang.org/x/website/internal/memcache"
 	"golang.org/x/website/internal/pkgdoc"
 	"golang.org/x/website/internal/proxy"
@@ -52,6 +54,8 @@
 	contentDir = flag.String("content", "", "path to _content directory")
 
 	runningOnAppEngine = os.Getenv("PORT") != ""
+
+	googleAnalytics string
 )
 
 func usage() {
@@ -145,6 +149,9 @@
 	if err != nil {
 		log.Fatalf("newSite: %v", err)
 	}
+	if _, err := newSite(mux, "golang.google.cn", content, gorootFS); err != nil {
+		log.Fatalf("newSite: %v", err)
+	}
 
 	// tip.golang.org serves content from the very latest Git commit
 	// of the main Go repo, instead of the one the app is bundled with.
@@ -198,12 +205,15 @@
 // and registers it in mux to handle requests for host.
 // If host is the empty string, the registrations are for the wildcard host.
 func newSite(mux *http.ServeMux, host string, content, goroot fs.FS) (*web.Site, error) {
-	fsys := unionFS{content, goroot}
-	site, err := web.NewSite(fsys)
-	if err != nil {
-		return nil, err
-	}
-	docs, err := pkgdoc.NewServer(fsys, site)
+	fsys := unionFS{content, &fixSpecsFS{goroot}}
+	site := web.NewSite(fsys)
+	site.Funcs(template.FuncMap{
+		"googleAnalytics": func() string { return googleAnalytics },
+		"googleCN":        func() bool { return host == "golang.google.cn" },
+		"releases":        func() []*history.Major { return history.Majors },
+		"version":         func() string { return runtime.Version() },
+	})
+	docs, err := pkgdoc.NewServer(fsys, site, googleCN)
 	if err != nil {
 		return nil, err
 	}
@@ -282,7 +292,7 @@
 }
 
 func appEngineSetup(site *web.Site, mux *http.ServeMux) {
-	site.GoogleAnalytics = os.Getenv("GOLANGORG_ANALYTICS")
+	googleAnalytics = os.Getenv("GOLANGORG_ANALYTICS")
 
 	ctx := context.Background()
 
@@ -302,7 +312,7 @@
 
 	dl.RegisterHandlers(mux, site, datastoreClient, memcacheClient)
 	short.RegisterHandlers(mux, datastoreClient, memcacheClient)
-	proxy.RegisterHandlers(mux)
+	proxy.RegisterHandlers(mux, googleCN)
 
 	log.Println("AppEngine initialization complete")
 }
@@ -339,22 +349,30 @@
 }
 
 // hostEnforcerHandler redirects http://foo.golang.org/bar to https://golang.org/bar.
-// It permits golang.google.cn as an alias for golang.org, for use in China.
+// It also forces all requests coming from China to use golang.google.cn.
 func hostEnforcerHandler(h http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		isHTTPS := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" || r.URL.Scheme == "https"
+		defaultHost := "golang.org"
 		isValidHost := validHosts[strings.ToLower(r.Host)]
 
+		if googleCN(r) {
+			// golang.google.cn is the only web site in China.
+			defaultHost = "golang.google.cn"
+			isValidHost = strings.ToLower(r.Host) == defaultHost
+		}
+
 		if !isHTTPS || !isValidHost {
 			r.URL.Scheme = "https"
 			if isValidHost {
 				r.URL.Host = r.Host
 			} else {
-				r.URL.Host = "golang.org"
+				r.URL.Host = defaultHost
 			}
 			http.Redirect(w, r, r.URL.String(), http.StatusFound)
 			return
 		}
+
 		w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
 		h.ServeHTTP(w, r)
 	})
@@ -543,6 +561,33 @@
 	return nil, errOut
 }
 
+// A fixSpecsFS is an FS mapping /ref/mem.html and /ref/spec.html to
+// /doc/go_mem.html and /doc/go_spec.html.
+var _ fs.FS = &fixSpecsFS{}
+
+type fixSpecsFS struct {
+	fs fs.FS
+}
+
+func (fsys fixSpecsFS) Open(name string) (fs.File, error) {
+	switch name {
+	case "ref/mem.html", "ref/spec.html":
+		if f, err := fsys.fs.Open(name); err == nil {
+			// Let Go distribution win if they move.
+			return f, nil
+		}
+		// Otherwise fall back to doc/go_*.html
+		name = "doc/go_" + strings.TrimPrefix(name, "ref/")
+		return fsys.fs.Open(name)
+
+	case "doc/go_mem.html", "doc/go_spec.html":
+		data := []byte("<!--{\n\t\"Redirect\": \"/ref/" + strings.TrimPrefix(strings.TrimSuffix(name, ".html"), "doc/go_") + "\"\n}-->\n")
+		return &memFile{path.Base(name), bytes.NewReader(data)}, nil
+	}
+
+	return fsys.fs.Open(name)
+}
+
 // A seekableFS is an FS wrapper that makes every file seekable
 // by reading it entirely into memory when it is opened and then
 // serving read operations (including seek) from the memory copy.
@@ -587,6 +632,20 @@
 	return f.Reader.Read(b)
 }
 
+// A memFile is an fs.File implementation backed by in-memory data.
+type memFile struct {
+	name string
+	*bytes.Reader
+}
+
+func (f *memFile) Stat() (fs.FileInfo, error) { return f, nil }
+func (f *memFile) Name() string               { return f.name }
+func (*memFile) Mode() fs.FileMode            { return 0444 }
+func (*memFile) ModTime() time.Time           { return time.Time{} }
+func (*memFile) IsDir() bool                  { return false }
+func (*memFile) Sys() interface{}             { return nil }
+func (*memFile) Close() error                 { return nil }
+
 // An atomicFS is an fs.FS value safe for reading from multiple goroutines
 // as well as updating (assigning a different fs.FS to use in future read requests).
 type atomicFS struct {
diff --git a/cmd/golangorg/testdata/web.txt b/cmd/golangorg/testdata/web.txt
index 76e919e..0bd66f9 100644
--- a/cmd/golangorg/testdata/web.txt
+++ b/cmd/golangorg/testdata/web.txt
@@ -88,9 +88,11 @@
 
 GET https://golang.org/pkg/fmt/?m=old
 body contains Package fmt implements formatted I/O
+body contains Share this code
 
 GET https://golang.google.cn/pkg/fmt/
 body contains Package fmt implements formatted I/O
+body !contains Share this code
 
 GET https://golang.org/pkg
 redirect == /pkg/
@@ -203,3 +205,18 @@
 
 GET https://m.golang.org/anything
 redirect == https://mail.google.com/a/golang.org/
+
+GET https://golang.org/doc/effective_go
+body contains KB ByteSize
+
+GET https://golang.org/doc/codewalk/codewalk/
+body contains Codewalk: How to Write a Codewalk
+body contains A codewalk is a guided tour
+
+GET https://golang.org/doc/codewalk/
+body contains Codewalks
+body contains <td>How to Write a Codewalk</td>
+
+GET https://golang.org/asdf
+code == 404
+body ~ <span class="alert" style="font-size:120%">open ../../_content/asdf: (.*)</span>
diff --git a/go.dev/_content/article.tmpl b/go.dev/_content/article.tmpl
index 6b6d7a3..15c4401 100644
--- a/go.dev/_content/article.tmpl
+++ b/go.dev/_content/article.tmpl
@@ -1,5 +1,5 @@
 {{define "layout"}}
-  <article class="Article {{if ne .Section "/"}}Article--{{trim .Section "/"}}{{end -}}">
+  <article class="Article {{with section .}}Article--{{strings.Trim . "/"}}{{end}}">
     <h1>{{.title}}</h1>
     {{.Content}}
   </article>
diff --git a/_content/lib/godoc/error.html b/go.dev/_content/error.tmpl
similarity index 68%
copy from _content/lib/godoc/error.html
copy to go.dev/_content/error.tmpl
index cb5325e..30fe79d 100644
--- a/_content/lib/godoc/error.html
+++ b/go.dev/_content/error.tmpl
@@ -4,8 +4,8 @@
 	license that can be found in the LICENSE file.
 -->
 
-{{with .Data}}
+{{define "layout"}}
 <p>
-<span class="alert" style="font-size:120%">{{.}}</span>
+<span class="alert" style="font-size:120%">{{.error}}</span>
 </p>
 {{end}}
diff --git a/go.dev/_content/index.md b/go.dev/_content/index.md
index 72d429c..db1c048 100644
--- a/go.dev/_content/index.md
+++ b/go.dev/_content/index.md
@@ -79,7 +79,7 @@
     </div>
   <div class="WhoUsesCaseStudyList">
     <ul class="WhoUsesCaseStudyList-gridContainer">
-    {{- range newest (pages "solutions/*")}}{{if eq .series "Case Studies"}}
+    {{- range newest (pages "/solutions/*")}}{{if eq .series "Case Studies"}}
       {{- if .link }}
         {{- if .inLandingPageGrid }}
           <li class="WhoUsesCaseStudyList-caseStudy">
@@ -97,7 +97,7 @@
         {{- end}}
       {{- else}}
         <li class="WhoUsesCaseStudyList-caseStudy">
-          <a href="{{.Path}}" class="WhoUsesCaseStudyList-caseStudyLink">
+          <a href="{{.URL}}" class="WhoUsesCaseStudyList-caseStudyLink">
             <img
               loading="lazy"
               height="48"
@@ -119,7 +119,7 @@
     <div class="GoCarousel-controlsContainer">
       <div class="GoCarousel-wrapper">
         <ul class="js-testimonialsGoQuotes TestimonialsGo-quotes">
-          {{- range $index, $element := data "testimonials"}}
+          {{- range $index, $element := data "/testimonials.yaml"}}
             <li class="TestimonialsGo-quoteGroup GoCarousel-slide" id="quote_slide{{$index}}">
               <div class="TestimonialsGo-quoteSingleItem">
                 <div class="TestimonialsGo-quoteSection">
@@ -153,7 +153,7 @@
       </h4>
     </div>
     <ul class="WhyGo-reasons">
-      {{- range first 4 (data "resources")}}
+      {{- range first 4 (data "/resources.yaml")}}
         <li class="WhyGo-reason">
           <div class="WhyGo-reasonDetails">
             <div class="WhyGo-reasonIcon" role="presentation">
@@ -188,7 +188,7 @@
           </div>
         </li>
       {{- end}}
-      {{- if gt (len (data "resources")) 3}}
+      {{- if gt (len (data "resources.yaml")) 3}}
         <li class="WhyGo-reason">
           <div class="WhyGo-reasonShowMore">
             <div class="WhyGo-reasonShowMoreImgWrapper">
@@ -216,7 +216,7 @@
     <div class="GoCarousel-controlsContainer">
       <div class="GoCarousel-eventsWrapper">
         <ul class="js-goCarouselEventsSlides GoCarousel-eventsSlides">
-          {{- range $index, $element := (data "events").all}}
+          {{- range $index, $element := (data "/events.yaml").all}}
             <li
             class="GoCarousel-eventGroup"
             id="event_slide{{$index}}">
@@ -312,7 +312,7 @@
         <li class="GettingStartedGo-resourcesHeader">
           In-Person Trainings
         </li>
-        {{- range first 4 (data "learn/training")}}
+        {{- range first 4 (data "/learn/training.yaml")}}
           <li class="GettingStartedGo-resourceItem">
             <a href="{{.url}}" class="GettingStartedGo-resourceItemTitle">
               {{.title}}
diff --git a/go.dev/_content/learn/index.md b/go.dev/_content/learn/index.md
index fe84ce3..816d669 100644
--- a/go.dev/_content/learn/index.md
+++ b/go.dev/_content/learn/index.md
@@ -45,7 +45,7 @@
     </div>
     <div class="LearnGo-gridContainer">
       <ul class="Learn-quickstarts Learn-cardList">
-        {{- range first 3 (data "learn/quickstart")}}
+        {{- range first 3 (data "quickstart.yaml")}}
           <li class="Learn-quickstart Learn-card">
             {{- template "learn-card" .}}
           </li>
@@ -66,7 +66,7 @@
     </div>
     <div class="LearnGo-gridContainer">
       <ul class="Learn-cardList">
-        {{- range first 4 (data "learn/guided")}}
+        {{- range first 4 (data "guided.yaml")}}
           <li class="Learn-card">
             {{- template "learn-card" .}}
           </li>
@@ -83,7 +83,7 @@
     </div>
     <div class="LearnGo-gridContainer">
       <ul class="Learn-cardList">
-        {{- range first 4 (data "learn/courses") }}
+        {{- range first 4 (data "courses.yaml") }}
           <li class="Learn-card">
             {{- template "learn-card" .}}
           </li>
@@ -100,7 +100,7 @@
     </div>
     <div class="LearnGo-gridContainer">
       <ul class="Learn-cardList">
-        {{- range first 4 (data "learn/cloud")}}
+        {{- range first 4 (data "cloud.yaml")}}
         <li class="Learn-card">
           {{- template "learn-self-paced-card" .}}
         </li>
@@ -118,7 +118,7 @@
     </div>
     <div class="LearnGo-gridContainer">
       <ul class="Learn-cardList Learn-bookList">
-        {{- range first 5 (data "learn/books")}}
+        {{- range first 5 (data "books.yaml")}}
           <li class="Learn-card Learn-book">
             {{template "learn-book" .}}
           </li>
@@ -135,7 +135,7 @@
     </div>
     <div class="LearnGo-gridContainer">
       <ul class="Learn-inPersonList">
-        {{- range first 4 (data "learn/training")}}
+        {{- range first 4 (data "training.yaml")}}
         <li class="Learn-inPerson">
           <p class="Learn-inPersonTitle">
             <a href="{{.url}}">{{.title}} </a>
@@ -157,7 +157,7 @@
       </p>
     </div>
     <ul class="Learn-events">
-      {{- range first 3 (data "events").all}}
+      {{- range first 3 (data "/events.yaml").all}}
       <li class="Learn-eventItem">
         <div
           class="Learn-eventThumbnail {{if not .photourl}}Learn-eventThumbnail--noimage{{end}}"
diff --git a/go.dev/_content/site.tmpl b/go.dev/_content/site.tmpl
index 12271ff..c77e6d3 100644
--- a/go.dev/_content/site.tmpl
+++ b/go.dev/_content/site.tmpl
@@ -23,7 +23,7 @@
   })(window,document,'script','dataLayer','GTM-W8MVQXG');</script>
   <!-- End Google Tag Manager -->
 <script src="/js/site.js"></script>
-<title>{{.title}}{{if .Parent}} - go.dev{{end}}</title>
+<title>{{.title}}{{if ne .URL "/"}} - go.dev{{end}}</title>
 {{if .link -}}
 <meta http-equiv="refresh" content="0; url={{.link}}">
 {{end -}}
@@ -34,7 +34,7 @@
   height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
   <!-- End Google Tag Manager (noscript) -->
 
-{{$menus := data "menus"}}
+{{$menus := data "/menus.yaml"}}
 <header class="Site-header js-siteHeader">
   <div class="Banner">
     <div class="Banner-inner">
@@ -93,7 +93,7 @@
     </div>
     <ul class="NavigationDrawer-list">
       {{- range $menus.main}}
-        <li class="NavigationDrawer-listItem {{if eq .url $currentPage.Section}} NavigationDrawer-listItem--active{{end}}">
+        <li class="NavigationDrawer-listItem {{if eq .url (section $currentPage)}} NavigationDrawer-listItem--active{{end}}">
           <a href="{{.url}}">{{.name}}</a>
         </li>
       {{- end}}
@@ -102,7 +102,7 @@
 </aside>
 <div class="NavigationDrawer-scrim js-scrim" role="presentation"></div>
 <main class="SiteContent SiteContent--default">
-  {{- block "layout" . -}}{{- end -}}
+  {{block "layout" .}}{{.Content}}{{end}}
 </main>
 <footer class="Site-footer">
   <div class="Footer">
@@ -173,13 +173,13 @@
 </body>
 </html>
 
-{{define "breadcrumbnav"}}
-{{- if .p1.Parent}}
-  {{- template "breadcrumbnav" (dict "p1" (page .p1.Parent) "p2" .p2 )}}
+{{define "breadcrumbnav p1 p2"}}
+{{- if ne .p1.URL "/"}}
+  {{- breadcrumbnav (page (path.Dir (strings.TrimRight .p1.URL "/"))) .p2}}
 {{- end}}
 {{- if not (eq .p1.title "go.dev")}}
-<li class="BreadcrumbNav-li {{if eq .p1.Path .p2.Path}}active{{end}}">
-  <a class="BreadcrumbNav-link" href="{{.p1.Path}}">
+<li class="BreadcrumbNav-li {{if eq .p1.URL .p2.URL}}active{{end}}">
+  <a class="BreadcrumbNav-link" href="{{.p1.URL}}">
   {{or .p1.company .p1.title}}
   </a>
 </li>
@@ -189,7 +189,7 @@
 {{define "breadcrumbs"}}
 <div class="BreadcrumbNav">
     <ol class="BreadcrumbNav-inner">
-      {{template "breadcrumbnav" (dict "p1" . "p2" .)}}
+      {{breadcrumbnav . .}}
     </ol>
 </div>
 {{- end}}
diff --git a/go.dev/_content/site.yaml b/go.dev/_content/site.yaml
deleted file mode 100644
index 6878875..0000000
--- a/go.dev/_content/site.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-url: https://go.dev/
-title: go.dev
diff --git a/go.dev/_content/solutions/default.tmpl b/go.dev/_content/solutions/default.tmpl
index 79372a2..a6cb0c7 100644
--- a/go.dev/_content/solutions/default.tmpl
+++ b/go.dev/_content/solutions/default.tmpl
@@ -10,7 +10,7 @@
             <div class="Article-author">{{.}}</div>
           {{end}}
           {{if .date}}
-            <div class="Article-date">{{.Date.Format "2 January 2006"}}</div>
+            <div class="Article-date">{{.date.Format "2 January 2006"}}</div>
           {{end}}
         </div>
           {{if .company}}
@@ -22,7 +22,7 @@
       </div>
 
   </div>
-  <article class="Article {{if ne .Section "/"}}Article--{{trim .Section "/"}}{{end -}}">
+  <article class="Article {{if ne (section .) "/"}}Article--{{strings.Trim (section .) "/"}}{{end -}}">
     {{if (eq .series "Case Studies") }}
       <div class="CaseStudy-content">
         <div class="CaseStudy-contentBody">
@@ -73,7 +73,7 @@
               </span>
             </div>
           {{ end }}
-          {{rawhtml (replace .Content ` .sectionHeading">` `" class="sectionHeading">`)}}
+          {{.Content}}
         </div>
       </div>
     {{end}}
diff --git a/go.dev/_content/solutions/index.md b/go.dev/_content/solutions/index.md
index e4ea287..db07513 100644
--- a/go.dev/_content/solutions/index.md
+++ b/go.dev/_content/solutions/index.md
@@ -1,8 +1,9 @@
 ---
 title: Why Go
+layout: none
 ---
 
-{{$solutions := pages "solutions/*"}}
+{{$solutions := pages "/solutions/*"}}
 <section class="Solutions-headline">
   <div class="GoCarousel" id="SolutionsHeroCarousel-carousel">
     <div class="GoCarousel-controlsContainer">
@@ -17,17 +18,17 @@
                 <div class="Solutions-headlineImg">
                   <img
                     src="/images/{{.carouselImgSrc}}"
-                    alt="{{.linkTitle}}"
+                    alt="{{(or .linkTitle .title)}}"
                   />
                 </div>
                 <div class="Solutions-headlineText">
                   <p class="Solutions-headlineNotification">RECENTLY UPDATED</p>
                   <h2>
-                    {{.linkTitle}}
+                    {{(or .linkTitle .title)}}
                   </h2>
                   <p class="Solutions-headlineBody">
                     {{with .quote}}{{.}}{{end}}
-                    <a href="{{.Path}}"
+                    <a href="{{.URL}}"
                       >Learn more
                       <i class="material-icons Solutions-forwardArrowIcon"
                         >arrow_forward</i
@@ -106,7 +107,7 @@
             />
           </div>
           <div class="Solutions-useCaseBody">
-            <h3 class="Solutions-useCaseTitle">{{.linkTitle}}</h3>
+            <h3 class="Solutions-useCaseTitle">{{or .linkTitle .title}}</h3>
             <p class="Solutions-useCaseDescription">
               {{.description}}
             </p>
@@ -117,7 +118,7 @@
           </p>
         </a>
         {{- else}}
-        <a href="{{.Path}}" class="Solutions-useCaseLink">
+        <a href="{{.URL}}" class="Solutions-useCaseLink">
           <div class="Solutions-useCaseLogo">
             <img
               loading="lazy"
@@ -126,7 +127,7 @@
             />
           </div>
           <div class="Solutions-useCaseBody">
-            <h3 class="Solutions-useCaseTitle">{{.linkTitle}}</h3>
+            <h3 class="Solutions-useCaseTitle">{{or .linkTitle .title}}</h3>
             <p class="Solutions-useCaseDescription">
               {{with .quote}}{{.}}{{end}}
             </p>
@@ -149,19 +150,19 @@
     >
       {{- range newest $solutions}}{{if eq .series "Use Cases"}}
       <li class="Solutions-card">
-        <a href="{{.Path}}" class="Solutions-useCaseLink">
+        <a href="{{.URL}}" class="Solutions-useCaseLink">
           <div class="Solutions-useCaseLogo">
             {{- $icon := .icon}}
             {{- if $icon}}
             <img
               loading="lazy"
               alt="{{$icon.alt}}"
-              src="{{.Dir}}/{{$icon.file}}"
+              src="{{path.Dir .URL}}/{{$icon.file}}"
             />
             {{- end}}
           </div>
           <div class="Solutions-useCaseBody">
-            <h3 class="Solutions-useCaseTitle">{{.linkTitle}}</h3>
+            <h3 class="Solutions-useCaseTitle">{{or .linkTitle .title}}</h3>
             <p class="Solutions-useCaseDescription">
               {{.description}}
             </p>
diff --git a/go.dev/cmd/internal/site/site_test.go b/go.dev/cmd/frontend/golden_test.go
similarity index 79%
rename from go.dev/cmd/internal/site/site_test.go
rename to go.dev/cmd/frontend/golden_test.go
index f19b95c..04b6435 100644
--- a/go.dev/cmd/internal/site/site_test.go
+++ b/go.dev/cmd/frontend/golden_test.go
@@ -2,11 +2,12 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package site
+package main
 
 import (
 	"bytes"
 	"io/ioutil"
+	"net/http/httptest"
 	"os"
 	"path"
 	"path/filepath"
@@ -19,14 +20,14 @@
 
 func TestGolden(t *testing.T) {
 	start := time.Now()
-	site, err := Load("../../..")
+	h, err := NewHandler("../../_content")
 	if err != nil {
 		t.Fatal(err)
 	}
 	total := time.Since(start)
 	t.Logf("Load %v\n", total)
 
-	root := "../../../testdata/golden"
+	root := "../../testdata/golden"
 	err = filepath.Walk(root, func(name string, info os.FileInfo, err error) error {
 		if err != nil {
 			return err
@@ -55,20 +56,29 @@
 			return nil
 		}
 
-		want, err := ioutil.ReadFile(site.file("testdata/golden/" + name))
+		want, err := ioutil.ReadFile(filepath.Join(root, name))
 		if err != nil {
 			t.Fatal(err)
 		}
 
 		start := time.Now()
-		f, err := site.Open(name)
-		if err != nil {
-			t.Fatal(err)
+		r := httptest.NewRequest("GET", "/"+name, nil)
+		resp := httptest.NewRecorder()
+		resp.Body = new(bytes.Buffer)
+		h.ServeHTTP(resp, r)
+		for nredir := 0; resp.Code/10 == 30; nredir++ {
+			if nredir > 10 {
+				t.Fatalf("%s <- redirect loop!", name)
+			}
+			r.URL.Path = resp.Result().Header.Get("Location")
+			resp = httptest.NewRecorder()
+			resp.Body = new(bytes.Buffer)
+			h.ServeHTTP(resp, r)
 		}
-		have, err := ioutil.ReadAll(f)
-		if err != nil {
-			t.Fatalf("%v: %v", name, err)
+		if resp.Code != 200 {
+			t.Fatalf("GET %s <- %d, want 200", r.URL, resp.Code)
 		}
+		have := resp.Body.Bytes()
 		total += time.Since(start)
 
 		if path.Ext(name) == ".html" {
diff --git a/go.dev/cmd/frontend/main.go b/go.dev/cmd/frontend/main.go
index db67169..effd257 100644
--- a/go.dev/cmd/frontend/main.go
+++ b/go.dev/cmd/frontend/main.go
@@ -11,9 +11,13 @@
 	"net/url"
 	"os"
 	"path/filepath"
+	"sort"
 	"strings"
+	"time"
 
-	"golang.org/x/website/go.dev/cmd/internal/site"
+	"golang.org/x/website/internal/backport/html/template"
+	"golang.org/x/website/internal/backport/osfs"
+	"golang.org/x/website/internal/web"
 	"golang.org/x/website/internal/webtest"
 )
 
@@ -24,10 +28,10 @@
 }
 
 func main() {
-	dir := "../.."
+	dir := "../../_content"
 	if _, err := os.Stat("go.dev/_content/events.yaml"); err == nil {
 		// Running in repo root.
-		dir = "go.dev"
+		dir = "go.dev/_content"
 	}
 
 	h, err := NewHandler(dir)
@@ -52,17 +56,63 @@
 }
 
 func NewHandler(dir string) (http.Handler, error) {
-	godev, err := site.Load(dir)
-	if err != nil {
-		return nil, err
-	}
+	godev := web.NewSite(osfs.DirFS(dir))
+	godev.Funcs(template.FuncMap{
+		"newest":  newest,
+		"section": section,
+	})
 	mux := http.NewServeMux()
-	mux.Handle("/", addCSP(http.FileServer(godev)))
+	mux.Handle("/", addCSP(godev))
 	mux.Handle("/explore/", http.StripPrefix("/explore/", redirectHosts(discoveryHosts)))
 	mux.Handle("learn.go.dev/", http.HandlerFunc(redirectLearn))
 	return mux, nil
 }
 
+// newest returns the pages sorted newest first,
+// breaking ties by .linkTitle or else .title.
+func newest(pages []web.Page) []web.Page {
+	out := make([]web.Page, len(pages))
+	copy(out, pages)
+
+	sort.Slice(out, func(i, j int) bool {
+		pi := out[i]
+		pj := out[j]
+		di, _ := pi["date"].(time.Time)
+		dj, _ := pj["date"].(time.Time)
+		if !di.Equal(dj) {
+			return di.After(dj)
+		}
+		ti, _ := pi["linkTitle"].(string)
+		if ti == "" {
+			ti, _ = pi["title"].(string)
+		}
+		tj, _ := pj["linkTitle"].(string)
+		if tj == "" {
+			tj, _ = pj["title"].(string)
+		}
+		if ti != tj {
+			return ti < tj
+		}
+		return false
+	})
+	return out
+}
+
+// section returns the site section for the given Page,
+// defined as the first path element, or else an empty string.
+// For example if p's URL is /x/y/z then section is "x".
+func section(p web.Page) string {
+	u, _ := p["URL"].(string)
+	if !strings.HasPrefix(u, "/") {
+		return ""
+	}
+	i := strings.Index(u[1:], "/")
+	if i < 0 {
+		return ""
+	}
+	return u[:1+i+1]
+}
+
 func redirectLearn(w http.ResponseWriter, r *http.Request) {
 	http.Redirect(w, r, "https://go.dev/learn/"+strings.TrimPrefix(r.URL.Path, "/"), http.StatusMovedPermanently)
 }
diff --git a/go.dev/cmd/frontend/main_test.go b/go.dev/cmd/frontend/main_test.go
index 61c7d0f..05a60d8 100644
--- a/go.dev/cmd/frontend/main_test.go
+++ b/go.dev/cmd/frontend/main_test.go
@@ -10,8 +10,6 @@
 	"net/http/httptest"
 	"strings"
 	"testing"
-
-	"golang.org/x/website/go.dev/cmd/internal/site"
 )
 
 var testHosts = map[string]string{
@@ -79,11 +77,11 @@
 }{
 	{"/", []string{"Go is an open source programming language supported by Google"}},
 	{"/solutions/", []string{"Using Go at Google"}},
-	{"/solutions/dropbox/", []string{"About Dropbox"}},
+	{"/solutions/dropbox", []string{"About Dropbox"}},
 }
 
 func TestSite(t *testing.T) {
-	godev, err := site.Load("../..")
+	h, err := NewHandler("../../_content")
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -93,7 +91,7 @@
 			r := httptest.NewRequest("GET", tt.target, nil)
 			resp := httptest.NewRecorder()
 			resp.Body = new(bytes.Buffer)
-			http.FileServer(godev).ServeHTTP(resp, r)
+			h.ServeHTTP(resp, r)
 			if resp.Code != 200 {
 				t.Fatalf("Code = %d, want 200", resp.Code)
 			}
diff --git a/go.dev/cmd/frontend/server_test.go b/go.dev/cmd/frontend/server_test.go
index 831838a..4b0d4ec 100644
--- a/go.dev/cmd/frontend/server_test.go
+++ b/go.dev/cmd/frontend/server_test.go
@@ -11,7 +11,7 @@
 )
 
 func TestWeb(t *testing.T) {
-	h, err := NewHandler("../..")
+	h, err := NewHandler("../../_content")
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/go.dev/cmd/frontend/testdata/godev.txt b/go.dev/cmd/frontend/testdata/godev.txt
index 950b385..3785608 100644
--- a/go.dev/cmd/frontend/testdata/godev.txt
+++ b/go.dev/cmd/frontend/testdata/godev.txt
@@ -3,3 +3,19 @@
 
 GET https://go.dev/solutions/google/
 body ~ it\s+has\s+powered\s+many\s+projects\s+at\s+Google.
+
+GET /solutions/chrome
+redirect == /solutions/google/chrome
+
+GET /solutions/coredata
+redirect == /solutions/google/coredata
+
+GET /solutions/firebase
+redirect == /solutions/google/firebase
+
+GET /solutions/sitereliability
+redirect == /solutions/google/sitereliability
+
+GET /solutions/americanexpress
+body contains <div class="Article-date">19 December 2019</div>
+
diff --git a/go.dev/cmd/internal/site/md.go b/go.dev/cmd/internal/site/md.go
deleted file mode 100644
index a90f57f..0000000
--- a/go.dev/cmd/internal/site/md.go
+++ /dev/null
@@ -1,79 +0,0 @@
-// Copyright 2021 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package site
-
-import (
-	"bytes"
-	"regexp"
-	"strings"
-
-	"github.com/yuin/goldmark"
-	"github.com/yuin/goldmark/ast"
-	"github.com/yuin/goldmark/extension"
-	"github.com/yuin/goldmark/parser"
-	"github.com/yuin/goldmark/renderer/html"
-	"github.com/yuin/goldmark/text"
-	"github.com/yuin/goldmark/util"
-	"golang.org/x/website/internal/backport/html/template"
-)
-
-// markdownToHTML converts markdown to HTML using the renderer and settings that Hugo uses.
-func markdownToHTML(markdown string) (template.HTML, error) {
-	// parser.WithHeadingAttribute allows custom ids on headings.
-	// html.WithUnsafe allows use of raw HTML, which we need for tables.
-	md := goldmark.New(
-		goldmark.WithParserOptions(
-			parser.WithHeadingAttribute(),
-			parser.WithAutoHeadingID(),
-			parser.WithASTTransformers(util.Prioritized(mdTransformFunc(mdLink), 1)),
-		),
-		goldmark.WithRendererOptions(html.WithUnsafe()),
-		goldmark.WithExtensions(
-			extension.NewTypographer(),
-			extension.NewLinkify(
-				extension.WithLinkifyAllowedProtocols([][]byte{[]byte("http"), []byte("https")}),
-				extension.WithLinkifyEmailRegexp(regexp.MustCompile(`[^\x00-\x{10FFFF}]`)), // impossible
-			),
-		),
-	)
-	var buf bytes.Buffer
-	if err := md.Convert([]byte(markdown), &buf); err != nil {
-		return "", err
-	}
-	return template.HTML(buf.Bytes()), nil
-}
-
-// mdTransformFunc is a func implementing parser.ASTTransformer.
-type mdTransformFunc func(*ast.Document, text.Reader, parser.Context)
-
-func (f mdTransformFunc) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
-	f(node, reader, pc)
-}
-
-// mdLink walks doc, adding rel=noreferrer target=_blank to non-relative links.
-func mdLink(doc *ast.Document, _ text.Reader, _ parser.Context) {
-	mdLinkWalk(doc)
-}
-
-func mdLinkWalk(n ast.Node) {
-	switch n := n.(type) {
-	case *ast.Link:
-		dest := string(n.Destination)
-		if strings.HasPrefix(dest, "https://") || strings.HasPrefix(dest, "http://") {
-			n.SetAttributeString("rel", []byte("noreferrer"))
-			n.SetAttributeString("target", []byte("_blank"))
-		}
-		return
-	case *ast.AutoLink:
-		// All autolinks are non-relative.
-		n.SetAttributeString("rel", []byte("noreferrer"))
-		n.SetAttributeString("target", []byte("_blank"))
-		return
-	}
-
-	for child := n.FirstChild(); child != nil; child = child.NextSibling() {
-		mdLinkWalk(child)
-	}
-}
diff --git a/go.dev/cmd/internal/site/page.go b/go.dev/cmd/internal/site/page.go
deleted file mode 100644
index dea5439..0000000
--- a/go.dev/cmd/internal/site/page.go
+++ /dev/null
@@ -1,221 +0,0 @@
-// Copyright 2021 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package site
-
-import (
-	"bytes"
-	"fmt"
-	"io/ioutil"
-	"os"
-	"path"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"golang.org/x/website/internal/tmplfunc"
-	"gopkg.in/yaml.v3"
-)
-
-// A page is a single web page.
-// It corresponds to some .md file in the content tree.
-type page struct {
-	id     string // page ID (url path excluding site.BaseURL and trailing slash)
-	file   string // .md file for page
-	data   []byte // page data (markdown)
-	html   []byte // rendered page (HTML)
-	params tPage  // parameters passed to templates
-}
-
-// A tPage is the template form of the page, the data passed to rendering templates.
-type tPage map[string]interface{}
-
-// loadPage loads the site's page from the given file.
-// It returns the page but also adds the page to site.pages and site.pagesByID.
-func (site *Site) loadPage(file string) (*page, error) {
-	file = filepath.ToSlash(file)
-	id := strings.TrimPrefix(file, "_content/")
-	if id == "index.md" {
-		id = ""
-	} else if strings.HasSuffix(id, "/index.md") {
-		id = strings.TrimSuffix(id, "/index.md")
-	} else {
-		id = strings.TrimSuffix(id, ".md")
-	}
-
-	p := site.newPage(id)
-	p.file = file
-
-	urlPath := "/" + p.id
-	if strings.HasSuffix(p.file, "/index.md") && p.id != "" {
-		urlPath += "/"
-	}
-
-	// Load content, including leading yaml.
-	data, err := ioutil.ReadFile(site.file(file))
-	if err != nil {
-		return nil, err
-	}
-	if bytes.HasPrefix(data, []byte("---\n")) {
-		i := bytes.Index(data, []byte("\n---\n"))
-		if i < 0 {
-			if bytes.HasSuffix(data, []byte("\n---")) {
-				i = len(data) - 4
-			}
-		}
-		if i >= 0 {
-			meta := data[4 : i+1]
-			err := yaml.Unmarshal(meta, p.params)
-			if err != nil {
-				return nil, fmt.Errorf("load %s: %v", file, err)
-			}
-
-			// Drop YAML but insert the right number of newlines to keep line numbers correct in template errors.
-			nl := 0
-			for _, c := range data[:i+4] {
-				if c == '\n' {
-					nl++
-				}
-			}
-			i += 4
-			for ; nl > 0; nl-- {
-				i--
-				data[i] = '\n'
-			}
-			data = data[i:]
-		}
-	}
-	p.data = data
-
-	// Default linkTitle to title
-	if _, ok := p.params["linkTitle"]; !ok {
-		p.params["linkTitle"] = p.params["title"]
-	}
-
-	// Parse date to Date.
-	// Note that YAML parser may have done it for us (!)
-	p.params["Date"] = time.Time{}
-	if d, ok := p.params["date"].(string); ok {
-		t, err := parseDate(d)
-		if err != nil {
-			return nil, err
-		}
-		p.params["Date"] = t
-	} else if d, ok := p.params["date"].(time.Time); ok {
-		p.params["Date"] = d
-	}
-
-	// Path, Dir, URL
-	p.params["Path"] = urlPath
-	p.params["Dir"] = path.Dir(urlPath)
-	p.params["URL"] = strings.TrimRight(site.URL, "/") + urlPath
-
-	// Parent
-	if p.id != "" {
-		parent := path.Dir("/" + p.id)
-		if parent != "/" {
-			parent += "/"
-		}
-		p.params["Parent"] = parent
-	}
-
-	// Section
-	section := "/"
-	if i := strings.Index(p.id, "/"); i >= 0 {
-		section = "/" + p.id[:i+1]
-	} else if strings.HasSuffix(p.file, "/index.md") {
-		section = "/" + p.id + "/"
-	}
-	p.params["Section"] = section
-
-	return p, nil
-}
-
-// renderHTML renders the HTML for the page, leaving it in p.html.
-func (site *Site) renderHTML(p *page) error {
-	// Load base template.
-	base, err := ioutil.ReadFile(site.file("_content/site.tmpl"))
-	if err != nil {
-		return err
-	}
-	t := site.clone().New("_content/site.tmpl")
-	if err := tmplfunc.Parse(t, string(base)); err != nil {
-		return err
-	}
-
-	// Load page-specific layout template.
-	layout, _ := p.params["layout"].(string)
-	if layout == "" {
-		// Determine nearest default.tmpl in current or parent directory.
-		// In the case of index.md, the current directory's default.tmpl
-		// is ignored, under the assumption that it's for the other pages
-		// in the directory but not the index page.
-		dir := path.Dir(p.file)
-		rel := ""
-		if strings.HasSuffix(p.file, "/index.md") && p.id != "" {
-			dir = path.Dir(dir)
-			rel = "../"
-		}
-		for {
-			name := site.file(path.Join(dir, "default.tmpl"))
-			if _, err := os.Stat(name); err == nil {
-				layout = rel + "default"
-				break
-			}
-			if dir == "." {
-				break
-			}
-			dir = path.Dir(dir)
-			rel += "../"
-		}
-		if layout == "" {
-			return fmt.Errorf("%s: cannot find default template", p.id)
-		}
-	}
-
-	layout = path.Join(path.Dir(p.file), layout+".tmpl")
-	data, err := ioutil.ReadFile(site.file(layout))
-	if err != nil {
-		return err
-	}
-	if err := tmplfunc.Parse(t.New(layout), string(data)); err != nil {
-		return err
-	}
-
-	// Load actual Markdown content (also a template).
-	tf := t.New(p.file)
-	if err := tmplfunc.Parse(tf, string(p.data)); err != nil {
-		return err
-	}
-	var buf bytes.Buffer
-	if err := tf.Execute(&buf, p.params); err != nil {
-		return err
-	}
-	html, err := markdownToHTML(buf.String())
-	if err != nil {
-		return err
-	}
-	p.params["Content"] = html
-
-	buf.Reset()
-	if err := t.Execute(&buf, p.params); err != nil {
-		return err
-	}
-	p.html = buf.Bytes()
-	return nil
-}
-
-var dateFormats = []string{
-	"2006-01-02",
-	time.RFC3339,
-}
-
-func parseDate(d string) (time.Time, error) {
-	for _, f := range dateFormats {
-		if tt, err := time.Parse(f, d); err == nil {
-			return tt, nil
-		}
-	}
-	return time.Time{}, fmt.Errorf("invalid date: %s", d)
-}
diff --git a/go.dev/cmd/internal/site/site.go b/go.dev/cmd/internal/site/site.go
deleted file mode 100644
index 7ac0a19..0000000
--- a/go.dev/cmd/internal/site/site.go
+++ /dev/null
@@ -1,251 +0,0 @@
-// Copyright 2021 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// Package site implements generation of content for serving from go.dev.
-// It is meant to support a transition from being a Hugo-based web site
-// to being a site compatible with x/website.
-package site
-
-import (
-	"bytes"
-	"fmt"
-	"io"
-	"io/ioutil"
-	"net/http"
-	"os"
-	"path"
-	"path/filepath"
-	"sort"
-	"strings"
-	"time"
-
-	"golang.org/x/website/internal/backport/html/template"
-	"gopkg.in/yaml.v3"
-)
-
-// A Site holds metadata about the entire site.
-type Site struct {
-	URL   string
-	Title string
-
-	pagesByID map[string]*page
-	dir       string
-	base      *template.Template
-}
-
-// Load loads and returns the site in the directory rooted at dir.
-func Load(dir string) (*Site, error) {
-	dir, err := filepath.Abs(dir)
-	if err != nil {
-		return nil, err
-	}
-	site := &Site{
-		dir:       dir,
-		pagesByID: make(map[string]*page),
-	}
-	if err := site.initTemplate(); err != nil {
-		return nil, err
-	}
-
-	// Read site config.
-	data, err := ioutil.ReadFile(site.file("_content/site.yaml"))
-	if err != nil {
-		return nil, err
-	}
-	if err := yaml.Unmarshal(data, &site); err != nil {
-		return nil, fmt.Errorf("parsing _content/site.yaml: %v", err)
-	}
-
-	// Load site pages from md files.
-	err = filepath.Walk(site.file("_content"), func(name string, info os.FileInfo, err error) error {
-		if err != nil {
-			return err
-		}
-		if strings.HasSuffix(name, ".md") {
-			_, err := site.loadPage(name[len(site.file("."))+1:])
-			return err
-		}
-		return nil
-	})
-	if err != nil {
-		return nil, fmt.Errorf("loading pages: %v", err)
-	}
-
-	// Now that all pages are loaded and set up, can render all.
-	// (Pages can refer to other pages.)
-	for _, p := range site.pagesByID {
-		if err := site.renderHTML(p); err != nil {
-			return nil, err
-		}
-	}
-
-	return site, nil
-}
-
-// file returns the full path to the named file within the site.
-func (site *Site) file(name string) string { return filepath.Join(site.dir, name) }
-
-// newPage returns a new page belonging to site.
-func (site *Site) newPage(short string) *page {
-	p := &page{
-		id:     short,
-		params: make(tPage),
-	}
-	site.pagesByID[p.id] = p
-	return p
-}
-
-// data parses the named yaml file and returns its structured data.
-func (site *Site) data(name string) (interface{}, error) {
-	data, err := ioutil.ReadFile(site.file("_content/" + name + ".yaml"))
-	if err != nil {
-		return nil, err
-	}
-	var d interface{}
-	if err := yaml.Unmarshal(data, &d); err != nil {
-		return nil, err
-	}
-	return d, nil
-}
-
-// pageByID returns the page with a given path.
-func (site *Site) pageByPath(path string) (tPage, error) {
-	p := site.pagesByID[strings.Trim(path, "/")]
-	if p == nil {
-		return nil, fmt.Errorf("no such page with path %q", path)
-	}
-	return p.params, nil
-}
-
-// pagesGlob returns the pages with IDs matching glob.
-func (site *Site) pagesGlob(glob string) ([]tPage, error) {
-	_, err := path.Match(glob, "")
-	if err != nil {
-		return nil, err
-	}
-	glob = strings.Trim(glob, "/")
-	var out []tPage
-	for _, p := range site.pagesByID {
-		if ok, _ := path.Match(glob, p.id); ok {
-			out = append(out, p.params)
-		}
-	}
-
-	sort.Slice(out, func(i, j int) bool {
-		return out[i]["Path"].(string) < out[j]["Path"].(string)
-	})
-	return out, nil
-}
-
-// newest returns the pages sorted newest first,
-// breaking ties by .linkTitle or else .title.
-func newest(pages []tPage) []tPage {
-	out := make([]tPage, len(pages))
-	copy(out, pages)
-
-	sort.Slice(out, func(i, j int) bool {
-		pi := out[i]
-		pj := out[j]
-		di, _ := pi["Date"].(time.Time)
-		dj, _ := pj["Date"].(time.Time)
-		if !di.Equal(dj) {
-			return di.After(dj)
-		}
-		ti, _ := pi["linkTitle"].(string)
-		tj, _ := pj["linkTitle"].(string)
-		if ti != tj {
-			return ti < tj
-		}
-		return false
-	})
-	return out
-}
-
-// Open returns the content to serve at the given path.
-// This function makes Site an http.FileServer, for easy HTTP serving.
-func (site *Site) Open(name string) (http.File, error) {
-	name = strings.TrimPrefix(name, "/")
-	switch ext := path.Ext(name); ext {
-	case ".css", ".jpeg", ".jpg", ".js", ".png", ".svg", ".txt":
-		if f, err := os.Open(site.file("_content/" + name)); err == nil {
-			return f, nil
-		}
-
-	case ".html":
-		id := strings.TrimSuffix(name, "/index.html")
-		if name == "index.html" {
-			id = ""
-		}
-		if p := site.pagesByID[id]; p != nil {
-			if redir, ok := p.params["redirect"].(string); ok {
-				s := fmt.Sprintf(redirectFmt, redir)
-				return &httpFile{strings.NewReader(s), int64(len(s))}, nil
-			}
-			return &httpFile{bytes.NewReader(p.html), int64(len(p.html))}, nil
-		}
-	}
-
-	if !strings.HasSuffix(name, ".html") {
-		if f, err := site.Open(name + "/index.html"); err == nil {
-			size, err := f.Seek(0, io.SeekEnd)
-			f.Close()
-			if err == nil {
-				return &httpDir{httpFileInfo{"index.html", size, false}, 0}, nil
-			}
-		}
-	}
-
-	return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrNotExist}
-}
-
-type httpFile struct {
-	io.ReadSeeker
-	size int64
-}
-
-func (*httpFile) Close() error                 { return nil }
-func (f *httpFile) Stat() (os.FileInfo, error) { return &httpFileInfo{".", f.size, false}, nil }
-func (*httpFile) Readdir(count int) ([]os.FileInfo, error) {
-	return nil, fmt.Errorf("readdir not available")
-}
-
-const redirectFmt = `<!DOCTYPE html><html><head><title>%s</title><link rel="canonical" href="%[1]s"/><meta name="robots" content="noindex"><meta charset="utf-8" /><meta http-equiv="refresh" content="0; url=%[1]s" /></head></html>`
-
-type httpDir struct {
-	info httpFileInfo
-	off  int // 0 or 1
-}
-
-func (*httpDir) Close() error                   { return nil }
-func (*httpDir) Read([]byte) (int, error)       { return 0, fmt.Errorf("read not available") }
-func (*httpDir) Seek(int64, int) (int64, error) { return 0, fmt.Errorf("seek not available") }
-func (*httpDir) Stat() (os.FileInfo, error)     { return &httpFileInfo{".", 0, true}, nil }
-func (d *httpDir) Readdir(count int) ([]os.FileInfo, error) {
-	if count == 0 {
-		return nil, nil
-	}
-	if d.off > 0 {
-		return nil, io.EOF
-	}
-	d.off = 1
-	return []os.FileInfo{&d.info}, nil
-}
-
-type httpFileInfo struct {
-	name string
-	size int64
-	dir  bool
-}
-
-func (info *httpFileInfo) Name() string { return info.name }
-func (info *httpFileInfo) Size() int64  { return info.size }
-func (info *httpFileInfo) Mode() os.FileMode {
-	if info.dir {
-		return os.ModeDir | 0555
-	}
-	return 0444
-}
-func (info *httpFileInfo) ModTime() time.Time { return time.Time{} }
-func (info *httpFileInfo) IsDir() bool        { return info.dir }
-func (info *httpFileInfo) Sys() interface{}   { return nil }
diff --git a/go.dev/cmd/internal/site/tmpl.go b/go.dev/cmd/internal/site/tmpl.go
deleted file mode 100644
index 848cdae..0000000
--- a/go.dev/cmd/internal/site/tmpl.go
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright 2021 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package site
-
-import (
-	"fmt"
-	"reflect"
-	"strings"
-
-	"golang.org/x/website/internal/backport/html/template"
-	"golang.org/x/website/internal/tmplfunc"
-	"gopkg.in/yaml.v3"
-)
-
-func (site *Site) initTemplate() error {
-	funcs := template.FuncMap{
-		"add":      func(i, j int) int { return i + j },
-		"data":     site.data,
-		"dict":     dict,
-		"first":    first,
-		"markdown": markdown,
-		"newest":   newest,
-		"page":     site.pageByPath,
-		"pages":    site.pagesGlob,
-		"replace":  replace,
-		"rawhtml":  rawhtml,
-		"trim":     strings.Trim,
-		"yaml":     yamlFn,
-	}
-
-	site.base = template.New("site").Funcs(funcs)
-	if err := tmplfunc.ParseGlob(site.base, site.file("_templates/*.tmpl")); err != nil && !strings.Contains(err.Error(), "pattern matches no files") {
-		return err
-	}
-	return nil
-}
-
-func (site *Site) clone() *template.Template {
-	t := template.Must(site.base.Clone())
-	if err := tmplfunc.Funcs(t); err != nil {
-		panic(err)
-	}
-	return t
-}
-
-func toString(x interface{}) string {
-	switch x := x.(type) {
-	case string:
-		return x
-	case template.HTML:
-		return string(x)
-	case nil:
-		return ""
-	default:
-		panic(fmt.Sprintf("cannot toString %T", x))
-	}
-}
-
-func first(n int, list reflect.Value) reflect.Value {
-	if !list.IsValid() {
-		return list
-	}
-	if list.Kind() == reflect.Interface {
-		if list.IsNil() {
-			return list
-		}
-		list = list.Elem()
-	}
-
-	if list.Len() < n {
-		return list
-	}
-	return list.Slice(0, n)
-}
-
-func dict(args ...interface{}) map[string]interface{} {
-	m := make(map[string]interface{})
-	for i := 0; i < len(args); i += 2 {
-		m[args[i].(string)] = args[i+1]
-	}
-	m["Identifier"] = "IDENT"
-	return m
-}
-
-func list(args ...interface{}) []interface{} {
-	return args
-}
-
-// markdown is the function provided to templates.
-func markdown(data interface{}) (template.HTML, error) {
-	h, err := markdownToHTML(toString(data))
-	if err != nil {
-		return "", err
-	}
-	s := strings.TrimSpace(string(h))
-	if strings.HasPrefix(s, "<p>") && strings.HasSuffix(s, "</p>") && strings.Count(s, "<p>") == 1 {
-		h = template.HTML(strings.TrimSpace(s[len("<p>") : len(s)-len("</p>")]))
-	}
-	return h, nil
-}
-
-func replace(input, x, y interface{}) string {
-	return strings.ReplaceAll(toString(input), toString(x), toString(y))
-}
-
-func rawhtml(s interface{}) template.HTML {
-	return template.HTML(toString(s))
-}
-
-func yamlFn(s string) (interface{}, error) {
-	var d interface{}
-	if err := yaml.Unmarshal([]byte(s), &d); err != nil {
-		return nil, err
-	}
-	return d, nil
-}
diff --git a/go.dev/testdata/golden/solutions/chrome/index.html b/go.dev/testdata/golden/solutions/chrome/index.html
deleted file mode 100644
index 6cc9549..0000000
--- a/go.dev/testdata/golden/solutions/chrome/index.html
+++ /dev/null
@@ -1 +0,0 @@
-<!DOCTYPE html><html><head><title>/solutions/google/chrome</title><link rel="canonical" href="/solutions/google/chrome"/><meta name="robots" content="noindex"><meta charset="utf-8" /><meta http-equiv="refresh" content="0; url=/solutions/google/chrome" /></head></html>
diff --git a/go.dev/testdata/golden/solutions/coredata/index.html b/go.dev/testdata/golden/solutions/coredata/index.html
deleted file mode 100644
index 5e2d4bd..0000000
--- a/go.dev/testdata/golden/solutions/coredata/index.html
+++ /dev/null
@@ -1 +0,0 @@
-<!DOCTYPE html><html><head><title>/solutions/google/coredata</title><link rel="canonical" href="/solutions/google/coredata"/><meta name="robots" content="noindex"><meta charset="utf-8" /><meta http-equiv="refresh" content="0; url=/solutions/google/coredata" /></head></html>
diff --git a/go.dev/testdata/golden/solutions/firebase/index.html b/go.dev/testdata/golden/solutions/firebase/index.html
deleted file mode 100644
index 8cd7d16..0000000
--- a/go.dev/testdata/golden/solutions/firebase/index.html
+++ /dev/null
@@ -1 +0,0 @@
-<!DOCTYPE html><html><head><title>/solutions/google/firebase</title><link rel="canonical" href="/solutions/google/firebase"/><meta name="robots" content="noindex"><meta charset="utf-8" /><meta http-equiv="refresh" content="0; url=/solutions/google/firebase" /></head></html>
diff --git a/go.dev/testdata/golden/solutions/sitereliability/index.html b/go.dev/testdata/golden/solutions/sitereliability/index.html
deleted file mode 100644
index 8c17ec9..0000000
--- a/go.dev/testdata/golden/solutions/sitereliability/index.html
+++ /dev/null
@@ -1 +0,0 @@
-<!DOCTYPE html><html><head><title>/solutions/google/sitereliability</title><link rel="canonical" href="/solutions/google/sitereliability"/><meta name="robots" content="noindex"><meta charset="utf-8" /><meta http-equiv="refresh" content="0; url=/solutions/google/sitereliability" /></head></html>
diff --git a/internal/codewalk/codewalk.go b/internal/codewalk/codewalk.go
index cefe691..0824347 100644
--- a/internal/codewalk/codewalk.go
+++ b/internal/codewalk/codewalk.go
@@ -81,10 +81,10 @@
 	}
 
 	s.site.ServePage(w, r, web.Page{
-		Title:    "Codewalk: " + cw.Title,
-		TabTitle: cw.Title,
-		Template: "codewalk.html",
-		Data:     cw,
+		"title":    "Codewalk: " + cw.Title,
+		"tabTitle": cw.Title,
+		"layout":   "codewalk",
+		"codewalk": cw,
 	})
 }
 
@@ -234,9 +234,9 @@
 	}
 
 	s.site.ServePage(w, r, web.Page{
-		Title:    "Codewalks",
-		Template: "codewalkdir.html",
-		Data:     v,
+		"title":  "Codewalks",
+		"layout": "codewalkdir",
+		"dirs":   v,
 	})
 }
 
diff --git a/internal/dl/server.go b/internal/dl/server.go
index dfb8b03..a663ac8 100644
--- a/internal/dl/server.go
+++ b/internal/dl/server.go
@@ -83,9 +83,9 @@
 	}
 
 	h.site.ServePage(w, r, web.Page{
-		Title:    "Downloads",
-		Template: "dl.html",
-		Data:     d,
+		"title":  "Downloads",
+		"layout": "dl",
+		"dl":     d,
 	})
 }
 
diff --git a/internal/pkgdoc/doc.go b/internal/pkgdoc/doc.go
index 0ad9abc..81fe1fb 100644
--- a/internal/pkgdoc/doc.go
+++ b/internal/pkgdoc/doc.go
@@ -34,16 +34,20 @@
 )
 
 type docs struct {
-	fs   fs.FS
-	api  api.DB
-	site *web.Site
-	root *Dir
+	fs       fs.FS
+	api      api.DB
+	site     *web.Site
+	root     *Dir
+	forceOld func(*http.Request) bool
 }
 
 // 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) {
+// If forceOld is not nil and returns true for a given request,
+// NewServer will serve docs itself instead of redirecting to pkg.go.dev
+// (forcing the ?m=old behavior).
+func NewServer(fsys fs.FS, site *web.Site, forceOld func(*http.Request) bool) (http.Handler, error) {
 	apiDB, err := api.Load(fsys)
 	if err != nil {
 		return nil, err
@@ -57,10 +61,11 @@
 		Dirs: dirs,
 	}
 	docs := &docs{
-		fs:   fsys,
-		api:  apiDB,
-		site: site,
-		root: root,
+		fs:       fsys,
+		api:      apiDB,
+		site:     site,
+		root:     root,
+		forceOld: forceOld,
 	}
 	return docs, nil
 }
@@ -68,8 +73,6 @@
 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
@@ -90,10 +93,6 @@
 	DirFlat bool       // if set, show directory in a flat (non-indented) manner
 }
 
-func (p *Page) SetWebPage(w *web.Page) {
-	p.Web = w
-}
-
 type mode uint
 
 const (
@@ -433,7 +432,7 @@
 	// 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 mode&modeOld == 0 && (d.forceOld == nil || !d.forceOld(r)) {
 		if relpath == "" {
 			relpath = "std"
 		}
@@ -500,16 +499,16 @@
 		tabtitle = "Commands"
 	}
 
-	name := "package.html"
+	layout := "pkg"
 	if info.Dirname == "src" {
-		name = "packageroot.html"
+		layout = "pkgroot"
 	}
 	d.site.ServePage(w, r, web.Page{
-		Title:    title,
-		TabTitle: tabtitle,
-		Subtitle: subtitle,
-		Template: name,
-		Data:     info,
+		"title":    title,
+		"tabTitle": tabtitle,
+		"subtitle": subtitle,
+		"layout":   layout,
+		"pkg":      info,
 	})
 }
 
diff --git a/internal/pkgdoc/doc_test.go b/internal/pkgdoc/doc_test.go
index ad1eaa1..d5fa102 100644
--- a/internal/pkgdoc/doc_test.go
+++ b/internal/pkgdoc/doc_test.go
@@ -24,11 +24,8 @@
 // ` + packageComment + `
 package main`)},
 	}
-	site, err := web.NewSite(fs)
-	if err != nil {
-		t.Fatal(err)
-	}
-	h, err := NewServer(fs, site)
+	site := web.NewSite(fs)
+	h, err := NewServer(fs, site, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -66,11 +63,8 @@
 //line foo.go:100`)}, // No newline at end to check corner cases.
 	}
 
-	site, err := web.NewSite(fs)
-	if err != nil {
-		t.Fatal(err)
-	}
-	h, err := NewServer(fs, site)
+	site := web.NewSite(fs)
+	h, err := NewServer(fs, site, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/pkgdoc/html_test.go b/internal/pkgdoc/html_test.go
index 3b02145..af540ed 100644
--- a/internal/pkgdoc/html_test.go
+++ b/internal/pkgdoc/html_test.go
@@ -13,7 +13,6 @@
 	"testing"
 
 	"golang.org/x/website/internal/backport/html/template"
-	"golang.org/x/website/internal/web"
 )
 
 func TestSrcPosLink(t *testing.T) {
@@ -164,7 +163,6 @@
 }
 
 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 {
@@ -174,11 +172,6 @@
 	pi := &Page{
 		fset: fset,
 	}
-	pg := &web.Page{
-		Data: pi,
-		Site: site,
-	}
-	pi.SetWebPage(pg)
 	sep := ""
 	for _, decl := range af.Decls {
 		buf.WriteString(sep)
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
index 2584b36..799e11b 100644
--- a/internal/proxy/proxy.go
+++ b/internal/proxy/proxy.go
@@ -16,8 +16,6 @@
 	"log"
 	"net/http"
 	"time"
-
-	"golang.org/x/website/internal/web"
 )
 
 const playgroundURL = "https://play.golang.org"
@@ -40,9 +38,13 @@
 const expires = 7 * 24 * time.Hour // 1 week
 var cacheControlHeader = fmt.Sprintf("public, max-age=%d", int(expires.Seconds()))
 
-func RegisterHandlers(mux *http.ServeMux) {
+// RegisterHandlers registers handlers
+// for golang.org/compile and golang.org/share on mux.
+// If disallow is non-nil, then the share handler disallows requests
+// for which disallowShare returns true.
+func RegisterHandlers(mux *http.ServeMux, disallowShare func(*http.Request) bool) {
 	mux.HandleFunc("golang.org/compile", compile)
-	mux.HandleFunc("golang.org/share", share)
+	mux.HandleFunc("golang.org/share", share(disallowShare))
 }
 
 func compile(w http.ResponseWriter, r *http.Request) {
@@ -122,31 +124,33 @@
 	return buf.String()
 }
 
-func share(w http.ResponseWriter, r *http.Request) {
-	if web.GoogleCN(r) {
-		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-		return
-	}
-
-	// HACK(cbro): use a simple proxy rather than httputil.ReverseProxy because of Issue #28168.
-	// TODO: investigate using ReverseProxy with a Director, unsetting whatever's necessary to make that work.
-	req, _ := http.NewRequest("POST", playgroundURL+"/share", r.Body)
-	req.Header.Set("Content-Type", r.Header.Get("Content-Type"))
-	req = req.WithContext(r.Context())
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		log.Printf("ERROR share error: %v", err)
-		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
-		return
-	}
-	copyHeader := func(k string) {
-		if v := resp.Header.Get(k); v != "" {
-			w.Header().Set(k, v)
+func share(disallow func(*http.Request) bool) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if disallow != nil && disallow(r) {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
 		}
+
+		// HACK(cbro): use a simple proxy rather than httputil.ReverseProxy because of Issue #28168.
+		// TODO: investigate using ReverseProxy with a Director, unsetting whatever's necessary to make that work.
+		req, _ := http.NewRequest("POST", playgroundURL+"/share", r.Body)
+		req.Header.Set("Content-Type", r.Header.Get("Content-Type"))
+		req = req.WithContext(r.Context())
+		resp, err := http.DefaultClient.Do(req)
+		if err != nil {
+			log.Printf("ERROR share error: %v", err)
+			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+			return
+		}
+		copyHeader := func(k string) {
+			if v := resp.Header.Get(k); v != "" {
+				w.Header().Set(k, v)
+			}
+		}
+		copyHeader("Content-Type")
+		copyHeader("Content-Length")
+		defer resp.Body.Close()
+		w.WriteHeader(resp.StatusCode)
+		io.Copy(w, resp.Body)
 	}
-	copyHeader("Content-Type")
-	copyHeader("Content-Length")
-	defer resp.Body.Close()
-	w.WriteHeader(resp.StatusCode)
-	io.Copy(w, resp.Body)
 }
diff --git a/internal/web/docfuncs.go b/internal/web/code.go
similarity index 90%
rename from internal/web/docfuncs.go
rename to internal/web/code.go
index 6494cd7..3da494e 100644
--- a/internal/web/docfuncs.go
+++ b/internal/web/code.go
@@ -8,32 +8,21 @@
 	"bytes"
 	"fmt"
 	"log"
-	"path"
 	"regexp"
 	"strings"
 
 	"golang.org/x/website/internal/backport/html/template"
-	"golang.org/x/website/internal/backport/io/fs"
-	"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) {
+func (s *siteDir) code(file string, arg ...interface{}) (_ template.HTML, err error) {
 	defer func() {
 		if r := recover(); r != nil {
 			err = fmt.Errorf("%v", r)
 		}
 	}()
 
-	file = path.Clean(strings.TrimPrefix(file, "/"))
-	btext, err := fs.ReadFile(s.fs, file)
+	btext, err := s.readFile(s.dir, file)
 	if err != nil {
 		return "", err
 	}
diff --git a/internal/web/file.go b/internal/web/file.go
deleted file mode 100644
index 4b83422..0000000
--- a/internal/web/file.go
+++ /dev/null
@@ -1,133 +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.
-
-package web
-
-import (
-	"bytes"
-	"encoding/json"
-	"log"
-	"path"
-	"strings"
-
-	"golang.org/x/website/internal/backport/io/fs"
-)
-
-type file struct {
-	// Copied from document metadata directives
-	Title    string
-	Subtitle string
-	Template bool
-
-	Path     string // URL path
-	FilePath string // filesystem path relative to goroot
-	Body     []byte // content after metadata
-}
-
-type fileJSON struct {
-	Title    string
-	Subtitle string
-	Template bool
-	Redirect string // if set, redirect to other URL
-}
-
-// open returns the *file for a given relative path or nil if none exists.
-func open(fsys fs.FS, relpath string) *file {
-	// Strip trailing .html or .md or /; it all names the same page.
-	if strings.HasSuffix(relpath, ".html") {
-		relpath = strings.TrimSuffix(relpath, ".html")
-	} else if strings.HasSuffix(relpath, ".md") {
-		relpath = strings.TrimSuffix(relpath, ".md")
-	} else if strings.HasSuffix(relpath, "/") {
-		relpath = strings.TrimSuffix(relpath, "/")
-	}
-
-	// Check md before html to work correctly when x/website is layered atop Go 1.15 goroot during Go 1.15 tests.
-	// Want to find x/website's debugging_with_gdb.md not Go 1.15's debuging_with_gdb.html.
-	files := []string{relpath + ".md", relpath + ".html", path.Join(relpath, "index.md"), path.Join(relpath, "index.html")}
-	var filePath string
-	var b []byte
-	var err error
-	for _, filePath = range files {
-		b, err = fs.ReadFile(fsys, filePath)
-		if err == nil {
-			break
-		}
-	}
-
-	// Special case for memory model and spec, which live
-	// in the main Go repo's doc directory and therefore have not
-	// been renamed to their serving relpaths.
-	// We wait until the ReadFiles above have failed so that the
-	// code works if these are ever moved to /ref/spec and /ref/mem.
-	if err != nil && relpath == "ref/spec" {
-		return open(fsys, "doc/go_spec")
-	}
-	if err != nil && relpath == "ref/mem" {
-		return open(fsys, "doc/go_mem")
-	}
-
-	if err != nil {
-		return nil
-	}
-
-	// Special case for memory model and spec, continued.
-	switch relpath {
-	case "doc/go_spec":
-		relpath = "ref/spec"
-	case "doc/go_mem":
-		relpath = "ref/mem"
-	}
-
-	// If we read an index.md or index.html, the canonical relpath is without the index.md/index.html suffix.
-	if name := path.Base(filePath); name == "index.html" || name == "index.md" {
-		relpath, _ = path.Split(filePath)
-	}
-
-	js, body, err := parseFile(b)
-	if err != nil {
-		log.Printf("extractMetadata %s: %v", relpath, err)
-		return nil
-	}
-
-	f := &file{
-		Title:    js.Title,
-		Subtitle: js.Subtitle,
-		Template: js.Template,
-		Path:     "/" + relpath,
-		FilePath: filePath,
-		Body:     body,
-	}
-	if js.Redirect != "" {
-		// Allow (placeholder) documents to declare a redirect.
-		f.Path = js.Redirect
-	}
-
-	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/web/markdown.go b/internal/web/markdown.go
deleted file mode 100644
index 2cee319..0000000
--- a/internal/web/markdown.go
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright 2020 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"
-	"unicode/utf8"
-
-	"github.com/yuin/goldmark"
-	"github.com/yuin/goldmark/parser"
-	"github.com/yuin/goldmark/renderer/html"
-)
-
-// renderMarkdown converts a limited and opinionated flavor of Markdown (compliant with
-// CommonMark 0.29) to HTML for the purposes of Go websites.
-//
-// The Markdown source may contain raw HTML,
-// but Go templates have already been processed.
-func renderMarkdown(src []byte) ([]byte, error) {
-	src = replaceTabs(src)
-	// parser.WithHeadingAttribute allows custom ids on headings.
-	// html.WithUnsafe allows use of raw HTML, which we need for tables.
-	md := goldmark.New(
-		goldmark.WithParserOptions(parser.WithHeadingAttribute()),
-		goldmark.WithRendererOptions(html.WithUnsafe()))
-	var buf bytes.Buffer
-	if err := md.Convert(src, &buf); err != nil {
-		return nil, err
-	}
-	return buf.Bytes(), nil
-}
-
-// replaceTabs replaces all tabs in text with spaces up to a 4-space tab stop.
-//
-// In Markdown, tabs used for indentation are required to be interpreted as
-// 4-space tab stops. See https://spec.commonmark.org/0.30/#tabs.
-// Go also renders nicely and more compactly on the screen with 4-space
-// tab stops, while browsers often use 8-space.
-// And Goldmark crashes in some inputs that mix spaces and tabs.
-// Fix the crashes and make the Go code consistently compact across browsers,
-// all while staying Markdown-compatible, by expanding to 4-space tab stops.
-//
-// This function does not handle multi-codepoint Unicode sequences correctly.
-func replaceTabs(text []byte) []byte {
-	var buf bytes.Buffer
-	col := 0
-	for len(text) > 0 {
-		r, size := utf8.DecodeRune(text)
-		text = text[size:]
-
-		switch r {
-		case '\n':
-			buf.WriteByte('\n')
-			col = 0
-
-		case '\t':
-			buf.WriteByte(' ')
-			col++
-			for col%4 != 0 {
-				buf.WriteByte(' ')
-				col++
-			}
-
-		default:
-			buf.WriteRune(r)
-			col++
-		}
-	}
-	return buf.Bytes()
-}
diff --git a/internal/web/page.go b/internal/web/page.go
new file mode 100644
index 0000000..e6fccd7
--- /dev/null
+++ b/internal/web/page.go
@@ -0,0 +1,179 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package web
+
+import (
+	"bytes"
+	"encoding/json"
+	"path"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"golang.org/x/website/internal/backport/io/fs"
+	"gopkg.in/yaml.v3"
+)
+
+// A pageFile is a Page loaded from a file.
+// It corresponds to some .md or .html file in the content tree.
+type pageFile struct {
+	file string      // .md file for page
+	stat fs.FileInfo // stat for file when page was loaded
+	url  string      // url excluding site.BaseURL; always begins with slash
+	data []byte      // page data (markdown)
+	page Page        // parameters passed to templates
+
+	checked int64 // unix nano, atomically updated
+}
+
+// A Page is the data for a web page.
+// See the package doc comment for details.
+type Page map[string]interface{}
+
+func (site *Site) openPage(file string) (*pageFile, error) {
+	// Strip trailing .html or .md or /; it all names the same page.
+	if strings.HasSuffix(file, "/index.md") {
+		file = strings.TrimSuffix(file, "/index.md")
+	} else if strings.HasSuffix(file, "/index.html") {
+		file = strings.TrimSuffix(file, "/index.html")
+	} else if file == "index.md" || file == "index.html" {
+		file = "."
+	} else if strings.HasSuffix(file, "/") {
+		file = strings.TrimSuffix(file, "/")
+	} else if strings.HasSuffix(file, ".html") {
+		file = strings.TrimSuffix(file, ".html")
+	} else {
+		file = strings.TrimSuffix(file, ".md")
+	}
+
+	now := time.Now().UnixNano()
+	if cp, ok := site.cache.Load(file); ok {
+		// Have cache entry; only use if the underlying file hasn't changed.
+		// To avoid continuous stats, only check it has been 3s since the last one.
+		// TODO(rsc): Move caching into a more general layer and cache templates.
+		p := cp.(*pageFile)
+		if now-atomic.LoadInt64(&p.checked) >= 3e9 {
+			info, err := fs.Stat(site.fs, p.file)
+			if err == nil && info.ModTime().Equal(p.stat.ModTime()) && info.Size() == p.stat.Size() {
+				atomic.StoreInt64(&p.checked, now)
+				return p, nil
+			}
+		}
+	}
+
+	// Check md before html to work correctly when x/website is layered atop Go 1.15 goroot during Go 1.15 tests.
+	// Want to find x/website's debugging_with_gdb.md not Go 1.15's debuging_with_gdb.html.
+	files := []string{file + ".md", file + ".html", path.Join(file, "index.md"), path.Join(file, "index.html")}
+	var filePath string
+	var b []byte
+	var err error
+	var stat fs.FileInfo
+	for _, filePath = range files {
+		stat, err = fs.Stat(site.fs, filePath)
+		if err == nil {
+			b, err = site.readFile(".", filePath)
+			if err == nil {
+				break
+			}
+		}
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	// If we read an index.md or index.html, the canonical relpath is without the index.md/index.html suffix.
+	url := path.Join("/", file)
+	if name := path.Base(filePath); name == "index.html" || name == "index.md" {
+		url, _ = path.Split(path.Join("/", filePath))
+	}
+
+	params, body, err := parseMeta(b)
+	if err != nil {
+		return nil, err
+	}
+
+	p := &pageFile{
+		file:    filePath,
+		stat:    stat,
+		url:     url,
+		data:    body,
+		page:    params,
+		checked: now,
+	}
+
+	// File, FileData, URL
+	p.page["File"] = filePath
+	p.page["FileData"] = string(body)
+	p.page["URL"] = p.url
+
+	// User-specified redirect: overrides url but not URL.
+	if redir, _ := p.page["redirect"].(string); redir != "" {
+		p.url = redir
+	}
+
+	site.cache.Store(file, p)
+
+	return p, nil
+}
+
+var (
+	jsonStart = []byte("<!--{")
+	jsonEnd   = []byte("}-->")
+
+	yamlStart = []byte("---\n")
+	yamlEnd   = []byte("\n---\n")
+)
+
+// parseMeta extracts top-of-file metadata from the file contents b.
+// If there is no metadata, parseMeta returns Page{}, b, nil.
+// Otherwise, the metdata is extracted, and parseMeta returns
+// the metadata and the remainder of the file.
+// The end of the metadata is overwritten in b to preserve
+// the correct number of newlines so that the line numbers in tail
+// match the line numbers in b.
+//
+// A JSON metadata object is bracketed by <!--{...}-->.
+// A YAML metadata object is bracketed by "---\n" above and below the YAML.
+//
+// JSON is typically used in HTML; YAML is typically used in Markdown.
+func parseMeta(b []byte) (meta Page, tail []byte, err error) {
+	tail = b
+	meta = make(Page)
+	var end int
+	if bytes.HasPrefix(b, jsonStart) {
+		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
+		}
+		end += len(jsonEnd)
+		for k, v := range meta {
+			delete(meta, k)
+			meta[strings.ToLower(k)] = v
+		}
+	} else if bytes.HasPrefix(b, yamlStart) {
+		end = bytes.Index(b, yamlEnd)
+		if end < 0 {
+			return
+		}
+		b = b[len(yamlStart) : end+1] // drop ---\n but include final \n
+		if err = yaml.Unmarshal(b, &meta); err != nil {
+			return
+		}
+		end += len(yamlEnd)
+	}
+
+	// Put the right number of \n at the start of tail to preserve line numbers.
+	nl := bytes.Count(tail[:end], []byte("\n"))
+	for i := 0; i < nl; i++ {
+		end--
+		tail[end] = '\n'
+	}
+	tail = tail[end:]
+	return
+}
diff --git a/internal/web/pkg.go b/internal/web/pkg.go
new file mode 100644
index 0000000..37709d3
--- /dev/null
+++ b/internal/web/pkg.go
@@ -0,0 +1,82 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package web
+
+import (
+	"path"
+	"strings"
+	"unicode"
+)
+
+type pkgPath struct{}
+
+func (pkgPath) Base(a string) string            { return path.Base(a) }
+func (pkgPath) Clean(a string) string           { return path.Clean(a) }
+func (pkgPath) Dir(a string) string             { return path.Dir(a) }
+func (pkgPath) Ext(a string) string             { return path.Ext(a) }
+func (pkgPath) IsAbs(a string) bool             { return path.IsAbs(a) }
+func (pkgPath) Join(a ...string) string         { return path.Join(a...) }
+func (pkgPath) Match(a, b string) (bool, error) { return path.Match(a, b) }
+func (pkgPath) Split(a string) (string, string) { return path.Split(a) }
+
+type pkgStrings struct{}
+
+func (pkgStrings) Compare(a, b string) int                         { return strings.Compare(a, b) }
+func (pkgStrings) Contains(a, b string) bool                       { return strings.Contains(a, b) }
+func (pkgStrings) ContainsAny(a, b string) bool                    { return strings.ContainsAny(a, b) }
+func (pkgStrings) ContainsRune(a string, b rune) bool              { return strings.ContainsRune(a, b) }
+func (pkgStrings) Count(a, b string) int                           { return strings.Count(a, b) }
+func (pkgStrings) EqualFold(a, b string) bool                      { return strings.EqualFold(a, b) }
+func (pkgStrings) Fields(a string) []string                        { return strings.Fields(a) }
+func (pkgStrings) FieldsFunc(a string, b func(rune) bool) []string { return strings.FieldsFunc(a, b) }
+func (pkgStrings) HasPrefix(a, b string) bool                      { return strings.HasPrefix(a, b) }
+func (pkgStrings) HasSuffix(a, b string) bool                      { return strings.HasSuffix(a, b) }
+func (pkgStrings) Index(a, b string) int                           { return strings.Index(a, b) }
+func (pkgStrings) IndexAny(a, b string) int                        { return strings.IndexAny(a, b) }
+func (pkgStrings) IndexByte(a string, b byte) int                  { return strings.IndexByte(a, b) }
+func (pkgStrings) IndexFunc(a string, b func(rune) bool) int       { return strings.IndexFunc(a, b) }
+func (pkgStrings) IndexRune(a string, b rune) int                  { return strings.IndexRune(a, b) }
+func (pkgStrings) Join(a []string, b string) string                { return strings.Join(a, b) }
+func (pkgStrings) LastIndex(a, b string) int                       { return strings.LastIndex(a, b) }
+func (pkgStrings) LastIndexAny(a, b string) int                    { return strings.LastIndexAny(a, b) }
+func (pkgStrings) LastIndexByte(a string, b byte) int              { return strings.LastIndexByte(a, b) }
+func (pkgStrings) LastIndexFunc(a string, b func(rune) bool) int {
+	return strings.LastIndexFunc(a, b)
+}
+func (pkgStrings) Map(a func(rune) rune, b string) string    { return strings.Map(a, b) }
+func (pkgStrings) NewReader(a string) *strings.Reader        { return strings.NewReader(a) }
+func (pkgStrings) NewReplacer(a ...string) *strings.Replacer { return strings.NewReplacer(a...) }
+func (pkgStrings) Repeat(a string, b int) string             { return strings.Repeat(a, b) }
+func (pkgStrings) Replace(a, b, c string, d int) string      { return strings.Replace(a, b, c, d) }
+func (pkgStrings) ReplaceAll(a, b, c string) string          { return strings.ReplaceAll(a, b, c) }
+func (pkgStrings) Split(a, b string) []string                { return strings.Split(a, b) }
+func (pkgStrings) SplitAfter(a, b string) []string           { return strings.SplitAfter(a, b) }
+func (pkgStrings) SplitAfterN(a, b string, c int) []string   { return strings.SplitAfterN(a, b, c) }
+func (pkgStrings) SplitN(a, b string, c int) []string        { return strings.SplitN(a, b, c) }
+func (pkgStrings) Title(a string) string                     { return strings.Title(a) }
+func (pkgStrings) ToLower(a string) string                   { return strings.ToLower(a) }
+func (pkgStrings) ToLowerSpecial(a unicode.SpecialCase, b string) string {
+	return strings.ToLowerSpecial(a, b)
+}
+func (pkgStrings) ToTitle(a string) string { return strings.ToTitle(a) }
+func (pkgStrings) ToTitleSpecial(a unicode.SpecialCase, b string) string {
+	return strings.ToTitleSpecial(a, b)
+}
+func (pkgStrings) ToUpper(a string) string { return strings.ToUpper(a) }
+func (pkgStrings) ToUpperSpecial(a unicode.SpecialCase, b string) string {
+	return strings.ToUpperSpecial(a, b)
+}
+func (pkgStrings) ToValidUTF8(a, b string) string                  { return strings.ToValidUTF8(a, b) }
+func (pkgStrings) Trim(a, b string) string                         { return strings.Trim(a, b) }
+func (pkgStrings) TrimFunc(a string, b func(rune) bool) string     { return strings.TrimFunc(a, b) }
+func (pkgStrings) TrimLeft(a, b string) string                     { return strings.TrimLeft(a, b) }
+func (pkgStrings) TrimLeftFunc(a string, b func(rune) bool) string { return strings.TrimLeftFunc(a, b) }
+func (pkgStrings) TrimPrefix(a, b string) string                   { return strings.TrimPrefix(a, b) }
+func (pkgStrings) TrimRight(a, b string) string                    { return strings.TrimRight(a, b) }
+func (pkgStrings) TrimRightFunc(a string, b func(rune) bool) string {
+	return strings.TrimRightFunc(a, b)
+}
+func (pkgStrings) TrimSpace(a string) string     { return strings.TrimSpace(a) }
+func (pkgStrings) TrimSuffix(a, b string) string { return strings.TrimSuffix(a, b) }
diff --git a/internal/web/render.go b/internal/web/render.go
new file mode 100644
index 0000000..fbcc2dd
--- /dev/null
+++ b/internal/web/render.go
@@ -0,0 +1,254 @@
+// Copyright 2020 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"
+	"fmt"
+	"net/http"
+	"path"
+	"regexp"
+	"strings"
+	"unicode/utf8"
+
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/extension"
+	"github.com/yuin/goldmark/parser"
+	"github.com/yuin/goldmark/renderer/html"
+	"github.com/yuin/goldmark/text"
+	"github.com/yuin/goldmark/util"
+	"golang.org/x/website/internal/backport/html/template"
+	"golang.org/x/website/internal/backport/io/fs"
+	"golang.org/x/website/internal/tmplfunc"
+)
+
+// renderHTML renders and returns the HTML for the page.
+func (site *Site) renderHTML(p Page, r *http.Request) ([]byte, error) {
+	// Clone p, because we are going to set its Content key-value pair.
+	p2 := make(Page)
+	for k, v := range p {
+		p2[k] = v
+	}
+	p = p2
+
+	url, ok := p["URL"].(string)
+	if !ok {
+		// Set URL - caller did not.
+		p["URL"] = r.URL.Path
+	}
+	file, _ := p["File"].(string)
+	data, _ := p["FileData"].(string)
+
+	// Load base template.
+	base, err := site.readFile(".", "site.tmpl")
+	if err != nil {
+		return nil, err
+	}
+
+	dir := strings.Trim(path.Dir(url), "/")
+	if dir == "" {
+		dir = "."
+	}
+	sd := &siteDir{site, dir}
+
+	t := template.New("site.tmpl").Funcs(template.FuncMap{
+		"add":      func(a, b int) int { return a + b },
+		"sub":      func(a, b int) int { return a - b },
+		"mul":      func(a, b int) int { return a * b },
+		"div":      func(a, b int) int { return a / b },
+		"code":     sd.code,
+		"data":     sd.data,
+		"page":     sd.page,
+		"pages":    sd.pages,
+		"request":  func() *http.Request { return r },
+		"path":     func() pkgPath { return pkgPath{} },
+		"strings":  func() pkgStrings { return pkgStrings{} },
+		"first":    first,
+		"markdown": markdown,
+		"rawhtml":  rawhtml,
+		"readfile": sd.readfile,
+		"yaml":     yamlFn,
+	})
+	t.Funcs(site.funcs)
+
+	if err := tmplfunc.Parse(t, string(base)); err != nil {
+		return nil, err
+	}
+
+	// Load page-specific layout template.
+	layout, _ := p["layout"].(string)
+	if layout == "" {
+		l, ok := site.findLayout(dir, "default")
+		if ok {
+			layout = l
+		} else {
+			layout = "none"
+		}
+	} else if path.IsAbs(layout) {
+		layout = strings.TrimLeft(path.Clean(layout+".tmpl"), "/")
+	} else if strings.Contains(layout, "/") {
+		layout = path.Join(dir, layout+".tmpl")
+	} else if layout != "none" {
+		l, ok := site.findLayout(dir, layout)
+		if !ok {
+			return nil, fmt.Errorf("cannot find layout %q", layout)
+		}
+		layout = l
+	}
+
+	if layout != "none" {
+		ldata, err := site.readFile(".", layout)
+		if err != nil {
+			return nil, err
+		}
+		if err := tmplfunc.Parse(t.New(layout), string(ldata)); err != nil {
+			return nil, err
+		}
+	}
+
+	var buf bytes.Buffer
+	if _, ok := p["Content"]; !ok && data != "" {
+		// Load actual Markdown content (also a template).
+		tf := t.New(file)
+		if err := tmplfunc.Parse(tf, data); err != nil {
+			return nil, err
+		}
+		if err := tf.Execute(&buf, p); err != nil {
+			return nil, err
+		}
+		if strings.HasSuffix(file, ".md") {
+			html, err := markdownToHTML(buf.String())
+			if err != nil {
+				return nil, err
+			}
+			p["Content"] = html
+		} else {
+			p["Content"] = template.HTML(buf.String())
+		}
+		buf.Reset()
+	}
+
+	if err := t.Execute(&buf, p); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
+
+// findLayout searches the start directory and parent directories for a template with the given base name.
+func (site *Site) findLayout(dir, name string) (string, bool) {
+	name += ".tmpl"
+	for {
+		abs := path.Join(dir, name)
+		if _, err := fs.Stat(site.fs, abs); err == nil {
+			return abs, true
+		}
+		if dir == "." {
+			return "", false
+		}
+		dir = path.Dir(dir)
+	}
+}
+
+// markdownToHTML converts Markdown to HTML.
+// The Markdown source may contain raw HTML,
+// but Go templates have already been processed.
+func markdownToHTML(markdown string) (template.HTML, error) {
+	// parser.WithHeadingAttribute allows custom ids on headings.
+	// html.WithUnsafe allows use of raw HTML, which we need for tables.
+	md := goldmark.New(
+		goldmark.WithParserOptions(
+			parser.WithHeadingAttribute(),
+			parser.WithAutoHeadingID(),
+			parser.WithASTTransformers(util.Prioritized(mdTransformFunc(mdLink), 1)),
+		),
+		goldmark.WithRendererOptions(html.WithUnsafe()),
+		goldmark.WithExtensions(
+			extension.NewTypographer(),
+			extension.NewLinkify(
+				extension.WithLinkifyAllowedProtocols([][]byte{[]byte("http"), []byte("https")}),
+				extension.WithLinkifyEmailRegexp(regexp.MustCompile(`[^\x00-\x{10FFFF}]`)), // impossible
+			),
+			extension.DefinitionList,
+		),
+	)
+	var buf bytes.Buffer
+	if err := md.Convert(replaceTabs([]byte(markdown)), &buf); err != nil {
+		return "", err
+	}
+	return template.HTML(buf.Bytes()), nil
+}
+
+// mdTransformFunc is a func implementing parser.ASTTransformer.
+type mdTransformFunc func(*ast.Document, text.Reader, parser.Context)
+
+func (f mdTransformFunc) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
+	f(node, reader, pc)
+}
+
+// mdLink walks doc, adding rel=noreferrer target=_blank to non-relative links.
+func mdLink(doc *ast.Document, _ text.Reader, _ parser.Context) {
+	mdLinkWalk(doc)
+}
+
+func mdLinkWalk(n ast.Node) {
+	switch n := n.(type) {
+	case *ast.Link:
+		dest := string(n.Destination)
+		if strings.HasPrefix(dest, "https://") || strings.HasPrefix(dest, "http://") {
+			n.SetAttributeString("rel", []byte("noreferrer"))
+			n.SetAttributeString("target", []byte("_blank"))
+		}
+		return
+	case *ast.AutoLink:
+		// All autolinks are non-relative.
+		n.SetAttributeString("rel", []byte("noreferrer"))
+		n.SetAttributeString("target", []byte("_blank"))
+		return
+	}
+
+	for child := n.FirstChild(); child != nil; child = child.NextSibling() {
+		mdLinkWalk(child)
+	}
+}
+
+// replaceTabs replaces all tabs in text with spaces up to a 4-space tab stop.
+//
+// In Markdown, tabs used for indentation are required to be interpreted as
+// 4-space tab stops. See https://spec.commonmark.org/0.30/#tabs.
+// Go also renders nicely and more compactly on the screen with 4-space
+// tab stops, while browsers often use 8-space.
+// And Goldmark crashes in some inputs that mix spaces and tabs.
+// Fix the crashes and make the Go code consistently compact across browsers,
+// all while staying Markdown-compatible, by expanding to 4-space tab stops.
+//
+// This function does not handle multi-codepoint Unicode sequences correctly.
+func replaceTabs(text []byte) []byte {
+	var buf bytes.Buffer
+	col := 0
+	for len(text) > 0 {
+		r, size := utf8.DecodeRune(text)
+		text = text[size:]
+
+		switch r {
+		case '\n':
+			buf.WriteByte('\n')
+			col = 0
+
+		case '\t':
+			buf.WriteByte(' ')
+			col++
+			for col%4 != 0 {
+				buf.WriteByte(' ')
+				col++
+			}
+
+		default:
+			buf.WriteRune(r)
+			col++
+		}
+	}
+	return buf.Bytes()
+}
diff --git a/internal/web/site.go b/internal/web/site.go
index 4d57ab7..e5a814a 100644
--- a/internal/web/site.go
+++ b/internal/web/site.go
@@ -2,20 +2,310 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+// Package web implements a basic web site serving framework.
+// The two fundamental types in this package are Site and Page.
+//
+// Sites
+//
+// A Site is an http.Handler that serves requests from a file system.
+// Use NewSite(fsys) to create a new Site.
+//
+// The Site is defined primarily by the content of its file system fsys,
+// which holds files to be served as well as templates for
+// converting Markdown or HTML fragments into full HTML pages.
+//
+// Pages
+//
+// A Page, which is a map[string]interface{}, is the raw data that a Site renders into a web page.
+// Typically a Page is loaded from a *.html or *.md file in the file system fsys, although
+// dynamic pages can be computed and passed to ServePage as well,
+// as described in “Serving Dynamic Pages” below.
+//
+// For a Page loaded from the file system, the key-value pairs in the map
+// are initialized from the YAML or JSON metadata block at the top of a Markdown or HTML file,
+// which looks like (YAML):
+//
+//	---
+//	key: value
+//	...
+//	---
+//
+// or (JSON):
+//
+//	<!--{
+//		"Key": "value",
+//		...
+//	}-->
+//
+// By convention, key-value pairs loaded from a metadata block use lower-case keys.
+// For historical reasons, keys in JSON metadata are converted to lower-case when read,
+// so that the two headers above both refer to a key with a lower-case k.
+//
+// A few keys have special meanings:
+//
+// The key-value pair “status: n” sets the HTTP response status to the integer code n.
+//
+// The key-value pair “redirect: url” causes requests for this page redirect to the given
+// relative or absolute URL.
+//
+// The key-value pair “layout: name” selects the page layout template with the given name.
+// See the next section, “Page Rendering”, for details about layout and rendering.
+//
+// In addition to these explicit key-value pairs, pages loaded from the file system
+// have a few implicit key-value pairs added by the page loading process:
+//
+//	- File: the path in fsys to the file containing the page
+//	- FileData: the file body, with the key-value metadata stripped
+//	- URL: this page's URL path (/x/y/z for x/y/z.md, /x/y/ for x/y/index.md)
+//
+// The key “Content” is added during during the rendering process.
+// See “Page Rendering” for details.
+//
+// Page Rendering
+//
+// A Page's content is rendered in two steps: conversion to content, and framing of content.
+//
+// To convert a page to content, the page's file body (its FileData key, a []byte) is parsed
+// and executed as an HTML template, with the page itself passed as the template input data.
+// The template output is then interpreted as Markdown (perhaps with embedded HTML),
+// and converted to HTML. The result is stored in the page under the key “Content”,
+// with type template.HTML.
+//
+// A page's conversion to content can be skipped entirely in dynamically-generated pages
+// by setting the “Content” key before passing the page to ServePage.
+//
+// The second step is framing the content in the overall site HTML, which is done by
+// executing the site template, again using the Page itself as the template input data.
+//
+// The site template is constructed from two files in the file system.
+// The first file is the fsys's “site.tmpl”, which provides the overall HTML frame for the site.
+// The second file is a layout-specific template file, selected by the Page's
+// “layout: name” key-value pair.
+// The renderer searches for “name.tmpl” in the directory containing the page's file,
+// then in the parent of that directory, and so on up to the root.
+// If no such template is found, the rendering fails and reports that error.
+// As a special case, “layout: none” skips the second file entirely.
+//
+// If there is no “layout: name” key-value pair, then the renderer tries using an
+// implicit “layout: default”, but if no such “default.tmpl” template file can be found,
+// the renderer uses an implicit “layout: none” instead.
+//
+// By convention, the site template and the layout-specific template are connected as follows.
+// The site template, at the point where the content should be rendered, executes:
+//
+//	{{block "layout" .}}{{.Content}}{{end}}
+//
+// The layout-specific template overrides this block by defining its own template named “layout”.
+// For example:
+//
+//	{{define "layout"}}
+//	Here's some <blink>great</blink> content: {{.Content}}
+//	{{end}}
+//
+// The use of the “block” template construct ensures that
+// if there is no layout-specific template,
+// the content will still be rendered.
+//
+// Page Template Functions
+//
+// In this web server, templates can themselves be invoked as functions.
+// See https://pkg.go.dev/rsc.io/tmplfunc for more details about that feature.
+//
+// During page rendering, both when rendering a page to content and when framing the content,
+// the following template functions are available (in addition to those provided by the
+// template package itself and the per-template functions just mentioned).
+//
+// In all functions taking a file path f, if the path begins with a slash,
+// it is interpreted relative to the fsys root.
+// Otherwise, it is interpreted relative to the directory of the current page's URL.
+//
+// The “{{add x y}}”, “{{sub x y}}”, “{{mul x y}}”, and “{{div x y}}” functions
+// provide basic math on arguments of type int.
+//
+// The “{{code f [start [end]]}}” function returns a template.HTML of a formatted display
+// of code lines from the file f.
+// If both start and end are omitted, then the display shows the entire file.
+// If only the start line is specified, then the display shows that single line.
+// If both start and end are specified, then the display shows a range of lines
+// starting at start up to and including end.
+// The arguments start and end can take two forms: a number indicates a specific line number,
+// and a string is taken to be a regular expresion indicating the earliest matching line
+// in the file (or, for end, the earliest matching line after the start line).
+// Any lines ending in “OMIT” are elided from the display.
+//
+// For example:
+//
+//	{{code "hello.go" `^func main` `^}`}}
+//
+// The “{{data f}}” function reads the file f,
+// decodes it as YAML, and then returns the resulting data,
+// typically a map[string]interface{}.
+// It is effectively shorthand for “{{yaml (file f)}}”.
+//
+// The “{{file f}}” function reads the file f and returns its content as a string.
+//
+// The “{{first n slice}}” function returns a slice of the first n elements of slice,
+// or else slice itself when slice has fewer than n elements.
+//
+// The “{{markdown text}}” function interprets text (a string) as Markdown
+// and returns the equivalent HTML as a template.HTML.
+//
+// The “{{page f}}” function returns the page data (a Page)
+// for the static page contained in the file f.
+// The lookup ignores trailing slashes in f as well as the presence or absence
+// of extensions like .md, .html, /index.md, and /index.html,
+// making it possible for f to be a relative or absolute URL path instead of a file path.
+//
+// The “{{pages glob}}” function returns a slice of page data (a []Page)
+// for all pages loaded from files or directories
+// in fsys matching the given glob (a string),
+// according to the usual file path rules (if the glob starts with slash,
+// it is interpreted relative to the fsys root, and otherwise
+// relative to the directory of the page's URL).
+// If the glob pattern matches a directory,
+// the page for the directory's index.md or index.html is used.
+//
+// For example:
+//
+//	Here are all the articles:
+//	{{range (pages "/articles/*")}}
+//	- [{{.title}}]({{.URL}})
+//	{{end}}
+//
+// The “{{rawhtml s}}” function converts s (a string) to type template.HTML without any escaping,
+// to allow using s as raw HTML in the final output.
+//
+// The “{{yaml s}}” function decodes s (a string) as YAML and returns the resulting data.
+// It is most useful for defining templates that accept YAML-structured data as a literal argument.
+// For example:
+//
+//	{{define "quote info"}}
+//	{{with (yaml .info)}}
+//	.text
+//	— .name{{if .title}}, .title{{end}}
+//	{{end}}
+//
+//	{{quote `
+//	  text: If a program is too slow, it must have a loop.
+//	  name: Ken Thompson
+//	`}}
+//
+// The “path” and “strings” functions return package objects with methods for every top-level
+// function in these packages (except path.Split, which has more than one non-error result
+// and would not be invokable). For example, “{{strings.ToUpper "abc"}}”.
+//
+// Serving Requests
+//
+// A Site is an http.Handler that serves requests by consulting the underlying
+// file system and constructing and rendering pages, as well as serving binary
+// and text files.
+//
+// To serve a request for URL path /p, if fsys has a file
+// p/index.md, p/index.html, p.md, or p.html
+// (in that order of preference), then the Site opens that file,
+// parses it into a Page, renders the page as described
+// in the “Page Rendering” section above,
+// and responds to the request with the generated HTML.
+// If the request URL does not match the parsed page's URL,
+// then the Site responds with a redirect to the canonical URL.
+//
+// Otherwise, if fsys has a directory p and the Site
+// can find a template “dir.tmpl” in that directory or a parent,
+// then the Site responds with the rendering of
+//
+//	Page{
+//		"URL": "/p/",
+//		"File": "p",
+//		"layout": "dir",
+//		"dir": []fs.FileInfo(dir),
+//	}
+//
+// where dir is the directory contents.
+//
+// Otherwise, if fsys has a file p containing valid UTF-8 text
+// (at least up to the first kilobyte of the file) and the Site
+// can find a template “text.tmpl” in that file's directory or a parent,
+// and the file is not named robots.txt,
+// and the file does not have a .css, .js, or .svg extension,
+// then the Site responds with the rendering of
+//
+//	Page{
+//		"URL": "/p",
+//		"File": "p",
+//		"layout": "texthtml",
+//		"texthtml": template.HTML(texthtml),
+//	}
+//
+// where texthtml is the text file as rendered by the
+// golang.org/x/website/internal/texthtml package.
+// In the texthtml.Config, GoComments is set to true for
+// file names ending in .go;
+// the h URL query parameter, if present, is passed as Highlight,
+// and the s URL query parameter, if set to lo:hi, is passed as a
+// single-range Selection.
+//
+// If the request has the URL query parameter m=text,
+// then the text file content is not rendered or framed and is instead
+// served directly as a plain text response.
+//
+// Otherwise, if none of those cases apply but the request path p
+// does exist in the file system, then the Site passes the
+// request to an http.FileServer serving from fsys.
+// This last case handles binary static content as well as
+// textual static content excluded from the text file case above.
+//
+// Otherwise, the Site responds with the rendering of
+//
+//	Page{
+//		"URL": r.URL.Path,
+//		"status": 404,
+//		"layout": "error",
+//		"error": err,
+//	}
+//
+// where err is the “not exist” error returned by fs.Stat(fsys, p).
+// (See also the “Serving Errors” section below.)
+//
+// Serving Dynamic Requests
+//
+// Of course, a web site may wish to serve more than static content.
+// To allow dynamically generated web pages to make use of page
+// rendering and site templates, the Site.ServePage method can be
+// called with a dynamically generated Page value, which will then
+// be rendered and served as the result of the request.
+//
+// Serving Errors
+//
+// If an error occurs while serving a request r,
+// the Site responds with the rendering of
+//
+//	Page{
+//		"URL": r.URL.Path,
+//		"status": 500,
+//		"layout": "error",
+//		"error": err,
+//	}
+//
+// If that rendering itself fails, the Site responds with status 500
+// and the cryptic page text “error rendering error”.
+//
+// The Site.ServeError and Site.ServeErrorStatus methods provide a way
+// for dynamic servers to generate similar responses.
+//
 package web
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"html"
-	"io"
 	"log"
 	"net/http"
 	"path"
 	"regexp"
-	"runtime"
 	"strconv"
 	"strings"
+	"sync"
 
 	"golang.org/x/website/internal/backport/html/template"
 	"golang.org/x/website/internal/backport/httpfs"
@@ -24,220 +314,164 @@
 	"golang.org/x/website/internal/texthtml"
 )
 
-// Site is a website served from a file system.
+// A Site is an http.Handler that serves requests from a file system.
+// See the package doc comment for details.
 type Site struct {
-	fs fs.FS
-
-	mux        *http.ServeMux
-	fileServer http.Handler
-
-	Templates *template.Template
-
-	// GoogleAnalytics optionally adds Google Analytics via the provided
-	// tracking ID to each page.
-	GoogleAnalytics string
-
-	docFuncs template.FuncMap
+	fs         fs.FS            // from NewSite
+	fileServer http.Handler     // http.FileServer(http.FS(fs))
+	funcs      template.FuncMap // accumulated from s.Funcs
+	cache      sync.Map         // canonical file path -> *pageFile, for site.openPage
 }
 
-var siteFuncs = template.FuncMap{
-	"add": func(a, b int) int { return a + b },
-	"sub": func(a, b int) int { return a - b },
-	"mul": func(a, b int) int { return a * b },
-	"div": func(a, b int) int { return a / b },
-
-	"basename": path.Base,
-
-	"split":      strings.Split,
-	"join":       strings.Join,
-	"hasPrefix":  strings.HasPrefix,
-	"hasSuffix":  strings.HasSuffix,
-	"trimPrefix": strings.TrimPrefix,
-	"trimSuffix": strings.TrimSuffix,
-}
-
-// NewSite returns a new Presentation from a file system.
-func NewSite(fsys fs.FS) (*Site, error) {
-	p := &Site{
+// NewSite returns a new Site for serving pages from the file system fsys.
+func NewSite(fsys fs.FS) *Site {
+	return &Site{
 		fs:         fsys,
-		mux:        http.NewServeMux(),
 		fileServer: http.FileServer(httpfs.FS(fsys)),
 	}
-	p.mux.HandleFunc("/", p.serveFile)
-	p.initDocFuncs()
+}
 
-	t, err := template.New("").Funcs(siteFuncs).ParseFS(fsys, "lib/godoc/*.html")
-	if err != nil {
-		return nil, err
+// Funcs adds the functions in m to the set of functions available to templates.
+// Funcs must not be called concurrently with any page rendering.
+func (s *Site) Funcs(m template.FuncMap) {
+	if s.funcs == nil {
+		s.funcs = make(template.FuncMap)
 	}
-	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)
-	if d, ok := page.Data.(interface{ SetWebPage(*Page) }); ok {
-		d.SetWebPage(&page)
+	for k, v := range m {
+		s.funcs[k] = v
 	}
+}
 
-	if page.Template != "" {
-		t := s.Templates.Lookup(page.Template)
-		var buf bytes.Buffer
-		if err := t.Execute(&buf, &page); err != nil {
-			log.Printf("%s.Execute: %s", t.Name(), err)
-		}
-		page.HTML = template.HTML(buf.String())
+// readFile returns the content of the named file in the site's file system.
+// If file begins with a slash, it is interpreted relative to the root of the file system.
+// Otherwise, it is interpreted relative to dir.
+func (site *Site) readFile(dir, file string) ([]byte, error) {
+	if strings.HasPrefix(file, "/") {
+		file = path.Clean(file)
 	} else {
-		page.HTML = page.Data.(template.HTML)
+		file = path.Join(dir, file)
 	}
-
-	applyTemplateToResponseWriter(w, s.Templates.Lookup("site.html"), &page)
+	file = strings.Trim(file, "/")
+	if file == "" {
+		file = "."
+	}
+	return fs.ReadFile(site.fs, file)
 }
 
-// A Page describes the contents of a webpage to be served.
+// ServeError is ServeErrorStatus with HTTP status code 500 (internal server error).
+func (s *Site) ServeError(w http.ResponseWriter, r *http.Request, err error) {
+	s.ServeErrorStatus(w, r, err, http.StatusInternalServerError)
+}
+
+// ServeErrorStatus responds to the request
+// with the given error and HTTP status.
+// It is equivalent to calling ServePage(w, r, p) where p is:
 //
-// 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
-
-	HTML template.HTML
-
-	// Filled in automatically by ServePage
-	GoogleCN        bool   // served on golang.google.cn
-	GoogleAnalytics string // Google Analytics tag
-	Version         string
-	Site            *Site
+//	Page{
+//		"URL": r.URL.Path,
+//		"status": status,
+//		"layout": error,
+//		"error": err,
+//	}
+//
+func (s *Site) ServeErrorStatus(w http.ResponseWriter, r *http.Request, err error, status int) {
+	s.serveErrorStatus(w, r, err, status, false)
 }
 
-// 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 = GoogleCN(r)
-	page.GoogleAnalytics = s.GoogleAnalytics
-	page.Site = s
-	return page
-}
+func (s *Site) serveErrorStatus(w http.ResponseWriter, r *http.Request, err error, status int, renderingError bool) {
 
-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)
+	if renderingError {
+		log.Printf("error rendering error: %v", err)
+		w.WriteHeader(status)
+		w.Write([]byte("error rendering error"))
 		return
 	}
 
-	// Check to see if we need to redirect or serve another file.
+	p := Page{
+		"URL":    r.URL.Path,
+		"status": status,
+		"layout": "error",
+		"error":  err,
+	}
+	s.servePage(w, r, p, true)
+}
+
+// ServePage renders the page p to HTML and writes that HTML to w.
+// See the package doc comment for details about page rendering.
+//
+// So that all templates can assume the presence of p["URL"],
+// if p["URL"] is unset or does not have type string, then ServePage
+// sets p["URL"] to r.URL.Path in a clone of p before rendering the page.
+func (s *Site) ServePage(w http.ResponseWriter, r *http.Request, p Page) {
+	s.servePage(w, r, p, false)
+}
+
+func (s *Site) servePage(w http.ResponseWriter, r *http.Request, p Page, renderingError bool) {
+	html, err := s.renderHTML(p, r)
+	if err != nil {
+		s.serveErrorStatus(w, r, fmt.Errorf("template execution: %v", err), http.StatusInternalServerError, renderingError)
+		return
+	}
+	if code, ok := p["status"].(int); ok {
+		w.WriteHeader(code)
+	}
+	w.Write(html)
+}
+
+// ServeHTTP implements http.Handler, serving from a file in the site.
+// See the Site type documentation for details about how requests are handled.
+func (s *Site) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	abspath := r.URL.Path
 	relpath := path.Clean(strings.TrimPrefix(abspath, "/"))
-	if f := open(s.fs, relpath); f != nil {
-		if f.Path != abspath {
+
+	// Is it a page we can generate?
+	if p, err := s.openPage(relpath); err == nil {
+		if p.url != abspath {
 			// Redirect to canonical path.
-			http.Redirect(w, r, f.Path, http.StatusMovedPermanently)
+			status := http.StatusMovedPermanently
+			if i, ok := p.page["status"].(int); ok {
+				status = i
+			}
+			http.Redirect(w, r, p.url, status)
 			return
 		}
 		// Serve from the actual filesystem path.
-		s.serveHTML(w, r, f)
+		s.serveHTML(w, r, p)
 		return
 	}
 
-	dir, err := fs.Stat(s.fs, relpath)
+	// Is it a directory or file we can serve?
+	info, err := fs.Stat(s.fs, relpath)
 	if err != nil {
-		// Check for spurious trailing slash.
-		if strings.HasSuffix(abspath, "/") {
-			trimmed := relpath[:len(relpath)-1]
-			if _, err := fs.Stat(s.fs, trimmed); err == nil ||
-				open(s.fs, trimmed) != nil {
-				http.Redirect(w, r, "/"+trimmed, http.StatusMovedPermanently)
-				return
+		status := http.StatusInternalServerError
+		if errors.Is(err, fs.ErrNotExist) {
+			status = http.StatusNotFound
+		}
+		s.ServeErrorStatus(w, r, err, status)
+		return
+	}
+
+	// Serve directory.
+	if info != nil && info.IsDir() {
+		if _, ok := s.findLayout(relpath, "dir"); ok {
+			if !maybeRedirect(w, r) {
+				s.serveDir(w, r, relpath)
 			}
-		}
-		s.ServeError(w, r, err)
-		return
-	}
-
-	if dir != nil && dir.IsDir() {
-		if maybeRedirect(w, r) {
 			return
 		}
-		s.serveDir(w, r, relpath)
-		return
 	}
 
+	// Serve text file.
 	if isTextFile(s.fs, relpath) {
-		if maybeRedirectFile(w, r) {
+		if _, ok := s.findLayout(path.Dir(relpath), "text"); ok {
+			if !maybeRedirectFile(w, r) {
+				s.serveText(w, r, relpath)
+			}
 			return
 		}
-		s.serveText(w, r, relpath)
-		return
 	}
 
+	// Serve raw bytes.
 	s.fileServer.ServeHTTP(w, r)
 }
 
@@ -267,63 +501,32 @@
 	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")
+func (s *Site) serveHTML(w http.ResponseWriter, r *http.Request, p *pageFile) {
+	src, _ := p.page["FileData"].(string)
+	filePath, _ := p.page["File"].(string)
+	isMarkdown := strings.HasSuffix(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)
+	if strings.HasPrefix(src, "<!DOCTYPE ") {
+		w.Write([]byte(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") {
+	if strings.HasSuffix(filePath, "go_spec.html") {
 		var buf bytes.Buffer
-		spec.Linkify(&buf, src)
-		src = buf.Bytes()
+		spec.Linkify(&buf, []byte(src))
+		src = buf.String()
 	}
 
-	page.Data = template.HTML(src)
-	s.ServePage(w, r, page)
+	// Template is enabled always in Markdown.
+	// It can only be disabled for HTML files.
+	isTemplate, _ := p.page["template"].(bool)
+	if !isTemplate && !isMarkdown {
+		p.page["Content"] = template.HTML(src)
+	}
+	s.ServePage(w, r, p.page)
 }
 
 func (s *Site) serveDir(w http.ResponseWriter, r *http.Request, relpath string) {
@@ -345,13 +548,11 @@
 		}
 	}
 
-	dirpath := strings.TrimSuffix(relpath, "/") + "/"
 	s.ServePage(w, r, Page{
-		Title:    "Directory",
-		SrcPath:  dirpath,
-		TabTitle: dirpath,
-		Template: "dirlist.html",
-		Data:     info,
+		"URL":    r.URL.Path,
+		"File":   relpath,
+		"layout": "dir",
+		"dir":    info,
 	})
 }
 
@@ -382,15 +583,11 @@
 
 	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()),
+		"URL":      r.URL.Path,
+		"File":     relpath,
+		"layout":   "texthtml",
+		"texthtml": template.HTML(buf.String()),
 	})
 }
 
diff --git a/internal/web/site_test.go b/internal/web/site_test.go
index 2f3290b..5b962d4 100644
--- a/internal/web/site_test.go
+++ b/internal/web/site_test.go
@@ -18,7 +18,7 @@
 	t.Helper()
 	r := &http.Request{URL: &url.URL{Path: path}}
 	rw := httptest.NewRecorder()
-	p.serveFile(rw, r)
+	p.ServeHTTP(rw, r)
 	if rw.Code != 200 || !strings.Contains(rw.Body.String(), body) {
 		t.Fatalf("GET %s: expected 200 w/ %q: got %d w/ body:\n%s",
 			path, body, rw.Code, rw.Body)
@@ -27,13 +27,11 @@
 
 func TestRedirectAndMetadata(t *testing.T) {
 	fsys := fstest.MapFS{
+		"site.tmpl":           {Data: []byte(`{{.Content}}`)},
 		"doc/x/index.html":    {Data: []byte("Hello, x.")},
 		"lib/godoc/site.html": {Data: []byte(`{{.Data}}`)},
 	}
-	p, err := NewSite(fsys)
-	if err != nil {
-		t.Fatal(err)
-	}
+	site := NewSite(fsys)
 
 	// Test that redirect is sent back correctly.
 	// Used to panic. See golang.org/issue/40665.
@@ -41,25 +39,23 @@
 
 	r := &http.Request{URL: &url.URL{Path: dir + "index.html"}}
 	rw := httptest.NewRecorder()
-	p.serveFile(rw, r)
+	site.ServeHTTP(rw, r)
 	loc := rw.Result().Header.Get("Location")
 	if rw.Code != 301 || loc != dir {
 		t.Errorf("GET %s: expected 301 -> %q, got %d -> %q", r.URL.Path, dir, rw.Code, loc)
 	}
 
-	testServeBody(t, p, dir, "Hello, x")
+	testServeBody(t, site, dir, "Hello, x")
 }
 
 func TestMarkdown(t *testing.T) {
-	p, err := NewSite(fstest.MapFS{
+	site := NewSite(fstest.MapFS{
+		"site.tmpl":           {Data: []byte(`{{.Content}}`)},
 		"doc/test.md":         {Data: []byte("**bold**")},
 		"doc/test2.md":        {Data: []byte(`{{"*template*"}}`)},
 		"lib/godoc/site.html": {Data: []byte(`{{.Data}}`)},
 	})
-	if err != nil {
-		t.Fatal(err)
-	}
 
-	testServeBody(t, p, "/doc/test", "<strong>bold</strong>")
-	testServeBody(t, p, "/doc/test2", "<em>template</em>")
+	testServeBody(t, site, "/doc/test", "<strong>bold</strong>")
+	testServeBody(t, site, "/doc/test2", "<em>template</em>")
 }
diff --git a/internal/web/tmpl.go b/internal/web/tmpl.go
new file mode 100644
index 0000000..2f65f35
--- /dev/null
+++ b/internal/web/tmpl.go
@@ -0,0 +1,161 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package web
+
+import (
+	"fmt"
+	"path"
+	"reflect"
+	"sort"
+	"strings"
+
+	"golang.org/x/website/internal/backport/html/template"
+	"golang.org/x/website/internal/backport/io/fs"
+	"gopkg.in/yaml.v3"
+)
+
+// A siteDir is a site extended with a known directory for interpreting relative paths.
+type siteDir struct {
+	*Site
+	dir string
+}
+
+func toString(x interface{}) string {
+	switch x := x.(type) {
+	case string:
+		return x
+	case template.HTML:
+		return string(x)
+	case nil:
+		return ""
+	default:
+		panic(fmt.Sprintf("cannot toString %T", x))
+	}
+}
+
+// data parses the named yaml file (relative to dir) and returns its structured data.
+func (site *siteDir) data(name string) (interface{}, error) {
+	data, err := site.readFile(site.dir, name)
+	if err != nil {
+		return nil, err
+	}
+	var d interface{}
+	if err := yaml.Unmarshal(data, &d); err != nil {
+		return nil, err
+	}
+	return d, nil
+}
+
+func first(n int, list reflect.Value) reflect.Value {
+	if !list.IsValid() {
+		return list
+	}
+	if list.Kind() == reflect.Interface {
+		if list.IsNil() {
+			return list
+		}
+		list = list.Elem()
+	}
+
+	if list.Len() < n {
+		return list
+	}
+	return list.Slice(0, n)
+}
+
+// markdown is the function provided to templates.
+func markdown(data interface{}) (template.HTML, error) {
+	h, err := markdownToHTML(toString(data))
+	if err != nil {
+		return "", err
+	}
+	s := strings.TrimSpace(string(h))
+	if strings.HasPrefix(s, "<p>") && strings.HasSuffix(s, "</p>") && strings.Count(s, "<p>") == 1 {
+		h = template.HTML(strings.TrimSpace(s[len("<p>") : len(s)-len("</p>")]))
+	}
+	return h, nil
+}
+
+func (site *siteDir) readfile(name string) (string, error) {
+	data, err := site.readFile(site.dir, name)
+	return string(data), err
+}
+
+// page returns the page params for the page with a given url u.
+// The url may or may not have its leading slash.
+func (site *siteDir) page(u string) (Page, error) {
+	if !path.IsAbs(u) {
+		u = path.Join(site.dir, u)
+	}
+	p, err := site.openPage(strings.Trim(u, "/"))
+	if err != nil {
+		return nil, err
+	}
+	return p.page, nil
+}
+
+// pages returns the page params for pages with urls matching glob.
+func (site *siteDir) pages(glob string) ([]Page, error) {
+	if !path.IsAbs(glob) {
+		glob = path.Join(site.dir, glob)
+	}
+	// TODO(rsc): Add a cache?
+	_, err := path.Match(glob, "")
+	if err != nil {
+		return nil, err
+	}
+	glob = strings.Trim(glob, "/")
+	if glob == "" {
+		glob = "."
+	}
+	matches, err := fs.Glob(site.fs, glob)
+	if err != nil {
+		return nil, err
+	}
+	var out []Page
+	for _, file := range matches {
+		if !strings.HasSuffix(file, ".md") && !strings.HasSuffix(file, ".html") {
+			f := path.Join(file, "index.md")
+			if _, err := fs.Stat(site.fs, f); err != nil {
+				f = path.Join(file, "index.html")
+				if _, err = fs.Stat(site.fs, f); err != nil {
+					continue
+				}
+			}
+			file = f
+		}
+		p, err := site.openPage(file)
+		if err != nil {
+			return nil, err
+		}
+		out = append(out, p.page)
+	}
+
+	sort.Slice(out, func(i, j int) bool {
+		return out[i]["URL"].(string) < out[j]["URL"].(string)
+	})
+	return out, nil
+}
+
+// file parses the named file (relative to dir) and returns its content as a string.
+func (site *siteDir) file(name string) (string, error) {
+	data, err := site.readFile(site.dir, name)
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+func rawhtml(s interface{}) template.HTML {
+	return template.HTML(toString(s))
+}
+
+func yamlFn(s string) (interface{}, error) {
+	var d interface{}
+	if err := yaml.Unmarshal([]byte(s), &d); err != nil {
+		return nil, err
+	}
+	return d, nil
+}