examples: derive examples from the examples directory

With the gotip playground we may want to swap out examples with greater
frequency, to demonstrate new features at Go tip. Make this easier by
deriving examples directly from the examples directory via a new
examplesHandler type. This also enables having dynamic content for
hello.txt, which depends on the value of runtime.GoVersion().

This will impact the non-tip playground in a few ways:
- We will now pre-load examples at server start up.
- Examples will be sorted by their title (with the exception of Hello,
  playground, which is always first).
- We will set a CORS header for examples. This was added for consistency
  with other handlers, and seems harmless.

Generalize TestShare to TestServer, and use it to test the examples
handler.

Add a single gotip example demonstrating generics.

Updates golang/go#48517

Change-Id: I7ab58eb391829d581f7aeae95c291666be5718b9
Reviewed-on: https://go-review.googlesource.com/c/playground/+/364374
Trust: Robert Findley <rfindley@google.com>
Run-TryBot: Robert Findley <rfindley@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Alexander Rakoczy <alex@golang.org>
diff --git a/edit.go b/edit.go
index 3d63848..500d664 100644
--- a/edit.go
+++ b/edit.go
@@ -24,6 +24,7 @@
 	Analytics bool
 	GoVersion string
 	Gotip     bool
+	Examples  []example
 }
 
 func (s *server) handleEdit(w http.ResponseWriter, r *http.Request) {
@@ -45,11 +46,7 @@
 		return
 	}
 
-	content := hello
-	if s.gotip {
-		content = helloGotip
-	}
-	snip := &snippet{Body: []byte(content)}
+	snip := &snippet{Body: []byte(s.examples.hello())}
 	if strings.HasPrefix(r.URL.Path, "/p/") {
 		if !allowShare(r) {
 			w.WriteHeader(http.StatusUnavailableForLegalReasons)
@@ -88,40 +85,10 @@
 		Analytics: r.Host == hostname,
 		GoVersion: runtime.Version(),
 		Gotip:     s.gotip,
+		Examples:  s.examples.examples,
 	}
 	if err := editTemplate.Execute(w, data); err != nil {
 		s.log.Errorf("editTemplate.Execute(w, %+v): %v", data, err)
 		return
 	}
 }
-
-const hello = `package main
-
-import (
-	"fmt"
-)
-
-func main() {
-	fmt.Println("Hello, playground")
-}
-`
-
-var helloGotip = fmt.Sprintf(`package main
-
-import (
-	"fmt"
-)
-
-// This playground uses a development build of Go:
-// %s
-
-func Print[T any](s ...T) {
-	for _, v := range s {
-		fmt.Print(v)
-	}
-}
-
-func main() {
-	Print("Hello, ", "playground\n")
-}
-`, runtime.Version())
diff --git a/edit.html b/edit.html
index bf5e4df..1045005 100644
--- a/edit.html
+++ b/edit.html
@@ -121,13 +121,9 @@
 			</label>
 			{{end}}
 			<select class="js-playgroundToysEl">
-				<option value="hello.txt">Hello, playground</option>
-				<option value="test.txt">Tests</option>
-				<option value="multi.txt">Multiple files</option>
-				<option value="http.txt">HTTP server</option>
-				<option value="image.txt">Display image</option>
-				<option value="sleep.txt">Sleep</option>
-				<option value="clear.txt">Clear</option>
+				{{range .Examples}}
+				<option value="{{.Path}}">{{.Title}}</option>
+				{{end}}
 			</select>
 			<input type="button" value="About" id="aboutButton">
 		</div>
diff --git a/examples.go b/examples.go
new file mode 100644
index 0000000..d834d1f
--- /dev/null
+++ b/examples.go
@@ -0,0 +1,153 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+	"runtime"
+	"sort"
+	"strings"
+	"time"
+)
+
+// examplesHandler serves example content out of the examples directory.
+type examplesHandler struct {
+	modtime  time.Time
+	examples []example
+}
+
+type example struct {
+	Title   string
+	Path    string
+	Content string
+}
+
+func (h *examplesHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+	for _, e := range h.examples {
+		if e.Path == req.URL.Path {
+			http.ServeContent(w, req, e.Path, h.modtime, strings.NewReader(e.Content))
+			return
+		}
+	}
+	http.NotFound(w, req)
+}
+
+// hello returns the hello text for this instance, which depends on the Go
+// version and whether or not we are serving Gotip examples.
+func (h *examplesHandler) hello() string {
+	return h.examples[0].Content
+}
+
+// newExamplesHandler reads from the examples directory, returning a handler to
+// serve their content.
+//
+// If gotip is set, all files ending in .txt will be included in the set of
+// examples. If gotip is not set, files ending in .gotip.txt are excluded.
+// Examples must start with a line beginning "// Title:" that sets their title.
+//
+// modtime is used for content caching headers.
+func newExamplesHandler(gotip bool, modtime time.Time) (*examplesHandler, error) {
+	const dir = "examples"
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return nil, err
+	}
+
+	var examples []example
+	for _, entry := range entries {
+		name := entry.Name()
+
+		// Read examples ending in .txt, skipping those ending in .gotip.txt if
+		// gotip is not set.
+		prefix := "" // if non-empty, this is a relevant example file
+		if strings.HasSuffix(name, ".gotip.txt") {
+			if gotip {
+				prefix = strings.TrimSuffix(name, ".gotip.txt")
+			}
+		} else if strings.HasSuffix(name, ".txt") {
+			prefix = strings.TrimSuffix(name, ".txt")
+		}
+
+		if prefix == "" {
+			continue
+		}
+
+		data, err := os.ReadFile(filepath.Join(dir, name))
+		if err != nil {
+			return nil, err
+		}
+		content := string(data)
+
+		// Extract the magic "// Title:" comment specifying the example's title.
+		nl := strings.IndexByte(content, '\n')
+		const titlePrefix = "// Title:"
+		if nl == -1 || !strings.HasPrefix(content, titlePrefix) {
+			return nil, fmt.Errorf("malformed example for %q: must start with a title line beginning %q", name, titlePrefix)
+		}
+		title := strings.TrimPrefix(content[:nl], titlePrefix)
+		title = strings.TrimSpace(title)
+
+		examples = append(examples, example{
+			Title:   title,
+			Path:    name,
+			Content: content[nl+1:],
+		})
+	}
+
+	// Sort by title, before prepending the hello example (we always want Hello
+	// to be first).
+	sort.Slice(examples, func(i, j int) bool {
+		return examples[i].Title < examples[j].Title
+	})
+
+	// For Gotip, serve hello content that includes the Go version.
+	hi := hello
+	if gotip {
+		hi = helloGotip
+	}
+
+	examples = append([]example{
+		{"Hello, playground", "hello.txt", hi},
+	}, examples...)
+	return &examplesHandler{
+		modtime:  modtime,
+		examples: examples,
+	}, nil
+}
+
+const hello = `package main
+
+import (
+	"fmt"
+)
+
+func main() {
+	fmt.Println("Hello, playground")
+}
+`
+
+var helloGotip = fmt.Sprintf(`package main
+
+import (
+	"fmt"
+)
+
+// This playground uses a development build of Go:
+// %s
+
+func Print[T any](s ...T) {
+	for _, v := range s {
+		fmt.Print(v)
+	}
+}
+
+func main() {
+	Print("Hello, ", "playground\n")
+}
+`, runtime.Version())
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..1021ad3
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,9 @@
+# Playground Examples
+
+Add examples to the playground by adding files to this directory with the
+`.txt` file extension. Examples with file names ending in `.gotip.txt` are only
+displayed on the gotip playground.
+
+Each example must start with a line beginning with "// Title:", specifying the
+title of the example in the selection menu. This title line will be stripped
+from the example before serving.
diff --git a/examples/clear.txt b/examples/clear.txt
index c5381d7..26c767a 100644
--- a/examples/clear.txt
+++ b/examples/clear.txt
@@ -1,3 +1,4 @@
+// Title: Clear
 package main
 
 import (
diff --git a/examples/hello.txt b/examples/hello.txt
deleted file mode 100644
index 8fd43ed..0000000
--- a/examples/hello.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-package main
-
-import (
-	"fmt"
-)
-
-func main() {
-	fmt.Println("Hello, playground")
-}
diff --git a/examples/http.txt b/examples/http.txt
index 7c8f651..d35788a 100644
--- a/examples/http.txt
+++ b/examples/http.txt
@@ -1,3 +1,4 @@
+// Title: HTTP server
 package main
 
 import (
diff --git a/examples/image.txt b/examples/image.txt
index 4180a45..01c6cc1 100644
--- a/examples/image.txt
+++ b/examples/image.txt
@@ -1,3 +1,4 @@
+// Title: Display image
 package main
 
 import (
diff --git a/examples/min.gotip.txt b/examples/min.gotip.txt
new file mode 100644
index 0000000..3c71978
--- /dev/null
+++ b/examples/min.gotip.txt
@@ -0,0 +1,19 @@
+// Title: Generic min
+package main
+
+import (
+	"fmt"
+	"constraints"
+)
+
+func min[P constraints.Ordered](x, y P) P {
+	if x < y {
+		return x
+	} else {
+		return y
+	}
+}
+
+func main() {
+	fmt.Println(min(42, 24))
+}
diff --git a/examples/multi.txt b/examples/multi.txt
index ad41446..0c2800d 100644
--- a/examples/multi.txt
+++ b/examples/multi.txt
@@ -1,3 +1,4 @@
+// Title: Multiple files
 package main
 
 import (
diff --git a/examples/sleep.txt b/examples/sleep.txt
index d68a3d8..6377ab1 100644
--- a/examples/sleep.txt
+++ b/examples/sleep.txt
@@ -1,3 +1,4 @@
+// Title: Sleep
 package main
 
 import (
diff --git a/examples/test.txt b/examples/test.txt
index 4bf86e9..c31fcda 100644
--- a/examples/test.txt
+++ b/examples/test.txt
@@ -1,3 +1,4 @@
+// Title: Test
 package main
 
 import (
diff --git a/main.go b/main.go
index 1d6c9ac..3adf653 100644
--- a/main.go
+++ b/main.go
@@ -47,6 +47,17 @@
 		if gotip := os.Getenv("GOTIP"); gotip == "true" {
 			s.gotip = true
 		}
+		execpath, _ := os.Executable()
+		if execpath != "" {
+			if fi, _ := os.Stat(execpath); fi != nil {
+				s.modtime = fi.ModTime()
+			}
+		}
+		eh, err := newExamplesHandler(s.gotip, s.modtime)
+		if err != nil {
+			return err
+		}
+		s.examples = eh
 		return nil
 	}, enableMetrics)
 	if err != nil {
diff --git a/server.go b/server.go
index ff03baa..21a9270 100644
--- a/server.go
+++ b/server.go
@@ -7,7 +7,6 @@
 import (
 	"fmt"
 	"net/http"
-	"os"
 	"strings"
 	"time"
 
@@ -15,11 +14,12 @@
 )
 
 type server struct {
-	mux   *http.ServeMux
-	db    store
-	log   logger
-	cache responseCache
-	gotip bool // if set, server is using gotip
+	mux      *http.ServeMux
+	db       store
+	log      logger
+	cache    responseCache
+	gotip    bool // if set, server is using gotip
+	examples *examplesHandler
 
 	// When the executable was last modified. Used for caching headers of compiled assets.
 	modtime time.Time
@@ -38,11 +38,8 @@
 	if s.log == nil {
 		return nil, fmt.Errorf("must provide an option func that specifies a logger")
 	}
-	execpath, _ := os.Executable()
-	if execpath != "" {
-		if fi, _ := os.Stat(execpath); fi != nil {
-			s.modtime = fi.ModTime()
-		}
+	if s.examples == nil {
+		return nil, fmt.Errorf("must provide an option func that sets the examples handler")
 	}
 	s.init()
 	return s, nil
@@ -60,9 +57,7 @@
 
 	staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))
 	s.mux.Handle("/static/", staticHandler)
-
-	examplesHandler := http.StripPrefix("/doc/play/", http.FileServer(http.Dir("./examples")))
-	s.mux.Handle("/doc/play/", examplesHandler)
+	s.mux.Handle("/doc/play/", http.StripPrefix("/doc/play/", s.examples))
 }
 
 func (s *server) handlePlaygroundJS(w http.ResponseWriter, r *http.Request) {
diff --git a/server_test.go b/server_test.go
index ae3a9cd..1fb178c 100644
--- a/server_test.go
+++ b/server_test.go
@@ -15,6 +15,7 @@
 	"os"
 	"sync"
 	"testing"
+	"time"
 
 	"github.com/bradfitz/gomemcache/memcache"
 	"github.com/google/go-cmp/cmp"
@@ -38,6 +39,11 @@
 	return func(s *server) error {
 		s.db = &inMemStore{}
 		s.log = testLogger{t}
+		var err error
+		s.examples, err = newExamplesHandler(false, time.Now())
+		if err != nil {
+			return err
+		}
 		return nil
 	}
 }
@@ -101,30 +107,38 @@
 	}
 }
 
-func TestShare(t *testing.T) {
+func TestServer(t *testing.T) {
 	s, err := newServer(testingOptions(t))
 	if err != nil {
 		t.Fatalf("newServer(testingOptions(t)): %v", err)
 	}
 
-	const url = "https://play.golang.org/share"
+	const shareURL = "https://play.golang.org/share"
 	testCases := []struct {
 		desc       string
 		method     string
+		url        string
 		statusCode int
 		reqBody    []byte
 		respBody   []byte
 	}{
-		{"OPTIONS no-op", http.MethodOptions, http.StatusOK, nil, nil},
-		{"Non-POST request", http.MethodGet, http.StatusMethodNotAllowed, nil, nil},
-		{"Standard flow", http.MethodPost, http.StatusOK, []byte("Snippy McSnipface"), []byte("N_M_YelfGeR")},
-		{"Snippet too large", http.MethodPost, http.StatusRequestEntityTooLarge, make([]byte, maxSnippetSize+1), nil},
+		// Share tests.
+		{"OPTIONS no-op", http.MethodOptions, shareURL, http.StatusOK, nil, nil},
+		{"Non-POST request", http.MethodGet, shareURL, http.StatusMethodNotAllowed, nil, nil},
+		{"Standard flow", http.MethodPost, shareURL, http.StatusOK, []byte("Snippy McSnipface"), []byte("N_M_YelfGeR")},
+		{"Snippet too large", http.MethodPost, shareURL, http.StatusRequestEntityTooLarge, make([]byte, maxSnippetSize+1), nil},
+
+		// Examples tests.
+		{"Hello example", http.MethodGet, "https://play.golang.org/doc/play/hello.txt", http.StatusOK, nil, []byte("Hello")},
+		{"HTTP example", http.MethodGet, "https://play.golang.org/doc/play/http.txt", http.StatusOK, nil, []byte("net/http")},
+		// Gotip examples should not be available on the non-tip playground.
+		{"Gotip example", http.MethodGet, "https://play.golang.org/doc/play/min.gotip.txt", http.StatusNotFound, nil, nil},
 	}
 
 	for _, tc := range testCases {
-		req := httptest.NewRequest(tc.method, url, bytes.NewReader(tc.reqBody))
+		req := httptest.NewRequest(tc.method, tc.url, bytes.NewReader(tc.reqBody))
 		w := httptest.NewRecorder()
-		s.handleShare(w, req)
+		s.mux.ServeHTTP(w, req)
 		resp := w.Result()
 		corsHeader := "Access-Control-Allow-Origin"
 		if got, want := resp.Header.Get(corsHeader), "*"; got != want {
@@ -139,8 +153,8 @@
 			if err != nil {
 				t.Errorf("%s: ioutil.ReadAll(resp.Body): %v", tc.desc, err)
 			}
-			if !bytes.Equal(b, tc.respBody) {
-				t.Errorf("%s: got unexpected body %q; want %q", tc.desc, b, tc.respBody)
+			if !bytes.Contains(b, tc.respBody) {
+				t.Errorf("%s: got unexpected body %q; want contains %q", tc.desc, b, tc.respBody)
 			}
 		}
 	}
@@ -172,6 +186,11 @@
 		// instead of just printing or failing the test?
 		s.log = newStdLogger()
 		s.cache = new(inMemCache)
+		var err error
+		s.examples, err = newExamplesHandler(false, time.Now())
+		if err != nil {
+			return err
+		}
 		return nil
 	})
 	if err != nil {