godoc: migrate to App Engine flexible

See bug for more details on exactly what was migrated.

Notably:
* No more Google-internal deployment scripts; see README.godoc-app and
  the Makefile for details.
* Build tag "golangorg" is used for the godoc configuration used for
  golang.org.
* Use of App Engine libraries replaced with GCP client libraries.
* Redis is used to replace App Engine memcache.
* Google analytics is controlled by an environment variable.
* Regression tests have been migrated from Google-internal.
* hg -> git hash map is moved from Google-internal.

Updates golang/go#27205.

Change-Id: Ia0a983f239c50eda8be2363494c8b784f60c2c6d
Reviewed-on: https://go-review.googlesource.com/133355
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/cmd/godoc/Dockerfile.prod b/cmd/godoc/Dockerfile.prod
new file mode 100644
index 0000000..21fb92d
--- /dev/null
+++ b/cmd/godoc/Dockerfile.prod
@@ -0,0 +1,42 @@
+# Builder
+#########
+
+FROM golang:1.11 AS build
+
+RUN apt-get update && apt-get install -y \
+      zip # required for generate-index.bash
+
+ENV GODOC_REF release-branch.go1.11
+
+RUN go get -v -d \
+      golang.org/x/net/context \
+      google.golang.org/appengine \
+      cloud.google.com/go/datastore \
+      golang.org/x/build \
+      github.com/gomodule/redigo/redis
+
+COPY . /go/src/golang.org/x/tools
+
+WORKDIR /go/src/golang.org/x/tools/cmd/godoc
+RUN git clone --single-branch --depth=1 -b $GODOC_REF https://go.googlesource.com/go /docset
+RUN GODOC_DOCSET=/docset ./generate-index.bash
+
+RUN go build -o /godoc -tags=golangorg golang.org/x/tools/cmd/godoc
+
+
+# Final image
+#############
+
+FROM gcr.io/distroless/base
+
+WORKDIR /app
+COPY --from=build /godoc /app/
+COPY --from=build /go/src/golang.org/x/tools/cmd/godoc/hg-git-mapping.bin /app/
+
+COPY --from=build /docset /goroot
+ENV GOROOT /goroot
+
+COPY --from=build /go/src/golang.org/x/tools/cmd/godoc/index.split.* /app/
+ENV GODOC_INDEX_GLOB index.split.*
+
+CMD ["/app/godoc"]
diff --git a/cmd/godoc/Makefile b/cmd/godoc/Makefile
new file mode 100644
index 0000000..217515b
--- /dev/null
+++ b/cmd/godoc/Makefile
@@ -0,0 +1,24 @@
+# Copyright 2018 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.
+
+.PHONY: usage
+
+usage:
+	echo "See Makefile"
+	exit 1
+
+docker-prod: Dockerfile.prod
+	cd ../..; docker build -f cmd/godoc/Dockerfile.prod --tag=gcr.io/golang-org/godoc:$(VERSION) .
+
+push-prod: docker-prod
+	docker push gcr.io/golang-org/godoc:$(VERSION)
+
+deploy-prod: push-prod
+	gcloud -q app deploy app.prod.yaml --project golang-org --no-promote --image-url gcr.io/golang-org/godoc:$(VERSION)
+
+get-latest-url:
+	@gcloud app versions list -s default --project golang-org --sort-by=~version.createTime --format='value(version.versionUrl)' --limit 1 | cut -f1
+
+regtest:
+	./regtest.bash $(shell make get-latest-url)
diff --git a/cmd/godoc/README.godoc-app b/cmd/godoc/README.godoc-app
index 94abf0c..0bb8112 100644
--- a/cmd/godoc/README.godoc-app
+++ b/cmd/godoc/README.godoc-app
@@ -7,31 +7,78 @@
 * Google Cloud SDK
   https://cloud.google.com/sdk/
 
+* Redis
+
 * Go sources under $GOROOT
 
 * Godoc sources inside $GOPATH
   (go get -d golang.org/x/tools/cmd/godoc)
 
 
-Running in dev_appserver.py
----------------------------
+Running locally, in production mode
+-----------------------------------
 
-Use dev_appserver.py to run the server in development mode:
+Build the app:
 
-	dev_appserver.py app.dev.yaml
+  go build -tags golangorg
 
-To run the server with generated zip file and search index:
+Run the app:
 
-	./generate-index.bash
-	dev_appserver.py app.prod.yaml
+  ./godoc
 
 godoc should come up at http://localhost:8080
-Use the --host and --port flags to listen on a different address.
 
-To clean up the index files, use git:
+Use the PORT environment variable to change the port:
 
-	git clean -xn # n is dry run, replace with f
+    PORT=8081 ./godoc
 
+Running locally, in production mode, using Docker
+-------------------------------------------------
+
+Build the app's Docker container:
+
+  VERSION=$(git rev-parse HEAD) make docker-prod
+
+Make sure redis is running on port 6379:
+
+  $ echo PING | nc localhost 6379
+  +PONG
+  ^C
+
+Run the datastore emulator:
+
+  gcloud beta emulators datastore start --project golang-org
+
+In another terminal window, run the container:
+
+  $(gcloud beta emulators datastore env-init)
+
+  docker run --rm \
+    --net host \
+    --env GODOC_REDIS_ADDR=localhost:6379 \
+    --env DATASTORE_EMULATOR_HOST=$DATASTORE_EMULATOR_HOST \
+    --env DATASTORE_PROJECT_ID=$DATASTORE_PROJECT_ID \
+    gcr.io/golang-org/godoc
+
+godoc should come up at http://localhost:8080
+
+
+Deploying to golang.org
+-----------------------
+
+Build the image, push it to gcr.io, and deploy to Flex:
+
+  VERSION=$(git rev-parse HEAD) make deploy-prod
+
+Run regression tests:
+
+  make regtest
+
+Go to the console to migrate traffic to the newly deployed version:
+
+  https://console.cloud.google.com/appengine/versions?project=golang-org&serviceId=default&versionssize=50
+
+Shut down any very old versions (keep at least one to roll back to, just in case).
 
 Troubleshooting
 ---------------
diff --git a/cmd/godoc/app.prod.yaml b/cmd/godoc/app.prod.yaml
index 6a18a64..832db09 100644
--- a/cmd/godoc/app.prod.yaml
+++ b/cmd/godoc/app.prod.yaml
@@ -1,18 +1,16 @@
-runtime: go
-api_version: go1
-instance_class: F4_1G
-
-handlers:
-- url: /s
-  script: _go_app
-  login: admin
-- url: /dl/init
-  script: _go_app
-  login: admin
-- url: /.*
-  script: _go_app
+runtime: custom
+env: flex
 
 env_variables:
-  GODOC_ZIP: godoc.zip
-  GODOC_ZIP_PREFIX: goroot
-  GODOC_INDEX_GLOB: 'index.split.*'
+  GODOC_PROD: true
+  #  GODOC_ENFORCE_HOSTS: true # TODO(cbro): modify host filter to allow version-specific URLs (see issue 27205).
+  GODOC_REDIS_ADDR: 10.0.0.4:6379 # instance "gophercache"
+  GODOC_ANALYTICS: UA-11222381-2
+  DATASTORE_PROJECT_ID: golang-org
+
+network:
+  name: golang
+
+resources:
+  cpu: 4
+  memory_gb: 7.50
diff --git a/cmd/godoc/appinit.go b/cmd/godoc/appinit.go
index e422d9c..293ad52 100644
--- a/cmd/godoc/appinit.go
+++ b/cmd/godoc/appinit.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// +build appengine
+// +build golangorg
 
 package main
 
@@ -11,26 +11,34 @@
 
 import (
 	"archive/zip"
+	"context"
+	"io"
 	"log"
 	"net/http"
 	"os"
 	"path"
 	"regexp"
 	"runtime"
+	"strings"
 
 	"golang.org/x/tools/godoc"
 	"golang.org/x/tools/godoc/dl"
 	"golang.org/x/tools/godoc/proxy"
+	"golang.org/x/tools/godoc/redirect"
 	"golang.org/x/tools/godoc/short"
 	"golang.org/x/tools/godoc/static"
 	"golang.org/x/tools/godoc/vfs"
 	"golang.org/x/tools/godoc/vfs/gatefs"
 	"golang.org/x/tools/godoc/vfs/mapfs"
 	"golang.org/x/tools/godoc/vfs/zipfs"
-	"google.golang.org/appengine"
+
+	"cloud.google.com/go/datastore"
+	"golang.org/x/tools/internal/memcache"
 )
 
-func init() {
+func main() {
+	log.SetFlags(log.Lshortfile | log.LstdFlags)
+
 	var (
 		// .zip filename
 		zipFilename = os.Getenv("GODOC_ZIP")
@@ -43,7 +51,6 @@
 		indexFilenames = os.Getenv("GODOC_INDEX_GLOB")
 	)
 
-	enforceHosts = !appengine.IsDevAppServer()
 	playEnabled = true
 
 	log.Println("initializing godoc ...")
@@ -84,17 +91,61 @@
 	pres.ShowExamples = true
 	pres.DeclLinks = true
 	pres.NotesRx = regexp.MustCompile("BUG")
+	pres.GoogleAnalytics = os.Getenv("GODOC_ANALYTICS")
 
 	readTemplates(pres, true)
 
+	datastoreClient, memcacheClient := getClients()
+
+	// NOTE(cbro): registerHandlers registers itself against DefaultServeMux.
+	// The mux returned has host enforcement, so it's important to register
+	// against this mux and not DefaultServeMux.
 	mux := registerHandlers(pres)
-	dl.RegisterHandlers(mux)
-	short.RegisterHandlers(mux)
+	dl.RegisterHandlers(mux, datastoreClient, memcacheClient)
+	short.RegisterHandlers(mux, datastoreClient, memcacheClient)
 
 	// Register /compile and /share handlers against the default serve mux
 	// so that other app modules can make plain HTTP requests to those
 	// hosts. (For reasons, HTTPS communication between modules is broken.)
 	proxy.RegisterHandlers(http.DefaultServeMux)
 
+	http.HandleFunc("/_ah/health", func(w http.ResponseWriter, r *http.Request) {
+		io.WriteString(w, "ok")
+	})
+
+	http.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
+		io.WriteString(w, "User-agent: *\nDisallow: /search\n")
+	})
+
+	if err := redirect.LoadChangeMap("hg-git-mapping.bin"); err != nil {
+		log.Fatalf("LoadChangeMap: %v", err)
+	}
+
 	log.Println("godoc initialization complete")
+
+	// TODO(cbro): add instrumentation via opencensus.
+	port := "8080"
+	if p := os.Getenv("PORT"); p != "" { // PORT is set by GAE flex.
+		port = p
+	}
+	log.Fatal(http.ListenAndServe(":"+port, nil))
+}
+
+func getClients() (*datastore.Client, *memcache.Client) {
+	ctx := context.Background()
+
+	datastoreClient, err := datastore.NewClient(ctx, "")
+	if err != nil {
+		if strings.Contains(err.Error(), "missing project") {
+			log.Fatalf("Missing datastore project. Set the DATASTORE_PROJECT_ID env variable. Use `gcloud beta emulators datastore` to start a local datastore.")
+		}
+		log.Fatalf("datastore.NewClient: %v.", err)
+	}
+
+	redisAddr := os.Getenv("GODOC_REDIS_ADDR")
+	if redisAddr == "" {
+		log.Fatalf("Missing redis server for godoc in production mode. set GODOC_REDIS_ADDR environment variable.")
+	}
+	memcacheClient := memcache.New(redisAddr)
+	return datastoreClient, memcacheClient
 }
diff --git a/cmd/godoc/dl.go b/cmd/godoc/dl.go
index 40e6658..edeecb8 100644
--- a/cmd/godoc/dl.go
+++ b/cmd/godoc/dl.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// +build !appengine
+// +build !golangorg
 
 package main
 
diff --git a/cmd/godoc/generate-index.bash b/cmd/godoc/generate-index.bash
index 38ac79a..21b567a 100755
--- a/cmd/godoc/generate-index.bash
+++ b/cmd/godoc/generate-index.bash
@@ -25,24 +25,24 @@
 }
 
 getArgs() {
-	if [ ! -v GOROOT ]; then
-		GOROOT="$(go env GOROOT)"
-		echo "GOROOT not set explicitly, using go env value instead"
+	if [ ! -v GODOC_DOCSET ]; then
+		GODOC_DOCSET="$(go env GOROOT)"
+		echo "GODOC_DOCSET not set explicitly, using GOROOT instead"
 	fi
 
 	# safety checks
-	if [ ! -d "$GOROOT" ]; then
-		error "$GOROOT is not a directory"
+	if [ ! -d "$GODOC_DOCSET" ]; then
+		error "$GODOC_DOCSET is not a directory"
 	fi
 
 	# reporting
-	echo "GOROOT = $GOROOT"
+	echo "GODOC_DOCSET = $GODOC_DOCSET"
 }
 
 makeZipfile() {
 	echo "*** make $ZIPFILE"
 	rm -f $ZIPFILE goroot
-	ln -s "$GOROOT" goroot
+	ln -s "$GODOC_DOCSET" goroot
 	zip -q -r $ZIPFILE goroot/* # glob to ignore dotfiles (like .git)
 	rm goroot
 }
diff --git a/cmd/godoc/handlers.go b/cmd/godoc/handlers.go
index a8447b3..4152a3e 100644
--- a/cmd/godoc/handlers.go
+++ b/cmd/godoc/handlers.go
@@ -21,6 +21,7 @@
 	"text/template"
 
 	"golang.org/x/tools/godoc"
+	"golang.org/x/tools/godoc/env"
 	"golang.org/x/tools/godoc/redirect"
 	"golang.org/x/tools/godoc/vfs"
 )
@@ -30,8 +31,6 @@
 	fs   = vfs.NameSpace{}
 )
 
-var enforceHosts = false // set true in production on app engine
-
 // hostEnforcerHandler redirects requests to "http://foo.golang.org/bar"
 // to "https://golang.org/bar".
 // It permits requests to the host "godoc-test.golang.org" for testing and
@@ -41,7 +40,7 @@
 }
 
 func (h hostEnforcerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	if !enforceHosts {
+	if !env.EnforceHosts() {
 		h.h.ServeHTTP(w, r)
 		return
 	}
diff --git a/cmd/godoc/hg-git-mapping.bin b/cmd/godoc/hg-git-mapping.bin
new file mode 100644
index 0000000..3f6ca77
--- /dev/null
+++ b/cmd/godoc/hg-git-mapping.bin
Binary files differ
diff --git a/cmd/godoc/main.go b/cmd/godoc/main.go
index 89e9bba..7f0ac0c 100644
--- a/cmd/godoc/main.go
+++ b/cmd/godoc/main.go
@@ -23,7 +23,7 @@
 //	godoc crypto/block Cipher NewCMAC
 //		- prints doc for Cipher and NewCMAC in package crypto/block
 
-// +build !appengine
+// +build !golangorg
 
 package main
 
diff --git a/cmd/godoc/play.go b/cmd/godoc/play.go
index 02f477d..f44a3cc 100644
--- a/cmd/godoc/play.go
+++ b/cmd/godoc/play.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// +build !appengine
+// +build !golangorg
 
 package main
 
diff --git a/cmd/godoc/regtest.bash b/cmd/godoc/regtest.bash
new file mode 100755
index 0000000..9b596fa
--- /dev/null
+++ b/cmd/godoc/regtest.bash
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+
+# Copyright 2018 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.
+
+# Regression tests for golang.org.
+# Usage: ./regtest.bash https://golang.org/
+
+#TODO: turn this into a Go program. maybe behind a build tag and "go run regtest.go <url>"
+
+set -e
+
+addr="$(echo $1 | sed -e 's/\/$//')"
+if [ -z "$addr" ]; then
+	echo "usage: $0 <addr>" 1>&2
+	echo "example: $0 https://20180928t023837-dot-golang-org.appspot.com/" 1>&2
+	exit 1
+fi
+
+set -u
+
+# fetch url, check the response with a regexp.
+fetch() {
+	curl -s "${addr}$1" | grep "$2" > /dev/null
+}
+fatal() {
+	log "$1"
+	exit 1
+}
+log() {
+	echo "$1" 1>&2
+}
+logn() {
+	echo -n "$1" 1>&2
+}
+
+log "Checking FAQ..."
+fetch /doc/faq 'What is the purpose of the project' || {
+	fatal "FAQ did not match."
+}
+
+log "Checking package listing..."
+fetch /pkg/ 'Package tar' || {
+	fatal "package listing page did not match."
+}
+
+log "Checking os package..."
+fetch /pkg/os/ 'func Open' || {
+	fatal "os package page did not match."
+}
+
+log "Checking robots.txt..."
+fetch /robots.txt 'Disallow: /search' || {
+	fatal "robots.txt did not match."
+}
+
+log "Checking /change/ redirect..."
+fetch /change/75944e2e3a63 'bdb10cf' || {
+	fatal "/change/ direct did not match."
+}
+
+log "Checking /dl/ page has data..."
+fetch /dl/ 'go1.11.windows-amd64.msi' || {
+	fatal "/dl/ did not match."
+}
+
+log "Checking /dl/?mode=json page has data..."
+fetch /dl/?mode=json 'go1.11.windows-amd64.msi' || {
+	fatal "/dl/?mode=json did not match."
+}
+
+log "Checking shortlinks (/s/go2design)..."
+fetch /s/go2design 'proposal.*Found' || {
+	fatal "/s/go2design did not match."
+}
+
+log "Checking analytics on pages..."
+ga_id="UA-11222381-2"
+fetch / $ga_id || fatal "/ missing GA."
+fetch /dl/ $ga_id || fatal "/dl/ missing GA."
+fetch /project/ $ga_id || fatal "/project missing GA."
+fetch /pkg/context/ $ga_id || fatal "/pkg/context missing GA."
+
+log "Checking search..."
+fetch /search?q=IsDir 'src/os/types.go' || {
+	fatal "search result did not match."
+}
+
+log "Checking compile service..."
+compile="curl -s ${addr}/compile"
+
+p="package main; func main() { print(6*7); }"
+$compile --data-urlencode "body=$p" | tee /tmp/compile.out | grep '^{"compile_errors":"","output":"42"}$' > /dev/null || {
+	cat /tmp/compile.out
+	fatal "compile service output did not match."
+}
+
+$compile --data-urlencode "body=//empty" | tee /tmp/compile.out | grep "expected 'package', found 'EOF'" > /dev/null || {
+	cat /tmp/compile.out
+	fatal "compile service error output did not match."
+}
+
+# Check API version 2
+d="version=2&body=package+main%3Bimport+(%22fmt%22%3B%22time%22)%3Bfunc+main()%7Bfmt.Print(%22A%22)%3Btime.Sleep(time.Second)%3Bfmt.Print(%22B%22)%7D"
+$compile --data "$d" | grep '^{"Errors":"","Events":\[{"Message":"A","Kind":"stdout","Delay":0},{"Message":"B","Kind":"stdout","Delay":1000000000}\]}$' > /dev/null || {
+	fatal "compile service v2 output did not match."
+}
+
+log "All OK"
diff --git a/cmd/godoc/remotesearch.go b/cmd/godoc/remotesearch.go
index f01d5c7..6f27d0b 100644
--- a/cmd/godoc/remotesearch.go
+++ b/cmd/godoc/remotesearch.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// +build !appengine
+// +build !golangorg
 
 package main
 
diff --git a/godoc/appengine.go b/godoc/appengine.go
deleted file mode 100644
index fe5e687..0000000
--- a/godoc/appengine.go
+++ /dev/null
@@ -1,13 +0,0 @@
-// Copyright 2015 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.
-
-// +build appengine
-
-package godoc
-
-import "google.golang.org/appengine"
-
-func init() {
-	onAppengine = !appengine.IsDevAppServer()
-}
diff --git a/godoc/dl/dl.go b/godoc/dl/dl.go
index 83bb21e..edc09aa 100644
--- a/godoc/dl/dl.go
+++ b/godoc/dl/dl.go
@@ -2,8 +2,6 @@
 // Use of this source code is governed by the Apache 2.0
 // license that can be found in the LICENSE file.
 
-// +build appengine
-
 // Package dl implements a simple downloads frontend server.
 //
 // It accepts HTTP POST requests to create a new download metadata entity, and
@@ -19,6 +17,7 @@
 	"html"
 	"html/template"
 	"io"
+	"log"
 	"net/http"
 	"regexp"
 	"sort"
@@ -27,11 +26,10 @@
 	"sync"
 	"time"
 
+	"cloud.google.com/go/datastore"
 	"golang.org/x/net/context"
-	"google.golang.org/appengine"
-	"google.golang.org/appengine/datastore"
-	"google.golang.org/appengine/log"
-	"google.golang.org/appengine/memcache"
+	"golang.org/x/tools/godoc/env"
+	"golang.org/x/tools/internal/memcache"
 )
 
 const (
@@ -40,11 +38,21 @@
 	cacheDuration   = time.Hour
 )
 
-func RegisterHandlers(mux *http.ServeMux) {
-	mux.HandleFunc("/dl", getHandler)
-	mux.HandleFunc("/dl/", getHandler) // also serves listHandler
-	mux.HandleFunc("/dl/upload", uploadHandler)
-	mux.HandleFunc("/dl/init", initHandler)
+type server struct {
+	datastore *datastore.Client
+	memcache  *memcache.CodecClient
+}
+
+func RegisterHandlers(mux *http.ServeMux, dc *datastore.Client, mc *memcache.Client) {
+	s := server{dc, mc.WithCodec(memcache.Gob)}
+	mux.HandleFunc("/dl", s.getHandler)
+	mux.HandleFunc("/dl/", s.getHandler) // also serves listHandler
+	mux.HandleFunc("/dl/upload", s.uploadHandler)
+
+	// NOTE(cbro): this only needs to be run once per project,
+	// and should be behind an admin login.
+	// TODO(cbro): move into a locally-run program? or remove?
+	// mux.HandleFunc("/dl/init", initHandler)
 }
 
 // File represents a file on the golang.org downloads page.
@@ -191,26 +199,25 @@
 	templateFuncs = template.FuncMap{"pretty": pretty}
 )
 
-func listHandler(w http.ResponseWriter, r *http.Request) {
+func (h server) listHandler(w http.ResponseWriter, r *http.Request) {
 	if r.Method != "GET" {
 		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 		return
 	}
-	var (
-		c = appengine.NewContext(r)
-		d listTemplateData
-	)
-	if _, err := memcache.Gob.Get(c, cacheKey, &d); err != nil {
-		if err == memcache.ErrCacheMiss {
-			log.Debugf(c, "cache miss")
-		} else {
-			log.Errorf(c, "cache get error: %v", err)
+	ctx := r.Context()
+	var d listTemplateData
+
+	if err := h.memcache.Get(ctx, cacheKey, &d); err != nil {
+		if err != memcache.ErrCacheMiss {
+			log.Printf("ERROR cache get error: %v", err)
+			// NOTE(cbro): continue to hit datastore if the memcache is down.
 		}
 
 		var fs []File
-		_, err := datastore.NewQuery("File").Ancestor(rootKey(c)).GetAll(c, &fs)
-		if err != nil {
-			log.Errorf(c, "error listing: %v", err)
+		q := datastore.NewQuery("File").Ancestor(rootKey)
+		if _, err := h.datastore.GetAll(ctx, q, &fs); err != nil {
+			log.Printf("ERROR error listing: %v", err)
+			http.Error(w, "Could not get download page. Try again in a few minutes.", 500)
 			return
 		}
 		d.Stable, d.Unstable, d.Archive = filesToReleases(fs)
@@ -219,8 +226,8 @@
 		}
 
 		item := &memcache.Item{Key: cacheKey, Object: &d, Expiration: cacheDuration}
-		if err := memcache.Gob.Set(c, item); err != nil {
-			log.Errorf(c, "cache set error: %v", err)
+		if err := h.memcache.Set(ctx, item); err != nil {
+			log.Printf("ERROR cache set error: %v", err)
 		}
 	}
 
@@ -229,13 +236,13 @@
 		enc := json.NewEncoder(w)
 		enc.SetIndent("", " ")
 		if err := enc.Encode(d.Stable); err != nil {
-			log.Errorf(c, "failed rendering JSON for releases: %v", err)
+			log.Printf("ERROR rendering JSON for releases: %v", err)
 		}
 		return
 	}
 
 	if err := listTemplate.ExecuteTemplate(w, "root", d); err != nil {
-		log.Errorf(c, "error executing template: %v", err)
+		log.Printf("ERROR executing template: %v", err)
 	}
 }
 
@@ -383,12 +390,12 @@
 	return
 }
 
-func uploadHandler(w http.ResponseWriter, r *http.Request) {
+func (h server) uploadHandler(w http.ResponseWriter, r *http.Request) {
 	if r.Method != "POST" {
 		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 		return
 	}
-	c := appengine.NewContext(r)
+	ctx := r.Context()
 
 	// Authenticate using a user token (same as gomote).
 	user := r.FormValue("user")
@@ -396,7 +403,7 @@
 		http.Error(w, "bad user", http.StatusForbidden)
 		return
 	}
-	if r.FormValue("key") != userKey(c, user) {
+	if r.FormValue("key") != h.userKey(ctx, user) {
 		http.Error(w, "bad key", http.StatusForbidden)
 		return
 	}
@@ -404,7 +411,7 @@
 	var f File
 	defer r.Body.Close()
 	if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
-		log.Errorf(c, "error decoding upload JSON: %v", err)
+		log.Printf("ERROR decoding upload JSON: %v", err)
 		http.Error(w, "Something broke", http.StatusInternalServerError)
 		return
 	}
@@ -415,19 +422,19 @@
 	if f.Uploaded.IsZero() {
 		f.Uploaded = time.Now()
 	}
-	k := datastore.NewKey(c, "File", f.Filename, 0, rootKey(c))
-	if _, err := datastore.Put(c, k, &f); err != nil {
-		log.Errorf(c, "putting File entity: %v", err)
+	k := datastore.NameKey("File", f.Filename, rootKey)
+	if _, err := h.datastore.Put(ctx, k, &f); err != nil {
+		log.Printf("ERROR File entity: %v", err)
 		http.Error(w, "could not put File entity", http.StatusInternalServerError)
 		return
 	}
-	if err := memcache.Delete(c, cacheKey); err != nil {
-		log.Errorf(c, "cache delete error: %v", err)
+	if err := h.memcache.Delete(ctx, cacheKey); err != nil {
+		log.Printf("ERROR delete error: %v", err)
 	}
 	io.WriteString(w, "OK")
 }
 
-func getHandler(w http.ResponseWriter, r *http.Request) {
+func (h server) getHandler(w http.ResponseWriter, r *http.Request) {
 	// For go get golang.org/dl/go1.x.y, we need to serve the
 	// same meta tags at /dl for cmd/go to validate against /dl/go1.x.y:
 	if r.URL.Path == "/dl" && (r.Method == "GET" || r.Method == "HEAD") && r.FormValue("go-get") == "1" {
@@ -444,7 +451,7 @@
 
 	name := strings.TrimPrefix(r.URL.Path, "/dl/")
 	if name == "" {
-		listHandler(w, r)
+		h.listHandler(w, r)
 		return
 	}
 	if fileRe.MatchString(name) {
@@ -486,10 +493,10 @@
 	return false
 }
 
-func userKey(c context.Context, user string) string {
-	h := hmac.New(md5.New, []byte(secret(c)))
-	h.Write([]byte("user-" + user))
-	return fmt.Sprintf("%x", h.Sum(nil))
+func (h server) userKey(c context.Context, user string) string {
+	hash := hmac.New(md5.New, []byte(h.secret(c)))
+	hash.Write([]byte("user-" + user))
+	return fmt.Sprintf("%x", hash.Sum(nil))
 }
 
 var (
@@ -497,18 +504,18 @@
 	goGetRe = regexp.MustCompile(`^go[0-9a-z.]+\.[0-9a-z.-]+$`)
 )
 
-func initHandler(w http.ResponseWriter, r *http.Request) {
+func (h server) initHandler(w http.ResponseWriter, r *http.Request) {
 	var fileRoot struct {
 		Root string
 	}
-	c := appengine.NewContext(r)
-	k := rootKey(c)
-	err := datastore.RunInTransaction(c, func(c context.Context) error {
-		err := datastore.Get(c, k, &fileRoot)
+	ctx := r.Context()
+	k := rootKey
+	_, err := h.datastore.RunInTransaction(ctx, func(tx *datastore.Transaction) error {
+		err := tx.Get(k, &fileRoot)
 		if err != nil && err != datastore.ErrNoSuchEntity {
 			return err
 		}
-		_, err = datastore.Put(c, k, &fileRoot)
+		_, err = tx.Put(k, &fileRoot)
 		return err
 	}, nil)
 	if err != nil {
@@ -519,9 +526,7 @@
 }
 
 // rootKey is the ancestor of all File entities.
-func rootKey(c context.Context) *datastore.Key {
-	return datastore.NewKey(c, "FileRoot", "root", 0, nil)
-}
+var rootKey = datastore.NameKey("FileRoot", "root", nil)
 
 // pretty returns a human-readable version of the given OS, Arch, or Kind.
 func pretty(s string) string {
@@ -559,11 +564,11 @@
 	Secret string
 }
 
-func (k *builderKey) Key(c context.Context) *datastore.Key {
-	return datastore.NewKey(c, "BuilderKey", "root", 0, nil)
+func (k *builderKey) Key() *datastore.Key {
+	return datastore.NameKey("BuilderKey", "root", nil)
 }
 
-func secret(c context.Context) string {
+func (h server) secret(ctx context.Context) string {
 	// check with rlock
 	theKey.RLock()
 	k := theKey.Secret
@@ -580,18 +585,18 @@
 	}
 
 	// fill
-	if err := datastore.Get(c, theKey.Key(c), &theKey.builderKey); err != nil {
+	if err := h.datastore.Get(ctx, theKey.Key(), &theKey.builderKey); err != nil {
 		if err == datastore.ErrNoSuchEntity {
 			// If the key is not stored in datastore, write it.
 			// This only happens at the beginning of a new deployment.
 			// The code is left here for SDK use and in case a fresh
 			// deployment is ever needed.  "gophers rule" is not the
 			// real key.
-			if !appengine.IsDevAppServer() {
+			if env.IsProd() {
 				panic("lost key from datastore")
 			}
 			theKey.Secret = "gophers rule"
-			datastore.Put(c, theKey.Key(c), &theKey.builderKey)
+			h.datastore.Put(ctx, theKey.Key(), &theKey.builderKey)
 			return theKey.Secret
 		}
 		panic("cannot load builder key: " + err.Error())
diff --git a/godoc/dl/dl_test.go b/godoc/dl/dl_test.go
index 3f61fe9..2cdc1aa 100644
--- a/godoc/dl/dl_test.go
+++ b/godoc/dl/dl_test.go
@@ -2,8 +2,6 @@
 // Use of this source code is governed by the Apache 2.0
 // license that can be found in the LICENSE file.
 
-// +build appengine
-
 package dl
 
 import (
diff --git a/godoc/dl/tmpl.go b/godoc/dl/tmpl.go
index 47ef9f4..d086b69 100644
--- a/godoc/dl/tmpl.go
+++ b/godoc/dl/tmpl.go
@@ -2,8 +2,6 @@
 // Use of this source code is governed by the Apache 2.0
 // license that can be found in the LICENSE file.
 
-// +build appengine
-
 package dl
 
 // TODO(adg): refactor this to use the tools/godoc/static template.
diff --git a/godoc/env/env.go b/godoc/env/env.go
new file mode 100644
index 0000000..e1f55cd
--- /dev/null
+++ b/godoc/env/env.go
@@ -0,0 +1,41 @@
+// Copyright 2018 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 env provides environment information for the godoc server running on
+// golang.org.
+package env
+
+import (
+	"log"
+	"os"
+	"strconv"
+)
+
+var (
+	isProd       = boolEnv("GODOC_PROD")
+	enforceHosts = boolEnv("GODOC_ENFORCE_HOSTS")
+)
+
+// IsProd reports whether the server is running in its production configuration
+// on golang.org.
+func IsProd() bool {
+	return isProd
+}
+
+// EnforceHosts reports whether host filtering should be enforced.
+func EnforceHosts() bool {
+	return enforceHosts
+}
+
+func boolEnv(key string) bool {
+	v := os.Getenv(key)
+	if v == "" {
+		return false
+	}
+	b, err := strconv.ParseBool(v)
+	if err != nil {
+		log.Fatalf("environment variable %s (%q) must be a boolean", key, v)
+	}
+	return b
+}
diff --git a/godoc/page.go b/godoc/page.go
index 10e86e5..819af55 100644
--- a/godoc/page.go
+++ b/godoc/page.go
@@ -10,6 +10,8 @@
 	"path/filepath"
 	"runtime"
 	"strings"
+
+	"golang.org/x/tools/godoc/env"
 )
 
 // Page describes the contents of the top-level godoc webpage.
@@ -22,10 +24,11 @@
 	Body     []byte
 	GoogleCN bool // page is being served from golang.google.cn
 
-	// filled in by servePage
-	SearchBox  bool
-	Playground bool
-	Version    string
+	// filled in by ServePage
+	SearchBox       bool
+	Playground      bool
+	Version         string
+	GoogleAnalytics string
 }
 
 func (p *Presentation) ServePage(w http.ResponseWriter, page Page) {
@@ -35,6 +38,7 @@
 	page.SearchBox = p.Corpus.IndexEnabled
 	page.Playground = p.ShowPlayground
 	page.Version = runtime.Version()
+	page.GoogleAnalytics = p.GoogleAnalytics
 	applyTemplateToResponseWriter(w, p.GodocHTML, page)
 }
 
@@ -49,20 +53,19 @@
 		}
 	}
 	p.ServePage(w, Page{
-		Title:    "File " + relpath,
-		Subtitle: relpath,
-		Body:     applyTemplate(p.ErrorHTML, "errorHTML", err),
-		GoogleCN: googleCN(r),
+		Title:           "File " + relpath,
+		Subtitle:        relpath,
+		Body:            applyTemplate(p.ErrorHTML, "errorHTML", err),
+		GoogleCN:        googleCN(r),
+		GoogleAnalytics: p.GoogleAnalytics,
 	})
 }
 
-var onAppengine = false // overridden in appengine.go when on app engine
-
 func googleCN(r *http.Request) bool {
 	if r.FormValue("googlecn") != "" {
 		return true
 	}
-	if !onAppengine {
+	if !env.IsProd() {
 		return false
 	}
 	if strings.HasSuffix(r.Host, ".cn") {
diff --git a/godoc/pres.go b/godoc/pres.go
index de23c75..b0077fd 100644
--- a/godoc/pres.go
+++ b/godoc/pres.go
@@ -92,6 +92,10 @@
 	// body for displaying search results.
 	SearchResults []SearchResultFunc
 
+	// GoogleAnalytics optionally adds Google Analytics via the provided
+	// tracking ID to each page.
+	GoogleAnalytics string
+
 	initFuncMapOnce sync.Once
 	funcMap         template.FuncMap
 	templateFuncs   template.FuncMap
diff --git a/godoc/proxy/proxy.go b/godoc/proxy/proxy.go
index cdac3bf..f302372 100644
--- a/godoc/proxy/proxy.go
+++ b/godoc/proxy/proxy.go
@@ -2,8 +2,6 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// +build appengine
-
 // Package proxy proxies requests to the playground's compile and share handlers.
 // It is designed to run only on the instance of godoc that serves golang.org.
 package proxy
@@ -13,6 +11,7 @@
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
+	"log"
 	"net/http"
 	"net/http/httputil"
 	"net/url"
@@ -20,12 +19,18 @@
 	"time"
 
 	"golang.org/x/net/context"
-
-	"google.golang.org/appengine"
-	"google.golang.org/appengine/log"
-	"google.golang.org/appengine/urlfetch"
+	"golang.org/x/tools/godoc/env"
 )
 
+const playgroundURL = "https://play.golang.org"
+
+var proxy *httputil.ReverseProxy
+
+func init() {
+	target, _ := url.Parse(playgroundURL)
+	proxy = httputil.NewSingleHostReverseProxy(target)
+}
+
 type Request struct {
 	Body string
 }
@@ -41,8 +46,6 @@
 	Delay   time.Duration // time to wait before printing Message
 }
 
-const playgroundURL = "https://play.golang.org"
-
 const expires = 7 * 24 * time.Hour // 1 week
 var cacheControlHeader = fmt.Sprintf("public, max-age=%d", int(expires.Seconds()))
 
@@ -57,21 +60,17 @@
 		return
 	}
 
-	ctx := appengine.NewContext(r)
+	ctx := r.Context()
 
 	body := r.FormValue("body")
 	res := &Response{}
 	req := &Request{Body: body}
 	if err := makeCompileRequest(ctx, req, res); err != nil {
-		log.Errorf(ctx, "compile error: %v", err)
+		log.Printf("ERROR compile error: %v", err)
 		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
 		return
 	}
 
-	expiresTime := time.Now().Add(expires).UTC()
-	w.Header().Set("Expires", expiresTime.Format(time.RFC1123))
-	w.Header().Set("Cache-Control", cacheControlHeader)
-
 	var out interface{}
 	switch r.FormValue("version") {
 	case "2":
@@ -82,9 +81,17 @@
 			Output        string `json:"output"`
 		}{res.Errors, flatten(res.Events)}
 	}
-	if err := json.NewEncoder(w).Encode(out); err != nil {
-		log.Errorf(ctx, "encoding response: %v", err)
+	b, err := json.Marshal(out)
+	if err != nil {
+		log.Printf("ERROR encoding response: %v", err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
 	}
+
+	expiresTime := time.Now().Add(expires).UTC()
+	w.Header().Set("Expires", expiresTime.Format(time.RFC1123))
+	w.Header().Set("Cache-Control", cacheControlHeader)
+	w.Write(b)
 }
 
 // makePlaygroundRequest sends the given Request to the playground compile
@@ -94,17 +101,22 @@
 	if err != nil {
 		return fmt.Errorf("marshalling request: %v", err)
 	}
-	r, err := urlfetch.Client(ctx).Post(playgroundURL+"/compile", "application/json", bytes.NewReader(reqJ))
+	hReq, _ := http.NewRequest("POST", playgroundURL+"/compile", bytes.NewReader(reqJ))
+	hReq.Header.Set("Content-Type", "application/json")
+	hReq = hReq.WithContext(ctx)
+
+	r, err := http.DefaultClient.Do(hReq)
 	if err != nil {
 		return fmt.Errorf("making request: %v", err)
 	}
 	defer r.Body.Close()
+
 	if r.StatusCode != http.StatusOK {
 		b, _ := ioutil.ReadAll(r.Body)
 		return fmt.Errorf("bad status: %v body:\n%s", r.Status, b)
 	}
-	err = json.NewDecoder(r.Body).Decode(res)
-	if err != nil {
+
+	if err := json.NewDecoder(r.Body).Decode(res); err != nil {
 		return fmt.Errorf("unmarshalling response: %v", err)
 	}
 	return nil
@@ -124,17 +136,14 @@
 		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
 		return
 	}
-	target, _ := url.Parse(playgroundURL)
-	p := httputil.NewSingleHostReverseProxy(target)
-	p.Transport = &urlfetch.Transport{Context: appengine.NewContext(r)}
-	p.ServeHTTP(w, r)
+	proxy.ServeHTTP(w, r)
 }
 
 func googleCN(r *http.Request) bool {
 	if r.FormValue("googlecn") != "" {
 		return true
 	}
-	if appengine.IsDevAppServer() {
+	if !env.IsProd() {
 		return false
 	}
 	if strings.HasSuffix(r.Host, ".cn") {
diff --git a/godoc/short/short.go b/godoc/short/short.go
index 44d3c93..da710eb 100644
--- a/godoc/short/short.go
+++ b/godoc/short/short.go
@@ -2,8 +2,6 @@
 // Use of this source code is governed by the Apache 2.0
 // license that can be found in the LICENSE file.
 
-// +build appengine
-
 // Package short implements a simple URL shortener, serving an administrative
 // interface at /s and shortened urls from /s/key.
 // It is designed to run only on the instance of godoc that serves golang.org.
@@ -15,16 +13,15 @@
 	"errors"
 	"fmt"
 	"html/template"
+	"io"
+	"log"
 	"net/http"
 	"net/url"
 	"regexp"
 
+	"cloud.google.com/go/datastore"
 	"golang.org/x/net/context"
-
-	"google.golang.org/appengine"
-	"google.golang.org/appengine/datastore"
-	"google.golang.org/appengine/log"
-	"google.golang.org/appengine/memcache"
+	"golang.org/x/tools/internal/memcache"
 	"google.golang.org/appengine/user"
 )
 
@@ -41,17 +38,32 @@
 
 var validKey = regexp.MustCompile(`^[a-zA-Z0-9-_.]+$`)
 
-func RegisterHandlers(mux *http.ServeMux) {
-	mux.HandleFunc(prefix, adminHandler)
-	mux.HandleFunc(prefix+"/", linkHandler)
+type server struct {
+	datastore *datastore.Client
+	memcache  *memcache.CodecClient
+}
+
+func RegisterHandlers(mux *http.ServeMux, dc *datastore.Client, mc *memcache.Client) {
+	s := server{dc, mc.WithCodec(memcache.JSON)}
+	mux.HandleFunc(prefix+"/", s.linkHandler)
+
+	// TODO(cbro): move storage of the links to a text file in Gerrit.
+	// Disable the admin handler until that happens, since GAE Flex doesn't support
+	// the "google.golang.org/appengine/user" package.
+	// See golang.org/issue/27205#issuecomment-418673218
+	// mux.HandleFunc(prefix, adminHandler)
+	mux.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusForbidden)
+		io.WriteString(w, "Link creation temporarily unavailable. See golang.org/issue/27205.")
+	})
 }
 
 // linkHandler services requests to short URLs.
 //   http://golang.org/s/key
 // It consults memcache and datastore for the Link for key.
 // It then sends a redirects or an error message.
-func linkHandler(w http.ResponseWriter, r *http.Request) {
-	c := appengine.NewContext(r)
+func (h server) linkHandler(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
 
 	key := r.URL.Path[len(prefix)+1:]
 	if !validKey.MatchString(key) {
@@ -60,16 +72,15 @@
 	}
 
 	var link Link
-	_, err := memcache.JSON.Get(c, cacheKey(key), &link)
-	if err != nil {
-		k := datastore.NewKey(c, kind, key, 0, nil)
-		err = datastore.Get(c, k, &link)
+	if err := h.memcache.Get(ctx, cacheKey(key), &link); err != nil {
+		k := datastore.NameKey(kind, key, nil)
+		err = h.datastore.Get(ctx, k, &link)
 		switch err {
 		case datastore.ErrNoSuchEntity:
 			http.Error(w, "not found", http.StatusNotFound)
 			return
 		default: // != nil
-			log.Errorf(c, "%q: %v", key, err)
+			log.Printf("ERROR %q: %v", key, err)
 			http.Error(w, "internal server error", http.StatusInternalServerError)
 			return
 		case nil:
@@ -77,8 +88,8 @@
 				Key:    cacheKey(key),
 				Object: &link,
 			}
-			if err := memcache.JSON.Set(c, item); err != nil {
-				log.Warningf(c, "%q: %v", key, err)
+			if err := h.memcache.Set(ctx, item); err != nil {
+				log.Printf("WARNING %q: %v", key, err)
 			}
 		}
 	}
@@ -89,10 +100,10 @@
 var adminTemplate = template.Must(template.New("admin").Parse(templateHTML))
 
 // adminHandler serves an administrative interface.
-func adminHandler(w http.ResponseWriter, r *http.Request) {
-	c := appengine.NewContext(r)
+func (h server) adminHandler(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
 
-	if !user.IsAdmin(c) {
+	if !user.IsAdmin(ctx) {
 		http.Error(w, "forbidden", http.StatusForbidden)
 		return
 	}
@@ -104,24 +115,24 @@
 		switch r.FormValue("do") {
 		case "Add":
 			newLink = &Link{key, r.FormValue("target")}
-			doErr = putLink(c, newLink)
+			doErr = h.putLink(ctx, newLink)
 		case "Delete":
-			k := datastore.NewKey(c, kind, key, 0, nil)
-			doErr = datastore.Delete(c, k)
+			k := datastore.NameKey(kind, key, nil)
+			doErr = h.datastore.Delete(ctx, k)
 		default:
 			http.Error(w, "unknown action", http.StatusBadRequest)
 		}
-		err := memcache.Delete(c, cacheKey(key))
+		err := h.memcache.Delete(ctx, cacheKey(key))
 		if err != nil && err != memcache.ErrCacheMiss {
-			log.Warningf(c, "%q: %v", key, err)
+			log.Printf("WARNING %q: %v", key, err)
 		}
 	}
 
 	var links []*Link
-	_, err := datastore.NewQuery(kind).Order("Key").GetAll(c, &links)
-	if err != nil {
+	q := datastore.NewQuery(kind).Order("Key")
+	if _, err := h.datastore.GetAll(ctx, q, &links); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
-		log.Errorf(c, "%v", err)
+		log.Printf("ERROR %v", err)
 		return
 	}
 
@@ -150,20 +161,20 @@
 		Error   error
 	}{baseURL, prefix, links, newLink, doErr}
 	if err := adminTemplate.Execute(w, &data); err != nil {
-		log.Criticalf(c, "adminTemplate: %v", err)
+		log.Printf("ERROR adminTemplate: %v", err)
 	}
 }
 
 // putLink validates the provided link and puts it into the datastore.
-func putLink(c context.Context, link *Link) error {
+func (h server) putLink(ctx context.Context, link *Link) error {
 	if !validKey.MatchString(link.Key) {
 		return errors.New("invalid key; must match " + validKey.String())
 	}
 	if _, err := url.Parse(link.Target); err != nil {
 		return fmt.Errorf("bad target: %v", err)
 	}
-	k := datastore.NewKey(c, kind, link.Key, 0, nil)
-	_, err := datastore.Put(c, k, link)
+	k := datastore.NameKey(kind, link.Key, nil)
+	_, err := h.datastore.Put(ctx, k, link)
 	return err
 }
 
diff --git a/godoc/short/tmpl.go b/godoc/short/tmpl.go
index 95e4c2a..66f5401 100644
--- a/godoc/short/tmpl.go
+++ b/godoc/short/tmpl.go
@@ -2,8 +2,6 @@
 // Use of this source code is governed by the Apache 2.0
 // license that can be found in the LICENSE file.
 
-// +build appengine
-
 package short
 
 const templateHTML = `
diff --git a/godoc/static/godoc.html b/godoc/static/godoc.html
index 6c7889f..2688b16 100644
--- a/godoc/static/godoc.html
+++ b/godoc/static/godoc.html
@@ -15,6 +15,19 @@
 {{end}}
 <link rel="stylesheet" href="/lib/godoc/jquery.treeview.css">
 <script>window.initFuncs = [];</script>
+{{with .GoogleAnalytics}}
+<script type="text/javascript">
+var _gaq = _gaq || [];
+_gaq.push(["_setAccount", "{{.}}"]);
+window.trackPageview = function() {
+  _gaq.push(["_trackPageview", location.pathname+location.hash]);
+};
+window.trackPageview();
+window.trackEvent = function(category, action, opt_label, opt_value, opt_noninteraction) {
+  _gaq.push(["_trackEvent", category, action, opt_label, opt_value, opt_noninteraction]);
+};
+</script>
+{{end}}
 <script src="/lib/godoc/jquery.js" defer></script>
 <script src="/lib/godoc/jquery.treeview.js" defer></script>
 <script src="/lib/godoc/jquery.treeview.edit.js" defer></script>
@@ -112,6 +125,15 @@
 
 </div><!-- .container -->
 </div><!-- #page -->
+{{if .GoogleAnalytics}}
+<script type="text/javascript">
+(function() {
+  var ga = document.createElement("script"); ga.type = "text/javascript"; ga.async = true;
+  ga.src = ("https:" == document.location.protocol ? "https://ssl" : "http://www") + ".google-analytics.com/ga.js";
+  var s = document.getElementsByTagName("script")[0]; s.parentNode.insertBefore(ga, s);
+})();
+</script>
+{{end}}
 </body>
 </html>
 
diff --git a/godoc/static/static.go b/godoc/static/static.go
index eacd495..97b84c6 100644
--- a/godoc/static/static.go
+++ b/godoc/static/static.go
@@ -51,7 +51,7 @@
 
 	"example.html": "<div\x20id=\"example_{{.Name}}\"\x20class=\"toggle\">\x0a\x09<div\x20class=\"collapsed\">\x0a\x09\x09<p\x20class=\"exampleHeading\x20toggleButton\">\xe2\x96\xb9\x20<span\x20class=\"text\">Example{{example_suffix\x20.Name}}</span></p>\x0a\x09</div>\x0a\x09<div\x20class=\"expanded\">\x0a\x09\x09<p\x20class=\"exampleHeading\x20toggleButton\">\xe2\x96\xbe\x20<span\x20class=\"text\">Example{{example_suffix\x20.Name}}</span></p>\x0a\x09\x09{{with\x20.Doc}}<p>{{html\x20.}}</p>{{end}}\x0a\x09\x09{{$output\x20:=\x20.Output}}\x0a\x09\x09{{with\x20.Play}}\x0a\x09\x09\x09<div\x20class=\"play\">\x0a\x09\x09\x09\x09<div\x20class=\"input\"><textarea\x20class=\"code\"\x20spellcheck=\"false\">{{html\x20.}}</textarea></div>\x0a\x09\x09\x09\x09<div\x20class=\"output\"><pre>{{html\x20$output}}</pre></div>\x0a\x09\x09\x09\x09<div\x20class=\"buttons\">\x0a\x09\x09\x09\x09\x09<a\x20class=\"run\"\x20title=\"Run\x20this\x20code\x20[shift-enter]\">Run</a>\x0a\x09\x09\x09\x09\x09<a\x20class=\"fmt\"\x20title=\"Format\x20this\x20code\">Format</a>\x0a\x09\x09\x09\x09\x09{{if\x20not\x20$.GoogleCN}}\x0a\x09\x09\x09\x09\x09<a\x20class=\"share\"\x20title=\"Share\x20this\x20code\">Share</a>\x0a\x09\x09\x09\x09\x09{{end}}\x0a\x09\x09\x09\x09</div>\x0a\x09\x09\x09</div>\x0a\x09\x09{{else}}\x0a\x09\x09\x09<p>Code:</p>\x0a\x09\x09\x09<pre\x20class=\"code\">{{.Code}}</pre>\x0a\x09\x09\x09{{with\x20.Output}}\x0a\x09\x09\x09<p>Output:</p>\x0a\x09\x09\x09<pre\x20class=\"output\">{{html\x20.}}</pre>\x0a\x09\x09\x09{{end}}\x0a\x09\x09{{end}}\x0a\x09</div>\x0a</div>\x0a",
 
-	"godoc.html": "<!DOCTYPE\x20html>\x0a<html>\x0a<head>\x0a<meta\x20http-equiv=\"Content-Type\"\x20content=\"text/html;\x20charset=utf-8\">\x0a<meta\x20name=\"viewport\"\x20content=\"width=device-width,\x20initial-scale=1\">\x0a<meta\x20name=\"theme-color\"\x20content=\"#375EAB\">\x0a{{with\x20.Tabtitle}}\x0a\x20\x20<title>{{html\x20.}}\x20-\x20The\x20Go\x20Programming\x20Language</title>\x0a{{else}}\x0a\x20\x20<title>The\x20Go\x20Programming\x20Language</title>\x0a{{end}}\x0a<link\x20type=\"text/css\"\x20rel=\"stylesheet\"\x20href=\"/lib/godoc/style.css\">\x0a{{if\x20.SearchBox}}\x0a<link\x20rel=\"search\"\x20type=\"application/opensearchdescription+xml\"\x20title=\"godoc\"\x20href=\"/opensearch.xml\"\x20/>\x0a{{end}}\x0a<link\x20rel=\"stylesheet\"\x20href=\"/lib/godoc/jquery.treeview.css\">\x0a<script>window.initFuncs\x20=\x20[];</script>\x0a<script\x20src=\"/lib/godoc/jquery.js\"\x20defer></script>\x0a<script\x20src=\"/lib/godoc/jquery.treeview.js\"\x20defer></script>\x0a<script\x20src=\"/lib/godoc/jquery.treeview.edit.js\"\x20defer></script>\x0a\x0a{{if\x20.Playground}}\x0a<script\x20src=\"/lib/godoc/playground.js\"\x20defer></script>\x0a{{end}}\x0a{{with\x20.Version}}<script>var\x20goVersion\x20=\x20{{printf\x20\"%q\"\x20.}};</script>{{end}}\x0a<script\x20src=\"/lib/godoc/godocs.js\"\x20defer></script>\x0a</head>\x0a<body>\x0a\x0a<div\x20id='lowframe'\x20style=\"position:\x20fixed;\x20bottom:\x200;\x20left:\x200;\x20height:\x200;\x20width:\x20100%;\x20border-top:\x20thin\x20solid\x20grey;\x20background-color:\x20white;\x20overflow:\x20auto;\">\x0a...\x0a</div><!--\x20#lowframe\x20-->\x0a\x0a<div\x20id=\"topbar\"{{if\x20.Title}}\x20class=\"wide\"{{end}}><div\x20class=\"container\">\x0a<div\x20class=\"top-heading\"\x20id=\"heading-wide\"><a\x20href=\"/\">The\x20Go\x20Programming\x20Language</a></div>\x0a<div\x20class=\"top-heading\"\x20id=\"heading-narrow\"><a\x20href=\"/\">Go</a></div>\x0a<a\x20href=\"#\"\x20id=\"menu-button\"><span\x20id=\"menu-button-arrow\">&#9661;</span></a>\x0a<form\x20method=\"GET\"\x20action=\"/search\">\x0a<div\x20id=\"menu\">\x0a<a\x20href=\"/doc/\">Documents</a>\x0a<a\x20href=\"/pkg/\">Packages</a>\x0a<a\x20href=\"/project/\">The\x20Project</a>\x0a<a\x20href=\"/help/\">Help</a>\x0a{{if\x20not\x20.GoogleCN}}\x0a<a\x20href=\"/blog/\">Blog</a>\x0a{{end}}\x0a{{if\x20.Playground}}\x0a<a\x20id=\"playgroundButton\"\x20href=\"http://play.golang.org/\"\x20title=\"Show\x20Go\x20Playground\">Play</a>\x0a{{end}}\x0a<span\x20class=\"search-box\"><input\x20type=\"search\"\x20id=\"search\"\x20name=\"q\"\x20placeholder=\"Search\"\x20aria-label=\"Search\"\x20required><button\x20type=\"submit\"><span><!--\x20magnifying\x20glass:\x20--><svg\x20width=\"24\"\x20height=\"24\"\x20viewBox=\"0\x200\x2024\x2024\"><title>submit\x20search</title><path\x20d=\"M15.5\x2014h-.79l-.28-.27C15.41\x2012.59\x2016\x2011.11\x2016\x209.5\x2016\x205.91\x2013.09\x203\x209.5\x203S3\x205.91\x203\x209.5\x205.91\x2016\x209.5\x2016c1.61\x200\x203.09-.59\x204.23-1.57l.27.28v.79l5\x204.99L20.49\x2019l-4.99-5zm-6\x200C7.01\x2014\x205\x2011.99\x205\x209.5S7.01\x205\x209.5\x205\x2014\x207.01\x2014\x209.5\x2011.99\x2014\x209.5\x2014z\"/><path\x20d=\"M0\x200h24v24H0z\"\x20fill=\"none\"/></svg></span></button></span>\x0a</div>\x0a</form>\x0a\x0a</div></div>\x0a\x0a{{if\x20.Playground}}\x0a<div\x20id=\"playground\"\x20class=\"play\">\x0a\x09<div\x20class=\"input\"><textarea\x20class=\"code\"\x20spellcheck=\"false\">package\x20main\x0a\x0aimport\x20\"fmt\"\x0a\x0afunc\x20main()\x20{\x0a\x09fmt.Println(\"Hello,\x20\xe4\xb8\x96\xe7\x95\x8c\")\x0a}</textarea></div>\x0a\x09<div\x20class=\"output\"></div>\x0a\x09<div\x20class=\"buttons\">\x0a\x09\x09<a\x20class=\"run\"\x20title=\"Run\x20this\x20code\x20[shift-enter]\">Run</a>\x0a\x09\x09<a\x20class=\"fmt\"\x20title=\"Format\x20this\x20code\">Format</a>\x0a\x09\x09{{if\x20not\x20$.GoogleCN}}\x0a\x09\x09<a\x20class=\"share\"\x20title=\"Share\x20this\x20code\">Share</a>\x0a\x09\x09{{end}}\x0a\x09</div>\x0a</div>\x0a{{end}}\x0a\x0a<div\x20id=\"page\"{{if\x20.Title}}\x20class=\"wide\"{{end}}>\x0a<div\x20class=\"container\">\x0a\x0a{{if\x20or\x20.Title\x20.SrcPath}}\x0a\x20\x20<h1>\x0a\x20\x20\x20\x20{{html\x20.Title}}\x0a\x20\x20\x20\x20{{html\x20.SrcPath\x20|\x20srcBreadcrumb}}\x0a\x20\x20</h1>\x0a{{end}}\x0a\x0a{{with\x20.Subtitle}}\x0a\x20\x20<h2>{{html\x20.}}</h2>\x0a{{end}}\x0a\x0a{{with\x20.SrcPath}}\x0a\x20\x20<h2>\x0a\x20\x20\x20\x20Documentation:\x20{{html\x20.\x20|\x20srcToPkgLink}}\x0a\x20\x20</h2>\x0a{{end}}\x0a\x0a{{/*\x20The\x20Table\x20of\x20Contents\x20is\x20automatically\x20inserted\x20in\x20this\x20<div>.\x0a\x20\x20\x20\x20\x20Do\x20not\x20delete\x20this\x20<div>.\x20*/}}\x0a<div\x20id=\"nav\"></div>\x0a\x0a{{/*\x20Body\x20is\x20HTML-escaped\x20elsewhere\x20*/}}\x0a{{printf\x20\"%s\"\x20.Body}}\x0a\x0a<div\x20id=\"footer\">\x0aBuild\x20version\x20{{html\x20.Version}}.<br>\x0aExcept\x20as\x20<a\x20href=\"https://developers.google.com/site-policies#restrictions\">noted</a>,\x0athe\x20content\x20of\x20this\x20page\x20is\x20licensed\x20under\x20the\x0aCreative\x20Commons\x20Attribution\x203.0\x20License,\x0aand\x20code\x20is\x20licensed\x20under\x20a\x20<a\x20href=\"/LICENSE\">BSD\x20license</a>.<br>\x0a<a\x20href=\"/doc/tos.html\">Terms\x20of\x20Service</a>\x20|\x0a<a\x20href=\"http://www.google.com/intl/en/policies/privacy/\">Privacy\x20Policy</a>\x0a</div>\x0a\x0a</div><!--\x20.container\x20-->\x0a</div><!--\x20#page\x20-->\x0a</body>\x0a</html>\x0a\x0a",
+	"godoc.html": "<!DOCTYPE\x20html>\x0a<html>\x0a<head>\x0a<meta\x20http-equiv=\"Content-Type\"\x20content=\"text/html;\x20charset=utf-8\">\x0a<meta\x20name=\"viewport\"\x20content=\"width=device-width,\x20initial-scale=1\">\x0a<meta\x20name=\"theme-color\"\x20content=\"#375EAB\">\x0a{{with\x20.Tabtitle}}\x0a\x20\x20<title>{{html\x20.}}\x20-\x20The\x20Go\x20Programming\x20Language</title>\x0a{{else}}\x0a\x20\x20<title>The\x20Go\x20Programming\x20Language</title>\x0a{{end}}\x0a<link\x20type=\"text/css\"\x20rel=\"stylesheet\"\x20href=\"/lib/godoc/style.css\">\x0a{{if\x20.SearchBox}}\x0a<link\x20rel=\"search\"\x20type=\"application/opensearchdescription+xml\"\x20title=\"godoc\"\x20href=\"/opensearch.xml\"\x20/>\x0a{{end}}\x0a<link\x20rel=\"stylesheet\"\x20href=\"/lib/godoc/jquery.treeview.css\">\x0a<script>window.initFuncs\x20=\x20[];</script>\x0a{{with\x20.GoogleAnalytics}}\x0a<script\x20type=\"text/javascript\">\x0avar\x20_gaq\x20=\x20_gaq\x20||\x20[];\x0a_gaq.push([\"_setAccount\",\x20\"{{.}}\"]);\x0awindow.trackPageview\x20=\x20function()\x20{\x0a\x20\x20_gaq.push([\"_trackPageview\",\x20location.pathname+location.hash]);\x0a};\x0awindow.trackPageview();\x0awindow.trackEvent\x20=\x20function(category,\x20action,\x20opt_label,\x20opt_value,\x20opt_noninteraction)\x20{\x0a\x20\x20_gaq.push([\"_trackEvent\",\x20category,\x20action,\x20opt_label,\x20opt_value,\x20opt_noninteraction]);\x0a};\x0a</script>\x0a{{end}}\x0a<script\x20src=\"/lib/godoc/jquery.js\"\x20defer></script>\x0a<script\x20src=\"/lib/godoc/jquery.treeview.js\"\x20defer></script>\x0a<script\x20src=\"/lib/godoc/jquery.treeview.edit.js\"\x20defer></script>\x0a\x0a{{if\x20.Playground}}\x0a<script\x20src=\"/lib/godoc/playground.js\"\x20defer></script>\x0a{{end}}\x0a{{with\x20.Version}}<script>var\x20goVersion\x20=\x20{{printf\x20\"%q\"\x20.}};</script>{{end}}\x0a<script\x20src=\"/lib/godoc/godocs.js\"\x20defer></script>\x0a</head>\x0a<body>\x0a\x0a<div\x20id='lowframe'\x20style=\"position:\x20fixed;\x20bottom:\x200;\x20left:\x200;\x20height:\x200;\x20width:\x20100%;\x20border-top:\x20thin\x20solid\x20grey;\x20background-color:\x20white;\x20overflow:\x20auto;\">\x0a...\x0a</div><!--\x20#lowframe\x20-->\x0a\x0a<div\x20id=\"topbar\"{{if\x20.Title}}\x20class=\"wide\"{{end}}><div\x20class=\"container\">\x0a<div\x20class=\"top-heading\"\x20id=\"heading-wide\"><a\x20href=\"/\">The\x20Go\x20Programming\x20Language</a></div>\x0a<div\x20class=\"top-heading\"\x20id=\"heading-narrow\"><a\x20href=\"/\">Go</a></div>\x0a<a\x20href=\"#\"\x20id=\"menu-button\"><span\x20id=\"menu-button-arrow\">&#9661;</span></a>\x0a<form\x20method=\"GET\"\x20action=\"/search\">\x0a<div\x20id=\"menu\">\x0a<a\x20href=\"/doc/\">Documents</a>\x0a<a\x20href=\"/pkg/\">Packages</a>\x0a<a\x20href=\"/project/\">The\x20Project</a>\x0a<a\x20href=\"/help/\">Help</a>\x0a{{if\x20not\x20.GoogleCN}}\x0a<a\x20href=\"/blog/\">Blog</a>\x0a{{end}}\x0a{{if\x20.Playground}}\x0a<a\x20id=\"playgroundButton\"\x20href=\"http://play.golang.org/\"\x20title=\"Show\x20Go\x20Playground\">Play</a>\x0a{{end}}\x0a<span\x20class=\"search-box\"><input\x20type=\"search\"\x20id=\"search\"\x20name=\"q\"\x20placeholder=\"Search\"\x20aria-label=\"Search\"\x20required><button\x20type=\"submit\"><span><!--\x20magnifying\x20glass:\x20--><svg\x20width=\"24\"\x20height=\"24\"\x20viewBox=\"0\x200\x2024\x2024\"><title>submit\x20search</title><path\x20d=\"M15.5\x2014h-.79l-.28-.27C15.41\x2012.59\x2016\x2011.11\x2016\x209.5\x2016\x205.91\x2013.09\x203\x209.5\x203S3\x205.91\x203\x209.5\x205.91\x2016\x209.5\x2016c1.61\x200\x203.09-.59\x204.23-1.57l.27.28v.79l5\x204.99L20.49\x2019l-4.99-5zm-6\x200C7.01\x2014\x205\x2011.99\x205\x209.5S7.01\x205\x209.5\x205\x2014\x207.01\x2014\x209.5\x2011.99\x2014\x209.5\x2014z\"/><path\x20d=\"M0\x200h24v24H0z\"\x20fill=\"none\"/></svg></span></button></span>\x0a</div>\x0a</form>\x0a\x0a</div></div>\x0a\x0a{{if\x20.Playground}}\x0a<div\x20id=\"playground\"\x20class=\"play\">\x0a\x09<div\x20class=\"input\"><textarea\x20class=\"code\"\x20spellcheck=\"false\">package\x20main\x0a\x0aimport\x20\"fmt\"\x0a\x0afunc\x20main()\x20{\x0a\x09fmt.Println(\"Hello,\x20\xe4\xb8\x96\xe7\x95\x8c\")\x0a}</textarea></div>\x0a\x09<div\x20class=\"output\"></div>\x0a\x09<div\x20class=\"buttons\">\x0a\x09\x09<a\x20class=\"run\"\x20title=\"Run\x20this\x20code\x20[shift-enter]\">Run</a>\x0a\x09\x09<a\x20class=\"fmt\"\x20title=\"Format\x20this\x20code\">Format</a>\x0a\x09\x09{{if\x20not\x20$.GoogleCN}}\x0a\x09\x09<a\x20class=\"share\"\x20title=\"Share\x20this\x20code\">Share</a>\x0a\x09\x09{{end}}\x0a\x09</div>\x0a</div>\x0a{{end}}\x0a\x0a<div\x20id=\"page\"{{if\x20.Title}}\x20class=\"wide\"{{end}}>\x0a<div\x20class=\"container\">\x0a\x0a{{if\x20or\x20.Title\x20.SrcPath}}\x0a\x20\x20<h1>\x0a\x20\x20\x20\x20{{html\x20.Title}}\x0a\x20\x20\x20\x20{{html\x20.SrcPath\x20|\x20srcBreadcrumb}}\x0a\x20\x20</h1>\x0a{{end}}\x0a\x0a{{with\x20.Subtitle}}\x0a\x20\x20<h2>{{html\x20.}}</h2>\x0a{{end}}\x0a\x0a{{with\x20.SrcPath}}\x0a\x20\x20<h2>\x0a\x20\x20\x20\x20Documentation:\x20{{html\x20.\x20|\x20srcToPkgLink}}\x0a\x20\x20</h2>\x0a{{end}}\x0a\x0a{{/*\x20The\x20Table\x20of\x20Contents\x20is\x20automatically\x20inserted\x20in\x20this\x20<div>.\x0a\x20\x20\x20\x20\x20Do\x20not\x20delete\x20this\x20<div>.\x20*/}}\x0a<div\x20id=\"nav\"></div>\x0a\x0a{{/*\x20Body\x20is\x20HTML-escaped\x20elsewhere\x20*/}}\x0a{{printf\x20\"%s\"\x20.Body}}\x0a\x0a<div\x20id=\"footer\">\x0aBuild\x20version\x20{{html\x20.Version}}.<br>\x0aExcept\x20as\x20<a\x20href=\"https://developers.google.com/site-policies#restrictions\">noted</a>,\x0athe\x20content\x20of\x20this\x20page\x20is\x20licensed\x20under\x20the\x0aCreative\x20Commons\x20Attribution\x203.0\x20License,\x0aand\x20code\x20is\x20licensed\x20under\x20a\x20<a\x20href=\"/LICENSE\">BSD\x20license</a>.<br>\x0a<a\x20href=\"/doc/tos.html\">Terms\x20of\x20Service</a>\x20|\x0a<a\x20href=\"http://www.google.com/intl/en/policies/privacy/\">Privacy\x20Policy</a>\x0a</div>\x0a\x0a</div><!--\x20.container\x20-->\x0a</div><!--\x20#page\x20-->\x0a{{with\x20.GoogleAnalytics}}\x0a<script\x20type=\"text/javascript\">\x0a(function()\x20{\x0a\x20\x20var\x20ga\x20=\x20document.createElement(\"script\");\x20ga.type\x20=\x20\"text/javascript\";\x20ga.async\x20=\x20true;\x0a\x20\x20ga.src\x20=\x20(\"https:\"\x20==\x20document.location.protocol\x20?\x20\"https://ssl\"\x20:\x20\"http://www\")\x20+\x20\".google-analytics.com/ga.js\";\x0a\x20\x20var\x20s\x20=\x20document.getElementsByTagName(\"script\")[0];\x20s.parentNode.insertBefore(ga,\x20s);\x0a})();\x0a</script>\x0a{{end}}\x0a</body>\x0a</html>\x0a\x0a",
 
 	"godocs.js": "//\x20Copyright\x202012\x20The\x20Go\x20Authors.\x20All\x20rights\x20reserved.\x0a//\x20Use\x20of\x20this\x20source\x20code\x20is\x20governed\x20by\x20a\x20BSD-style\x0a//\x20license\x20that\x20can\x20be\x20found\x20in\x20the\x20LICENSE\x20file.\x0a\x0a/*\x20A\x20little\x20code\x20to\x20ease\x20navigation\x20of\x20these\x20documents.\x0a\x20*\x0a\x20*\x20On\x20window\x20load\x20we:\x0a\x20*\x20\x20+\x20Generate\x20a\x20table\x20of\x20contents\x20(generateTOC)\x0a\x20*\x20\x20+\x20Bind\x20foldable\x20sections\x20(bindToggles)\x0a\x20*\x20\x20+\x20Bind\x20links\x20to\x20foldable\x20sections\x20(bindToggleLinks)\x0a\x20*/\x0a\x0a(function()\x20{\x0a'use\x20strict';\x0a\x0a//\x20Mobile-friendly\x20topbar\x20menu\x0a$(function()\x20{\x0a\x20\x20var\x20menu\x20=\x20$('#menu');\x0a\x20\x20var\x20menuButton\x20=\x20$('#menu-button');\x0a\x20\x20var\x20menuButtonArrow\x20=\x20$('#menu-button-arrow');\x0a\x20\x20menuButton.click(function(event)\x20{\x0a\x20\x20\x20\x20menu.toggleClass('menu-visible');\x0a\x20\x20\x20\x20menuButtonArrow.toggleClass('vertical-flip');\x0a\x20\x20\x20\x20event.preventDefault();\x0a\x20\x20\x20\x20return\x20false;\x0a\x20\x20});\x0a});\x0a\x0a/*\x20Generates\x20a\x20table\x20of\x20contents:\x20looks\x20for\x20h2\x20and\x20h3\x20elements\x20and\x20generates\x0a\x20*\x20links.\x20\"Decorates\"\x20the\x20element\x20with\x20id==\"nav\"\x20with\x20this\x20table\x20of\x20contents.\x0a\x20*/\x0afunction\x20generateTOC()\x20{\x0a\x20\x20if\x20($('#manual-nav').length\x20>\x200)\x20{\x0a\x20\x20\x20\x20return;\x0a\x20\x20}\x0a\x0a\x20\x20var\x20nav\x20=\x20$('#nav');\x0a\x20\x20if\x20(nav.length\x20===\x200)\x20{\x0a\x20\x20\x20\x20return;\x0a\x20\x20}\x0a\x0a\x20\x20var\x20toc_items\x20=\x20[];\x0a\x20\x20$(nav).nextAll('h2,\x20h3').each(function()\x20{\x0a\x20\x20\x20\x20var\x20node\x20=\x20this;\x0a\x20\x20\x20\x20if\x20(node.id\x20==\x20'')\x0a\x20\x20\x20\x20\x20\x20node.id\x20=\x20'tmp_'\x20+\x20toc_items.length;\x0a\x20\x20\x20\x20var\x20link\x20=\x20$('<a/>').attr('href',\x20'#'\x20+\x20node.id).text($(node).text());\x0a\x20\x20\x20\x20var\x20item;\x0a\x20\x20\x20\x20if\x20($(node).is('h2'))\x20{\x0a\x20\x20\x20\x20\x20\x20item\x20=\x20$('<dt/>');\x0a\x20\x20\x20\x20}\x20else\x20{\x20//\x20h3\x0a\x20\x20\x20\x20\x20\x20item\x20=\x20$('<dd\x20class=\"indent\"/>');\x0a\x20\x20\x20\x20}\x0a\x20\x20\x20\x20item.append(link);\x0a\x20\x20\x20\x20toc_items.push(item);\x0a\x20\x20});\x0a\x20\x20if\x20(toc_items.length\x20<=\x201)\x20{\x0a\x20\x20\x20\x20return;\x0a\x20\x20}\x0a\x0a\x20\x20var\x20dl1\x20=\x20$('<dl/>');\x0a\x20\x20var\x20dl2\x20=\x20$('<dl/>');\x0a\x0a\x20\x20var\x20split_index\x20=\x20(toc_items.length\x20/\x202)\x20+\x201;\x0a\x20\x20if\x20(split_index\x20<\x208)\x20{\x0a\x20\x20\x20\x20split_index\x20=\x20toc_items.length;\x0a\x20\x20}\x0a\x20\x20for\x20(var\x20i\x20=\x200;\x20i\x20<\x20split_index;\x20i++)\x20{\x0a\x20\x20\x20\x20dl1.append(toc_items[i]);\x0a\x20\x20}\x0a\x20\x20for\x20(/*\x20keep\x20using\x20i\x20*/;\x20i\x20<\x20toc_items.length;\x20i++)\x20{\x0a\x20\x20\x20\x20dl2.append(toc_items[i]);\x0a\x20\x20}\x0a\x0a\x20\x20var\x20tocTable\x20=\x20$('<table\x20class=\"unruled\"/>').appendTo(nav);\x0a\x20\x20var\x20tocBody\x20=\x20$('<tbody/>').appendTo(tocTable);\x0a\x20\x20var\x20tocRow\x20=\x20$('<tr/>').appendTo(tocBody);\x0a\x0a\x20\x20//\x201st\x20column\x0a\x20\x20$('<td\x20class=\"first\"/>').appendTo(tocRow).append(dl1);\x0a\x20\x20//\x202nd\x20column\x0a\x20\x20$('<td/>').appendTo(tocRow).append(dl2);\x0a}\x0a\x0afunction\x20bindToggle(el)\x20{\x0a\x20\x20$('.toggleButton',\x20el).click(function()\x20{\x0a\x20\x20\x20\x20if\x20($(this).closest(\".toggle,\x20.toggleVisible\")[0]\x20!=\x20el)\x20{\x0a\x20\x20\x20\x20\x20\x20//\x20Only\x20trigger\x20the\x20closest\x20toggle\x20header.\x0a\x20\x20\x20\x20\x20\x20return;\x0a\x20\x20\x20\x20}\x0a\x0a\x20\x20\x20\x20if\x20($(el).is('.toggle'))\x20{\x0a\x20\x20\x20\x20\x20\x20$(el).addClass('toggleVisible').removeClass('toggle');\x0a\x20\x20\x20\x20}\x20else\x20{\x0a\x20\x20\x20\x20\x20\x20$(el).addClass('toggle').removeClass('toggleVisible');\x0a\x20\x20\x20\x20}\x0a\x20\x20});\x0a}\x0a\x0afunction\x20bindToggles(selector)\x20{\x0a\x20\x20$(selector).each(function(i,\x20el)\x20{\x0a\x20\x20\x20\x20bindToggle(el);\x0a\x20\x20});\x0a}\x0a\x0afunction\x20bindToggleLink(el,\x20prefix)\x20{\x0a\x20\x20$(el).click(function()\x20{\x0a\x20\x20\x20\x20var\x20href\x20=\x20$(el).attr('href');\x0a\x20\x20\x20\x20var\x20i\x20=\x20href.indexOf('#'+prefix);\x0a\x20\x20\x20\x20if\x20(i\x20<\x200)\x20{\x0a\x20\x20\x20\x20\x20\x20return;\x0a\x20\x20\x20\x20}\x0a\x20\x20\x20\x20var\x20id\x20=\x20'#'\x20+\x20prefix\x20+\x20href.slice(i+1+prefix.length);\x0a\x20\x20\x20\x20if\x20($(id).is('.toggle'))\x20{\x0a\x20\x20\x20\x20\x20\x20$(id).find('.toggleButton').first().click();\x0a\x20\x20\x20\x20}\x0a\x20\x20});\x0a}\x0afunction\x20bindToggleLinks(selector,\x20prefix)\x20{\x0a\x20\x20$(selector).each(function(i,\x20el)\x20{\x0a\x20\x20\x20\x20bindToggleLink(el,\x20prefix);\x0a\x20\x20});\x0a}\x0a\x0afunction\x20setupDropdownPlayground()\x20{\x0a\x20\x20if\x20(!$('#page').is('.wide'))\x20{\x0a\x20\x20\x20\x20return;\x20//\x20don't\x20show\x20on\x20front\x20page\x0a\x20\x20}\x0a\x20\x20var\x20button\x20=\x20$('#playgroundButton');\x0a\x20\x20var\x20div\x20=\x20$('#playground');\x0a\x20\x20var\x20setup\x20=\x20false;\x0a\x20\x20button.toggle(function()\x20{\x0a\x20\x20\x20\x20button.addClass('active');\x0a\x20\x20\x20\x20div.show();\x0a\x20\x20\x20\x20if\x20(setup)\x20{\x0a\x20\x20\x20\x20\x20\x20return;\x0a\x20\x20\x20\x20}\x0a\x20\x20\x20\x20setup\x20=\x20true;\x0a\x20\x20\x20\x20playground({\x0a\x20\x20\x20\x20\x20\x20'codeEl':\x20$('.code',\x20div),\x0a\x20\x20\x20\x20\x20\x20'outputEl':\x20$('.output',\x20div),\x0a\x20\x20\x20\x20\x20\x20'runEl':\x20$('.run',\x20div),\x0a\x20\x20\x20\x20\x20\x20'fmtEl':\x20$('.fmt',\x20div),\x0a\x20\x20\x20\x20\x20\x20'shareEl':\x20$('.share',\x20div),\x0a\x20\x20\x20\x20\x20\x20'shareRedirect':\x20'//play.golang.org/p/'\x0a\x20\x20\x20\x20});\x0a\x20\x20},\x0a\x20\x20function()\x20{\x0a\x20\x20\x20\x20button.removeClass('active');\x0a\x20\x20\x20\x20div.hide();\x0a\x20\x20});\x0a\x20\x20button.show();\x0a\x20\x20$('#menu').css('min-width',\x20'+=60');\x0a}\x0a\x0afunction\x20setupInlinePlayground()\x20{\x0a\x09'use\x20strict';\x0a\x09//\x20Set\x20up\x20playground\x20when\x20each\x20element\x20is\x20toggled.\x0a\x09$('div.play').each(function\x20(i,\x20el)\x20{\x0a\x09\x09//\x20Set\x20up\x20playground\x20for\x20this\x20example.\x0a\x09\x09var\x20setup\x20=\x20function()\x20{\x0a\x09\x09\x09var\x20code\x20=\x20$('.code',\x20el);\x0a\x09\x09\x09playground({\x0a\x09\x09\x09\x09'codeEl':\x20\x20\x20code,\x0a\x09\x09\x09\x09'outputEl':\x20$('.output',\x20el),\x0a\x09\x09\x09\x09'runEl':\x20\x20\x20\x20$('.run',\x20el),\x0a\x09\x09\x09\x09'fmtEl':\x20\x20\x20\x20$('.fmt',\x20el),\x0a\x09\x09\x09\x09'shareEl':\x20\x20$('.share',\x20el),\x0a\x09\x09\x09\x09'shareRedirect':\x20'//play.golang.org/p/'\x0a\x09\x09\x09});\x0a\x0a\x09\x09\x09//\x20Make\x20the\x20code\x20textarea\x20resize\x20to\x20fit\x20content.\x0a\x09\x09\x09var\x20resize\x20=\x20function()\x20{\x0a\x09\x09\x09\x09code.height(0);\x0a\x09\x09\x09\x09var\x20h\x20=\x20code[0].scrollHeight;\x0a\x09\x09\x09\x09code.height(h+20);\x20//\x20minimize\x20bouncing.\x0a\x09\x09\x09\x09code.closest('.input').height(h);\x0a\x09\x09\x09};\x0a\x09\x09\x09code.on('keydown',\x20resize);\x0a\x09\x09\x09code.on('keyup',\x20resize);\x0a\x09\x09\x09code.keyup();\x20//\x20resize\x20now.\x0a\x09\x09};\x0a\x0a\x09\x09//\x20If\x20example\x20already\x20visible,\x20set\x20up\x20playground\x20now.\x0a\x09\x09if\x20($(el).is(':visible'))\x20{\x0a\x09\x09\x09setup();\x0a\x09\x09\x09return;\x0a\x09\x09}\x0a\x0a\x09\x09//\x20Otherwise,\x20set\x20up\x20playground\x20when\x20example\x20is\x20expanded.\x0a\x09\x09var\x20built\x20=\x20false;\x0a\x09\x09$(el).closest('.toggle').click(function()\x20{\x0a\x09\x09\x09//\x20Only\x20set\x20up\x20once.\x0a\x09\x09\x09if\x20(!built)\x20{\x0a\x09\x09\x09\x09setup();\x0a\x09\x09\x09\x09built\x20=\x20true;\x0a\x09\x09\x09}\x0a\x09\x09});\x0a\x09});\x0a}\x0a\x0a//\x20fixFocus\x20tries\x20to\x20put\x20focus\x20to\x20div#page\x20so\x20that\x20keyboard\x20navigation\x20works.\x0afunction\x20fixFocus()\x20{\x0a\x20\x20var\x20page\x20=\x20$('div#page');\x0a\x20\x20var\x20topbar\x20=\x20$('div#topbar');\x0a\x20\x20page.css('outline',\x200);\x20//\x20disable\x20outline\x20when\x20focused\x0a\x20\x20page.attr('tabindex',\x20-1);\x20//\x20and\x20set\x20tabindex\x20so\x20that\x20it\x20is\x20focusable\x0a\x20\x20$(window).resize(function\x20(evt)\x20{\x0a\x20\x20\x20\x20//\x20only\x20focus\x20page\x20when\x20the\x20topbar\x20is\x20at\x20fixed\x20position\x20(that\x20is,\x20it's\x20in\x0a\x20\x20\x20\x20//\x20front\x20of\x20page,\x20and\x20keyboard\x20event\x20will\x20go\x20to\x20the\x20former\x20by\x20default.)\x0a\x20\x20\x20\x20//\x20by\x20focusing\x20page,\x20keyboard\x20event\x20will\x20go\x20to\x20page\x20so\x20that\x20up/down\x20arrow,\x0a\x20\x20\x20\x20//\x20space,\x20etc.\x20will\x20work\x20as\x20expected.\x0a\x20\x20\x20\x20if\x20(topbar.css('position')\x20==\x20\"fixed\")\x0a\x20\x20\x20\x20\x20\x20page.focus();\x0a\x20\x20}).resize();\x0a}\x0a\x0afunction\x20toggleHash()\x20{\x0a\x20\x20var\x20id\x20=\x20window.location.hash.substring(1);\x0a\x20\x20//\x20Open\x20all\x20of\x20the\x20toggles\x20for\x20a\x20particular\x20hash.\x0a\x20\x20var\x20els\x20=\x20$(\x0a\x20\x20\x20\x20document.getElementById(id),\x0a\x20\x20\x20\x20$('a[name]').filter(function()\x20{\x0a\x20\x20\x20\x20\x20\x20return\x20$(this).attr('name')\x20==\x20id;\x0a\x20\x20\x20\x20})\x0a\x20\x20);\x0a\x0a\x20\x20while\x20(els.length)\x20{\x0a\x20\x20\x20\x20for\x20(var\x20i\x20=\x200;\x20i\x20<\x20els.length;\x20i++)\x20{\x0a\x20\x20\x20\x20\x20\x20var\x20el\x20=\x20$(els[i]);\x0a\x20\x20\x20\x20\x20\x20if\x20(el.is('.toggle'))\x20{\x0a\x20\x20\x20\x20\x20\x20\x20\x20el.find('.toggleButton').first().click();\x0a\x20\x20\x20\x20\x20\x20}\x0a\x20\x20\x20\x20}\x0a\x20\x20\x20\x20els\x20=\x20el.parent();\x0a\x20\x20}\x0a}\x0a\x0afunction\x20personalizeInstallInstructions()\x20{\x0a\x20\x20var\x20prefix\x20=\x20'?download=';\x0a\x20\x20var\x20s\x20=\x20window.location.search;\x0a\x20\x20if\x20(s.indexOf(prefix)\x20!=\x200)\x20{\x0a\x20\x20\x20\x20//\x20No\x20'download'\x20query\x20string;\x20detect\x20\"test\"\x20instructions\x20from\x20User\x20Agent.\x0a\x20\x20\x20\x20if\x20(navigator.platform.indexOf('Win')\x20!=\x20-1)\x20{\x0a\x20\x20\x20\x20\x20\x20$('.testUnix').hide();\x0a\x20\x20\x20\x20\x20\x20$('.testWindows').show();\x0a\x20\x20\x20\x20}\x20else\x20{\x0a\x20\x20\x20\x20\x20\x20$('.testUnix').show();\x0a\x20\x20\x20\x20\x20\x20$('.testWindows').hide();\x0a\x20\x20\x20\x20}\x0a\x20\x20\x20\x20return;\x0a\x20\x20}\x0a\x0a\x20\x20var\x20filename\x20=\x20s.substr(prefix.length);\x0a\x20\x20var\x20filenameRE\x20=\x20/^go1\\.\\d+(\\.\\d+)?([a-z0-9]+)?\\.([a-z0-9]+)(-[a-z0-9]+)?(-osx10\\.[68])?\\.([a-z.]+)$/;\x0a\x20\x20var\x20m\x20=\x20filenameRE.exec(filename);\x0a\x20\x20if\x20(!m)\x20{\x0a\x20\x20\x20\x20//\x20Can't\x20interpret\x20file\x20name;\x20bail.\x0a\x20\x20\x20\x20return;\x0a\x20\x20}\x0a\x20\x20$('.downloadFilename').text(filename);\x0a\x20\x20$('.hideFromDownload').hide();\x0a\x0a\x20\x20var\x20os\x20=\x20m[3];\x0a\x20\x20var\x20ext\x20=\x20m[6];\x0a\x20\x20if\x20(ext\x20!=\x20'tar.gz')\x20{\x0a\x20\x20\x20\x20$('#tarballInstructions').hide();\x0a\x20\x20}\x0a\x20\x20if\x20(os\x20!=\x20'darwin'\x20||\x20ext\x20!=\x20'pkg')\x20{\x0a\x20\x20\x20\x20$('#darwinPackageInstructions').hide();\x0a\x20\x20}\x0a\x20\x20if\x20(os\x20!=\x20'windows')\x20{\x0a\x20\x20\x20\x20$('#windowsInstructions').hide();\x0a\x20\x20\x20\x20$('.testUnix').show();\x0a\x20\x20\x20\x20$('.testWindows').hide();\x0a\x20\x20}\x20else\x20{\x0a\x20\x20\x20\x20if\x20(ext\x20!=\x20'msi')\x20{\x0a\x20\x20\x20\x20\x20\x20$('#windowsInstallerInstructions').hide();\x0a\x20\x20\x20\x20}\x0a\x20\x20\x20\x20if\x20(ext\x20!=\x20'zip')\x20{\x0a\x20\x20\x20\x20\x20\x20$('#windowsZipInstructions').hide();\x0a\x20\x20\x20\x20}\x0a\x20\x20\x20\x20$('.testUnix').hide();\x0a\x20\x20\x20\x20$('.testWindows').show();\x0a\x20\x20}\x0a\x0a\x20\x20var\x20download\x20=\x20\"https://dl.google.com/go/\"\x20+\x20filename;\x0a\x0a\x20\x20var\x20message\x20=\x20$('<p\x20class=\"downloading\">'+\x0a\x20\x20\x20\x20'Your\x20download\x20should\x20begin\x20shortly.\x20'+\x0a\x20\x20\x20\x20'If\x20it\x20does\x20not,\x20click\x20<a>this\x20link</a>.</p>');\x0a\x20\x20message.find('a').attr('href',\x20download);\x0a\x20\x20message.insertAfter('#nav');\x0a\x0a\x20\x20window.location\x20=\x20download;\x0a}\x0a\x0afunction\x20updateVersionTags()\x20{\x0a\x20\x20var\x20v\x20=\x20window.goVersion;\x0a\x20\x20if\x20(/^go[0-9.]+$/.test(v))\x20{\x0a\x20\x20\x20\x20$(\".versionTag\").empty().text(v);\x0a\x20\x20\x20\x20$(\".whereTag\").hide();\x0a\x20\x20}\x0a}\x0a\x0afunction\x20addPermalinks()\x20{\x0a\x20\x20function\x20addPermalink(source,\x20parent)\x20{\x0a\x20\x20\x20\x20var\x20id\x20=\x20source.attr(\"id\");\x0a\x20\x20\x20\x20if\x20(id\x20==\x20\"\"\x20||\x20id.indexOf(\"tmp_\")\x20===\x200)\x20{\x0a\x20\x20\x20\x20\x20\x20//\x20Auto-generated\x20permalink.\x0a\x20\x20\x20\x20\x20\x20return;\x0a\x20\x20\x20\x20}\x0a\x20\x20\x20\x20if\x20(parent.find(\">\x20.permalink\").length)\x20{\x0a\x20\x20\x20\x20\x20\x20//\x20Already\x20attached.\x0a\x20\x20\x20\x20\x20\x20return;\x0a\x20\x20\x20\x20}\x0a\x20\x20\x20\x20parent.append(\"\x20\").append($(\"<a\x20class='permalink'>&#xb6;</a>\").attr(\"href\",\x20\"#\"\x20+\x20id));\x0a\x20\x20}\x0a\x0a\x20\x20$(\"#page\x20.container\").find(\"h2[id],\x20h3[id]\").each(function()\x20{\x0a\x20\x20\x20\x20var\x20el\x20=\x20$(this);\x0a\x20\x20\x20\x20addPermalink(el,\x20el);\x0a\x20\x20});\x0a\x0a\x20\x20$(\"#page\x20.container\").find(\"dl[id]\").each(function()\x20{\x0a\x20\x20\x20\x20var\x20el\x20=\x20$(this);\x0a\x20\x20\x20\x20//\x20Add\x20the\x20anchor\x20to\x20the\x20\"dt\"\x20element.\x0a\x20\x20\x20\x20addPermalink(el,\x20el.find(\">\x20dt\").first());\x0a\x20\x20});\x0a}\x0a\x0a$(\".js-expandAll\").click(function()\x20{\x0a\x20\x20if\x20($(this).hasClass(\"collapsed\"))\x20{\x0a\x20\x20\x20\x20toggleExamples('toggle');\x0a\x20\x20\x20\x20$(this).text(\"(Collapse\x20All)\");\x0a\x20\x20}\x20else\x20{\x0a\x20\x20\x20\x20toggleExamples('toggleVisible');\x0a\x20\x20\x20\x20$(this).text(\"(Expand\x20All)\");\x0a\x20\x20}\x0a\x20\x20$(this).toggleClass(\"collapsed\")\x0a});\x0a\x0afunction\x20toggleExamples(className)\x20{\x0a\x20\x20//\x20We\x20need\x20to\x20explicitly\x20iterate\x20through\x20divs\x20starting\x20with\x20\"example_\"\x0a\x20\x20//\x20to\x20avoid\x20toggling\x20Overview\x20and\x20Index\x20collapsibles.\x0a\x20\x20$(\"[id^='example_']\").each(function()\x20{\x0a\x20\x20\x20\x20//\x20Check\x20for\x20state\x20and\x20click\x20it\x20only\x20if\x20required.\x0a\x20\x20\x20\x20if\x20($(this).hasClass(className))\x20{\x0a\x20\x20\x20\x20\x20\x20$(this).find('.toggleButton').first().click();\x0a\x20\x20\x20\x20}\x0a\x20\x20});\x0a}\x0a\x0a$(document).ready(function()\x20{\x0a\x20\x20generateTOC();\x0a\x20\x20addPermalinks();\x0a\x20\x20bindToggles(\".toggle\");\x0a\x20\x20bindToggles(\".toggleVisible\");\x0a\x20\x20bindToggleLinks(\".exampleLink\",\x20\"example_\");\x0a\x20\x20bindToggleLinks(\".overviewLink\",\x20\"\");\x0a\x20\x20bindToggleLinks(\".examplesLink\",\x20\"\");\x0a\x20\x20bindToggleLinks(\".indexLink\",\x20\"\");\x0a\x20\x20setupDropdownPlayground();\x0a\x20\x20setupInlinePlayground();\x0a\x20\x20fixFocus();\x0a\x20\x20setupTypeInfo();\x0a\x20\x20setupCallgraphs();\x0a\x20\x20toggleHash();\x0a\x20\x20personalizeInstallInstructions();\x0a\x20\x20updateVersionTags();\x0a\x0a\x20\x20//\x20godoc.html\x20defines\x20window.initFuncs\x20in\x20the\x20<head>\x20tag,\x20and\x20root.html\x20and\x0a\x20\x20//\x20codewalk.js\x20push\x20their\x20on-page-ready\x20functions\x20to\x20the\x20list.\x0a\x20\x20//\x20We\x20execute\x20those\x20functions\x20here,\x20to\x20avoid\x20loading\x20jQuery\x20until\x20the\x20page\x0a\x20\x20//\x20content\x20is\x20loaded.\x0a\x20\x20for\x20(var\x20i\x20=\x200;\x20i\x20<\x20window.initFuncs.length;\x20i++)\x20window.initFuncs[i]();\x0a});\x0a\x0a//\x20--\x20analysis\x20---------------------------------------------------------\x0a\x0a//\x20escapeHTML\x20returns\x20HTML\x20for\x20s,\x20with\x20metacharacters\x20quoted.\x0a//\x20It\x20is\x20safe\x20for\x20use\x20in\x20both\x20elements\x20and\x20attributes\x0a//\x20(unlike\x20the\x20\"set\x20innerText,\x20read\x20innerHTML\"\x20trick).\x0afunction\x20escapeHTML(s)\x20{\x0a\x20\x20\x20\x20return\x20s.replace(/&/g,\x20'&amp;').\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20replace(/\\\"/g,\x20'&quot;').\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20replace(/\\'/g,\x20'&#39;').\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20replace(/</g,\x20'&lt;').\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20replace(/>/g,\x20'&gt;');\x0a}\x0a\x0a//\x20makeAnchor\x20returns\x20HTML\x20for\x20an\x20<a>\x20element,\x20given\x20an\x20anchorJSON\x20object.\x0afunction\x20makeAnchor(json)\x20{\x0a\x20\x20var\x20html\x20=\x20escapeHTML(json.Text);\x0a\x20\x20if\x20(json.Href\x20!=\x20\"\")\x20{\x0a\x20\x20\x20\x20\x20\x20html\x20=\x20\"<a\x20href='\"\x20+\x20escapeHTML(json.Href)\x20+\x20\"'>\"\x20+\x20html\x20+\x20\"</a>\";\x0a\x20\x20}\x0a\x20\x20return\x20html;\x0a}\x0a\x0afunction\x20showLowFrame(html)\x20{\x0a\x20\x20var\x20lowframe\x20=\x20document.getElementById('lowframe');\x0a\x20\x20lowframe.style.height\x20=\x20\"200px\";\x0a\x20\x20lowframe.innerHTML\x20=\x20\"<p\x20style='text-align:\x20left;'>\"\x20+\x20html\x20+\x20\"</p>\\n\"\x20+\x0a\x20\x20\x20\x20\x20\x20\"<div\x20onclick='hideLowFrame()'\x20style='position:\x20absolute;\x20top:\x200;\x20right:\x200;\x20cursor:\x20pointer;'>\xe2\x9c\x98</div>\"\x0a};\x0a\x0adocument.hideLowFrame\x20=\x20function()\x20{\x0a\x20\x20var\x20lowframe\x20=\x20document.getElementById('lowframe');\x0a\x20\x20lowframe.style.height\x20=\x20\"0px\";\x0a}\x0a\x0a//\x20onClickCallers\x20is\x20the\x20onclick\x20action\x20for\x20the\x20'func'\x20tokens\x20of\x20a\x0a//\x20function\x20declaration.\x0adocument.onClickCallers\x20=\x20function(index)\x20{\x0a\x20\x20var\x20data\x20=\x20document.ANALYSIS_DATA[index]\x0a\x20\x20if\x20(data.Callers.length\x20==\x201\x20&&\x20data.Callers[0].Sites.length\x20==\x201)\x20{\x0a\x20\x20\x20\x20document.location\x20=\x20data.Callers[0].Sites[0].Href;\x20//\x20jump\x20to\x20sole\x20caller\x0a\x20\x20\x20\x20return;\x0a\x20\x20}\x0a\x0a\x20\x20var\x20html\x20=\x20\"Callers\x20of\x20<code>\"\x20+\x20escapeHTML(data.Callee)\x20+\x20\"</code>:<br/>\\n\";\x0a\x20\x20for\x20(var\x20i\x20=\x200;\x20i\x20<\x20data.Callers.length;\x20i++)\x20{\x0a\x20\x20\x20\x20var\x20caller\x20=\x20data.Callers[i];\x0a\x20\x20\x20\x20html\x20+=\x20\"<code>\"\x20+\x20escapeHTML(caller.Func)\x20+\x20\"</code>\";\x0a\x20\x20\x20\x20var\x20sites\x20=\x20caller.Sites;\x0a\x20\x20\x20\x20if\x20(sites\x20!=\x20null\x20&&\x20sites.length\x20>\x200)\x20{\x0a\x20\x20\x20\x20\x20\x20html\x20+=\x20\"\x20at\x20line\x20\";\x0a\x20\x20\x20\x20\x20\x20for\x20(var\x20j\x20=\x200;\x20j\x20<\x20sites.length;\x20j++)\x20{\x0a\x20\x20\x20\x20\x20\x20\x20\x20if\x20(j\x20>\x200)\x20{\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20html\x20+=\x20\",\x20\";\x0a\x20\x20\x20\x20\x20\x20\x20\x20}\x0a\x20\x20\x20\x20\x20\x20\x20\x20html\x20+=\x20\"<code>\"\x20+\x20makeAnchor(sites[j])\x20+\x20\"</code>\";\x0a\x20\x20\x20\x20\x20\x20}\x0a\x20\x20\x20\x20}\x0a\x20\x20\x20\x20html\x20+=\x20\"<br/>\\n\";\x0a\x20\x20}\x0a\x20\x20showLowFrame(html);\x0a};\x0a\x0a//\x20onClickCallees\x20is\x20the\x20onclick\x20action\x20for\x20the\x20'('\x20token\x20of\x20a\x20function\x20call.\x0adocument.onClickCallees\x20=\x20function(index)\x20{\x0a\x20\x20var\x20data\x20=\x20document.ANALYSIS_DATA[index]\x0a\x20\x20if\x20(data.Callees.length\x20==\x201)\x20{\x0a\x20\x20\x20\x20document.location\x20=\x20data.Callees[0].Href;\x20//\x20jump\x20to\x20sole\x20callee\x0a\x20\x20\x20\x20return;\x0a\x20\x20}\x0a\x0a\x20\x20var\x20html\x20=\x20\"Callees\x20of\x20this\x20\"\x20+\x20escapeHTML(data.Descr)\x20+\x20\":<br/>\\n\";\x0a\x20\x20for\x20(var\x20i\x20=\x200;\x20i\x20<\x20data.Callees.length;\x20i++)\x20{\x0a\x20\x20\x20\x20html\x20+=\x20\"<code>\"\x20+\x20makeAnchor(data.Callees[i])\x20+\x20\"</code><br/>\\n\";\x0a\x20\x20}\x0a\x20\x20showLowFrame(html);\x0a};\x0a\x0a//\x20onClickTypeInfo\x20is\x20the\x20onclick\x20action\x20for\x20identifiers\x20declaring\x20a\x20named\x20type.\x0adocument.onClickTypeInfo\x20=\x20function(index)\x20{\x0a\x20\x20var\x20data\x20=\x20document.ANALYSIS_DATA[index];\x0a\x20\x20var\x20html\x20=\x20\"Type\x20<code>\"\x20+\x20data.Name\x20+\x20\"</code>:\x20\"\x20+\x0a\x20\x20\"&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<small>(size=\"\x20+\x20data.Size\x20+\x20\",\x20align=\"\x20+\x20data.Align\x20+\x20\")</small><br/>\\n\";\x0a\x20\x20html\x20+=\x20implementsHTML(data);\x0a\x20\x20html\x20+=\x20methodsetHTML(data);\x0a\x20\x20showLowFrame(html);\x0a};\x0a\x0a//\x20implementsHTML\x20returns\x20HTML\x20for\x20the\x20implements\x20relation\x20of\x20the\x0a//\x20specified\x20TypeInfoJSON\x20value.\x0afunction\x20implementsHTML(info)\x20{\x0a\x20\x20var\x20html\x20=\x20\"\";\x0a\x20\x20if\x20(info.ImplGroups\x20!=\x20null)\x20{\x0a\x20\x20\x20\x20for\x20(var\x20i\x20=\x200;\x20i\x20<\x20info.ImplGroups.length;\x20i++)\x20{\x0a\x20\x20\x20\x20\x20\x20var\x20group\x20=\x20info.ImplGroups[i];\x0a\x20\x20\x20\x20\x20\x20var\x20x\x20=\x20\"<code>\"\x20+\x20escapeHTML(group.Descr)\x20+\x20\"</code>\x20\";\x0a\x20\x20\x20\x20\x20\x20for\x20(var\x20j\x20=\x200;\x20j\x20<\x20group.Facts.length;\x20j++)\x20{\x0a\x20\x20\x20\x20\x20\x20\x20\x20var\x20fact\x20=\x20group.Facts[j];\x0a\x20\x20\x20\x20\x20\x20\x20\x20var\x20y\x20=\x20\"<code>\"\x20+\x20makeAnchor(fact.Other)\x20+\x20\"</code>\";\x0a\x20\x20\x20\x20\x20\x20\x20\x20if\x20(fact.ByKind\x20!=\x20null)\x20{\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20html\x20+=\x20escapeHTML(fact.ByKind)\x20+\x20\"\x20type\x20\"\x20+\x20y\x20+\x20\"\x20implements\x20\"\x20+\x20x;\x0a\x20\x20\x20\x20\x20\x20\x20\x20}\x20else\x20{\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20html\x20+=\x20x\x20+\x20\"\x20implements\x20\"\x20+\x20y;\x0a\x20\x20\x20\x20\x20\x20\x20\x20}\x0a\x20\x20\x20\x20\x20\x20\x20\x20html\x20+=\x20\"<br/>\\n\";\x0a\x20\x20\x20\x20\x20\x20}\x0a\x20\x20\x20\x20}\x0a\x20\x20}\x0a\x20\x20return\x20html;\x0a}\x0a\x0a\x0a//\x20methodsetHTML\x20returns\x20HTML\x20for\x20the\x20methodset\x20of\x20the\x20specified\x0a//\x20TypeInfoJSON\x20value.\x0afunction\x20methodsetHTML(info)\x20{\x0a\x20\x20var\x20html\x20=\x20\"\";\x0a\x20\x20if\x20(info.Methods\x20!=\x20null)\x20{\x0a\x20\x20\x20\x20for\x20(var\x20i\x20=\x200;\x20i\x20<\x20info.Methods.length;\x20i++)\x20{\x0a\x20\x20\x20\x20\x20\x20html\x20+=\x20\"<code>\"\x20+\x20makeAnchor(info.Methods[i])\x20+\x20\"</code><br/>\\n\";\x0a\x20\x20\x20\x20}\x0a\x20\x20}\x0a\x20\x20return\x20html;\x0a}\x0a\x0a//\x20onClickComm\x20is\x20the\x20onclick\x20action\x20for\x20channel\x20\"make\"\x20and\x20\"<-\"\x0a//\x20send/receive\x20tokens.\x0adocument.onClickComm\x20=\x20function(index)\x20{\x0a\x20\x20var\x20ops\x20=\x20document.ANALYSIS_DATA[index].Ops\x0a\x20\x20if\x20(ops.length\x20==\x201)\x20{\x0a\x20\x20\x20\x20document.location\x20=\x20ops[0].Op.Href;\x20//\x20jump\x20to\x20sole\x20element\x0a\x20\x20\x20\x20return;\x0a\x20\x20}\x0a\x0a\x20\x20var\x20html\x20=\x20\"Operations\x20on\x20this\x20channel:<br/>\\n\";\x0a\x20\x20for\x20(var\x20i\x20=\x200;\x20i\x20<\x20ops.length;\x20i++)\x20{\x0a\x20\x20\x20\x20html\x20+=\x20makeAnchor(ops[i].Op)\x20+\x20\"\x20by\x20<code>\"\x20+\x20escapeHTML(ops[i].Fn)\x20+\x20\"</code><br/>\\n\";\x0a\x20\x20}\x0a\x20\x20if\x20(ops.length\x20==\x200)\x20{\x0a\x20\x20\x20\x20html\x20+=\x20\"(none)<br/>\\n\";\x0a\x20\x20}\x0a\x20\x20showLowFrame(html);\x0a};\x0a\x0a$(window).load(function()\x20{\x0a\x20\x20\x20\x20//\x20Scroll\x20window\x20so\x20that\x20first\x20selection\x20is\x20visible.\x0a\x20\x20\x20\x20//\x20(This\x20means\x20we\x20don't\x20need\x20to\x20emit\x20id='L%d'\x20spans\x20for\x20each\x20line.)\x0a\x20\x20\x20\x20//\x20TODO(adonovan):\x20ideally,\x20scroll\x20it\x20so\x20that\x20it's\x20under\x20the\x20pointer,\x0a\x20\x20\x20\x20//\x20but\x20I\x20don't\x20know\x20how\x20to\x20get\x20the\x20pointer\x20y\x20coordinate.\x0a\x20\x20\x20\x20var\x20elts\x20=\x20document.getElementsByClassName(\"selection\");\x0a\x20\x20\x20\x20if\x20(elts.length\x20>\x200)\x20{\x0a\x09elts[0].scrollIntoView()\x0a\x20\x20\x20\x20}\x0a});\x0a\x0a//\x20setupTypeInfo\x20populates\x20the\x20\"Implements\"\x20and\x20\"Method\x20set\"\x20toggle\x20for\x0a//\x20each\x20type\x20in\x20the\x20package\x20doc.\x0afunction\x20setupTypeInfo()\x20{\x0a\x20\x20for\x20(var\x20i\x20in\x20document.ANALYSIS_DATA)\x20{\x0a\x20\x20\x20\x20var\x20data\x20=\x20document.ANALYSIS_DATA[i];\x0a\x0a\x20\x20\x20\x20var\x20el\x20=\x20document.getElementById(\"implements-\"\x20+\x20i);\x0a\x20\x20\x20\x20if\x20(el\x20!=\x20null)\x20{\x0a\x20\x20\x20\x20\x20\x20//\x20el\x20!=\x20null\x20=>\x20data\x20is\x20TypeInfoJSON.\x0a\x20\x20\x20\x20\x20\x20if\x20(data.ImplGroups\x20!=\x20null)\x20{\x0a\x20\x20\x20\x20\x20\x20\x20\x20el.innerHTML\x20=\x20implementsHTML(data);\x0a\x20\x20\x20\x20\x20\x20\x20\x20el.parentNode.parentNode.style.display\x20=\x20\"block\";\x0a\x20\x20\x20\x20\x20\x20}\x0a\x20\x20\x20\x20}\x0a\x0a\x20\x20\x20\x20var\x20el\x20=\x20document.getElementById(\"methodset-\"\x20+\x20i);\x0a\x20\x20\x20\x20if\x20(el\x20!=\x20null)\x20{\x0a\x20\x20\x20\x20\x20\x20//\x20el\x20!=\x20null\x20=>\x20data\x20is\x20TypeInfoJSON.\x0a\x20\x20\x20\x20\x20\x20if\x20(data.Methods\x20!=\x20null)\x20{\x0a\x20\x20\x20\x20\x20\x20\x20\x20el.innerHTML\x20=\x20methodsetHTML(data);\x0a\x20\x20\x20\x20\x20\x20\x20\x20el.parentNode.parentNode.style.display\x20=\x20\"block\";\x0a\x20\x20\x20\x20\x20\x20}\x0a\x20\x20\x20\x20}\x0a\x20\x20}\x0a}\x0a\x0afunction\x20setupCallgraphs()\x20{\x0a\x20\x20if\x20(document.CALLGRAPH\x20==\x20null)\x20{\x0a\x20\x20\x20\x20return\x0a\x20\x20}\x0a\x20\x20document.getElementById(\"pkg-callgraph\").style.display\x20=\x20\"block\";\x0a\x0a\x20\x20var\x20treeviews\x20=\x20document.getElementsByClassName(\"treeview\");\x0a\x20\x20for\x20(var\x20i\x20=\x200;\x20i\x20<\x20treeviews.length;\x20i++)\x20{\x0a\x20\x20\x20\x20var\x20tree\x20=\x20treeviews[i];\x0a\x20\x20\x20\x20if\x20(tree.id\x20==\x20null\x20||\x20tree.id.indexOf(\"callgraph-\")\x20!=\x200)\x20{\x0a\x20\x20\x20\x20\x20\x20continue;\x0a\x20\x20\x20\x20}\x0a\x20\x20\x20\x20var\x20id\x20=\x20tree.id.substring(\"callgraph-\".length);\x0a\x20\x20\x20\x20$(tree).treeview({collapsed:\x20true,\x20animated:\x20\"fast\"});\x0a\x20\x20\x20\x20document.cgAddChildren(tree,\x20tree,\x20[id]);\x0a\x20\x20\x20\x20tree.parentNode.parentNode.style.display\x20=\x20\"block\";\x0a\x20\x20}\x0a}\x0a\x0adocument.cgAddChildren\x20=\x20function(tree,\x20ul,\x20indices)\x20{\x0a\x20\x20if\x20(indices\x20!=\x20null)\x20{\x0a\x20\x20\x20\x20for\x20(var\x20i\x20=\x200;\x20i\x20<\x20indices.length;\x20i++)\x20{\x0a\x20\x20\x20\x20\x20\x20var\x20li\x20=\x20cgAddChild(tree,\x20ul,\x20document.CALLGRAPH[indices[i]]);\x0a\x20\x20\x20\x20\x20\x20if\x20(i\x20==\x20indices.length\x20-\x201)\x20{\x0a\x20\x20\x20\x20\x20\x20\x20\x20$(li).addClass(\"last\");\x0a\x20\x20\x20\x20\x20\x20}\x0a\x20\x20\x20\x20}\x0a\x20\x20}\x0a\x20\x20$(tree).treeview({animated:\x20\"fast\",\x20add:\x20ul});\x0a}\x0a\x0a//\x20cgAddChild\x20adds\x20an\x20<li>\x20element\x20for\x20document.CALLGRAPH\x20node\x20cgn\x20to\x0a//\x20the\x20parent\x20<ul>\x20element\x20ul.\x20tree\x20is\x20the\x20tree's\x20root\x20<ul>\x20element.\x0afunction\x20cgAddChild(tree,\x20ul,\x20cgn)\x20{\x0a\x20\x20\x20var\x20li\x20=\x20document.createElement(\"li\");\x0a\x20\x20\x20ul.appendChild(li);\x0a\x20\x20\x20li.className\x20=\x20\"closed\";\x0a\x0a\x20\x20\x20var\x20code\x20=\x20document.createElement(\"code\");\x0a\x0a\x20\x20\x20if\x20(cgn.Callees\x20!=\x20null)\x20{\x0a\x20\x20\x20\x20\x20$(li).addClass(\"expandable\");\x0a\x0a\x20\x20\x20\x20\x20//\x20Event\x20handlers\x20and\x20innerHTML\x20updates\x20don't\x20play\x20nicely\x20together,\x0a\x20\x20\x20\x20\x20//\x20hence\x20all\x20this\x20explicit\x20DOM\x20manipulation.\x0a\x20\x20\x20\x20\x20var\x20hitarea\x20=\x20document.createElement(\"div\");\x0a\x20\x20\x20\x20\x20hitarea.className\x20=\x20\"hitarea\x20expandable-hitarea\";\x0a\x20\x20\x20\x20\x20li.appendChild(hitarea);\x0a\x0a\x20\x20\x20\x20\x20li.appendChild(code);\x0a\x0a\x20\x20\x20\x20\x20var\x20childUL\x20=\x20document.createElement(\"ul\");\x0a\x20\x20\x20\x20\x20li.appendChild(childUL);\x0a\x20\x20\x20\x20\x20childUL.setAttribute('style',\x20\"display:\x20none;\");\x0a\x0a\x20\x20\x20\x20\x20var\x20onClick\x20=\x20function()\x20{\x0a\x20\x20\x20\x20\x20\x20\x20document.cgAddChildren(tree,\x20childUL,\x20cgn.Callees);\x0a\x20\x20\x20\x20\x20\x20\x20hitarea.removeEventListener('click',\x20onClick)\x0a\x20\x20\x20\x20\x20};\x0a\x20\x20\x20\x20\x20hitarea.addEventListener('click',\x20onClick);\x0a\x0a\x20\x20\x20}\x20else\x20{\x0a\x20\x20\x20\x20\x20li.appendChild(code);\x0a\x20\x20\x20}\x0a\x20\x20\x20code.innerHTML\x20+=\x20\"&nbsp;\"\x20+\x20makeAnchor(cgn.Func);\x0a\x20\x20\x20return\x20li\x0a}\x0a\x0a})();\x0a",
 
diff --git a/internal/memcache/memcache.go b/internal/memcache/memcache.go
new file mode 100644
index 0000000..25d5a62
--- /dev/null
+++ b/internal/memcache/memcache.go
@@ -0,0 +1,157 @@
+// Copyright 2018 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 memcache provides a minimally compatible interface for
+// google.golang.org/appengine/memcache
+// and stores the data in Redis (e.g., via Cloud Memorystore).
+package memcache
+
+import (
+	"bytes"
+	"context"
+	"encoding/gob"
+	"encoding/json"
+	"errors"
+	"time"
+
+	"github.com/gomodule/redigo/redis"
+)
+
+var ErrCacheMiss = errors.New("memcache: cache miss")
+
+func New(addr string) *Client {
+	const maxConns = 20
+
+	pool := redis.NewPool(func() (redis.Conn, error) {
+		return redis.Dial("tcp", addr)
+	}, maxConns)
+
+	return &Client{
+		pool: pool,
+	}
+}
+
+type Client struct {
+	pool *redis.Pool
+}
+
+type CodecClient struct {
+	client *Client
+	codec  Codec
+}
+
+type Item struct {
+	Key        string
+	Value      []byte
+	Object     interface{}   // Used with Codec.
+	Expiration time.Duration // Read-only.
+}
+
+func (c *Client) WithCodec(codec Codec) *CodecClient {
+	return &CodecClient{
+		c, codec,
+	}
+}
+
+func (c *Client) Delete(ctx context.Context, key string) error {
+	conn, err := c.pool.GetContext(ctx)
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+
+	_, err = conn.Do("DEL", key)
+	return err
+}
+
+func (c *CodecClient) Delete(ctx context.Context, key string) error {
+	return c.client.Delete(ctx, key)
+}
+
+func (c *Client) Set(ctx context.Context, item *Item) error {
+	if item.Value == nil {
+		return errors.New("nil item value")
+	}
+	return c.set(ctx, item.Key, item.Value, item.Expiration)
+}
+
+func (c *CodecClient) Set(ctx context.Context, item *Item) error {
+	if item.Object == nil {
+		return errors.New("nil object value")
+	}
+	b, err := c.codec.Marshal(item.Object)
+	if err != nil {
+		return err
+	}
+	return c.client.set(ctx, item.Key, b, item.Expiration)
+}
+
+func (c *Client) set(ctx context.Context, key string, value []byte, expiration time.Duration) error {
+	conn, err := c.pool.GetContext(ctx)
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+
+	if expiration == 0 {
+		_, err := conn.Do("SET", key, value)
+		return err
+	}
+
+	// NOTE(cbro): redis does not support expiry in units more granular than a second.
+	exp := int64(expiration.Seconds())
+	if exp == 0 {
+		// Redis doesn't allow a zero expiration, delete the key instead.
+		_, err := conn.Do("DEL", key)
+		return err
+	}
+
+	_, err = conn.Do("SETEX", key, exp, value)
+	return err
+}
+
+// Get gets the item.
+func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
+	conn, err := c.pool.GetContext(ctx)
+	if err != nil {
+		return nil, err
+	}
+	defer conn.Close()
+
+	b, err := redis.Bytes(conn.Do("GET", key))
+	if err == redis.ErrNil {
+		err = ErrCacheMiss
+	}
+	return b, err
+}
+
+func (c *CodecClient) Get(ctx context.Context, key string, v interface{}) error {
+	b, err := c.client.Get(ctx, key)
+	if err != nil {
+		return err
+	}
+	return c.codec.Unmarshal(b, v)
+}
+
+var (
+	Gob  = Codec{gobMarshal, gobUnmarshal}
+	JSON = Codec{json.Marshal, json.Unmarshal}
+)
+
+type Codec struct {
+	Marshal   func(interface{}) ([]byte, error)
+	Unmarshal func([]byte, interface{}) error
+}
+
+func gobMarshal(v interface{}) ([]byte, error) {
+	var buf bytes.Buffer
+	if err := gob.NewEncoder(&buf).Encode(v); err != nil {
+		return nil, err
+	}
+	return buf.Bytes(), nil
+}
+
+func gobUnmarshal(data []byte, v interface{}) error {
+	return gob.NewDecoder(bytes.NewBuffer(data)).Decode(v)
+}
diff --git a/internal/memcache/memcache_test.go b/internal/memcache/memcache_test.go
new file mode 100644
index 0000000..74f6ade
--- /dev/null
+++ b/internal/memcache/memcache_test.go
@@ -0,0 +1,83 @@
+// Copyright 2018 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 memcache
+
+import (
+	"context"
+	"os"
+	"testing"
+	"time"
+)
+
+func getClient(t *testing.T) *Client {
+	t.Helper()
+
+	addr := os.Getenv("GOLANG_REDIS_ADDR")
+	if addr == "" {
+		t.Skip("skipping because GOLANG_REDIS_ADDR is unset")
+	}
+
+	return New(addr)
+}
+
+func TestCacheMiss(t *testing.T) {
+	c := getClient(t)
+	ctx := context.Background()
+
+	if _, err := c.Get(ctx, "doesnotexist"); err != ErrCacheMiss {
+		t.Errorf("got %v; want ErrCacheMiss", err)
+	}
+}
+
+func TestExpiry(t *testing.T) {
+	c := getClient(t).WithCodec(Gob)
+	ctx := context.Background()
+
+	key := "testexpiry"
+
+	firstTime := time.Now()
+	err := c.Set(ctx, &Item{
+		Key:        key,
+		Object:     firstTime,
+		Expiration: 3500 * time.Millisecond, // NOTE: check that non-rounded expiries work.
+	})
+	if err != nil {
+		t.Fatalf("Set: %v", err)
+	}
+
+	var newTime time.Time
+	if err := c.Get(ctx, key, &newTime); err != nil {
+		t.Fatalf("Get: %v", err)
+	}
+	if !firstTime.Equal(newTime) {
+		t.Errorf("Get: got value %v, want %v", newTime, firstTime)
+	}
+
+	time.Sleep(4 * time.Second)
+
+	if err := c.Get(ctx, key, &newTime); err != ErrCacheMiss {
+		t.Errorf("Get: got %v, want ErrCacheMiss", err)
+	}
+}
+
+func TestShortExpiry(t *testing.T) {
+	c := getClient(t).WithCodec(Gob)
+	ctx := context.Background()
+
+	key := "testshortexpiry"
+
+	err := c.Set(ctx, &Item{
+		Key:        key,
+		Value:      []byte("ok"),
+		Expiration: time.Millisecond,
+	})
+	if err != nil {
+		t.Fatalf("Set: %v", err)
+	}
+
+	if err := c.Get(ctx, key, nil); err != ErrCacheMiss {
+		t.Errorf("GetBytes: got %v, want ErrCacheMiss", err)
+	}
+}