godev: create godev submodule and content package

The godev submodule contains code necessary to run the telemetry
services we plan to host on GCP. The submodule structure will keep
packages that rely on third party dependencies separate from the
telemetry collection code which may eventually be depended on by gopls
and the go command.

The content package implements a basic web serving framework with support
for markdown, go templates, and typescript files.

Change-Id: If3da206d858bd969a681d29d1d9ad829b31211da
Reviewed-on: https://go-review.googlesource.com/c/telemetry/+/495755
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Jamal Carvalho <jamal@golang.org>
diff --git a/doc.go b/doc.go
new file mode 100644
index 0000000..073f40d
--- /dev/null
+++ b/doc.go
@@ -0,0 +1 @@
+package telemetry
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..f427f76
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module golang.org/x/telemetry
+
+go 1.20
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/go.sum
diff --git a/godev/go.mod b/godev/go.mod
new file mode 100644
index 0000000..fbeacf7
--- /dev/null
+++ b/godev/go.mod
@@ -0,0 +1,17 @@
+module golang.org/x/telemetry/godev
+
+go 1.20
+
+require (
+	github.com/evanw/esbuild v0.17.19
+	github.com/google/go-cmp v0.5.9
+	github.com/yuin/goldmark v1.5.4
+	golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
+)
+
+require gopkg.in/yaml.v2 v2.4.0 // indirect
+
+require (
+	github.com/yuin/goldmark-meta v1.1.0
+	golang.org/x/sys v0.8.0 // indirect
+)
diff --git a/godev/go.sum b/godev/go.sum
new file mode 100644
index 0000000..ec938e0
--- /dev/null
+++ b/godev/go.sum
@@ -0,0 +1,17 @@
+github.com/evanw/esbuild v0.17.19 h1:JdzNCvfFEoUCXKHhdP326Vn2mhCu8PybXeBDHaSRyWo=
+github.com/evanw/esbuild v0.17.19/go.mod h1:iINY06rn799hi48UqEnaQvVfZWe6W9bET78LbvN8VWk=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
+github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
+github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
+golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
+golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
diff --git a/godev/internal/content/content.go b/godev/internal/content/content.go
new file mode 100644
index 0000000..b522f26
--- /dev/null
+++ b/godev/internal/content/content.go
@@ -0,0 +1,312 @@
+// Copyright 2023 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 content implements a basic web serving framework.
+//
+// # Content Server
+//
+// A content server is an http.Handler that serves requests from a file system.
+// Use Server(fsys) to create a new content server.
+//
+// The server is defined primarily by the content of its file system fsys,
+// which holds files to be served. It renders markdown files and golang
+// templates into HTML and transforms TypeScript into JavaScript.
+//
+// # Page Rendering
+//
+// A request for a path like "/page" will search the file system for
+// "page.md", "page.html", "page/index.md", and "page/index.html" and
+// render HTML output for the first file found.
+//
+// Partial templates with the extension ".tmpl" at the root of the file system
+// and in the same directory as the requested page are included in the
+// html/template execution step to allow for sharing and composing logic from
+// multiple templates.
+package content
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"html/template"
+	"io/fs"
+	"net/http"
+	"path"
+	"strconv"
+	"strings"
+
+	"github.com/yuin/goldmark"
+	meta "github.com/yuin/goldmark-meta"
+	"github.com/yuin/goldmark/extension"
+	"github.com/yuin/goldmark/parser"
+	"github.com/yuin/goldmark/renderer/html"
+	"golang.org/x/exp/slog"
+)
+
+// contentServer serves requests for a given file system. It can also render
+// templates and transform TypeScript into JavaScript.
+type contentServer struct {
+	fsys     fs.FS
+	fserv    http.Handler
+	handlers map[string]handlerFunc
+}
+
+type handler struct {
+	path string
+	fn   handlerFunc
+}
+
+type handlerFunc func(http.ResponseWriter, *http.Request, fs.FS) error
+
+// Server returns a handler that serves HTTP requests with the contents
+// of the file system rooted at fsys. For requests to a path without an
+// extension, the server will search fsys for markdown or html templates
+// first by appending .md, .html, /index.md, and /index.html to the
+// requested url path.
+//
+// The default behavior of looking for templates within fsys can be overriden
+// by using an optional set of content handlers.
+//
+// For example, a server can be constructed for a file system with a single
+// template, “index.html“, in a directory, “content“, and a handler:
+//
+//	 s := content.Server(os.DirFS("content"),
+//		 content.Handler("/", func(w http.ReponseWriter, _ *http.Request, fsys fs.FS) error {
+//		 	 return content.Template(w, fsys, "index.html", nil, http.StatusOK)
+//		 }))
+//
+// or without a handler:
+//
+//	content.Server(os.DirFS("content"))
+//
+// Both examples will render the template index.html for requests to "/".
+func Server(fsys fs.FS, handlers ...*handler) http.Handler {
+	fserv := http.FileServer(http.FS(fsys))
+	hs := make(map[string]handlerFunc)
+	for _, h := range handlers {
+		if _, ok := hs[h.path]; ok {
+			panic("multiple registrations for " + h.path)
+		}
+		hs[h.path] = h.fn
+	}
+	return &contentServer{fsys, fserv, hs}
+}
+
+func Handler(path string, h handlerFunc) *handler {
+	return &handler{path, h}
+}
+
+func (c *contentServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if len(r.URL.Path) > 255 {
+		Error(w, r, errors.New("url too long"), http.StatusBadRequest)
+		return
+	}
+
+	if handler, ok := c.handlers[r.URL.Path]; ok {
+		err := handler(w, r, c.fsys)
+		if err != nil {
+			Error(w, r, err, http.StatusInternalServerError)
+		}
+		return
+	}
+
+	ext := path.Ext(r.URL.Path)
+	if ext == ".md" || ext == ".html" {
+		http.Redirect(w, r, strings.TrimSuffix(r.URL.Path, ext), http.StatusMovedPermanently)
+		return
+	}
+
+	filepath, info, err := stat(c.fsys, r.URL.Path)
+	if errors.Is(err, fs.ErrNotExist) {
+		Error(w, r, errors.New("page not found"), http.StatusNotFound)
+		return
+	}
+	if err == nil {
+		if strings.HasSuffix(r.URL.Path, "/index") {
+			http.Redirect(w, r, strings.TrimSuffix(r.URL.Path, "/index"), http.StatusMovedPermanently)
+			return
+		}
+		switch path.Ext(filepath) {
+		case ".html":
+			err = Template(w, c.fsys, filepath, nil, http.StatusOK)
+		case ".md":
+			err = markdown(w, c.fsys, filepath, http.StatusOK)
+		case ".ts":
+			err = script(w, c.fsys, filepath, info)
+		default:
+			c.fserv.ServeHTTP(w, r)
+		}
+	}
+	if err != nil {
+		Error(w, r, err, http.StatusInternalServerError)
+	}
+}
+
+// Template executes a template response.
+func Template(w http.ResponseWriter, fsys fs.FS, tmplPath string, data any, code int) error {
+	patterns, err := tmplPatterns(fsys, tmplPath)
+	if err != nil {
+		return err
+	}
+	patterns = append(patterns, tmplPath)
+	tmpl, err := template.ParseFS(fsys, patterns...)
+	if err != nil {
+		return err
+	}
+	name := path.Base(tmplPath)
+	var buf bytes.Buffer
+	if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
+		return err
+	}
+	if code != 0 {
+		w.WriteHeader(code)
+	}
+	w.Header().Set("Content-Type", "text/html")
+	w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
+	if _, err := w.Write(buf.Bytes()); err != nil {
+		return err
+	}
+	return nil
+}
+
+// JSON encodes data as JSON response with a status code.
+func JSON(w http.ResponseWriter, data any, code int) error {
+	var buf bytes.Buffer
+	if err := json.NewEncoder(&buf).Encode(data); err != nil {
+		return err
+	}
+	if code != 0 {
+		w.WriteHeader(code)
+	}
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
+	if _, err := w.Write(buf.Bytes()); err != nil {
+		return err
+	}
+	return nil
+}
+
+// Text formats data as a text response with a status code.
+func Text(w http.ResponseWriter, data any, code int) error {
+	var buf bytes.Buffer
+	if _, err := fmt.Fprint(&buf, data); err != nil {
+		return err
+	}
+	if code != 0 {
+		w.WriteHeader(code)
+	}
+	w.Header().Set("Content-Type", "text/plain")
+	w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
+	if _, err := w.Write(buf.Bytes()); err != nil {
+		return err
+	}
+	return nil
+}
+
+// Error writes an error as an HTTP response with a status code.
+func Error(w http.ResponseWriter, req *http.Request, err error, code int) {
+	if code == http.StatusInternalServerError {
+		http.Error(w, http.StatusText(http.StatusInternalServerError), code)
+	} else {
+		http.Error(w, err.Error(), code)
+	}
+	slog.Error("request error",
+		slog.String("method", req.Method),
+		slog.String("uri", req.RequestURI),
+		slog.Int("status", code),
+		slog.String("error", err.Error()),
+	)
+}
+
+// markdown renders a markdown template as html.
+func markdown(w http.ResponseWriter, fsys fs.FS, tmplPath string, code int) error {
+	markdown, err := fs.ReadFile(fsys, tmplPath)
+	if err != nil {
+		return err
+	}
+	md := goldmark.New(
+		goldmark.WithParserOptions(
+			parser.WithHeadingAttribute(),
+			parser.WithAutoHeadingID(),
+		),
+		goldmark.WithRendererOptions(
+			html.WithUnsafe(),
+			html.WithXHTML(),
+		),
+		goldmark.WithExtensions(
+			extension.GFM,
+			extension.NewTypographer(),
+			meta.Meta,
+		),
+	)
+	var content bytes.Buffer
+	ctx := parser.NewContext()
+	if err := md.Convert(markdown, &content, parser.WithContext(ctx)); err != nil {
+		return err
+	}
+	data := meta.Get(ctx)
+	if data == nil {
+		data = map[string]interface{}{}
+	}
+	data["Content"] = template.HTML(content.String())
+	if _, ok := data["Template"]; !ok {
+		data["Template"] = "base.html"
+	}
+	return Template(w, fsys, data["Template"].(string), data, code)
+}
+
+// script serves TypeScript code tranformed into JavaScript.
+func script(w http.ResponseWriter, fsys fs.FS, filepath string, info fs.FileInfo) error {
+	data, err := fs.ReadFile(fsys, filepath)
+	if err != nil {
+		return err
+	}
+	output := esbuild(data)
+	w.Header().Set("Content-Type", "text/javascript")
+	w.Header().Set("Content-Length", strconv.Itoa(output.Len()))
+	w.Header().Set("Last-Modified", info.ModTime().Format("Mon, 02 Jan 2006 15:04:05 GMT"))
+	if _, err := w.Write(output.Bytes()); err != nil {
+		return err
+	}
+	return nil
+}
+
+// stat trys to coerce a urlPath into an openable file then returns the
+// file path and file info.
+func stat(fsys fs.FS, urlPath string) (string, fs.FileInfo, error) {
+	cleanPath := path.Clean(strings.TrimPrefix(urlPath, "/"))
+	ext := path.Ext(cleanPath)
+	filePaths := []string{cleanPath}
+	if ext == "" || ext == "." {
+		md := cleanPath + ".md"
+		html := cleanPath + ".html"
+		indexMD := path.Join(cleanPath, "index.md")
+		indexHTML := path.Join(cleanPath, "index.html")
+		filePaths = []string{md, html, indexMD, indexHTML, cleanPath}
+	}
+	var p string
+	var stat fs.FileInfo
+	var err error
+	for _, p = range filePaths {
+		if stat, err = fs.Stat(fsys, p); err == nil {
+			break
+		}
+	}
+	return p, stat, err
+}
+
+// tmplPatters generates a slice of file patterns to use in template.ParseFS.
+func tmplPatterns(fsys fs.FS, tmplPath string) ([]string, error) {
+	var patterns []string
+	globs := []string{"*.tmpl", path.Join(path.Dir(tmplPath), "*.tmpl")}
+	for _, g := range globs {
+		matches, err := fs.Glob(fsys, g)
+		if err != nil {
+			return nil, err
+		}
+		patterns = append(patterns, matches...)
+	}
+	return patterns, nil
+}
diff --git a/godev/internal/content/content_test.go b/godev/internal/content/content_test.go
new file mode 100644
index 0000000..1d2bcc5
--- /dev/null
+++ b/godev/internal/content/content_test.go
@@ -0,0 +1,158 @@
+// Copyright 2023 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.
+
+//go:build !plan9
+
+package content
+
+import (
+	"errors"
+	"io/fs"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestServer_ServeHTTP(t *testing.T) {
+	server := Server(os.DirFS("testdata"),
+		Handler("/data", handleTemplate),
+		Handler("/json", handleJSON),
+		Handler("/text", handleText),
+		Handler("/error", handleError),
+	)
+
+	tests := []struct {
+		path     string
+		wantOut  string
+		wantCode int
+	}{
+		{
+			"/index.html",
+			"redirect.html.out",
+			http.StatusMovedPermanently,
+		},
+		{
+			"/index",
+			"redirect.out",
+			http.StatusMovedPermanently,
+		},
+		{
+			"/json",
+			"json.out",
+			http.StatusOK,
+		},
+		{
+			"/text",
+			"text.out",
+			http.StatusOK,
+		},
+		{
+			"/error",
+			"error.out",
+			http.StatusBadRequest,
+		},
+		{
+			"/script.ts",
+			"script.ts.out",
+			http.StatusOK,
+		},
+		{
+			"/style.css",
+			"style.css.out",
+			http.StatusOK,
+		},
+		{
+			"/",
+			"index.html.out",
+			http.StatusOK,
+		},
+		{
+			"/data",
+			"data.html.out",
+			http.StatusOK,
+		},
+		{
+			"/markdown",
+			"markdown.md.out",
+			http.StatusOK,
+		},
+		{
+			"/404",
+			"404.html.out",
+			http.StatusNotFound,
+		},
+		{
+			"/subdir",
+			"subdir/index.html.out",
+			http.StatusOK,
+		},
+		{
+			"/noindex/",
+			"noindex/noindex.html.out",
+			http.StatusOK,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.path, func(t *testing.T) {
+			rr := httptest.NewRecorder()
+			req, err := http.NewRequest("GET", tt.path, nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			server.ServeHTTP(rr, req)
+			got := rr.Body.String()
+			data, err := os.ReadFile(path.Join("testdata", tt.wantOut))
+			if err != nil {
+				t.Fatal(err)
+			}
+			wantBody := string(data)
+			if diff := cmp.Diff(wantBody, got); diff != "" {
+				t.Errorf("GET %s response body mismatch (-want, +got):\n%s", tt.path, diff)
+			}
+			if diff := cmp.Diff(tt.wantCode, rr.Code); diff != "" {
+				t.Errorf("GET %s response code (-want, +got):\n%s", tt.path, diff)
+			}
+		})
+	}
+}
+
+func Test_stat(t *testing.T) {
+	fsys := os.DirFS("testdata")
+	tests := []struct {
+		urlPath string
+		want    string
+	}{
+		{"/", "index.html"},
+		{"/markdown", "markdown.md"},
+		{"/sub/path", "sub/path"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.urlPath, func(t *testing.T) {
+			if got, _, _ := stat(fsys, tt.urlPath); got != tt.want {
+				t.Errorf("stat() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func handleTemplate(w http.ResponseWriter, _ *http.Request, fsys fs.FS) error {
+	return Template(w, fsys, "data.html", "Data from Handler", http.StatusOK)
+}
+
+func handleJSON(w http.ResponseWriter, _ *http.Request, fsys fs.FS) error {
+	return JSON(w, struct{ Data string }{Data: "Data"}, http.StatusOK)
+}
+
+func handleText(w http.ResponseWriter, _ *http.Request, fsys fs.FS) error {
+	return Text(w, "Hello, World!", http.StatusOK)
+}
+
+func handleError(w http.ResponseWriter, r *http.Request, fsys fs.FS) error {
+	Error(w, r, errors.New("Bad Request"), http.StatusBadRequest)
+	return nil
+}
diff --git a/godev/internal/content/esbuild.go b/godev/internal/content/esbuild.go
new file mode 100644
index 0000000..0c743cf
--- /dev/null
+++ b/godev/internal/content/esbuild.go
@@ -0,0 +1,36 @@
+// Copyright 2023 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.
+
+//go:build !plan9
+
+package content
+
+import (
+	"bytes"
+	"fmt"
+	"strings"
+
+	"github.com/evanw/esbuild/pkg/api"
+)
+
+func esbuild(data []byte) *bytes.Buffer {
+	// TODO: cache the output of this transform operation, minify the output.
+	js := api.Transform(string(data), api.TransformOptions{
+		Loader: api.LoaderTS,
+	})
+	output := bytes.NewBuffer(js.Code)
+	if len(js.Warnings) != 0 {
+		messages := api.FormatMessages(js.Warnings, api.FormatMessagesOptions{})
+		for _, m := range messages {
+			fmt.Fprintf(output, ";console.warn(`%s`);", strings.ReplaceAll(m, "`", "\\`"))
+		}
+	}
+	if len(js.Errors) != 0 {
+		messages := api.FormatMessages(js.Errors, api.FormatMessagesOptions{})
+		for _, m := range messages {
+			fmt.Fprintf(output, ";console.error(`%s`);", strings.ReplaceAll(m, "`", "\\`"))
+		}
+	}
+	return output
+}
diff --git a/godev/internal/content/esbuild_plan9.go b/godev/internal/content/esbuild_plan9.go
new file mode 100644
index 0000000..aec014e
--- /dev/null
+++ b/godev/internal/content/esbuild_plan9.go
@@ -0,0 +1,13 @@
+// Copyright 2023 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.
+
+//go:build plan9
+
+package content
+
+import "bytes"
+
+func esbuild(data []byte) *bytes.Buffer {
+	return &bytes.Buffer{}
+}
diff --git a/godev/internal/content/testdata/404.html.out b/godev/internal/content/testdata/404.html.out
new file mode 100644
index 0000000..3261618
--- /dev/null
+++ b/godev/internal/content/testdata/404.html.out
@@ -0,0 +1 @@
+page not found
diff --git a/godev/internal/content/testdata/base.html b/godev/internal/content/testdata/base.html
new file mode 100644
index 0000000..ab01ea7
--- /dev/null
+++ b/godev/internal/content/testdata/base.html
@@ -0,0 +1,7 @@
+<!--
+  Copyright 2023 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.
+-->
+
+{{template "base" .}}
diff --git a/godev/internal/content/testdata/base.tmpl b/godev/internal/content/testdata/base.tmpl
new file mode 100644
index 0000000..417e9ae
--- /dev/null
+++ b/godev/internal/content/testdata/base.tmpl
@@ -0,0 +1,19 @@
+<!--
+  Copyright 2023 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.
+-->
+
+{{define "base"}}
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <title>{{block "title" .}}{{.Title}}{{end}}</title>
+</head>
+<body>
+  <main>
+  {{block "content" .}}{{.Content}}{{end}}
+  </main>
+</body>
+</html>
+{{end}}
diff --git a/godev/internal/content/testdata/data.html b/godev/internal/content/testdata/data.html
new file mode 100644
index 0000000..a4e2d2d
--- /dev/null
+++ b/godev/internal/content/testdata/data.html
@@ -0,0 +1,13 @@
+<!--
+  Copyright 2023 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.
+-->
+
+{{template "base" .}}
+{{define "title"}}Page Title{{end}}
+{{define "content"}}
+  <header>
+    <h1>{{.}}</h1>
+  </header>
+{{end}}
diff --git a/godev/internal/content/testdata/data.html.out b/godev/internal/content/testdata/data.html.out
new file mode 100644
index 0000000..9273ec1
--- /dev/null
+++ b/godev/internal/content/testdata/data.html.out
@@ -0,0 +1,21 @@
+
+
+
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <title>Page Title</title>
+</head>
+<body>
+  <main>
+  
+  <header>
+    <h1>Data from Handler</h1>
+  </header>
+
+  </main>
+</body>
+</html>
+
+
+
diff --git a/godev/internal/content/testdata/error.out b/godev/internal/content/testdata/error.out
new file mode 100644
index 0000000..489ff97
--- /dev/null
+++ b/godev/internal/content/testdata/error.out
@@ -0,0 +1 @@
+Bad Request
diff --git a/godev/internal/content/testdata/index.html b/godev/internal/content/testdata/index.html
new file mode 100644
index 0000000..12efa77
--- /dev/null
+++ b/godev/internal/content/testdata/index.html
@@ -0,0 +1,13 @@
+<!--
+  Copyright 2023 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.
+-->
+
+{{template "base" .}}
+{{define "title"}}Page Title{{end}}
+{{define "content"}}
+  <header>
+    <h1>Page Title</h1>
+  </header>
+{{end}}
diff --git a/godev/internal/content/testdata/index.html.out b/godev/internal/content/testdata/index.html.out
new file mode 100644
index 0000000..1d50474
--- /dev/null
+++ b/godev/internal/content/testdata/index.html.out
@@ -0,0 +1,21 @@
+
+
+
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <title>Page Title</title>
+</head>
+<body>
+  <main>
+  
+  <header>
+    <h1>Page Title</h1>
+  </header>
+
+  </main>
+</body>
+</html>
+
+
+
diff --git a/godev/internal/content/testdata/json.out b/godev/internal/content/testdata/json.out
new file mode 100644
index 0000000..34948e3
--- /dev/null
+++ b/godev/internal/content/testdata/json.out
@@ -0,0 +1 @@
+{"Data":"Data"}
diff --git a/godev/internal/content/testdata/markdown.md b/godev/internal/content/testdata/markdown.md
new file mode 100644
index 0000000..cb76515
--- /dev/null
+++ b/godev/internal/content/testdata/markdown.md
@@ -0,0 +1,9 @@
+---
+Title: Page Title
+---
+
+# This is a heading
+
+## This is a subheading
+
+[link](https://go.dev)
\ No newline at end of file
diff --git a/godev/internal/content/testdata/markdown.md.out b/godev/internal/content/testdata/markdown.md.out
new file mode 100644
index 0000000..861b57c
--- /dev/null
+++ b/godev/internal/content/testdata/markdown.md.out
@@ -0,0 +1,18 @@
+
+
+
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <title>Page Title</title>
+</head>
+<body>
+  <main>
+  <h1 id="this-is-a-heading">This is a heading</h1>
+<h2 id="this-is-a-subheading">This is a subheading</h2>
+<p><a href="https://go.dev">link</a></p>
+
+  </main>
+</body>
+</html>
+
diff --git a/godev/internal/content/testdata/noindex/noindex.html.out b/godev/internal/content/testdata/noindex/noindex.html.out
new file mode 100644
index 0000000..59ecac5
--- /dev/null
+++ b/godev/internal/content/testdata/noindex/noindex.html.out
@@ -0,0 +1,3 @@
+<pre>
+<a href="noindex.html.out">noindex.html.out</a>
+</pre>
diff --git a/godev/internal/content/testdata/redirect.html.out b/godev/internal/content/testdata/redirect.html.out
new file mode 100644
index 0000000..4245802
--- /dev/null
+++ b/godev/internal/content/testdata/redirect.html.out
@@ -0,0 +1,2 @@
+<a href="/index">Moved Permanently</a>.
+
diff --git a/godev/internal/content/testdata/redirect.out b/godev/internal/content/testdata/redirect.out
new file mode 100644
index 0000000..266448d
--- /dev/null
+++ b/godev/internal/content/testdata/redirect.out
@@ -0,0 +1,2 @@
+<a href="/">Moved Permanently</a>.
+
diff --git a/godev/internal/content/testdata/script.ts b/godev/internal/content/testdata/script.ts
new file mode 100644
index 0000000..2dab8f8
--- /dev/null
+++ b/godev/internal/content/testdata/script.ts
@@ -0,0 +1,16 @@
+/**
+ * @license
+ * Copyright 2023 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.
+ */
+
+interface Greeter {
+  (target: string): void;
+}
+
+const hello: Greeter = (target) => console.log(`Hello, ${target}!`);
+
+hello("world");
+
+export {};
diff --git a/godev/internal/content/testdata/script.ts.out b/godev/internal/content/testdata/script.ts.out
new file mode 100644
index 0000000..95f7be9
--- /dev/null
+++ b/godev/internal/content/testdata/script.ts.out
@@ -0,0 +1,9 @@
+/**
+ * @license
+ * Copyright 2023 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.
+ */
+const hello = (target) => console.log(`Hello, ${target}!`);
+hello("world");
+export {};
diff --git a/godev/internal/content/testdata/style.css b/godev/internal/content/testdata/style.css
new file mode 100644
index 0000000..447a4c6
--- /dev/null
+++ b/godev/internal/content/testdata/style.css
@@ -0,0 +1,9 @@
+/*!
+ * Copyright 2023 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.
+ */
+
+a {
+  color: blue;
+}
diff --git a/godev/internal/content/testdata/style.css.out b/godev/internal/content/testdata/style.css.out
new file mode 100644
index 0000000..447a4c6
--- /dev/null
+++ b/godev/internal/content/testdata/style.css.out
@@ -0,0 +1,9 @@
+/*!
+ * Copyright 2023 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.
+ */
+
+a {
+  color: blue;
+}
diff --git a/godev/internal/content/testdata/subdir/index.html b/godev/internal/content/testdata/subdir/index.html
new file mode 100644
index 0000000..91d35a5
--- /dev/null
+++ b/godev/internal/content/testdata/subdir/index.html
@@ -0,0 +1,13 @@
+<!--
+  Copyright 2023 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.
+-->
+
+{{template "base" .}}
+{{define "title"}}Page Title{{end}}
+{{define "content"}}
+  <header>
+    <h1>Page in subdirectory</h1>
+  </header>
+{{end}}
diff --git a/godev/internal/content/testdata/subdir/index.html.out b/godev/internal/content/testdata/subdir/index.html.out
new file mode 100644
index 0000000..49e1c29
--- /dev/null
+++ b/godev/internal/content/testdata/subdir/index.html.out
@@ -0,0 +1,21 @@
+
+
+
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <title>Page Title</title>
+</head>
+<body>
+  <main>
+  
+  <header>
+    <h1>Page in subdirectory</h1>
+  </header>
+
+  </main>
+</body>
+</html>
+
+
+
diff --git a/godev/internal/content/testdata/text.out b/godev/internal/content/testdata/text.out
new file mode 100644
index 0000000..b45ef6f
--- /dev/null
+++ b/godev/internal/content/testdata/text.out
@@ -0,0 +1 @@
+Hello, World!
\ No newline at end of file
diff --git a/godev/internal/middleware/middleware.go b/godev/internal/middleware/middleware.go
new file mode 100644
index 0000000..33fb42f
--- /dev/null
+++ b/godev/internal/middleware/middleware.go
@@ -0,0 +1,81 @@
+// Copyright 2023 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 middleware implements a simple middleware pattern for http handlers,
+// along with implementations for some common middlewares.
+package middleware
+
+import (
+	"fmt"
+	"net/http"
+	"runtime/debug"
+	"time"
+
+	"golang.org/x/exp/slog"
+)
+
+var Default = Chain(Log, Recover)
+
+// A Middleware is a func that wraps an http.Handler.
+type Middleware func(http.Handler) http.Handler
+
+// Chain creates a new Middleware that applies a sequence of Middlewares, so
+// that they execute in the given order when handling an http request.
+//
+// In other words, Chain(m1, m2)(handler) = m1(m2(handler))
+//
+// A similar pattern is used in e.g. github.com/justinas/alice:
+// https://github.com/justinas/alice/blob/ce87934/chain.go#L45
+func Chain(middlewares ...Middleware) Middleware {
+	return func(h http.Handler) http.Handler {
+		for i := range middlewares {
+			h = middlewares[len(middlewares)-1-i](h)
+		}
+		return h
+	}
+}
+
+// Log is a middleware that logs request start, end, duration, and status.
+func Log(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		start := time.Now()
+		slog.Info("request start",
+			slog.String("method", r.Method),
+			slog.String("uri", r.RequestURI),
+		)
+		w2 := &statusRecorder{w, 200}
+		next.ServeHTTP(w2, r)
+		slog.Info("request end",
+			slog.String("method", r.Method),
+			slog.String("uri", r.RequestURI),
+			slog.Int("status", w2.status),
+			slog.Duration("duration", time.Since(start)),
+		)
+	})
+}
+
+// Recover is a middleware that recovers from panics in the delegate
+// handler and prints a stack trace.
+func Recover(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		defer func() {
+			if err := recover(); err != nil {
+				http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+				slog.Error(r.RequestURI, fmt.Errorf(`panic("%s")`, err))
+				fmt.Println(string(debug.Stack()))
+			}
+		}()
+		next.ServeHTTP(w, r)
+	})
+}
+
+type statusRecorder struct {
+	http.ResponseWriter
+	status int
+}
+
+func (rec *statusRecorder) WriteHeader(code int) {
+	rec.status = code
+	rec.ResponseWriter.WriteHeader(code)
+}
diff --git a/godev/internal/unionfs/testdata/dir1/file1 b/godev/internal/unionfs/testdata/dir1/file1
new file mode 100644
index 0000000..d46a0e4
--- /dev/null
+++ b/godev/internal/unionfs/testdata/dir1/file1
@@ -0,0 +1 @@
+file 1 content from dir 1
diff --git a/godev/internal/unionfs/testdata/dir2/file1 b/godev/internal/unionfs/testdata/dir2/file1
new file mode 100644
index 0000000..af7eea4
--- /dev/null
+++ b/godev/internal/unionfs/testdata/dir2/file1
@@ -0,0 +1 @@
+file 1 content from dir 2
\ No newline at end of file
diff --git a/godev/internal/unionfs/testdata/dir2/file2 b/godev/internal/unionfs/testdata/dir2/file2
new file mode 100644
index 0000000..b8947b7
--- /dev/null
+++ b/godev/internal/unionfs/testdata/dir2/file2
@@ -0,0 +1 @@
+file 2 content
diff --git a/godev/internal/unionfs/unionfs.go b/godev/internal/unionfs/unionfs.go
new file mode 100644
index 0000000..139a6fa
--- /dev/null
+++ b/godev/internal/unionfs/unionfs.go
@@ -0,0 +1,82 @@
+// Copyright 2023 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 unionfs allows multiple file systems to be read as a union.
+package unionfs
+
+import (
+	"io/fs"
+)
+
+var _ fs.ReadDirFS = FS{}
+
+// A FS is an FS presenting the union of the file systems in the slice. If
+// multiple file systems provide a particular file, Open uses the FS listed
+// earlier in the slice.
+type FS []fs.FS
+
+// Sub returns an FS corresponding to the merged subtree rooted at a set of
+// fsys's dirs.
+func Sub(fsys fs.FS, dirs ...string) (FS, error) {
+	var subs FS
+	for _, dir := range dirs {
+		if _, err := fs.Stat(fsys, dir); err != nil {
+			return nil, err
+		}
+		sub, err := fs.Sub(fsys, dir)
+		if err != nil {
+			return nil, err
+		}
+		subs = append(subs, sub)
+	}
+	return subs, nil
+}
+
+func (fsys FS) Open(name string) (fs.File, error) {
+	var errOut error
+	for _, sub := range fsys {
+		f, err := sub.Open(name)
+		if err == nil {
+			return f, nil
+		}
+		if errOut == nil {
+			errOut = err
+		}
+	}
+	return nil, errOut
+}
+
+func (fsys FS) ReadDir(name string) ([]fs.DirEntry, error) {
+	var all []fs.DirEntry
+	var seen map[string]bool // seen[name] is true if name is listed in all; lazily initialized
+	var errOut error
+	for _, sub := range fsys {
+		list, err := fs.ReadDir(sub, name)
+		if err != nil {
+			errOut = err
+		}
+		if len(all) == 0 {
+			all = append(all, list...)
+		} else {
+			if seen == nil {
+				// Initialize seen only after we get two different directory listings.
+				seen = make(map[string]bool)
+				for _, d := range all {
+					seen[d.Name()] = true
+				}
+			}
+			for _, d := range list {
+				name := d.Name()
+				if !seen[name] {
+					seen[name] = true
+					all = append(all, d)
+				}
+			}
+		}
+	}
+	if len(all) > 0 {
+		return all, nil
+	}
+	return nil, errOut
+}
diff --git a/godev/internal/unionfs/unionfs_test.go b/godev/internal/unionfs/unionfs_test.go
new file mode 100644
index 0000000..d839b82
--- /dev/null
+++ b/godev/internal/unionfs/unionfs_test.go
@@ -0,0 +1,113 @@
+// Copyright 2023 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 unionfs
+
+import (
+	"io"
+	"os"
+	"reflect"
+	"testing"
+)
+
+func TestFS_Open(t *testing.T) {
+	fsys, err := Sub(os.DirFS("testdata"), "dir1", "dir2")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	type args struct {
+		name string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    string
+		wantErr bool
+	}{
+		{
+			name: "file1 from dir1",
+			args: args{
+				name: "file1",
+			},
+			want:    "file 1 content from dir 1\n",
+			wantErr: false,
+		},
+		{
+			name: "file2 from dir2",
+			args: args{
+				name: "file2",
+			},
+			want:    "file 2 content\n",
+			wantErr: false,
+		},
+		{
+			name: "file not found",
+			args: args{
+				name: "file3",
+			},
+			want:    "file3",
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			file, err := fsys.Open(tt.args.name)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("FS.Open() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !tt.wantErr {
+				bytes, err := io.ReadAll(file)
+				if err != nil {
+					t.Fatal(err)
+				}
+				got := string(bytes)
+				if got != tt.want {
+					t.Errorf("FS.Open() = %v, want %v", got, tt.want)
+				}
+			}
+		})
+	}
+}
+
+func TestFS_ReadDir(t *testing.T) {
+	var err error
+	fsys, err := Sub(os.DirFS("testdata"), "dir1", "dir2")
+	if err != nil {
+		t.Fatal(err)
+	}
+	type args struct {
+		name string
+	}
+	tests := []struct {
+		name      string
+		args      args
+		fsys      FS
+		wantFiles []string
+	}{
+		{
+			name:      "",
+			args:      args{"."},
+			fsys:      fsys,
+			wantFiles: []string{"file1", "file2"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			dirs, err := tt.fsys.ReadDir(tt.args.name)
+			if err != nil {
+				t.Errorf("FS.ReadDir() error = %v", err)
+				return
+			}
+			var got []string
+			for _, v := range dirs {
+				got = append(got, v.Name())
+			}
+			if !reflect.DeepEqual(got, tt.wantFiles) {
+				t.Errorf("FS.ReadDir() = %v, want %v", got, tt.wantFiles)
+			}
+		})
+	}
+}