internal/static: preserve license header

The static.go tool, which generates files from
.ts and .css files, now preserves license headers
from the original source.

Change-Id: Ife5e7e9cdd744fb5c8c0287a581d1a766a6a6964
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/802160
Auto-Submit: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Ethan Lee <ethanalee@google.com>
kokoro-CI: kokoro <noreply+kokoro@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/static/static.go b/internal/static/static.go
index caa1b84..058e2cb 100644
--- a/internal/static/static.go
+++ b/internal/static/static.go
@@ -9,12 +9,15 @@
 package static
 
 import (
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strings"
 
 	"github.com/evanw/esbuild/pkg/api"
+	"golang.org/x/pkgsite/internal/derrors"
 )
 
 // Build compiles TypeScript files into minified JavaScript
@@ -24,59 +27,82 @@
 // when cmd/frontend is run in dev mode and in
 // devtools/cmd/static/main.go with Watch=false for building
 // productionized assets.
-func Build(config Config) error {
-	files, err := getEntry(config.EntryPoint, config.Bundle)
+func Build(config Config) (err error) {
+	files, err := getFiles(config.EntryPoint, config.Bundle)
 	if err != nil {
 		return err
 	}
+
+	for _, file := range files {
+		if err := processFile(config, file); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func processFile(config Config, filename string) (err error) {
+	defer derrors.Wrap(&err, "%s", filename)
+	contents, err := os.ReadFile(filename)
+	if err != nil {
+		return err
+	}
+	license, err := extractLicense(contents)
+	if err != nil {
+		return err
+	}
+	license = normalizeLicense(license)
+	var ext string
+	if strings.HasSuffix(filename, ".ts") {
+		ext = "js"
+	} else if strings.HasSuffix(filename, ".css") {
+		ext = "css"
+	} else {
+		return errors.New("unsupported filename type")
+	}
 	options := api.BuildOptions{
-		EntryPoints:  files,
+		EntryPoints:  []string{filename},
 		Bundle:       config.Bundle,
 		Outdir:       config.EntryPoint,
+		Outbase:      config.EntryPoint,
 		Write:        true,
 		Platform:     api.PlatformBrowser,
 		Format:       api.FormatESModule,
 		OutExtension: map[string]string{".css": ".min.css"},
 		External:     []string{"*.svg"},
-		Banner: map[string]string{
-			"css": "/*!\n" +
-				" * Copyright 2021 The Go Authors. All rights reserved.\n" +
-				" * Use of this source code is governed by a BSD-style\n" +
-				" * license that can be found in the LICENSE file.\n" +
-				" */",
-			"js": "/*!\n" +
-				" * Copyright 2026 The Go Authors. All rights reserved.\n" +
-				" * Use of this source code is governed by a BSD-style\n" +
-				" * license that can be found in the LICENSE file.\n" +
-				" */",
-		},
+		Banner:       map[string]string{ext: license},
 	}
 	options.MinifyIdentifiers = true
 	options.MinifySyntax = true
 	options.MinifyWhitespace = true
 	options.Sourcemap = api.SourceMapLinked
+
 	if config.Watch {
 		ctx, err := api.Context(options)
 		if err != nil {
 			return err
 		}
-		return ctx.Watch(api.WatchOptions{})
-	}
-	result := api.Build(options)
-	if len(result.Errors) > 0 {
-		return fmt.Errorf("error building static files: %v", result.Errors)
-	}
-	if len(result.Warnings) > 0 {
-		return fmt.Errorf("error building static files: %v", result.Warnings)
+		defer ctx.Dispose()
+		if err := ctx.Watch(api.WatchOptions{}); err != nil {
+			return err
+		}
+	} else {
+		result := api.Build(options)
+		if len(result.Errors) > 0 {
+			return fmt.Errorf("error building: %v", result.Errors)
+		}
+		if len(result.Warnings) > 0 {
+			return fmt.Errorf("warning building: %v", result.Warnings)
+		}
 	}
 	return nil
 }
 
-// getEntry walks the given directory and collects entry file paths
+// getFiles walks the given directory and collects entry file paths
 // for esbuild. It ignores test files and files prefixed with an underscore.
 // Underscore prefixed files are assumed to be imported by and bundled together
 // with the output of an entry file.
-func getEntry(dir string, bundle bool) ([]string, error) {
+func getFiles(dir string, bundle bool) ([]string, error) {
 	var matches []string
 	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
 		if err != nil {
@@ -100,3 +126,28 @@
 	}
 	return matches, nil
 }
+
+// Match a /* ... */ comment starting at the beginning of the input.
+// The (?s:...) syntax matches over multiple lines.
+// match[1] contains the comment text with its close delimeter.
+var reBlock = regexp.MustCompile(`(?s:^\s*/\*[*!]?(.*?\*/))`)
+
+// extractLicense takes a []byte and returns the contents of the license comment in
+// it. The comment must be of the form /*...*/ at the start of the file.
+// The returned string includes the close comment delimiter but not the open one.
+func extractLicense(contents []byte) (string, error) {
+	match := reBlock.FindSubmatch(contents)
+	if match == nil {
+		return "", errors.New("no initial /*...*/ comment")
+	}
+	comment := string(match[1])
+	if !strings.Contains(strings.ToLower(comment), "copyright") {
+		return "", errors.New("initial comment missing 'copyright'")
+	}
+	return comment, nil
+}
+
+func normalizeLicense(lic string) string {
+	// TS files have a "@license" line which we want to remove.
+	return "/*!" + strings.Replace(lic, " * @license\n", "", 1)
+}
diff --git a/internal/static/static_test.go b/internal/static/static_test.go
new file mode 100644
index 0000000..fc71ff7
--- /dev/null
+++ b/internal/static/static_test.go
@@ -0,0 +1,87 @@
+// Copyright 2026 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 static
+
+import (
+	"testing"
+)
+
+func TestExtractLicense(t *testing.T) {
+	testCases := []struct {
+		name    string
+		input   string
+		want    string
+		wantErr bool
+	}{
+		{
+			name:    "standard",
+			input:   "/*\nCopyright 2000 */",
+			want:    "\nCopyright 2000 */",
+			wantErr: false,
+		},
+		{
+			name:    "exclamation",
+			input:   "/*!\nCopyright 2021 The Go Authors. All rights reserved.\n */",
+			want:    "\nCopyright 2021 The Go Authors. All rights reserved.\n */",
+			wantErr: false,
+		},
+		{
+			name:    "double asterisk",
+			input:   "/**\n * Copyright 2026\n */",
+			want:    "\n * Copyright 2026\n */",
+			wantErr: false,
+		},
+		{
+			name:    "leading whitespace",
+			input:   "   \n/*\nCopyright 2000 */",
+			want:    "\nCopyright 2000 */",
+			wantErr: false,
+		},
+		{
+			name:    "error: no comment",
+			input:   "var x = 1",
+			wantErr: true,
+		},
+		{
+			name:    "error: empty input",
+			input:   "",
+			wantErr: true,
+		},
+		{
+			name:    "error: comment not at start",
+			input:   "var x = 1 /*\nCopyright 2000 */",
+			wantErr: true,
+		},
+		{
+			name:    "error: line comment instead of block comment",
+			input:   "// Copyright 2000",
+			wantErr: true,
+		},
+		{
+			name:    "error: unclosed block comment",
+			input:   "/*\nCopyright 2000",
+			wantErr: true,
+		},
+		{
+			name:    "error: initial comment missing copyright",
+			input:   "/* Hello World */",
+			wantErr: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := extractLicense([]byte(tc.input))
+			if (err != nil) != tc.wantErr {
+				t.Fatalf("extractLicense(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr)
+			}
+			if got != tc.want {
+				t.Errorf("extractLicense(%q) = %q, want %q", tc.input, got, tc.want)
+			}
+		})
+	}
+}
diff --git a/static/frontend/about/about.min.css b/static/frontend/about/about.min.css
index 62e0117..061bfe2 100644
--- a/static/frontend/about/about.min.css
+++ b/static/frontend/about/about.min.css
@@ -1,5 +1,5 @@
 /*!
- * Copyright 2021 The Go Authors. All rights reserved.
+ * Copyright 2022 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.
  */
diff --git a/static/frontend/about/index.js b/static/frontend/about/index.js
index 5234fb5..5fe8ce6 100644
--- a/static/frontend/about/index.js
+++ b/static/frontend/about/index.js
@@ -1,5 +1,5 @@
 /*!
- * Copyright 2026 The Go Authors. All rights reserved.
+ * Copyright 2022 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.
  */
diff --git a/static/frontend/fetch/fetch.js b/static/frontend/fetch/fetch.js
index aba5a5a..deeb3ff 100644
--- a/static/frontend/fetch/fetch.js
+++ b/static/frontend/fetch/fetch.js
@@ -1,5 +1,5 @@
 /*!
- * Copyright 2026 The Go Authors. All rights reserved.
+ * Copyright 2020 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.
  */
diff --git a/static/frontend/frontend.js b/static/frontend/frontend.js
index dcb3468..3ff859f 100644
--- a/static/frontend/frontend.js
+++ b/static/frontend/frontend.js
@@ -1,5 +1,5 @@
 /*!
- * Copyright 2026 The Go Authors. All rights reserved.
+ * Copyright 2020 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.
  */
diff --git a/static/frontend/homepage/homepage.min.css b/static/frontend/homepage/homepage.min.css
index 553ada3..16665cd 100644
--- a/static/frontend/homepage/homepage.min.css
+++ b/static/frontend/homepage/homepage.min.css
@@ -1,5 +1,5 @@
 /*!
- * Copyright 2021 The Go Authors. All rights reserved.
+ * Copyright 2020 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.
  */
diff --git a/static/frontend/search/search.js b/static/frontend/search/search.js
index a698cec..fe4ef8c 100644
--- a/static/frontend/search/search.js
+++ b/static/frontend/search/search.js
@@ -1,5 +1,5 @@
 /*!
- * Copyright 2026 The Go Authors. All rights reserved.
+ * Copyright 2020 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.
  */
diff --git a/static/frontend/unit/main/main.min.css b/static/frontend/unit/main/main.min.css
index 16d226b..b1ebdad 100644
--- a/static/frontend/unit/main/main.min.css
+++ b/static/frontend/unit/main/main.min.css
@@ -1,5 +1,5 @@
 /*!
- * Copyright 2021 The Go Authors. All rights reserved.
+ * Copyright 2020 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.
  */
diff --git a/static/frontend/unit/unit.js b/static/frontend/unit/unit.js
index ddc0143..cdf429b 100644
--- a/static/frontend/unit/unit.js
+++ b/static/frontend/unit/unit.js
@@ -1,5 +1,5 @@
 /*!
- * Copyright 2026 The Go Authors. All rights reserved.
+ * 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.
  */
diff --git a/static/frontend/unit/versions/versions.js b/static/frontend/unit/versions/versions.js
index fcebeb5..8d3d848 100644
--- a/static/frontend/unit/versions/versions.js
+++ b/static/frontend/unit/versions/versions.js
@@ -1,5 +1,5 @@
 /*!
- * Copyright 2026 The Go Authors. All rights reserved.
+ * 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.
  */
diff --git a/static/worker/worker.js b/static/worker/worker.js
index e8a1c48..50b0e92 100644
--- a/static/worker/worker.js
+++ b/static/worker/worker.js
@@ -1,5 +1,5 @@
 /*!
- * Copyright 2026 The Go Authors. All rights reserved.
+ * 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.
  */
diff --git a/static/worker/worker.min.css b/static/worker/worker.min.css
index d3f2c74..9b64689 100644
--- a/static/worker/worker.min.css
+++ b/static/worker/worker.min.css
@@ -1,5 +1,5 @@
 /*!
- * Copyright 2021 The Go Authors. All rights reserved.
+ * Copyright 2019-2020 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.
  */