tour: use embed and io/fs

Embed content, static and template directories directly in the tour
binary. This way it will work even if the package source is deleted.
The content in those directories is not large, the binary size goes
up from 14.2 MB to 15.2 MB in my testing (with Go 1.17 darwin/arm64).

Start using the Go 1.16 runtime for App Engine deployment.

Updates golang/go#44243.

Change-Id: I35fb32961cdc1edec1f8f8c0fc0193b07cef9acd
Reviewed-on: https://go-review.googlesource.com/c/website/+/342711
Run-TryBot: Dmitri Shuralyov <dmitshur@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Russ Cox <rsc@golang.org>
Trust: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/tour/app.yaml b/tour/app.yaml
index 2ab1a65..7713a59 100644
--- a/tour/app.yaml
+++ b/tour/app.yaml
@@ -1,5 +1,5 @@
 service: tour
-runtime: go115
+runtime: go116
 main: ./tour
 
 env_variables:
diff --git a/tour/appengine.go b/tour/appengine.go
index 0f02356..697cf97 100644
--- a/tour/appengine.go
+++ b/tour/appengine.go
@@ -12,7 +12,6 @@
 	"log"
 	"net/http"
 	"os"
-	"path/filepath"
 
 	_ "golang.org/x/tools/playground"
 	"golang.org/x/website/internal/webtest"
@@ -23,16 +22,14 @@
 	socketAddr = gaeSocketAddr
 	analyticsHTML = template.HTML(os.Getenv("TOUR_ANALYTICS"))
 
-	root := "tour"
-
-	if err := initTour(root, "HTTPTransport"); err != nil {
+	if err := initTour("HTTPTransport"); err != nil {
 		log.Fatal(err)
 	}
 
 	http.Handle("/", hstsHandler(rootHandler))
 	http.Handle("/lesson/", hstsHandler(lessonHandler))
 
-	registerStatic(root)
+	registerStatic()
 
 	port := os.Getenv("PORT")
 	if port == "" {
@@ -40,7 +37,7 @@
 	}
 
 	h := webtest.HandlerWithCheck(http.DefaultServeMux, "/_readycheck",
-		filepath.Join(root, "testdata/*.txt"))
+		"tour/testdata/*.txt")
 
 	log.Fatal(http.ListenAndServe(":"+port, h))
 }
diff --git a/tour/local.go b/tour/local.go
index 5268814..f2a5513 100644
--- a/tour/local.go
+++ b/tour/local.go
@@ -5,20 +5,17 @@
 package main
 
 import (
-	"encoding/json"
 	"flag"
-	"go/build"
 	"html/template"
 	"io"
+	"io/fs"
 	"log"
 	"net"
 	"net/http"
 	"net/url"
 	"os"
 	"os/exec"
-	"path/filepath"
 	"runtime"
-	"runtime/debug"
 	"strings"
 	"time"
 
@@ -39,54 +36,6 @@
 	httpAddr string
 )
 
-// isRoot reports whether path is the root directory of the tour tree.
-// To be the root, it must have content and template subdirectories.
-func isRoot(path string) bool {
-	_, err := os.Stat(filepath.Join(path, "content", "welcome.article"))
-	if err == nil {
-		_, err = os.Stat(filepath.Join(path, "template", "index.tmpl"))
-	}
-	return err == nil
-}
-
-// findRoot is a best-effort attempt to find a tour directory
-// that contains the files it needs. It may not always work.
-//
-// TODO: Delete after Go 1.17 is out and we can just use embed; see CL 291849.
-func findRoot() (string, bool) {
-	// Try finding the golang.org/x/website/tour package in the
-	// legacy GOPATH mode workspace or in build list.
-	p, err := build.Import("golang.org/x/website/tour", "", build.FindOnly)
-	if err == nil && isRoot(p.Dir) {
-		return p.Dir, true
-	}
-	// If that didn't work, perhaps we're not inside any module
-	// and the binary was built in module mode (e.g., 'go install
-	// golang.org/x/website/tour@latest' or 'go get golang.org/x/website/tour'
-	// outside a module).
-	// If that's the case, find out what version it is,
-	// and access its content from the module cache.
-	if info, ok := debug.ReadBuildInfo(); ok &&
-		info.Main.Path == "golang.org/x/website" &&
-		info.Main.Replace == nil &&
-		info.Main.Version != "(devel)" {
-		// Make some assumptions for brevity:
-		// • the 'go' binary is in $PATH
-		// • the main module isn't replaced
-		// • the version isn't "(devel)"
-		// They should hold for the use cases we care about, until this
-		// entire mechanism is obsoleted by file embedding.
-		out, execError := exec.Command("go", "mod", "download", "-json", "--", "golang.org/x/website@"+info.Main.Version).Output()
-		var websiteRoot struct{ Dir string }
-		jsonError := json.Unmarshal(out, &websiteRoot)
-		tourDir := filepath.Join(websiteRoot.Dir, "tour")
-		if execError == nil && jsonError == nil && isRoot(tourDir) {
-			return tourDir, true
-		}
-	}
-	return "", false
-}
-
 func main() {
 	flag.Parse()
 
@@ -96,14 +45,6 @@
 		return
 	}
 
-	// find and serve the go tour files
-	root, ok := findRoot()
-	if !ok {
-		log.Fatalln("Couldn't find files for the Go tour. Try reinstalling it.")
-	}
-
-	log.Println("Serving content from", root)
-
 	host, port, err := net.SplitHostPort(*httpListen)
 	if err != nil {
 		log.Fatal(err)
@@ -116,7 +57,7 @@
 	}
 	httpAddr = host + ":" + port
 
-	if err := initTour(root, "SocketTransport"); err != nil {
+	if err := initTour("SocketTransport"); err != nil {
 		log.Fatal(err)
 	}
 
@@ -126,10 +67,10 @@
 	origin := &url.URL{Scheme: "http", Host: host + ":" + port}
 	http.Handle(socketPath, socket.NewHandler(origin))
 
-	registerStatic(root)
+	registerStatic()
 
 	h := webtest.HandlerWithCheck(http.DefaultServeMux, "/_readycheck",
-		filepath.Join(root, "testdata/*.txt"))
+		"tour/testdata/*.txt")
 
 	go func() {
 		url := "http://" + httpAddr
@@ -144,13 +85,20 @@
 
 // registerStatic registers handlers to serve static content
 // from the directory root.
-func registerStatic(root string) {
-	http.Handle("/favicon.ico", http.FileServer(http.Dir(filepath.Join(root, "static", "img"))))
-	static := http.FileServer(http.Dir(root))
+func registerStatic() {
+	http.Handle("/favicon.ico", http.FileServer(http.FS(must(fs.Sub(root, "static/img")))))
+	static := http.FileServer(http.FS(root))
 	http.Handle("/content/img/", static)
 	http.Handle("/static/", static)
 }
 
+func must(fsys fs.FS, err error) fs.FS {
+	if err != nil {
+		panic(err)
+	}
+	return fsys
+}
+
 // rootHandler returns a handler for all the requests except the ones for lessons.
 func rootHandler(w http.ResponseWriter, r *http.Request) {
 	if err := renderUI(w); err != nil {
diff --git a/tour/server_test.go b/tour/server_test.go
index acc6ee3..4d4ab96 100644
--- a/tour/server_test.go
+++ b/tour/server_test.go
@@ -13,12 +13,12 @@
 )
 
 func TestWeb(t *testing.T) {
-	if err := initTour(".", "SocketTransport"); err != nil {
+	if err := initTour("SocketTransport"); err != nil {
 		log.Fatal(err)
 	}
 	http.HandleFunc("/", rootHandler)
 	http.HandleFunc("/lesson/", lessonHandler)
-	registerStatic(".")
+	registerStatic()
 
 	webtest.TestHandler(t, "testdata/*.txt", http.DefaultServeMux)
 }
diff --git a/tour/tour.go b/tour/tour.go
index 3534d41..542d26c 100644
--- a/tour/tour.go
+++ b/tour/tour.go
@@ -2,19 +2,19 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package main // import "golang.org/x/website/tour"
+package main
 
 import (
 	"bytes"
 	"crypto/sha1"
+	"embed"
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"html/template"
 	"io"
-	"io/ioutil"
 	"net/http"
-	"os"
+	"path"
 	"path/filepath"
 	"strings"
 	"time"
@@ -29,28 +29,29 @@
 	lessonNotFound = fmt.Errorf("lesson not found")
 )
 
-// initTour loads tour.article and the relevant HTML templates from the given
-// tour root, and renders the template to the tourContent global variable.
-func initTour(root, transport string) error {
+var (
+	//go:embed content static template
+	root embed.FS
+)
+
+// initTour loads tour.article and the relevant HTML templates from root.
+func initTour(transport string) error {
 	// Make sure playground is enabled before rendering.
 	present.PlayEnabled = true
 
 	// Set up templates.
-	action := filepath.Join(root, "template", "action.tmpl")
-	tmpl, err := present.Template().ParseFiles(action)
+	tmpl, err := present.Template().ParseFS(root, "template/action.tmpl")
 	if err != nil {
 		return fmt.Errorf("parse templates: %v", err)
 	}
 
 	// Init lessons.
-	contentPath := filepath.Join(root, "content")
-	if err := initLessons(tmpl, contentPath); err != nil {
+	if err := initLessons(tmpl); err != nil {
 		return fmt.Errorf("init lessons: %v", err)
 	}
 
-	// Init UI
-	index := filepath.Join(root, "template", "index.tmpl")
-	ui, err := template.ParseFiles(index)
+	// Init UI.
+	ui, err := template.ParseFS(root, "template/index.tmpl")
 	if err != nil {
 		return fmt.Errorf("parse index.tmpl: %v", err)
 	}
@@ -67,29 +68,25 @@
 	}
 	uiContent = buf.Bytes()
 
-	return initScript(root)
+	return initScript()
 }
 
-// initLessonss finds all the lessons in the passed directory, renders them,
+// initLessonss finds all the lessons in the content directory, renders them,
 // using the given template and saves the content in the lessons map.
-func initLessons(tmpl *template.Template, content string) error {
-	dir, err := os.Open(content)
-	if err != nil {
-		return err
-	}
-	files, err := dir.Readdirnames(0)
+func initLessons(tmpl *template.Template) error {
+	files, err := root.ReadDir("content")
 	if err != nil {
 		return err
 	}
 	for _, f := range files {
-		if filepath.Ext(f) != ".article" {
+		if path.Ext(f.Name()) != ".article" {
 			continue
 		}
-		content, err := parseLesson(tmpl, filepath.Join(content, f))
+		content, err := parseLesson(path.Join("content", f.Name()), tmpl)
 		if err != nil {
-			return fmt.Errorf("parsing %v: %v", f, err)
+			return fmt.Errorf("parsing %v: %v", f.Name(), err)
 		}
-		name := strings.TrimSuffix(f, ".article")
+		name := strings.TrimSuffix(f.Name(), ".article")
 		lessons[name] = content
 	}
 	return nil
@@ -116,15 +113,20 @@
 	Pages       []Page
 }
 
-// parseLesson parses and returns a lesson content given its name and
-// the template to render it.
-func parseLesson(tmpl *template.Template, path string) ([]byte, error) {
-	f, err := os.Open(path)
+// parseLesson parses and returns a lesson content given its path
+// relative to root ('/'-separated) and the template to render it.
+func parseLesson(path string, tmpl *template.Template) ([]byte, error) {
+	f, err := root.Open(path)
 	if err != nil {
 		return nil, err
 	}
 	defer f.Close()
-	doc, err := present.Parse(prepContent(f), path, 0)
+	ctx := &present.Context{
+		ReadFile: func(filename string) ([]byte, error) {
+			return root.ReadFile(filepath.ToSlash(filename))
+		},
+	}
+	doc, err := ctx.Parse(prepContent(f), filepath.FromSlash(path), 0)
 	if err != nil {
 		return nil, err
 	}
@@ -225,7 +227,7 @@
 
 // initScript concatenates all the javascript files needed to render
 // the tour UI and serves the result on /script.js.
-func initScript(root string) error {
+func initScript() error {
 	modTime := time.Now()
 	b := new(bytes.Buffer)
 
@@ -251,9 +253,9 @@
 	}
 
 	for _, file := range files {
-		f, err := ioutil.ReadFile(filepath.Join(root, file))
+		f, err := root.ReadFile(file)
 		if err != nil {
-			return fmt.Errorf("couldn't open %v: %v", file, err)
+			return fmt.Errorf("couldn't read %v: %v", file, err)
 		}
 		_, err = b.Write(f)
 		if err != nil {