website: create typescript file handler

To allow for the use of TypeScript in go.dev pages
added a handler for TypeScript files served from
_content/ts. Files requested from this directory
are first transformed from TypeScript to JavaScript
using github.com/evanw/esbuild. JavaScript output is
written to a simple cache so subsequent requests skip
the tranformation step.

Change-Id: I0a161ce3dd20eaddddd5d369d359c65c90d9f607
Reviewed-on: https://go-review.googlesource.com/c/website/+/373718
Run-TryBot: Jamal Carvalho <jamal@golang.org>
Trust: Jamal Carvalho <jamalcarvalho@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Alex Rakoczy <alex@golang.org>
diff --git a/cmd/golangorg/server.go b/cmd/golangorg/server.go
index ae9dea3..2b154b3 100644
--- a/cmd/golangorg/server.go
+++ b/cmd/golangorg/server.go
@@ -36,6 +36,7 @@
 	"golang.org/x/website/internal/blog"
 	"golang.org/x/website/internal/codewalk"
 	"golang.org/x/website/internal/dl"
+	"golang.org/x/website/internal/esbuild"
 	"golang.org/x/website/internal/gitfs"
 	"golang.org/x/website/internal/history"
 	"golang.org/x/website/internal/memcache"
@@ -264,6 +265,7 @@
 	mux.Handle(host+"/cmd/", docs)
 	mux.Handle(host+"/pkg/", docs)
 	mux.Handle(host+"/doc/codewalk/", codewalk.NewServer(fsys, site))
+	mux.Handle(host+"/ts/", esbuild.NewServer(fsys, site))
 	return site, nil
 }
 
diff --git a/go.mod b/go.mod
index 0f7895d..214c029 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@
 	cloud.google.com/go/datastore v1.2.0
 	github.com/chromedp/cdproto v0.0.0-20211205231339-d2673e93eee4
 	github.com/chromedp/chromedp v0.7.6
+	github.com/evanw/esbuild v0.14.7
 	github.com/gomodule/redigo v2.0.0+incompatible
 	github.com/google/go-cmp v0.5.6
 	github.com/microcosm-cc/bluemonday v1.0.2
diff --git a/go.sum b/go.sum
index 8acfecf..31162e4 100644
--- a/go.sum
+++ b/go.sum
@@ -191,6 +191,8 @@
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/esimov/stackblur-go v1.0.1/go.mod h1:a3zzeKuJKUpCcReHmEsuPaEnq42D2b/bHoCI8UjIuMY=
+github.com/evanw/esbuild v0.14.7 h1:At4sSDNq+beZA+z6GUA/sRoqHys9qxKH1RT05eN6Kpo=
+github.com/evanw/esbuild v0.14.7/go.mod h1:GG+zjdi59yh3ehDn4ZWfPcATxjPDUH53iU4ZJbp7dkY=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
@@ -936,6 +938,7 @@
 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 h1:TyHqChC80pFkXWraUUf6RuB5IqFdQieMLwwCJokV2pc=
 golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
diff --git a/internal/esbuild/esbuild.go b/internal/esbuild/esbuild.go
new file mode 100644
index 0000000..a356e1e
--- /dev/null
+++ b/internal/esbuild/esbuild.go
@@ -0,0 +1,88 @@
+// 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 esbuild transforms TypeScript code into
+// JavaScript code.
+package esbuild
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"net/http"
+	"path"
+	"sync"
+
+	"github.com/evanw/esbuild/pkg/api"
+	"golang.org/x/website/internal/web"
+)
+
+const cacheHeader = "X-Go-Dev-Cache-Hit"
+
+type server struct {
+	fsys  fs.FS
+	site  *web.Site
+	cache sync.Map // TypeScript filepath -> JavaScript output
+}
+
+// NewServer returns a new server for handling TypeScript files.
+func NewServer(fsys fs.FS, site *web.Site) http.Handler {
+	return &server{fsys, site, sync.Map{}}
+}
+
+type JSOut struct {
+	output []byte
+	stat   fs.FileInfo // stat for file when page was loaded
+}
+
+// Handler for TypeScript files. Transforms TypeScript code into
+// JavaScript code before serving them.
+func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	filename := path.Clean(r.URL.Path)[1:]
+	if cjs, ok := s.cache.Load(filename); ok {
+		js := cjs.(*JSOut)
+		info, err := fs.Stat(s.fsys, filename)
+		if err == nil && info.ModTime().Equal(js.stat.ModTime()) {
+			w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+			w.Header().Set(cacheHeader, "true")
+			http.ServeContent(w, r, filename, info.ModTime(), bytes.NewReader(js.output))
+			return
+		}
+	}
+	file, err := s.fsys.Open(filename)
+	if err != nil {
+		s.site.ServeError(w, r, err)
+		return
+	}
+	var contents bytes.Buffer
+	_, err = io.Copy(&contents, file)
+	if err != nil {
+		s.site.ServeError(w, r, err)
+		return
+	}
+	result := api.Transform(contents.String(), api.TransformOptions{
+		Loader: api.LoaderTS,
+	})
+	var buf bytes.Buffer
+	for _, v := range result.Errors {
+		fmt.Fprintln(&buf, v.Text)
+	}
+	if buf.Len() > 0 {
+		s.site.ServeError(w, r, errors.New(buf.String()))
+		return
+	}
+	info, err := file.Stat()
+	if err != nil {
+		s.site.ServeError(w, r, err)
+		return
+	}
+	w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+	http.ServeContent(w, r, filename, info.ModTime(), bytes.NewReader(result.Code))
+	s.cache.Store(filename, &JSOut{
+		output: result.Code,
+		stat:   info,
+	})
+}
diff --git a/internal/esbuild/esbuild_test.go b/internal/esbuild/esbuild_test.go
new file mode 100644
index 0000000..565c6e7
--- /dev/null
+++ b/internal/esbuild/esbuild_test.go
@@ -0,0 +1,85 @@
+package esbuild
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"syscall"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/website/internal/web"
+)
+
+func TestServeHTTP(t *testing.T) {
+	exampleOut := `/**
+ * @license
+ * 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.
+ */
+function sayHello(to) {
+  console.log("Hello, " + to + "!");
+}
+const world = {
+  name: "World",
+  toString() {
+    return this.name;
+  }
+};
+sayHello(world);
+`
+	tests := []struct {
+		name            string
+		path            string
+		wantCode        int
+		wantBody        string
+		wantCacheHeader bool
+	}{
+		{
+			name:     "example code",
+			path:     "/example.ts",
+			wantCode: 200,
+			wantBody: exampleOut,
+		},
+		{
+			name:            "example code cached",
+			path:            "/example.ts",
+			wantCode:        200,
+			wantBody:        exampleOut,
+			wantCacheHeader: true,
+		},
+		{
+			name:     "file not found",
+			path:     "/notfound.ts",
+			wantCode: 500,
+			wantBody: fmt.Sprintf("\n\nopen testdata/notfound.ts: %s\n", syscall.ENOENT),
+		},
+		{
+			name:     "syntax error",
+			path:     "/error.ts",
+			wantCode: 500,
+			wantBody: "\n\nExpected identifier but found &#34;function&#34;\n\n",
+		},
+	}
+	fsys := os.DirFS("testdata")
+	server := NewServer(fsys, web.NewSite(fsys))
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
+			got := httptest.NewRecorder()
+			server.ServeHTTP(got, req)
+			gotHeader := got.Header().Get(cacheHeader) == "true"
+			if got.Code != tt.wantCode {
+				t.Errorf("got status %d but wanted %d", got.Code, http.StatusOK)
+			}
+			if (tt.wantCacheHeader && !gotHeader) || (!tt.wantCacheHeader && gotHeader) {
+				t.Errorf("got cache hit %v but wanted %v", gotHeader, tt.wantCacheHeader)
+			}
+			if diff := cmp.Diff(tt.wantBody, got.Body.String()); diff != "" {
+				t.Errorf("ServeHTTP() mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
diff --git a/internal/esbuild/testdata/error.tmpl b/internal/esbuild/testdata/error.tmpl
new file mode 100644
index 0000000..d0a2b49
--- /dev/null
+++ b/internal/esbuild/testdata/error.tmpl
@@ -0,0 +1,7 @@
+<!--
+	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.
+-->
+
+{{define "layout"}}{{.error}}{{end}}
diff --git a/internal/esbuild/testdata/error.ts b/internal/esbuild/testdata/error.ts
new file mode 100644
index 0000000..b60870b
--- /dev/null
+++ b/internal/esbuild/testdata/error.ts
@@ -0,0 +1,8 @@
+/**
+ * @license
+ * 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.
+ */
+
+const function = () => {};
diff --git a/internal/esbuild/testdata/example.ts b/internal/esbuild/testdata/example.ts
new file mode 100644
index 0000000..0bfd126
--- /dev/null
+++ b/internal/esbuild/testdata/example.ts
@@ -0,0 +1,23 @@
+/**
+ * @license
+ * 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.
+ */
+
+interface Target {
+  toString(): string;
+}
+
+function sayHello(to: Target): void {
+  console.log('Hello, ' + to + '!');
+}
+
+const world = {
+  name: 'World',
+  toString(): string {
+    return this.name;
+  },
+};
+
+sayHello(world);
diff --git a/internal/esbuild/testdata/site.tmpl b/internal/esbuild/testdata/site.tmpl
new file mode 100644
index 0000000..650ac44
--- /dev/null
+++ b/internal/esbuild/testdata/site.tmpl
@@ -0,0 +1,7 @@
+<!--
+	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.
+-->
+
+{{block "entirepage" .}}{{block "layout" .}}{{.Content}}{{end}}{{end}}