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 "function"\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}}