blob: 968127c9f279932a22e6e8316774d8d95d7fe815 [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 (
8 "crypto/sha256"
9 "embed"
10 "encoding/hex"
11 "fmt"
12 "io"
13 "io/fs"
14 "os"
15 "os/exec"
16 "path/filepath"
17 "strings"
18
19 esbuildcli "github.com/evanw/esbuild/pkg/cli"
20)
21
22//go:embed package.json package-lock.json src tsconfig.json postcss.config.js tailwind.config.js
23var embedded embed.FS
24
25func embeddedHash() (string, error) {
26 h := sha256.New()
27 err := fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
28 if d.IsDir() {
29 return nil
30 }
31 f, err := embedded.Open(path)
32 if err != nil {
33 return err
34 }
35 defer f.Close()
36 if _, err := io.Copy(h, f); err != nil {
37 return fmt.Errorf("%s: %w", path, err)
38 }
39 return nil
40 })
41 if err != nil {
42 return "", fmt.Errorf("embedded hash: %w", err)
43 }
44 return hex.EncodeToString(h.Sum(nil)), nil
45}
46
47func cleanBuildDir(buildDir string) error {
48 err := fs.WalkDir(os.DirFS(buildDir), ".", func(path string, d fs.DirEntry, err error) error {
49 if d.Name() == "." {
50 return nil
51 }
52 if d.Name() == "node_modules" {
53 return fs.SkipDir
54 }
55 osPath := filepath.Join(buildDir, path)
56 fmt.Printf("removing %s\n", osPath)
57 os.RemoveAll(osPath)
58 if d.IsDir() {
59 return fs.SkipDir
60 }
61 return nil
62 })
63 if err != nil {
64 return fmt.Errorf("clean build dir: %w", err)
65 }
66 return nil
67}
68
69func unpackFS(out string, srcFS fs.FS) error {
70 err := fs.WalkDir(srcFS, ".", func(path string, d fs.DirEntry, err error) error {
71 if d.Name() == "." {
72 return nil
73 }
74 if d.IsDir() {
75 if err := os.Mkdir(filepath.Join(out, path), 0o777); err != nil {
76 return err
77 }
78 return nil
79 }
80 f, err := srcFS.Open(path)
81 if err != nil {
82 return err
83 }
84 defer f.Close()
85 dst, err := os.Create(filepath.Join(out, path))
86 if err != nil {
87 return err
88 }
89 defer dst.Close()
90 if _, err := io.Copy(dst, f); err != nil {
91 return err
92 }
93 if err := dst.Close(); err != nil {
94 return err
95 }
96 return nil
97 })
98 if err != nil {
99 return fmt.Errorf("unpack fs into out dir %s: %w", out, err)
100 }
101 return nil
102}
103
104// Build unpacks and esbuild's all bundleTs typescript files
105func Build() (fs.FS, error) {
106 homeDir, err := os.UserHomeDir()
107 if err != nil {
108 return nil, err
109 }
110 cacheDir := filepath.Join(homeDir, ".cache", "sketch", "webui")
111 buildDir := filepath.Join(cacheDir, "build")
112 if err := os.MkdirAll(buildDir, 0o777); err != nil { // make sure .cache/sketch/build exists
113 return nil, err
114 }
115 hash, err := embeddedHash()
116 if err != nil {
117 return nil, err
118 }
119 finalHashDir := filepath.Join(cacheDir, hash)
120 if _, err := os.Stat(finalHashDir); err == nil {
121 // Build already done, serve it out.
122 return os.DirFS(finalHashDir), nil
123 }
124
125 // We need to do a build.
126
127 // Clear everything out of the build directory except node_modules.
128 if err := cleanBuildDir(buildDir); err != nil {
129 return nil, err
130 }
131 tmpHashDir := filepath.Join(buildDir, "out")
132 if err := os.Mkdir(tmpHashDir, 0o777); err != nil {
133 return nil, err
134 }
135
136 // Unpack everything from embedded into build dir.
137 if err := unpackFS(buildDir, embedded); err != nil {
138 return nil, err
139 }
140
141 // Do the build.
142 cmd := exec.Command("npm", "ci")
143 cmd.Dir = buildDir
144 if out, err := cmd.CombinedOutput(); err != nil {
145 return nil, fmt.Errorf("npm ci: %s: %v", out, err)
146 }
147 cmd = exec.Command("npx", "postcss", filepath.Join(buildDir, "./src/input.css"), "-o", filepath.Join(tmpHashDir, "tailwind.css"))
148 cmd.Dir = buildDir
149 if out, err := cmd.CombinedOutput(); err != nil {
150 return nil, fmt.Errorf("npm postcss: %s: %v", out, err)
151 }
152 bundleTs := []string{"src/timeline.ts"}
153 for _, tsName := range bundleTs {
154 if err := esbuildBundle(tmpHashDir, filepath.Join(buildDir, tsName)); err != nil {
155 return nil, fmt.Errorf("esbuild: %s: %w", tsName, err)
156 }
157 }
158
159 // Copy src files used directly into the new hash output dir.
160 err = fs.WalkDir(embedded, "src", func(path string, d fs.DirEntry, err error) error {
161 if d.IsDir() {
162 return nil
163 }
164 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".js") {
165 b, err := embedded.ReadFile(path)
166 if err != nil {
167 return err
168 }
169 dstPath := filepath.Join(tmpHashDir, strings.TrimPrefix(path, "src/"))
170 if err := os.WriteFile(dstPath, b, 0o777); err != nil {
171 return err
172 }
173 return nil
174 }
175 return nil
176 })
177 if err != nil {
178 return nil, err
179 }
180
181 // Copy xterm.css from node_modules
182 const xtermCssPath = "node_modules/@xterm/xterm/css/xterm.css"
183 xtermCss, err := os.ReadFile(filepath.Join(buildDir, xtermCssPath))
184 if err != nil {
185 return nil, fmt.Errorf("failed to read xterm.css: %w", err)
186 }
187 if err := os.WriteFile(filepath.Join(tmpHashDir, "xterm.css"), xtermCss, 0o666); err != nil {
188 return nil, fmt.Errorf("failed to write xterm.css: %w", err)
189 }
190
191 // Everything succeeded, so we move tmpHashDir to finalHashDir
192 if err := os.Rename(tmpHashDir, finalHashDir); err != nil {
193 return nil, err
194 }
195 return os.DirFS(finalHashDir), nil
196}
197
198// unpackTS unpacks all the typescript-relevant files from the embedded filesystem into tmpDir.
199func unpackTS(outDir string, embedded fs.FS) error {
200 return fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
201 if err != nil {
202 return err
203 }
204 tgt := filepath.Join(outDir, path)
205 if d.IsDir() {
206 if err := os.MkdirAll(tgt, 0o777); err != nil {
207 return err
208 }
209 return nil
210 }
211 if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".css") {
212 return nil
213 }
214 data, err := fs.ReadFile(embedded, path)
215 if err != nil {
216 return err
217 }
218 if err := os.WriteFile(tgt, data, 0o666); err != nil {
219 return err
220 }
221 return nil
222 })
223}
224
225func esbuildBundle(outDir, src string) error {
226 ret := esbuildcli.Run([]string{
227 src,
228 "--bundle",
229 "--sourcemap",
230 "--log-level=error",
231 // Disable minification for now
232 // "--minify",
233 "--outdir=" + outDir,
234 })
235 if ret != 0 {
236 return fmt.Errorf("esbuild %s failed: %d", filepath.Base(src), ret)
237 }
238 return nil
239}