cmd,internal: move typescript transform to web package

To transform .ts files from any path on a site and to ensure
it works for all domains the transform logic is moved to the
web package.

Change-Id: I1ce62eb35dbdfb987ccc2f48e79c558bcdadfbbe
Reviewed-on: https://go-review.googlesource.com/c/website/+/374994
Trust: Jamal Carvalho <jamalcarvalho@google.com>
Run-TryBot: Jamal Carvalho <jamal@golang.org>
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 6e1b371..41d2c6b 100644
--- a/cmd/golangorg/server.go
+++ b/cmd/golangorg/server.go
@@ -36,7 +36,6 @@
 	"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"
@@ -266,7 +265,6 @@
 	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/internal/esbuild/esbuild.go b/internal/esbuild/esbuild.go
deleted file mode 100644
index a356e1e..0000000
--- a/internal/esbuild/esbuild.go
+++ /dev/null
@@ -1,88 +0,0 @@
-// 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
deleted file mode 100644
index 565c6e7..0000000
--- a/internal/esbuild/esbuild_test.go
+++ /dev/null
@@ -1,85 +0,0 @@
-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/web/istext.go b/internal/web/istext.go
index aac2abb..a512bbf 100644
--- a/internal/web/istext.go
+++ b/internal/web/istext.go
@@ -41,7 +41,7 @@
 		return false
 	}
 	switch path.Ext(filename) {
-	case ".css", ".js", ".svg":
+	case ".css", ".js", ".svg", ".ts":
 		return false
 	}
 
diff --git a/internal/web/site.go b/internal/web/site.go
index 73a0f8a..e0206d5 100644
--- a/internal/web/site.go
+++ b/internal/web/site.go
@@ -299,6 +299,7 @@
 	"errors"
 	"fmt"
 	"html"
+	"io"
 	"io/fs"
 	"log"
 	"net/http"
@@ -308,6 +309,7 @@
 	"strings"
 	"sync"
 
+	"github.com/evanw/esbuild/pkg/api"
 	"golang.org/x/website/internal/backport/html/template"
 	"golang.org/x/website/internal/spec"
 	"golang.org/x/website/internal/texthtml"
@@ -423,6 +425,12 @@
 	abspath := r.URL.Path
 	relpath := path.Clean(strings.TrimPrefix(abspath, "/"))
 
+	// Is it a TypeScript file?
+	if strings.HasSuffix(relpath, ".ts") {
+		s.serveTypeScript(w, r)
+		return
+	}
+
 	// Is it a page we can generate?
 	if p, err := s.openPage(relpath); err == nil {
 		if p.url != abspath {
@@ -611,3 +619,57 @@
 	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
 	w.Write(text)
 }
+
+const cacheHeader = "X-Go-Dev-Cache-Hit"
+
+type jsout struct {
+	output []byte
+	stat   fs.FileInfo // stat for file when page was loaded
+}
+
+func (s *Site) serveTypeScript(w http.ResponseWriter, r *http.Request) {
+	filename := path.Clean(strings.TrimPrefix(r.URL.Path, "/"))
+	if cjs, ok := s.cache.Load(filename); ok {
+		js := cjs.(*jsout)
+		info, err := fs.Stat(s.fs, 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.fs.Open(filename)
+	if err != nil {
+		s.ServeError(w, r, err)
+		return
+	}
+	var contents bytes.Buffer
+	_, err = io.Copy(&contents, file)
+	if err != nil {
+		s.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.ServeError(w, r, errors.New(buf.String()))
+		return
+	}
+	info, err := file.Stat()
+	if err != nil {
+		s.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/web/site_test.go b/internal/web/site_test.go
index f1397a1..488673f 100644
--- a/internal/web/site_test.go
+++ b/internal/web/site_test.go
@@ -5,12 +5,17 @@
 package web
 
 import (
+	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"os"
 	"strings"
+	"syscall"
 	"testing"
 	"testing/fstest"
+
+	"github.com/google/go-cmp/cmp"
 )
 
 func testServeBody(t *testing.T, p *Site, path, body string) {
@@ -58,3 +63,62 @@
 	testServeBody(t, site, "/doc/test", "<strong>bold</strong>")
 	testServeBody(t, site, "/doc/test2", "<em>template</em>")
 }
+
+func TestTypeScript(t *testing.T) {
+	exampleOut, err := os.ReadFile("testdata/example.js")
+	if err != nil {
+		t.Fatal(err)
+	}
+	tests := []struct {
+		name            string
+		path            string
+		wantCode        int
+		wantBody        string
+		wantCacheHeader bool
+	}{
+		{
+			name:     "example code",
+			path:     "/example.ts",
+			wantCode: 200,
+			wantBody: string(exampleOut),
+		},
+		{
+			name:            "example code cached",
+			path:            "/example.ts",
+			wantCode:        200,
+			wantBody:        string(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")
+	site := 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()
+			site.serveTypeScript(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/web/testdata/error.tmpl
similarity index 100%
rename from internal/esbuild/testdata/error.tmpl
rename to internal/web/testdata/error.tmpl
diff --git a/internal/esbuild/testdata/error.ts b/internal/web/testdata/error.ts
similarity index 100%
rename from internal/esbuild/testdata/error.ts
rename to internal/web/testdata/error.ts
diff --git a/internal/web/testdata/example.js b/internal/web/testdata/example.js
new file mode 100644
index 0000000..0ab8d7f
--- /dev/null
+++ b/internal/web/testdata/example.js
@@ -0,0 +1,16 @@
+/**
+ * @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);
diff --git a/internal/esbuild/testdata/example.ts b/internal/web/testdata/example.ts
similarity index 100%
rename from internal/esbuild/testdata/example.ts
rename to internal/web/testdata/example.ts
diff --git a/internal/esbuild/testdata/site.tmpl b/internal/web/testdata/site.tmpl
similarity index 100%
rename from internal/esbuild/testdata/site.tmpl
rename to internal/web/testdata/site.tmpl