blob: ca42afa33519c00bcbf4faec32ff7c71906b9546 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001// Package webui provides the web interface for the sketch loop.
2// It bundles typescript files into JavaScript using esbuild.
3//
4// This is substantially the same mechanism as /esbuild.go in this repo as well.
5package webui
6
7import (
David Crawshaw8bff16a2025-04-18 01:16:49 -07008 "archive/zip"
9 "bytes"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "crypto/sha256"
11 "embed"
12 "encoding/hex"
13 "fmt"
14 "io"
15 "io/fs"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "strings"
20
21 esbuildcli "github.com/evanw/esbuild/pkg/cli"
22)
23
24//go:embed package.json package-lock.json src tsconfig.json postcss.config.js tailwind.config.js
25var embedded embed.FS
26
27func embeddedHash() (string, error) {
28 h := sha256.New()
29 err := fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
30 if d.IsDir() {
31 return nil
32 }
33 f, err := embedded.Open(path)
34 if err != nil {
35 return err
36 }
37 defer f.Close()
38 if _, err := io.Copy(h, f); err != nil {
39 return fmt.Errorf("%s: %w", path, err)
40 }
41 return nil
42 })
43 if err != nil {
44 return "", fmt.Errorf("embedded hash: %w", err)
45 }
David Crawshaw8bff16a2025-04-18 01:16:49 -070046 return hex.EncodeToString(h.Sum(nil))[:32], nil
Earl Lee2e463fb2025-04-17 11:22:22 -070047}
48
49func cleanBuildDir(buildDir string) error {
50 err := fs.WalkDir(os.DirFS(buildDir), ".", func(path string, d fs.DirEntry, err error) error {
51 if d.Name() == "." {
52 return nil
53 }
54 if d.Name() == "node_modules" {
55 return fs.SkipDir
56 }
57 osPath := filepath.Join(buildDir, path)
Earl Lee2e463fb2025-04-17 11:22:22 -070058 os.RemoveAll(osPath)
59 if d.IsDir() {
60 return fs.SkipDir
61 }
62 return nil
63 })
64 if err != nil {
65 return fmt.Errorf("clean build dir: %w", err)
66 }
67 return nil
68}
69
70func unpackFS(out string, srcFS fs.FS) error {
71 err := fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
72 if d.Name() == "." {
73 return nil
74 }
75 if d.IsDir() {
76 if err := os.Mkdir(filepath.Join(out, path), 0o777); err != nil {
77 return err
78 }
79 return nil
80 }
81 f, err := srcFS.Open(path)
82 if err != nil {
83 return err
84 }
85 defer f.Close()
86 dst, err := os.Create(filepath.Join(out, path))
87 if err != nil {
88 return err
89 }
90 defer dst.Close()
91 if _, err := io.Copy(dst, f); err != nil {
92 return err
93 }
94 if err := dst.Close(); err != nil {
95 return err
96 }
97 return nil
98 })
99 if err != nil {
100 return fmt.Errorf("unpack fs into out dir %s: %w", out, err)
101 }
102 return nil
103}
104
David Crawshaw8bff16a2025-04-18 01:16:49 -0700105func ZipPath() (string, error) {
106 _, hashZip, err := zipPath()
107 return hashZip, err
108}
109
110func zipPath() (cacheDir, hashZip string, err error) {
111 homeDir, err := os.UserHomeDir()
112 if err != nil {
113 return "", "", err
114 }
115 hash, err := embeddedHash()
116 if err != nil {
117 return "", "", err
118 }
119 cacheDir = filepath.Join(homeDir, ".cache", "sketch", "webui")
120 return cacheDir, filepath.Join(cacheDir, "skui-"+hash+".zip"), nil
121}
122
Earl Lee2e463fb2025-04-17 11:22:22 -0700123// Build unpacks and esbuild's all bundleTs typescript files
124func Build() (fs.FS, error) {
David Crawshaw8bff16a2025-04-18 01:16:49 -0700125 cacheDir, hashZip, err := zipPath()
Earl Lee2e463fb2025-04-17 11:22:22 -0700126 if err != nil {
127 return nil, err
128 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700129 buildDir := filepath.Join(cacheDir, "build")
130 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
131 return nil, err
132 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700133 if b, err := os.ReadFile(hashZip); err == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700134 // Build already done, serve it out.
David Crawshaw8bff16a2025-04-18 01:16:49 -0700135 return zip.NewReader(bytes.NewReader(b), int64(len(b)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700136 }
137
David Crawshaw8bff16a2025-04-18 01:16:49 -0700138 // TODO: try downloading "https://sketch.dev/webui/"+filepath.Base(hashZip)
139
Earl Lee2e463fb2025-04-17 11:22:22 -0700140 // We need to do a build.
141
142 // Clear everything out of the build directory except node_modules.
143 if err := cleanBuildDir(buildDir); err != nil {
144 return nil, err
145 }
146 tmpHashDir := filepath.Join(buildDir, "out")
147 if err := os.Mkdir(tmpHashDir, 0o777); err != nil {
148 return nil, err
149 }
150
151 // Unpack everything from embedded into build dir.
152 if err := unpackFS(buildDir, embedded); err != nil {
153 return nil, err
154 }
155
156 // Do the build.
157 cmd := exec.Command("npm", "ci")
158 cmd.Dir = buildDir
159 if out, err := cmd.CombinedOutput(); err != nil {
160 return nil, fmt.Errorf("npm ci: %s: %v", out, err)
161 }
162 cmd = exec.Command("npx", "postcss", filepath.Join(buildDir, "./src/input.css"), "-o", filepath.Join(tmpHashDir, "tailwind.css"))
163 cmd.Dir = buildDir
164 if out, err := cmd.CombinedOutput(); err != nil {
165 return nil, fmt.Errorf("npm postcss: %s: %v", out, err)
166 }
167 bundleTs := []string{"src/timeline.ts"}
168 for _, tsName := range bundleTs {
169 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName)); err != nil {
170 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
171 }
172 }
173
174 // Copy src files used directly into the new hash output dir.
175 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
176 if d.IsDir() {
177 return nil
178 }
179 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
180 b, err := embedded.ReadFile(path)
181 if err != nil {
182 return err
183 }
184 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
185 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
186 return err
187 }
188 return nil
189 }
190 return nil
191 })
192 if err != nil {
193 return nil, err
194 }
195
196 // Copy xterm.css from node_modules
197 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
198 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
199 if err != nil {
200 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
201 }
202 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
203 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
204 }
205
David Crawshaw8bff16a2025-04-18 01:16:49 -0700206 // Everything succeeded, so we write tmpHashDir to hashZip
207 buf := new(bytes.Buffer)
208 w := zip.NewWriter(buf)
209 if err := w.AddFS(os.DirFS(tmpHashDir)); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700210 return nil, err
211 }
David Crawshaw8bff16a2025-04-18 01:16:49 -0700212 if err := w.Close(); err != nil {
213 return nil, err
214 }
215 if err := os.WriteFile(hashZip, buf.Bytes(), 0o666); err != nil {
216 return nil, err
217 }
218 return zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
Earl Lee2e463fb2025-04-17 11:22:22 -0700219}
220
221func esbuildBundle(outDir, src string) error {
222 ret := esbuildcli.Run([]string{
223 src,
224 "--bundle",
225 "--sourcemap",
226 "--log-level=error",
227 // Disable minification for now
228 // "--minify",
229 "--outdir=" + outDir,
230 })
231 if ret != 0 {
232 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
233 }
234 return nil
235}