| // 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. | |
| // github.com/evanw/esbuild doesn't compile on plan9 | |
| //go:build !plan9 | |
| // Package static builds static assets for the frontend and the worker. | |
| 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 | |
| // files using github.com/evanw/esbuild. | |
| // | |
| // This function is used in Server.staticHandler with Watch=true | |
| // 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 := 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: []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{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 | |
| } | |
| 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 | |
| } | |
| // 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 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 { | |
| return err | |
| } | |
| if info.IsDir() { | |
| return nil | |
| } | |
| basePath := filepath.Base(path) | |
| notPartial := !strings.HasPrefix(basePath, "_") | |
| notTest := !strings.HasSuffix(basePath, ".test.ts") | |
| isTS := strings.HasSuffix(basePath, ".ts") | |
| isCSS := strings.HasSuffix(basePath, ".css") && !strings.HasSuffix(basePath, ".min.css") | |
| if notPartial && notTest && (isTS || (bundle && isCSS)) { | |
| matches = append(matches, path) | |
| } | |
| return nil | |
| }) | |
| if err != nil { | |
| return nil, err | |
| } | |
| 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) | |
| } |