1// ===== pkg/config/config.go =====
2// Package config pkg/config/config.go β the store's configuration.
3//
4// Values are sourced from a bash config file named by the MENV environment
5// variable (see `m2 gen` for the template), overridable by CLI flags. The
6// flag-registration helpers here bind a Values field to a flag whose default
7// is read from the MENV file, so every command that registers flags gets the
8// file-sourced defaults for free.
9package config
10
11import (
12 "embed"
13 "fmt"
14 "os"
15 "reflect"
16 "runtime"
17 "strconv"
18 "strings"
19 "time"
20
21 "github.com/bitfield/script"
22 "github.com/spf13/cobra"
23 "github.com/stripe/stripe-go/v81"
24)
25
26//go:embed *.go
27var Source embed.FS
28
29// MENV names the bash-sourced config file, from the environment.
30var MENV = os.Getenv("MENV")
31
32// Values holds every configurable value; field names map to the MENV file's
33// uppercase variable names (SITENAME, PRODUCTSCSV, ...).
34type Values struct {
35 Teststripekey bool
36 ProductsCSV string
37 WebPort int
38 StripelivePK string
39 StripeliveSK string
40 StripetestPK string
41 StripetestSK string
42 StripeSK string
43 StripePK string
44 Siteimagesrc string
45 Siteordersurl string
46 Sitename string
47 Siteext string
48 Sitedomain string
49 Sitelongname string
50 Sitetagline string
51 Sitemeta string
52 Siteprettyname string
53 Siteprettynamecap string
54 Siteprettynamecaps string
55 SiteASCIILogo string
56 Tgcontact string
57 Tgchannel string
58 UseTinygo bool
59 WasmSRC []string
60 WasmExecPath string
61 WasmExecPathGo string
62 WasmExecPathTinyGo string
63 Gobuild string
64 Tinygobuild string
65 Buildwasmwith string
66 LDFlagsX string
67 PrinterName string // CUPS queue name (blank = default)
68 CupsOptions string // comma-separated -o options
69 LpTimeout time.Duration // timeout for `lp`
70 Storeurl string // tui client mode: browse this store over http instead of local files
71
72 // The real-origin browse substrate: where the desk's nested browser
73 // renders the in-tab store. See pkg/web/browse.go. Empty disables it and
74 // the browser falls back to netscrape's transcoder.
75 BrowseSuffix string // browse-origin domain, a DIFFERENT registrable domain from the site's
76 BrowseAddr string // listen address for the browse-origin bootstrap
77
78 // Crypto checkout. Empty SkyXpub leaves the whole thing off and the
79 // store card-only, which is what a deployment that has not configured
80 // a wallet should get rather than a button that errors.
81 SkyXpub string // BIP44 ACCOUNT-level xpub, watch-only; must not be the reward account
82 SkyNodeURL string // skycoin node API root; empty means the local node
83 SkyRatePair string // exchange pair for pricing, e.g. "skycoin_usdt"; never a bare ticker
84 SkyRateFix string // a rate the operator sets instead of asking an exchange
85 RateMin string // refuse a quote below this, as a decimal string
86 RateMax string // refuse a quote above this
87 InvoiceMins int // how long a quote is held before it expires
88}
89
90// F is the live configuration, shared by every package.
91var F = Values{
92 // WasmSRC: []string{"wasm/stl2.go","wasm/checkout_wasm.go"},
93 WasmExecPath: runtime.GOROOT() + "/lib/wasm/wasm_exec.js", //nolint
94 WasmExecPathGo: runtime.GOROOT() + "/lib/wasm/wasm_exec.js", //nolint
95 WasmExecPathTinyGo: strings.TrimSuffix(runtime.GOROOT(), "go") + "tinygo" + "/targets/wasm_exec.js", //nolint
96 Gobuild: "go build",
97 Tinygobuild: "tinygo build -target=wasm --no-debug",
98 Buildwasmwith: "go build",
99 LDFlagsX: "stripePK",
100}
101
102// InitStripe selects the live or test key pair, hands the secret key to the
103// stripe library, and bakes the publishable key into the wasm ldflags.
104func InitStripe() {
105 F.StripeSK = F.StripeliveSK
106 F.StripePK = F.StripelivePK
107 if F.Teststripekey {
108 F.StripeSK = F.StripetestSK
109 F.StripePK = F.StripetestPK
110 }
111 stripe.Key = F.StripeSK
112 // awkward way to do this
113 F.LDFlagsX += "=" + F.StripePK
114}
115
116var (
117 // Hardcoded array of valid shorthand characters, excluding "h"
118 shorthandChars = []rune("abcdefgijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
119 nextShortIndex = 0 // Index for the next shorthand flag
120)
121
122func getNextShortFlag() string {
123 if nextShortIndex >= len(shorthandChars) {
124 return ""
125 }
126 short := shorthandChars[nextShortIndex]
127 nextShortIndex++
128 return string(short)
129}
130
131var a = true
132var b = false
133
134// AddStringFlag registers a string flag bound to a field of F on each command,
135// defaulting to the MENV file's value for the field's uppercase name.
136func AddStringFlag(cmds []*cobra.Command, fieldPtr *string, description string) {
137 for i := range cmds {
138 cmds[i].Flags().StringVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecString(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(s string) string {
139 if s != "" {
140 s = "-" + s
141 }
142 return s
143 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
144 }
145}
146
147// AddStringSliceFlag is AddStringFlag for []string fields (bash arrays).
148func AddStringSliceFlag(cmds []*cobra.Command, fieldPtr *[]string, description string) {
149 for i := range cmds {
150 cmds[i].Flags().StringSliceVarP(
151 fieldPtr,
152 ccc(fieldPtr, &F, b),
153 getNextShortFlag(),
154 ScriptExecStringSlice(fmt.Sprintf("${%s[@]}", ccc(fieldPtr, &F, a))),
155 fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
156 )
157 }
158}
159
160// AddBoolFlag is AddStringFlag for bool fields.
161func AddBoolFlag(cmds []*cobra.Command, fieldPtr *bool, description string) {
162 for i := range cmds {
163 cmds[i].Flags().BoolVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecBool(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(b bool) string {
164 return "-" + strconv.FormatBool(b)
165 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
166 }
167}
168
169// AddIntFlag is AddStringFlag for int fields.
170func AddIntFlag(cmds []*cobra.Command, fieldPtr *int, description string) {
171 for i := range cmds {
172 cmds[i].Flags().IntVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecInt(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(i int) string {
173 return fmt.Sprintf("-%d", i)
174 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
175 }
176}
177
178// AddDurationFlag is AddStringFlag for time.Duration fields.
179func AddDurationFlag(cmds []*cobra.Command, fieldPtr *time.Duration, description string) {
180 for i := range cmds {
181 // Keep parity with the pattern of embedding a "-" when a non-zero default is present.
182 def := ScriptExecDuration(fmt.Sprintf("${%s%s}",
183 ccc(fieldPtr, &F, a),
184 func(d time.Duration) string {
185 if d != 0 {
186 return "-" + d.String() // e.g. "-5s"
187 }
188 return ""
189 }(*fieldPtr),
190 ))
191 cmds[i].Flags().DurationVarP(
192 fieldPtr,
193 ccc(fieldPtr, &F, b),
194 getNextShortFlag(),
195 def,
196 fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
197 )
198 }
199}
200
201// ccc finds the name of the struct field val points into, upper or lower case.
202func ccc(val interface{}, strct interface{}, upper bool) string {
203 v := reflect.ValueOf(strct)
204 if v.Kind() == reflect.Ptr {
205 v = v.Elem()
206 }
207 if v.Kind() != reflect.Struct {
208 panic("uc: second argument must be a pointer to a struct")
209 }
210 for i := 0; i < v.NumField(); i++ {
211 field := v.Field(i)
212 if field.CanAddr() && field.Addr().Interface() == val {
213 if upper {
214 return strings.ToUpper(v.Type().Field(i).Name)
215 }
216 return strings.ToLower(v.Type().Field(i).Name)
217 }
218 }
219 return ""
220}
221
222// ScriptExecString evaluates a bash expression with the MENV file sourced.
223func ScriptExecString(s string) string {
224 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
225 if err == nil {
226 return strings.TrimSpace(z)
227 }
228 return ""
229}
230
231// ScriptExecStringSlice evaluates a bash array expression with the MENV file
232// sourced. Empty entries are dropped: an empty or unset array still prints
233// one empty line, which would otherwise come back as [""] and defeat every
234// `len == 0` check downstream (e.g. WASMSRC=() disabling wasm).
235func ScriptExecStringSlice(s string) []string {
236 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s" "%s"'`, MENV, "%s\n", s)).Slice()
237 if err != nil {
238 return nil
239 }
240 out := z[:0]
241 for _, e := range z {
242 if strings.TrimSpace(e) != "" {
243 out = append(out, e)
244 }
245 }
246 return out
247}
248
249// ScriptExecBool evaluates a bash expression as a bool with the MENV file sourced.
250func ScriptExecBool(s string) bool {
251 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
252 if err == nil {
253 b, err := strconv.ParseBool(z)
254 if err == nil {
255 return b
256 }
257 }
258 return false
259}
260
261// ScriptExecInt evaluates a bash expression as an int with the MENV file sourced.
262func ScriptExecInt(s string) int {
263 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
264 if err == nil {
265 if z == "" {
266 return 0
267 }
268 i, err := strconv.Atoi(z)
269 if err == nil {
270 return i
271 }
272 }
273 return 0
274}
275
276// ScriptExecDuration evaluates a bash expression as a duration with the MENV
277// file sourced. Accepts Go duration strings ("750ms", "2s", "5m", "1h") and
278// bare integers (treated as seconds).
279func ScriptExecDuration(s string) time.Duration {
280 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
281 if err != nil {
282 return 0
283 }
284 z = strings.TrimSpace(z)
285 if z == "" {
286 return 0
287 }
288 z = strings.TrimPrefix(z, "-") // keep parity with how defaults are built
289
290 // Try full Go duration syntax first.
291 if d, err := time.ParseDuration(z); err == nil {
292 return d
293 }
294 // Fallback: plain integer means seconds.
295 if n, err := strconv.ParseInt(z, 10, 64); err == nil {
296 return time.Duration(n) * time.Second
297 }
298 return 0
299}
300
301
302// ===== pkg/product/csv.go =====
303// Package product pkg/product/csv.go β catalog CSV loading.
304package product
305
306import (
307 "bufio"
308 "bytes"
309 "embed"
310 "fmt"
311 "log"
312 "strings"
313
314 "github.com/bitfield/script"
315)
316
317//go:embed *.go
318var Source embed.FS
319
320func readproductscsv(csvFile string) (data []byte) {
321 data, err := script.File(csvFile).Bytes() //nolint
322 if err != nil {
323 log.Printf(`Error reading %s file %v`, csvFile, err)
324 }
325 return data
326}
327
328const csvMinFields = 51 // f[0] through f[50]
329
330// ReadCSV reads the catalog from a file. The parsing is in ParseCSV so that
331// it can be tested without one.
332func ReadCSV(csvFile string) Products {
333 return ParseCSV(readproductscsv(csvFile))
334}
335
336// ParseCSV turns the catalog bytes into products, skipping rows that are not
337// enabled and rows too short to fill one.
338func ParseCSV(data []byte) (prods Products) {
339 scanner := bufio.NewScanner(bytes.NewReader(data))
340 lineNum := 0
341 for scanner.Scan() {
342 lineNum++
343 line := scanner.Text()
344 f := strings.Split(line, ",")
345 if len(f) < 4 {
346 continue
347 }
348 if f[3] != "TRUE" {
349 continue
350 }
351 if len(f) < csvMinFields {
352 log.Printf("csv line %d: expected %d fields, got %d β skipping", lineNum, csvMinFields, len(f))
353 continue
354 }
355 q := Product{
356 Image1: f[0],
357 Partno: f[1],
358 Name: f[2],
359 Enable: f[3],
360 Price: f[4],
361 Quantity: f[5],
362 Shippable: f[6],
363 Minorder: f[7],
364 Maxorder: f[8],
365 Defaultquantity: f[9],
366 Stepquantity: f[10],
367 Mfgpartno: f[11],
368 Mfgname: f[12],
369 Category: f[13],
370 Subcategory: f[14],
371 Location: f[15],
372 Msrp: f[16],
373 Cost: f[17],
374 Typ: f[18],
375 Packagetype: f[19],
376 Technology: f[20],
377 Materials: f[21],
378 Value: f[22],
379 ValUnit: f[23],
380 Resistance: f[24],
381 ResUnit: f[25],
382 Tolerance: f[26],
383 VoltsRating: f[27],
384 AmpsRating: f[28],
385 WattsRating: f[29],
386 TempRating: f[30],
387 TempUnit: f[31],
388 Description1: f[32],
389 Description2: f[33],
390 Color1: f[34],
391 Color2: f[35],
392 Sourceinfo: f[36],
393 Datasheet: f[37],
394 Docs: f[38],
395 Reference: f[39],
396 Attributes: f[40],
397 Year: f[41],
398 Condition: f[42],
399 Note: f[43],
400 Warning: f[44],
401 CableLengthInches: f[45],
402 LengthInches: f[46],
403 WidthInches: f[47],
404 HeightInches: f[48],
405 WeightLb: f[49],
406 WeightOz: f[50],
407 }
408 prods = append(prods, q)
409 }
410 return prods
411}
412
413// ValidateCSV scans products for patterns that could cause issues in HTML/JS rendering.
414// Returns a list of warnings. Call after ReadCSV to check data integrity.
415func ValidateCSV(prods Products) []string {
416 var warnings []string
417 for i, pr := range prods {
418 check := func(field, value string) {
419 if strings.ContainsAny(value, "<>\"'&") {
420 warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains HTML-unsafe characters: %q", i, pr.Partno, field, value))
421 }
422 if strings.Contains(value, "|") {
423 warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains pipe character: %q", i, pr.Partno, field, value))
424 }
425 }
426 check("Partno", pr.Partno)
427 check("Name", pr.Name)
428 check("Description1", pr.Description1)
429 check("Description2", pr.Description2)
430 check("Note", pr.Note)
431 check("Warning", pr.Warning)
432 check("Category", pr.Category)
433 check("Subcategory", pr.Subcategory)
434 check("Mfgname", pr.Mfgname)
435 check("Mfgpartno", pr.Mfgpartno)
436 }
437 return warnings
438}
439
440
441// ===== pkg/product/csv_test.go =====
442package product
443
444import (
445 "strings"
446 "testing"
447)
448
449// row builds a catalog line with the fields the parser needs, so a test can
450// set the few it cares about without writing fifty-one commas.
451func row(set map[int]string) string {
452 f := make([]string, csvMinFields)
453 f[0] = "img.jpg"
454 f[1] = "PN-1"
455 f[2] = "A Part"
456 f[3] = "TRUE"
457 f[4] = "1.50"
458 f[5] = "10"
459 for i, v := range set {
460 f[i] = v
461 }
462 return strings.Join(f, ",")
463}
464
465func TestParseCSVReadsAProduct(t *testing.T) {
466 prods := ParseCSV([]byte(row(nil)))
467 if len(prods) != 1 {
468 t.Fatalf("got %d products, want 1", len(prods))
469 }
470 got := prods[0]
471 if got.Partno != "PN-1" || got.Name != "A Part" || got.Price != "1.50" || got.Quantity != "10" {
472 t.Errorf("fields did not land where they should: %+v", got)
473 }
474}
475
476// The last field is the fiftieth, and off-by-one at the end of a fifty-one
477// column row is invisible in a spreadsheet.
478func TestParseCSVReadsTheLastField(t *testing.T) {
479 prods := ParseCSV([]byte(row(map[int]string{50: "3.25", 49: "1"})))
480 if len(prods) != 1 {
481 t.Fatalf("got %d products, want 1", len(prods))
482 }
483 if prods[0].WeightOz != "3.25" {
484 t.Errorf("WeightOz = %q, want the last column", prods[0].WeightOz)
485 }
486 if prods[0].WeightLb != "1" {
487 t.Errorf("WeightLb = %q, want the second to last column", prods[0].WeightLb)
488 }
489}
490
491// Only enabled rows are sold. A row that says anything but TRUE is a product
492// deliberately withdrawn, and showing it anyway sells something not in stock.
493func TestParseCSVSkipsRowsThatAreNotEnabled(t *testing.T) {
494 for _, enable := range []string{"FALSE", "false", "true", "", "1", "yes"} {
495 prods := ParseCSV([]byte(row(map[int]string{3: enable})))
496 if len(prods) != 0 {
497 t.Errorf("Enable=%q produced a product; only TRUE should", enable)
498 }
499 }
500}
501
502// A short row cannot fill a product, and reading one anyway would panic on a
503// live catalog rather than skip a line.
504func TestParseCSVSkipsShortRows(t *testing.T) {
505 short := strings.Join([]string{"img.jpg", "PN-1", "A Part", "TRUE", "1.50"}, ",")
506 if prods := ParseCSV([]byte(short)); len(prods) != 0 {
507 t.Errorf("a row with 5 fields produced %d products", len(prods))
508 }
509 // Exactly one short of the minimum is the boundary worth pinning.
510 f := make([]string, csvMinFields-1)
511 for i := range f {
512 f[i] = "x"
513 }
514 f[3] = "TRUE"
515 if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 0 {
516 t.Errorf("a row one field short produced %d products", len(prods))
517 }
518}
519
520func TestParseCSVSkipsVeryShortAndBlankRows(t *testing.T) {
521 for _, line := range []string{"", ",", "a,b,c", "\n\n"} {
522 if prods := ParseCSV([]byte(line)); len(prods) != 0 {
523 t.Errorf("%q produced %d products", line, len(prods))
524 }
525 }
526}
527
528func TestParseCSVReadsSeveralRows(t *testing.T) {
529 data := strings.Join([]string{
530 row(map[int]string{1: "AAA"}),
531 row(map[int]string{3: "FALSE", 1: "SKIPPED"}),
532 row(map[int]string{1: "BBB"}),
533 }, "\n")
534 prods := ParseCSV([]byte(data))
535 if len(prods) != 2 {
536 t.Fatalf("got %d products, want 2", len(prods))
537 }
538 if prods[0].Partno != "AAA" || prods[1].Partno != "BBB" {
539 t.Errorf("got %q and %q, want AAA and BBB", prods[0].Partno, prods[1].Partno)
540 }
541}
542
543// The catalog is split on commas rather than parsed as CSV, so a quoted
544// field containing one shifts every column after it β including Enable, which
545// is column 3. The row then does not say TRUE where the parser looks, and the
546// product is dropped from the store without a word.
547//
548// A spreadsheet writes that quoting itself the moment a name contains a comma,
549// so this is reachable from ordinary editing rather than from bad data.
550//
551// This test records what the parser does today. If it is ever changed to use
552// encoding/csv, this test is the one that should fail.
553func TestParseCSVSilentlyDropsRowsWithAQuotedComma(t *testing.T) {
554 f := make([]string, csvMinFields)
555 for i := range f {
556 f[i] = "x"
557 }
558 f[1] = "PN-1"
559 f[2] = `"Resistor, 10k"` // one field in a spreadsheet, two after a split
560 f[3] = "TRUE"
561 f[4] = "1.50"
562 line := strings.Join(f, ",")
563
564 // The row is long enough β it is the shift that loses it, not the length.
565 if n := len(strings.Split(line, ",")); n <= csvMinFields {
566 t.Fatalf("the fixture has %d fields; it needs more than %d to isolate the shift", n, csvMinFields)
567 }
568
569 prods := ParseCSV([]byte(line))
570 if len(prods) != 0 {
571 t.Fatalf("the parser now keeps rows with a quoted comma (%d products); "+
572 "replace this test with one asserting the fields are correct", len(prods))
573 }
574
575 // Without the comma the same row is read, which is what shows the comma is
576 // the cause rather than anything else in the fixture.
577 f[2] = "Resistor 10k"
578 if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 1 {
579 t.Errorf("the same row without the comma gave %d products, want 1", len(prods))
580 }
581}
582
583func TestValidateCSVAcceptsCleanData(t *testing.T) {
584 prods := ParseCSV([]byte(row(nil)))
585 if w := ValidateCSV(prods); len(w) != 0 {
586 t.Errorf("clean data produced warnings: %v", w)
587 }
588}
589
590// These characters are what turn a product name into markup on the page.
591func TestValidateCSVFlagsCharactersThatBreakThePage(t *testing.T) {
592 for _, bad := range []string{`<script>`, `a"b`, "a'b", "a&b", "a>b"} {
593 prods := ParseCSV([]byte(row(map[int]string{2: bad})))
594 if len(prods) != 1 {
595 t.Fatalf("%q: got %d products", bad, len(prods))
596 }
597 w := ValidateCSV(prods)
598 if len(w) == 0 {
599 t.Errorf("%q in a product name was not flagged", bad)
600 continue
601 }
602 if !strings.Contains(w[0], "Name") {
603 t.Errorf("%q: the warning does not name the field: %s", bad, w[0])
604 }
605 }
606}
607
608// The pipe is the field separator in the cart's stored format, so a pipe in a
609// product name corrupts the cart rather than the page.
610func TestValidateCSVFlagsPipes(t *testing.T) {
611 prods := ParseCSV([]byte(row(map[int]string{2: "A|B"})))
612 w := ValidateCSV(prods)
613 if len(w) == 0 {
614 t.Fatal("a pipe in a product name was not flagged")
615 }
616 if !strings.Contains(w[0], "pipe") {
617 t.Errorf("the warning does not mention the pipe: %s", w[0])
618 }
619}
620
621// Every field that reaches the page is checked, not just the name.
622func TestValidateCSVChecksEveryRenderedField(t *testing.T) {
623 fields := map[int]string{
624 1: "PN<", // Partno
625 2: "N<", // Name
626 12: "MFG<", // Mfgname
627 11: "MP<", // Mfgpartno
628 13: "CAT<", // Category
629 14: "SUB<", // Subcategory
630 32: "D1<", // Description1
631 33: "D2<", // Description2
632 43: "NT<", // Note
633 44: "WN<", // Warning
634 }
635 for col, val := range fields {
636 prods := ParseCSV([]byte(row(map[int]string{3: "TRUE", col: val})))
637 if len(prods) != 1 {
638 t.Fatalf("column %d: got %d products", col, len(prods))
639 }
640 if w := ValidateCSV(prods); len(w) == 0 {
641 t.Errorf("an unsafe character in column %d was not flagged", col)
642 }
643 }
644}
645
646func TestValidateCSVOnNoProducts(t *testing.T) {
647 if w := ValidateCSV(nil); len(w) != 0 {
648 t.Errorf("an empty catalog produced warnings: %v", w)
649 }
650}
651
652// The warning names the row so it can be found in a spreadsheet of thousands.
653func TestValidateCSVNamesTheProduct(t *testing.T) {
654 prods := ParseCSV([]byte(row(map[int]string{1: "PN-SPECIAL", 2: "bad<"})))
655 w := ValidateCSV(prods)
656 if len(w) == 0 {
657 t.Fatal("no warning")
658 }
659 if !strings.Contains(w[0], "PN-SPECIAL") {
660 t.Errorf("the warning does not name the part: %s", w[0])
661 }
662}
663
664
665// ===== pkg/product/product.go =====
666// Package product pkg/product/product.go
667package product
668
669type Product struct {
670 Enable string
671 Partno string
672 Name string
673 Image1 string
674 Price string
675 Quantity string
676 Shippable string
677 Minorder string
678 Maxorder string
679 Defaultquantity string
680 Stepquantity string
681 Mfgpartno string
682 Mfgname string
683 Category string
684 Subcategory string
685 Location string
686 Msrp string
687 Cost string
688 Typ string
689 Packagetype string
690 Technology string
691 Materials string
692 Value string
693 ValUnit string
694 Resistance string
695 ResUnit string
696 Tolerance string
697 VoltsRating string
698 AmpsRating string
699 WattsRating string
700 TempRating string
701 TempUnit string
702 Description1 string
703 Description2 string
704 Color1 string
705 Color2 string
706 Sourceinfo string
707 Datasheet string
708 Docs string
709 Reference string
710 Attributes string
711 Year string
712 Condition string
713 Note string
714 Warning string
715 CableLengthInches string
716 LengthInches string
717 WidthInches string
718 HeightInches string
719 WeightLb string
720 WeightOz string
721}
722
723// Products is an array of Product
724type Products []Product
725
726
727// ===== pkg/tui/address.go =====
728package tui
729
730import (
731 "strings"
732
733 "github.com/0magnet/websh/progressive"
734)
735
736// The page's address (websh's PROTOCOL.md, The page): where the host lets
737// it, the store tells it which page is showing, as the site's own path β
738// /p/<part number>, /cat/<category> β so the place can be linked to, and a
739// link opens the store there again. Elsewhere nothing is sent.
740
741// StartPath is the site path to open at, instead of the home page: the
742// path a link handed back (progressive.Caps.LinkPath).
743var StartPath string
744
745// announcePage tells the host the page now showing, when it changed: its
746// path for the page's address, and its mirror (mirror.go).
747func (u *ui) announcePage() {
748 c := progressive.Current()
749 if !c.Has("page") && !c.Has("mirror") {
750 return
751 }
752 p := u.pagePath()
753 if p == u.announced {
754 return
755 }
756 u.announced = p
757 if c.Has("mirror") {
758 for _, seq := range progressive.Mirror([]byte(u.mirrorHTML())) {
759 u.writeTerm(seq)
760 }
761 }
762 if !c.Has("page") {
763 return
764 }
765 title := u.sitedomain
766 if part, ok := strings.CutPrefix(p, "/p/"); ok {
767 if prod := u.cat.find(part); prod != nil && prod.Name != "" {
768 title = prod.Name + " β " + u.sitedomain
769 }
770 }
771 u.writeTerm(progressive.Page(p, title))
772}
773
774// pageFromPath is pagePath backwards: the page a site path names, or "" for
775// none this store has.
776func (u *ui) pageFromPath(p string) string {
777 switch {
778 case p == "":
779 return ""
780 case p == "/":
781 return "home"
782 case strings.HasPrefix(p, "/#"):
783 return "content:" + strings.TrimPrefix(p, "/#")
784 case strings.HasPrefix(p, "/p/"):
785 part := strings.TrimPrefix(p, "/p/")
786 if u.cat.find(part) == nil {
787 return ""
788 }
789 return "prod:" + part
790 case p == "/cat" || strings.HasPrefix(p, "/cat/"):
791 // As the site reads it: case aside, and a subcategory by either
792 // spelling.
793 if t, ok := u.cat.resolve(strings.TrimPrefix(p, "/cat")); ok {
794 return listPageID(t)
795 }
796 }
797 return ""
798}
799
800
801// ===== pkg/tui/address_test.go =====
802package tui
803
804import (
805 "strings"
806 "testing"
807
808 "github.com/0magnet/m2/pkg/product"
809)
810
811// TestAddressAndMirror: a site path names the page the store would show at
812// it, as the site reads it, and the mirror of each page is its structure,
813// escaped, with links to the site.
814func TestAddressAndMirror(t *testing.T) {
815 old := f.Storeurl
816 f.Storeurl = "https://shop.example"
817 defer func() { f.Storeurl = old }()
818 u := &ui{cat: newCatalogInfo(product.Products{
819 {Partno: "qjack", Name: "mono <jack>", Price: "1.25", Quantity: "50", Category: "audio", Subcategory: "jack"},
820 {Partno: "r10k", Name: "10k resistor", Price: "0.05", Category: "resistor"},
821 }), sitedomain: "shop.example"}
822
823 for path, want := range map[string]string{
824 "/": "home",
825 "/p/qjack": "prod:qjack",
826 "/p/nosuch": "",
827 "/cat": "list:",
828 "/cat/Audio": "list:audio",
829 "/cat/audio/jack": "list:audio|jack",
830 "/cat/nosuch": "",
831 "/elsewhere": "",
832 } {
833 if got := u.pageFromPath(path); got != want {
834 t.Errorf("%s: %q, want %q", path, got, want)
835 }
836 }
837
838 u.current = "prod:qjack"
839 if m := u.mirrorHTML(); !strings.Contains(m, "<h1>mono <jack></h1>") || !strings.Contains(m, `href="https://shop.example/p/qjack"`) || !strings.Contains(m, `href="https://shop.example/cat/audio/jack"`) {
840 t.Errorf("product mirror: %s", m)
841 }
842 u.current = "list:audio"
843 if m := u.mirrorHTML(); !strings.Contains(m, "<h1>audio</h1><p>1 products.</p>") || !strings.Contains(m, "mono <jack></a> β $1.25") {
844 t.Errorf("list mirror: %s", m)
845 }
846 u.current = "home"
847 if m := u.mirrorHTML(); !strings.Contains(m, `<a href="https://shop.example/cat/resistor">resistor</a> (1)`) {
848 t.Errorf("home mirror: %s", m)
849 }
850}
851
852
853// ===== pkg/tui/border.go =====
854// Package tui pkg/tui/border.go β the pisano frame around the product
855// table.
856//
857// The same drawing the website puts around its category tables, from the
858// same library: an integer sequence reduced modulo m, walked as a turtle
859// and rendered in box-drawing characters. On the page that is the wasm/deco
860// drop-in decorating the DOM after the fact; here there is no DOM to
861// decorate, so the frame is composed into the cells the table is drawn in.
862//
863// The figures are wasm/deco's, deliberately β one frame seen in two places
864// is the point, and a terminal that invented its own would just be a second
865// drawing to keep in step. What differs is the fit. The page knows the table
866// and grows a frame around it (FitAround); a terminal knows the area and has
867// to put both inside it (FitBox), because there is nowhere for a border to
868// overflow to.
869package tui
870
871import (
872 "strconv"
873 "sync"
874
875 "github.com/gdamore/tcell/v3"
876
877 "github.com/0magnet/pisano/pkg/border"
878 "github.com/0magnet/pisano/pkg/pisano"
879)
880
881// borderArt is a composed frame: the glyphs, the color of each, and the
882// clear middle the table goes in.
883type borderArt struct {
884 g border.Grid
885 tint border.Tint
886 in border.Rect
887}
888
889// recipe is one frame's three figures: the run along the top and bottom,
890// the run down the sides, and the closed figure at the corners.
891type recipe struct{ across, down, corner border.Figure }
892
893var (
894 recipesOnce sync.Once
895 borderSet []recipe
896)
897
898// recipes are wasm/deco's, and the reasoning is its: modulus 13 is both
899// runs β it travels +0,-4, which makes a side, and a quarter turn makes a
900// top square to it by construction rather than by search. Modulus 31 at
901// four passes is the corner, closed and with the full symmetry of the
902// square; an open figure would have loose ends of its own just where a run
903// is trying to stop. Modulus 8 is the second rung, for an area that cannot
904// spare the first's reach β a smaller figure beats no figure.
905//
906// The order is the preference, fullest corner first, and fitBorder keeps
907// it as long as the content can afford it.
908func recipes() []recipe {
909 recipesOnce.Do(func() {
910 side, ok := border.OfPasses(13, 1)
911 if !ok {
912 return
913 }
914 top := side.RotateCW()
915 for _, c := range [][2]int{{31, 4}, {8, 2}} {
916 corner, ok := border.OfPasses(c[0], c[1])
917 if !ok || !corner.Closed {
918 continue
919 }
920 borderSet = append(borderSet, recipe{across: top, down: side, corner: corner})
921 }
922 })
923 return borderSet
924}
925
926// borderPalette is pisano's six pass colors, which is what its own turtle
927// tints with β so the frame here and the frame on the page are the same
928// drawing in the same colors.
929var borderPalette = func() []tcell.Style {
930 hex := pisano.PassHex()
931 out := make([]tcell.Style, len(hex))
932 for i, h := range hex {
933 out[i] = styleText.Foreground(tcell.GetColor(h))
934 }
935 return out
936}()
937
938// frameCache keeps one frame per area size.
939//
940// Composing one is not free β it lays every copy of both runs and all four
941// corners onto a grid, cuts the runs where their own wave crosses, then
942// walks the result to check it is a single closed line. A draw happens on
943// every event the UI handles, pointer motion included, so composing per
944// draw would be the most expensive thing the program does. A size that
945// fits nothing is cached as a miss, so it is not retried either.
946var (
947 frameMu sync.Mutex
948 frameCache = map[string]*borderArt{}
949)
950
951// minFramed is the smallest clear middle worth taking an area down to. A
952// frame that fits but leaves room for a header and one row has spent the
953// table to draw a picture of one.
954const minFramed = 6
955
956// roomyRows and roomyCols are what a middle has to keep for the fuller
957// corner to be worth its longer reach: a table still worth reading β its
958// header and a dozen-odd products, at a width that does not start cutting
959// names β rather than merely a table.
960//
961// Below them the corner is competing with the content for the same cells
962// and the content wins, which is the only reason to draw a smaller figure
963// at all.
964const (
965 roomyRows = 16
966 roomyCols = 40
967)
968
969// fitBorder composes the largest frame that fits in a cols by rows area,
970// or reports none. The area is what the caller has; the frame is drawn
971// inside it and the table goes in what is left.
972func fitBorder(cols, rows int) *borderArt {
973 if cols < 8 || rows < 8 {
974 return nil
975 }
976 key := strconv.Itoa(cols) + "x" + strconv.Itoa(rows)
977 frameMu.Lock()
978 defer frameMu.Unlock()
979 if hit, seen := frameCache[key]; seen {
980 return hit
981 }
982 // The fullest corner that still leaves a table worth reading, and the
983 // roomiest otherwise.
984 //
985 // wasm/deco takes the first that fits, largest corner first, and can:
986 // it grows a frame AROUND its table, so a bigger corner only makes a
987 // bigger frame and costs the table nothing. Here the frame is cut OUT
988 // of a fixed area and every cell of corner is a row of products not
989 // shown, so the order alone is not an answer β but neither is simply
990 // taking the roomiest, which spends the good corner to buy four rows
991 // on a screen that had forty to spare.
992 //
993 // So: the same order deco uses, and the same preference, held only as
994 // long as the content is not the thing running out. Under that, the
995 // figure gives way rather than the table.
996 var got, roomiest *borderArt
997 var bestRoom int
998 for _, r := range recipes() {
999 l, g, ok := border.FitBox(r.across, r.down, r.corner, cols, rows)
1000 if !ok {
1001 continue
1002 }
1003 in := g.Inside()
1004 w, h := in.X1-in.X0, in.Y1-in.Y0
1005 if w < minFramed || h < minFramed {
1006 continue
1007 }
1008 art := &borderArt{g: g, tint: l.TintByCopy(g, len(borderPalette)), in: in}
1009 if h >= roomyRows && w >= roomyCols {
1010 // Room to spare, and this is the fullest corner left to try.
1011 // Nothing after it is better, and composing it would be work
1012 // for a frame this one has already beaten.
1013 got = art
1014 break
1015 }
1016 // Rows before columns: a terminal runs out of height first, and a
1017 // row lost is a product off the screen where a column lost is a
1018 // name a little shorter.
1019 if score := h<<16 | w; score > bestRoom {
1020 bestRoom, roomiest = score, art
1021 }
1022 }
1023 if got == nil {
1024 got = roomiest
1025 }
1026 frameCache[key] = got
1027 return got
1028}
1029
1030// place centers the frame in the area it was fitted to and reports the
1031// rectangle its clear middle occupies on screen β where the table goes.
1032//
1033// Centered because a frame is quantised by its figures and is never
1034// exactly the size asked for; hanging it from the top left would leave the
1035// slack in one corner.
1036func (b *borderArt) place(area rect) (origin, inner rect) {
1037 ox := area.x + (area.w-b.g.W())/2
1038 oy := area.y + (area.h-b.g.H())/2
1039 origin = rect{ox, oy, b.g.W(), b.g.H()}
1040 inner = rect{ox + b.in.X0, oy + b.in.Y0, b.in.X1 - b.in.X0, b.in.Y1 - b.in.Y0}
1041 return origin, inner
1042}
1043
1044// draw paints the frame. Blanks are skipped rather than drawn as spaces,
1045// so the frame never erases anything it overlaps β it is a decoration, and
1046// the page's is drawn under its table for the same reason.
1047func (b *borderArt) draw(sc tcell.Screen, origin rect) {
1048 for y, row := range b.g {
1049 for x, ch := range row {
1050 if ch == border.Blank {
1051 continue
1052 }
1053 st := styleText
1054 if y < len(b.tint) && x < len(b.tint[y]) {
1055 if i := b.tint[y][x]; i >= 0 {
1056 st = borderPalette[i%len(borderPalette)]
1057 }
1058 }
1059 sc.SetContent(origin.x+x, origin.y+y, ch, nil, st)
1060 }
1061 }
1062}
1063
1064// framed splits an area into a frame and the cells inside it. An area that
1065// fits no frame is handed back whole, which is what the page does too: a
1066// box with no border is the answer, not a box with a broken one.
1067func framed(area rect) (inner rect, b *borderArt, origin rect) {
1068 b = fitBorder(area.w, area.h)
1069 if b == nil {
1070 return area, nil, rect{}
1071 }
1072 origin, inner = b.place(area)
1073 return inner, b, origin
1074}
1075
1076
1077// ===== pkg/tui/border_test.go =====
1078package tui
1079
1080import (
1081 "testing"
1082
1083 "github.com/0magnet/pisano/pkg/border"
1084)
1085
1086// A frame has to fit in the area it was asked for. It is cut out of the
1087// table's own space, so one cell over means a border drawn across the
1088// heading or the footer.
1089func TestBorderFitsTheAreaItIsGiven(t *testing.T) {
1090 for _, sz := range [][2]int{{160, 60}, {120, 40}, {80, 30}, {60, 24}} {
1091 b := fitBorder(sz[0], sz[1])
1092 if b == nil {
1093 continue // nothing fits here, which is an answer
1094 }
1095 if b.g.W() > sz[0] || b.g.H() > sz[1] {
1096 t.Errorf("%dx%d: frame is %dx%d, larger than its area", sz[0], sz[1], b.g.W(), b.g.H())
1097 }
1098 w, h := b.in.X1-b.in.X0, b.in.Y1-b.in.Y0
1099 if w < minFramed || h < minFramed {
1100 t.Errorf("%dx%d: middle is %dx%d, under the minimum", sz[0], sz[1], w, h)
1101 }
1102 }
1103}
1104
1105// The clear middle is where the table is drawn, so nothing may be inked
1106// inside it β a glyph there would be a box character among the products.
1107func TestBorderMiddleIsClear(t *testing.T) {
1108 b := fitBorder(160, 60)
1109 if b == nil {
1110 t.Fatal("no frame at 160x60, which is the size this page runs at")
1111 }
1112 for y := b.in.Y0; y < b.in.Y1; y++ {
1113 for x := b.in.X0; x < b.in.X1; x++ {
1114 if ch := b.g[y][x]; ch != border.Blank {
1115 t.Fatalf("%q at %d,%d is inside the clear middle", ch, x, y)
1116 }
1117 }
1118 }
1119}
1120
1121// place puts the middle on screen where the table will be drawn, and the
1122// frame around it β both inside the area.
1123func TestBorderPlacesItsMiddleInsideTheArea(t *testing.T) {
1124 area := rect{x: 3, y: 9, w: 160, h: 60}
1125 b := fitBorder(area.w, area.h)
1126 if b == nil {
1127 t.Fatal("no frame")
1128 }
1129 origin, inner := b.place(area)
1130 if origin.x < area.x || origin.y < area.y ||
1131 origin.x+origin.w > area.x+area.w || origin.y+origin.h > area.y+area.h {
1132 t.Errorf("frame at %+v escapes the area %+v", origin, area)
1133 }
1134 if inner.x < origin.x || inner.y < origin.y ||
1135 inner.x+inner.w > origin.x+origin.w || inner.y+inner.h > origin.y+origin.h {
1136 t.Errorf("middle at %+v escapes the frame %+v", inner, origin)
1137 }
1138 if inner.w >= area.w || inner.h >= area.h {
1139 t.Errorf("middle %dx%d is not smaller than the area %dx%d", inner.w, inner.h, area.w, area.h)
1140 }
1141}
1142
1143// cornerReach is how far a frame's corner comes in from its own edge,
1144// which is what tells the two figures apart: the fuller corner reaches
1145// further, and that reach is its whole cost.
1146func cornerReach(b *borderArt) int { return b.in.Y0 }
1147
1148// With room to spare the frame uses the fuller corner, as the page does.
1149func TestBorderKeepsTheFullCornerWhenThereIsRoom(t *testing.T) {
1150 roomy := fitBorder(160, 60)
1151 tight := fitBorder(160, 30)
1152 if roomy == nil || tight == nil {
1153 t.Fatal("expected a frame at both sizes")
1154 }
1155 if cornerReach(roomy) <= cornerReach(tight) {
1156 t.Errorf("the roomy area's corner reaches %d, no further than the tight one's %d",
1157 cornerReach(roomy), cornerReach(tight))
1158 }
1159 if h := roomy.in.Y1 - roomy.in.Y0; h < roomyRows {
1160 t.Errorf("the full corner was kept on a middle of only %d rows", h)
1161 }
1162}
1163
1164// When it is the content running out, the figure gives way rather than
1165// the table: the frame chosen leaves more rows than the fuller corner
1166// would have.
1167func TestBorderGivesUpTheCornerRatherThanTheTable(t *testing.T) {
1168 const cols, rows = 160, 30
1169 got := fitBorder(cols, rows)
1170 if got == nil {
1171 t.Fatal("no frame at 160x30")
1172 }
1173 full := recipes()[0]
1174 _, g, ok := border.FitBox(full.across, full.down, full.corner, cols, rows)
1175 if !ok {
1176 t.Skip("the fuller corner does not fit here at all, which is a different path")
1177 }
1178 in := g.Inside()
1179 if want := in.Y1 - in.Y0; got.in.Y1-got.in.Y0 <= want {
1180 t.Errorf("gave up the corner for %d rows, no better than the %d it would have left",
1181 got.in.Y1-got.in.Y0, want)
1182 }
1183}
1184
1185// An area too small for any figure gets no frame, rather than a broken
1186// one or a table squeezed to nothing.
1187func TestBorderDeclinesASmallArea(t *testing.T) {
1188 for _, sz := range [][2]int{{4, 4}, {20, 6}, {7, 30}} {
1189 if b := fitBorder(sz[0], sz[1]); b != nil {
1190 t.Errorf("%dx%d got a frame with a %dx%d middle, want none",
1191 sz[0], sz[1], b.in.X1-b.in.X0, b.in.Y1-b.in.Y0)
1192 }
1193 }
1194}
1195
1196// Composing is the expensive thing here and a draw happens on every
1197// event, so the same size must come back from the cache rather than be
1198// composed again.
1199func TestBorderCachesBySize(t *testing.T) {
1200 a := fitBorder(132, 44)
1201 b := fitBorder(132, 44)
1202 if a != b {
1203 t.Error("the same size composed twice instead of coming from the cache")
1204 }
1205}
1206
1207
1208// ===== pkg/tui/cartsave.go =====
1209package tui
1210
1211import (
1212 "encoding/json"
1213 "os"
1214 "path/filepath"
1215 "runtime"
1216 "strings"
1217
1218 "github.com/0magnet/m2/pkg/storepage"
1219)
1220
1221// The cart is a file, as a desktop program keeps its state:
1222// ~/.config/m2/cart-<store>.json, read at start and written on every change.
1223// The same code runs in the browser, where the filesystem is bottle's jsfs β
1224// the page persists its /home to IndexedDB β so a reload finds the cart where
1225// a restart does on a desktop. One file per store, so carts at two stores do
1226// not mix.
1227//
1228// It holds what the buyer chose and nothing the store decides: part numbers
1229// and quantities, and where to ship. Prices come from the catalog when the
1230// cart is shown, and the server checks them again at checkout.
1231
1232// savedCart is the file.
1233type savedCart struct {
1234 Items []savedItem `json:"items"`
1235 Shipping *storepage.Shipping `json:"shipping,omitempty"`
1236}
1237
1238type savedItem struct {
1239 Partno string `json:"partno"`
1240 Qty int `json:"qty"`
1241}
1242
1243// configDir is where a user's settings live: XDG's, or the shell's home in
1244// a page, whose process has no HOME of its own.
1245func configDir() string {
1246 if d, err := os.UserConfigDir(); err == nil {
1247 return d
1248 }
1249 if runtime.GOOS == "js" {
1250 return "/home/user/.config"
1251 }
1252 return ""
1253}
1254
1255// cartPath is this store's cart file, or "" where there is nowhere to keep one.
1256func (u *ui) cartPath() string {
1257 dir := configDir()
1258 if dir == "" {
1259 return ""
1260 }
1261 name := strings.Map(func(r rune) rune {
1262 switch {
1263 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-':
1264 return r
1265 }
1266 return '_'
1267 }, u.sitedomain)
1268 return filepath.Join(dir, "m2", "cart-"+name+".json")
1269}
1270
1271// loadCart reads the cart file, if there is one.
1272func (u *ui) loadCart() {
1273 p := u.cartPath()
1274 if p == "" {
1275 return
1276 }
1277 b, err := os.ReadFile(p) //nolint:gosec // the user's own config file
1278 if err != nil {
1279 return
1280 }
1281 var c savedCart
1282 if json.Unmarshal(b, &c) != nil {
1283 return
1284 }
1285 u.cartQty = map[string]int{}
1286 u.cartOrder = nil
1287 for _, it := range c.Items {
1288 if it.Partno == "" || it.Qty <= 0 {
1289 continue
1290 }
1291 if _, ok := u.cartQty[it.Partno]; !ok {
1292 u.cartOrder = append(u.cartOrder, it.Partno)
1293 }
1294 u.cartQty[it.Partno] += it.Qty
1295 }
1296 u.shipping = c.Shipping
1297}
1298
1299// cartChanged writes the cart after any change to it: to a file beside it
1300// first, then over it, so a cart is never half written.
1301func (u *ui) cartChanged() {
1302 p := u.cartPath()
1303 if p == "" {
1304 return
1305 }
1306 c := savedCart{Items: []savedItem{}, Shipping: u.shipping}
1307 for _, partno := range u.cartOrder {
1308 if q := u.cartQty[partno]; q > 0 {
1309 c.Items = append(c.Items, savedItem{partno, q})
1310 }
1311 }
1312 b, err := json.MarshalIndent(c, "", " ")
1313 if err != nil {
1314 return
1315 }
1316 if os.MkdirAll(filepath.Dir(p), 0o700) != nil {
1317 return
1318 }
1319 tmp := p + ".tmp"
1320 if os.WriteFile(tmp, append(b, '\n'), 0o600) != nil {
1321 return
1322 }
1323 _ = os.Rename(tmp, p) //nolint:errcheck // the cart stays as it was; the next change tries again
1324}
1325
1326// cartLine is one line of the cart as the website's cart wasm writes it and
1327// /submit-order reads it: a product's id is its part number and its amount the line's cents
1328// (price Γ quantity); the shipping line's id is storepage's shipping-to line.
1329type cartLine struct {
1330 ID string `json:"id"`
1331 Amount int `json:"amount"`
1332 Qty int `json:"quantity"`
1333}
1334
1335// cartLines is the cart in that form, for the order sent at checkout.
1336func (u *ui) cartLines() []cartLine {
1337 var ls []cartLine
1338 for _, partno := range u.cartOrder {
1339 qty := u.cartQty[partno]
1340 if qty <= 0 {
1341 continue
1342 }
1343 cents := 0
1344 if p := u.cat.find(partno); p != nil {
1345 cents = priceCents(p.Price)
1346 }
1347 ls = append(ls, cartLine{ID: partno, Amount: cents * qty, Qty: qty})
1348 }
1349 if u.shipping != nil {
1350 ls = append(ls, cartLine{ID: u.shipping.ID(), Amount: u.shipping.Cents, Qty: 1})
1351 }
1352 return ls
1353}
1354
1355// setQty sets one product's quantity in the cart; zero takes it out.
1356func (u *ui) setQty(partno string, qty int) {
1357 if qty <= 0 {
1358 delete(u.cartQty, partno)
1359 for i, p := range u.cartOrder {
1360 if p == partno {
1361 u.cartOrder = append(u.cartOrder[:i], u.cartOrder[i+1:]...)
1362 break
1363 }
1364 }
1365 } else {
1366 if _, ok := u.cartQty[partno]; !ok {
1367 u.cartOrder = append(u.cartOrder, partno)
1368 }
1369 u.cartQty[partno] = qty
1370 }
1371 u.cartChanged()
1372}
1373
1374// emptyCart takes everything out, the shipping line too, as the page's
1375// Empty Cart does.
1376func (u *ui) emptyCart() {
1377 u.cartQty = map[string]int{}
1378 u.cartOrder = nil
1379 u.shipping = nil
1380 u.cartChanged()
1381}
1382
1383
1384// ===== pkg/tui/cartsave_test.go =====
1385package tui
1386
1387import (
1388 "os"
1389 "path/filepath"
1390 "reflect"
1391 "strings"
1392 "testing"
1393
1394 "github.com/gdamore/tcell/v3"
1395
1396 "github.com/0magnet/m2/pkg/product"
1397 "github.com/0magnet/m2/pkg/storepage"
1398)
1399
1400// TestTheCartIsAFile: the cart is written to the store's file in the user's
1401// config directory on every change and read back from it at start β what
1402// the buyer chose, without prices.
1403func TestTheCartIsAFile(t *testing.T) {
1404 dir := t.TempDir()
1405 t.Setenv("XDG_CONFIG_HOME", dir)
1406 cat := newCatalogInfo(product.Products{
1407 {Partno: "A1", Price: "1.50", Enable: "TRUE", Category: "x"},
1408 {Partno: "B2", Price: "10", Enable: "TRUE", Category: "x"},
1409 })
1410 u := &ui{cartQty: map[string]int{}, cat: cat, sitedomain: "shop.example"}
1411 u.setQty("A1", 3)
1412 u.setQty("B2", 1)
1413 u.shipping = &storepage.Shipping{Cents: 795, Name: "N", Address: "1 St", City: "C", State: "TX", Zip: "77001", Country: "USA", Phone: "5"}
1414 u.cartChanged()
1415
1416 b, err := os.ReadFile(filepath.Join(dir, "m2", "cart-shop.example.json")) //nolint:gosec // the test's own temp dir
1417 if err != nil {
1418 t.Fatal(err)
1419 }
1420 if strings.Contains(string(b), "1.50") || !strings.Contains(string(b), `"partno": "A1"`) {
1421 t.Errorf("the file:\n%s", b)
1422 }
1423
1424 v := &ui{cat: cat, sitedomain: "shop.example"}
1425 v.loadCart()
1426 if !reflect.DeepEqual(v.cartLines(), u.cartLines()) {
1427 t.Fatalf("read back as\n%+v\nwant\n%+v", v.cartLines(), u.cartLines())
1428 }
1429 if got := v.cartLines()[0]; got != (cartLine{ID: "A1", Amount: 450, Qty: 3}) {
1430 t.Errorf("first line %+v", got)
1431 }
1432
1433 v.setQty("A1", 0)
1434 if _, ok := v.cartQty["A1"]; ok || len(v.cartOrder) != 1 {
1435 t.Fatalf("A1 not removed: %v %v", v.cartQty, v.cartOrder)
1436 }
1437 w := &ui{cat: cat, sitedomain: "other.example"}
1438 w.loadCart()
1439 if len(w.cartOrder) != 0 {
1440 t.Error("another store's cart was read")
1441 }
1442}
1443
1444// TestPlacementOnItsOverlay: what an overlay places lies on that overlay,
1445// and only what is drawn after it takes it away.
1446func TestPlacementOnItsOverlay(t *testing.T) {
1447 u := &ui{screen: &rawScreen{}}
1448 dialog := rect{10, 5, 40, 20}
1449 u.overlaysDrawn = 1
1450 u.want("card", placed{r: rect{11, 6, 38, 17}, widget: "stripe"})
1451 u.overlaysDrawn = 0
1452 u.want("photo", placed{r: rect{0, 0, 30, 10}, url: "https://x/y.jpg"})
1453 u.placeSync([]rect{dialog})
1454 if _, ok := u.placeSent["card"]; !ok {
1455 t.Error("the card form was taken off its own dialog")
1456 }
1457 if _, ok := u.placeSent["photo"]; ok {
1458 t.Error("the photo under the dialog was left over it")
1459 }
1460
1461 u.overlaysDrawn = 1
1462 u.want("card", placed{r: rect{11, 6, 38, 17}, widget: "stripe"})
1463 u.placeSync([]rect{dialog, {20, 10, 10, 5}}) // a notice over the form
1464 if _, ok := u.placeSent["card"]; ok {
1465 t.Error("the card form was left over the notice drawn after it")
1466 }
1467}
1468
1469// rawScreen takes what is written to the terminal itself, as the page's
1470// terminal does; nothing else of it is used.
1471type rawScreen struct {
1472 tcell.Screen
1473 out []byte
1474}
1475
1476func (r *rawScreen) WriteRaw(b []byte) { r.out = append(r.out, b...) }
1477
1478
1479// ===== pkg/tui/catalog.go =====
1480// Package tui pkg/tui/catalog.go β the site's structures, derived the same
1481// way the templates derive them: category ordering from getcategories, the
1482// navigation tree from htmpl/catsubcats.html, the product page from
1483// htmpl/product.html. Where the website renders HTML, this renders the same
1484// data to terminal text.
1485package tui
1486
1487import (
1488 "fmt"
1489 "sort"
1490 "strconv"
1491 "strings"
1492
1493 "github.com/0magnet/tallytree"
1494
1495 "github.com/0magnet/m2/pkg/product"
1496 "github.com/0magnet/m2/pkg/storepage"
1497)
1498
1499// catalogInfo mirrors what pageMeta hands the templates: categories sorted
1500// by product count, subcategories likewise, with counts.
1501type catalogInfo struct {
1502 prods product.Products
1503 cats []string
1504 catCounts map[string]int
1505 subcatCounts map[string]map[string]int
1506 subcatsByCat map[string][]string
1507}
1508
1509func newCatalogInfo(prods product.Products) *catalogInfo {
1510 c := &catalogInfo{
1511 prods: prods,
1512 catCounts: make(map[string]int),
1513 subcatCounts: make(map[string]map[string]int),
1514 subcatsByCat: make(map[string][]string),
1515 }
1516 for _, prod := range prods {
1517 if prod.Category == "" {
1518 continue
1519 }
1520 c.catCounts[prod.Category]++
1521 if prod.Subcategory != "" {
1522 if c.subcatCounts[prod.Category] == nil {
1523 c.subcatCounts[prod.Category] = make(map[string]int)
1524 }
1525 c.subcatCounts[prod.Category][prod.Subcategory]++
1526 }
1527 }
1528 for cat := range c.catCounts {
1529 c.cats = append(c.cats, cat)
1530 }
1531 sort.Slice(c.cats, func(i, j int) bool {
1532 if c.catCounts[c.cats[i]] != c.catCounts[c.cats[j]] {
1533 return c.catCounts[c.cats[i]] > c.catCounts[c.cats[j]]
1534 }
1535 return c.cats[i] < c.cats[j]
1536 })
1537 for cat, subs := range c.subcatCounts {
1538 var names []string
1539 for sub := range subs {
1540 names = append(names, sub)
1541 }
1542 sort.Slice(names, func(i, j int) bool {
1543 if subs[names[i]] != subs[names[j]] {
1544 return subs[names[i]] > subs[names[j]]
1545 }
1546 return names[i] < names[j]
1547 })
1548 c.subcatsByCat[cat] = names
1549 }
1550 return c
1551}
1552
1553// products returns the rows the matching category/subcategory table
1554// lists, as /cat/<cat>[/<subcat>] does: every product in the category,
1555// subcategorized or not, and a subcategory page narrowing that to one.
1556//
1557// It used to hold back the products that have a subcategory, which is
1558// front.html's rule for its "Other Products in X" table and not this
1559// page's. Where every product in a category is subcategorized β which
1560// is most of them β that left the category page with nothing on it. The
1561// site had the same bug in its homepage sections and fixed it there
1562// (catTargetCSS in pkg/web); this is that fix, for that reason.
1563func (c *catalogInfo) products(cat, subcat string) product.Products {
1564 var out product.Products
1565 for _, p := range c.prods {
1566 switch {
1567 case cat == "":
1568 out = append(out, p)
1569 case subcat == "":
1570 if p.Category == cat {
1571 out = append(out, p)
1572 }
1573 default:
1574 if p.Category == cat && p.Subcategory == subcat {
1575 out = append(out, p)
1576 }
1577 }
1578 }
1579 return out
1580}
1581
1582// resolve reads a "<cat>" or "<cat>/<subcat>" path as a place in this
1583// catalog, matching the way a URL does: case-insensitively, and only
1584// where the catalog holds that name. It reports whether it found one.
1585func (c *catalogInfo) resolve(path string) (navTarget, bool) {
1586 parts := strings.SplitN(strings.Trim(path, "/"), "/", 2)
1587 if parts[0] == "" {
1588 return navTarget{}, true // /cat/ is all products
1589 }
1590 cat := ""
1591 for name := range c.catCounts {
1592 if strings.EqualFold(name, parts[0]) {
1593 cat = name
1594 break
1595 }
1596 }
1597 if cat == "" {
1598 return navTarget{}, false
1599 }
1600 if len(parts) == 1 {
1601 return navTarget{cat: cat}, true
1602 }
1603 // Either spelling: the name as the catalog holds it, or the name as
1604 // a URL spells it. The site writes "ΒΌ watt 5%" into a path as
1605 // quarter-watt-5-pct, and while the escaping lived only in pkg/web
1606 // this could not match those at all β every subcategory link with a
1607 // fraction or a percent in it left the store rather than opening the
1608 // listing right here.
1609 for _, sub := range c.subcatsByCat[cat] {
1610 if strings.EqualFold(sub, parts[1]) || strings.EqualFold(storepage.EscapeSubcat(sub), parts[1]) {
1611 return navTarget{cat: cat, subcat: sub}, true
1612 }
1613 }
1614 return navTarget{}, false
1615}
1616
1617func (c *catalogInfo) find(partno string) *product.Product {
1618 for i := range c.prods {
1619 if strings.EqualFold(c.prods[i].Partno, partno) {
1620 return &c.prods[i]
1621 }
1622 }
1623 return nil
1624}
1625
1626// navTarget is a place the categories tree can go: all products, a
1627// category, or a subcategory.
1628type navTarget struct{ cat, subcat string }
1629
1630type navItem struct {
1631 label string
1632 target navTarget
1633 hasSubs bool
1634}
1635
1636// buildTree lays the category menu out: counts right-aligned by β padding,
1637// β/β branches, β¬ where a category opens into subcategories.
1638//
1639// The drawing is tallytree's, not this file's. It used to be written out here
1640// and again in htmpl/catsubcats.html, two implementations of one picture kept
1641// in agreement by hand; both now call the library, so the alignment arithmetic
1642// exists once and has tests.
1643//
1644// Depth is pinned to two because the menu always COULD have subcategories, so
1645// it reserves room for them even in a catalog where nothing does β otherwise
1646// a flat catalog would draw a narrower tree than the same shop with one
1647// subcategory added.
1648func (c *catalogInfo) buildTree(expanded map[string]bool) []navItem {
1649 forest := []tallytree.Node{{Label: "All Products", Count: len(c.prods)}}
1650 for _, cat := range c.cats {
1651 n := tallytree.Node{Label: cat, Count: c.catCounts[cat]}
1652 for _, sub := range c.subcatsByCat[cat] {
1653 n.Children = append(n.Children, tallytree.Node{Label: sub, Count: c.subcatCounts[cat][sub]})
1654 }
1655 forest = append(forest, n)
1656 }
1657 rows := tallytree.Rows(forest, tallytree.Options{
1658 Depth: 2,
1659 Open: func(n tallytree.Node, d int) bool { return d != 0 || expanded == nil || expanded[n.Label] },
1660 })
1661 items := make([]navItem, 0, len(rows))
1662 cat := ""
1663 for _, r := range rows {
1664 t := navTarget{}
1665 switch {
1666 case r.Depth == 0 && r.Label != "All Products":
1667 cat = r.Label
1668 t = navTarget{cat: r.Label}
1669 case r.Depth > 0:
1670 t = navTarget{cat: cat, subcat: r.Label}
1671 }
1672 items = append(items, navItem{r.Line(tallytree.Options{}), t, r.HasChildren})
1673 }
1674 return items
1675}
1676
1677// checkerboard styles alternate letters inverse-video, as the site's
1678// checkerBoard wraps every other letter of the domain in an .nv span.
1679func checkerboard(s string) string {
1680 var out strings.Builder
1681 for i, ch := range s {
1682 if i%2 == 0 {
1683 fmt.Fprintf(&out, "[black:white]%c[white:black]", ch)
1684 } else {
1685 out.WriteRune(ch)
1686 }
1687 }
1688 out.WriteString("[-:-]")
1689 return out.String()
1690}
1691
1692// set reports a field the way product.html's conditions do: present unless
1693// empty or a bare zero.
1694func set(v string) bool {
1695 return v != "" && v != "0" && v != "0.0"
1696}
1697
1698// productLines renders htmpl/product.html: the same fields, labels, and
1699// order, as terminal text.
1700//
1701// anchor wraps the template's clickable things β its Add to cart button
1702// and its category, subcategory and datasheet links β in whatever makes
1703// them clickable on the page doing the drawing. A nil anchor draws them
1704// as plain text, which is what a page with nowhere to send a click gets.
1705func productLines(u *ui, p *product.Product, anchor func(text string, act func()) string) string {
1706 if anchor == nil {
1707 anchor = func(text string, _ func()) string { return text }
1708 }
1709 var b strings.Builder
1710 line := func(format string, args ...interface{}) {
1711 fmt.Fprintf(&b, format+"\n", args...)
1712 }
1713 line("[::b]%s[-:-:-]", esc(p.Name))
1714 line("Price: [white]$%s[-]", esc(p.Price))
1715 line("In stock: %s", esc(p.Quantity))
1716 if p.Quantity != "0" {
1717 // The template's <button onclick='addToCart(...)'>, in the place
1718 // it sits on the page: under the stock line, above the part no.
1719 line("%s", anchor("[black:#005f87] "+storepage.AddToCart+" [-:-]", func() { u.addToCart(p) }))
1720 line("partno: %s", esc(p.Partno))
1721 }
1722 if !strings.EqualFold(strings.Join(strings.Fields(p.Description1), ""), strings.Join(strings.Fields(p.Name), "")) && p.Description1 != "" {
1723 line("\n%s\n", esc(p.Description1))
1724 }
1725 if p.Mfgname != "" {
1726 line("Brand: %s", esc(p.Mfgname))
1727 }
1728 if p.Mfgpartno != "" {
1729 line("MPN: %s", esc(p.Mfgpartno))
1730 }
1731 cat, sub := p.Category, p.Subcategory
1732 line("Category: %s", anchor("[aqua]"+esc(cat)+"[-]",
1733 func() { u.show(listPageID(navTarget{cat: cat})) }))
1734 if sub != "" {
1735 line("Subcategory: %s", anchor("[aqua]"+esc(sub)+"[-]",
1736 func() { u.show(listPageID(navTarget{cat: cat, subcat: sub})) }))
1737 }
1738 if set(p.VoltsRating) {
1739 line("Voltage: %s", esc(p.VoltsRating))
1740 }
1741 if set(p.Value) {
1742 line("Value: %s%s", esc(p.Value), esc(p.ValUnit))
1743 }
1744 if set(p.AmpsRating) {
1745 line("Amperage: %s", esc(p.AmpsRating))
1746 }
1747 if p.Tolerance != "0" && p.Tolerance != "" {
1748 if f, err := strconv.ParseFloat(p.Tolerance, 64); err == nil {
1749 line("Tolerance: %.2f%%", 100*f)
1750 }
1751 }
1752 if p.Typ != "" {
1753 line("Typ: %s", esc(p.Typ))
1754 }
1755 if p.Packagetype != "" {
1756 line("Package Type: %s", esc(p.Packagetype))
1757 }
1758 if p.Technology != "" {
1759 line("Technology: %s", esc(p.Technology))
1760 }
1761 if p.Materials != "" {
1762 line("Materials: %s", esc(p.Materials))
1763 }
1764 if set(p.WattsRating) {
1765 line("Watts Rating: %s", esc(p.WattsRating))
1766 }
1767 if p.Year != "0" && p.Year != "" {
1768 line("Year: %s", esc(p.Year))
1769 }
1770 if set(p.CableLengthInches) {
1771 line("Cable Length: %s inches", esc(p.CableLengthInches))
1772 }
1773 if p.WeightOz != "0" && p.WeightOz != "0.0" && p.WeightOz != "" {
1774 line("Weight: %s oz", esc(p.WeightOz))
1775 }
1776 if p.TempRating != "0" && p.TempRating != "0.0" && p.TempRating != "" {
1777 line("Temp rating: %s%s", esc(p.TempRating), esc(p.TempUnit))
1778 }
1779 if p.Condition != "" {
1780 line("Condition: %s", esc(p.Condition))
1781 }
1782 if p.Datasheet != "" {
1783 // The template links this at <imgsrc>/pdf/<name>; the terminal
1784 // cannot show a PDF, so following it hands over the URL.
1785 ds := p.Datasheet
1786 line("Datasheet: %s", anchor("[aqua]"+esc(ds)+"[-]",
1787 func() { u.openURL(datasheetURL(ds)) }))
1788 }
1789 if p.Docs != "" {
1790 line("Documentation: %s", esc(p.Docs))
1791 }
1792 if p.Note != "" {
1793 line("Note: [yellow]%s[-]", esc(p.Note))
1794 }
1795 if p.Warning != "" {
1796 line("Warning: [red]%s[-]", esc(p.Warning))
1797 }
1798 if p.Description2 != "" {
1799 line("Additional Description: %s", esc(p.Description2))
1800 }
1801 return b.String()
1802}
1803
1804
1805// ===== pkg/tui/catalog_test.go =====
1806package tui
1807
1808import (
1809 "strings"
1810 "testing"
1811
1812 "github.com/0magnet/m2/pkg/product"
1813)
1814
1815// sampleCatalog is a catalog small enough to write the expected drawing out
1816// by hand, and shaped to exercise the alignment: counts of one, two, three and
1817// four digits, a category with subcategories and one without, and a last
1818// sibling on both levels.
1819func sampleCatalog() *catalogInfo {
1820 return &catalogInfo{
1821 prods: make(product.Products, 1247),
1822 cats: []string{"resistor", "inductor"},
1823 catCounts: map[string]int{"resistor": 111, "inductor": 34},
1824 subcatsByCat: map[string][]string{"resistor": {"quarter watt 5%", "ceramic"}},
1825 subcatCounts: map[string]map[string]int{"resistor": {"quarter watt 5%": 67, "ceramic": 1}},
1826 }
1827}
1828
1829// The menu's counts are right-aligned by padding the branch rule itself, so
1830// every count ends in the same column and the tree still reads as one line.
1831// These are the exact rows the site drew before the layout moved to tallytree.
1832func TestBuildTreeDrawing(t *testing.T) {
1833 want := []string{
1834 "βββ1247 All Products",
1835 "βββ¬β111 resistor",
1836 "β βββ67 quarter watt 5%",
1837 "β ββββ1 ceramic",
1838 "βββββ34 inductor",
1839 }
1840 got := sampleCatalog().buildTree(map[string]bool{"resistor": true})
1841 if len(got) != len(want) {
1842 t.Fatalf("%d rows, want %d", len(got), len(want))
1843 }
1844 for i := range want {
1845 if got[i].label != want[i] {
1846 t.Errorf("row %d:\n got %q\nwant %q", i, got[i].label, want[i])
1847 }
1848 }
1849}
1850
1851// A collapsed category keeps the β¬ that says it has subcategories, and the
1852// columns do not move when it closes.
1853func TestBuildTreeCollapsed(t *testing.T) {
1854 got := sampleCatalog().buildTree(map[string]bool{})
1855 if len(got) != 3 {
1856 t.Fatalf("%d rows with everything collapsed, want 3", len(got))
1857 }
1858 if !strings.Contains(got[1].label, "β¬") {
1859 t.Errorf("collapsed category lost its β¬: %q", got[1].label)
1860 }
1861 open := sampleCatalog().buildTree(map[string]bool{"resistor": true})
1862 if a, b := strings.Index(got[1].label, " "), strings.Index(open[1].label, " "); a != b {
1863 t.Errorf("collapsing moved the count column: %d vs %d", a, b)
1864 }
1865}
1866
1867// A category page lists everything in the category, as /cat/<cat> does
1868// β not only the products with no subcategory, which is front.html's
1869// rule for its "Other Products in X" table. Getting that wrong left
1870// every category page in this catalog empty.
1871func TestCategoryPageListsTheWholeCategory(t *testing.T) {
1872 c := newCatalogInfo(product.Products{
1873 {Partno: "a", Category: "diode", Subcategory: "1N47XX"},
1874 {Partno: "b", Category: "diode", Subcategory: "1N47XX"},
1875 {Partno: "c", Category: "diode", Subcategory: "bridge"},
1876 {Partno: "d", Category: "diode"},
1877 {Partno: "e", Category: "resistor"},
1878 })
1879 names := func(ps product.Products) string {
1880 var out []string
1881 for _, p := range ps {
1882 out = append(out, p.Partno)
1883 }
1884 return strings.Join(out, ",")
1885 }
1886 if got := names(c.products("diode", "")); got != "a,b,c,d" {
1887 t.Errorf("/cat/diode lists %q, want %q", got, "a,b,c,d")
1888 }
1889 if got := names(c.products("diode", "1N47XX")); got != "a,b" {
1890 t.Errorf("/cat/diode/1N47XX lists %q, want %q", got, "a,b")
1891 }
1892 if got := names(c.products("", "")); got != "a,b,c,d,e" {
1893 t.Errorf("all products lists %q, want %q", got, "a,b,c,d,e")
1894 }
1895}
1896
1897// Targets have to survive the layout: the rows carry where each entry goes.
1898func TestBuildTreeTargets(t *testing.T) {
1899 got := sampleCatalog().buildTree(map[string]bool{"resistor": true})
1900 want := []navTarget{{}, {cat: "resistor"}, {cat: "resistor", subcat: "quarter watt 5%"},
1901 {cat: "resistor", subcat: "ceramic"}, {cat: "inductor"}}
1902 for i := range want {
1903 if got[i].target != want[i] {
1904 t.Errorf("row %d target %+v, want %+v", i, got[i].target, want[i])
1905 }
1906 }
1907 if !got[1].hasSubs || got[4].hasSubs {
1908 t.Error("hasSubs is wrong: resistor has subcategories, inductor does not")
1909 }
1910}
1911
1912
1913// ===== pkg/tui/checkout.go =====
1914// Package tui pkg/tui/checkout.go β the cart, the shipping form, and
1915// checkout, mirroring the website's footer commerce (footer.html + the
1916// cart wasm). On the website the cart and the shipping form are
1917// <details> dropdowns rising from the fixed footer and checkout is a
1918// <dialog>; here they are overlays above the footer bar, outside the
1919// navigation history β v toggles the cart, Esc closes.
1920//
1921// The TUI is a client of the same store server the browser cart talks
1922// to: it builds the identical items payload ("partno X qty" lines plus
1923// a "shipping-to|..." line) and POSTs the same /create-payment-intent.
1924// The card-entry step lives behind payWithElements: in the browser
1925// (pay_js.go) it lays the Stripe Payment Element over the checkout
1926// dialog, as the cart wasm opens it in the page's; a native terminal
1927// (pay_native.go) cannot take card details, and says so.
1928package tui
1929
1930import (
1931 "bytes"
1932 "encoding/json"
1933 "fmt"
1934 "io"
1935 "net/http"
1936 "strings"
1937 "time"
1938
1939 "github.com/gdamore/tcell/v3"
1940
1941 "github.com/0magnet/m2/pkg/storepage"
1942)
1943
1944// The shipping line, the form that fills it and the state list all live in
1945// pkg/storepage now, so the page, this and the cart wasm cannot drift apart.
1946// See pkg/storepage/shipping.go.
1947
1948// commerceRect anchors an overlay just above the footer summary it
1949// expands from β the cart (and checkout) above View Cart, the form
1950// above Add Shipping Info.
1951func (u *ui) commerceRect() rect {
1952 w, h := u.commerce.size()
1953 sw, sh := u.screen.Size()
1954 if w > sw-2 {
1955 w = sw - 2
1956 }
1957 x := 1
1958 region := 0
1959 if _, ok := u.commerce.(*shippingOverlay); ok {
1960 region = 1
1961 }
1962 if region < len(u.footRegions) {
1963 x = u.footRegions[region].x1 - 1
1964 }
1965 if x+w > sw-1 {
1966 x = sw - 1 - w
1967 }
1968 if x < 0 {
1969 x = 0
1970 }
1971 y := sh - 2 - h
1972 if y < 1 {
1973 y = 1
1974 h = sh - 3
1975 }
1976 return rect{x, y, w, h}
1977}
1978
1979func (u *ui) closeCommerce() {
1980 u.commerce = nil
1981}
1982
1983func (u *ui) toggleCart() {
1984 if u.commerce != nil {
1985 u.closeCommerce()
1986 return
1987 }
1988 u.openCart()
1989}
1990
1991// ---- the cart overlay ----
1992
1993// The page's cart is a table β item, price, a quantity box and Remove on
1994// each line β so this one has a line the arrows are on, whose quantity +
1995// and - change and r takes out.
1996type cartOverlay struct {
1997 ta *textArea
1998 lines int
1999 sel int // the line the arrows are on
2000 items []string // the part numbers on its lines, in order
2001}
2002
2003func (u *ui) openCart() {
2004 sel := 0
2005 if c, ok := u.commerce.(*cartOverlay); ok {
2006 sel = c.sel
2007 }
2008 var items []string
2009 for _, partno := range u.cartOrder {
2010 if u.cartQty[partno] > 0 && u.cat.find(partno) != nil {
2011 items = append(items, partno)
2012 }
2013 }
2014 if sel >= len(items) {
2015 sel = len(items) - 1
2016 }
2017 if sel < 0 {
2018 sel = 0
2019 }
2020 var b strings.Builder
2021 total := 0
2022 for i, partno := range items {
2023 qty := u.cartQty[partno]
2024 p := u.cat.find(partno)
2025 cents := priceCents(p.Price) * qty
2026 total += cents
2027 mark, name := " ", "[aqua]"+esc(p.Name)+"[-]"
2028 if i == sel {
2029 mark, name = "[#5fd7ff]βΈ[-] ", "[black:aqua]"+esc(p.Name)+"[-:-]"
2030 }
2031 fmt.Fprintf(&b, "%s%3d Γ %s $%d.%02d\n", mark, qty, name, cents/100, cents%100)
2032 }
2033 if u.shipping != nil {
2034 total += u.shipping.Cents
2035 to := strings.TrimSpace(u.shipping.Name + " " + u.shipping.City + " " + u.shipping.State)
2036 fmt.Fprintf(&b, " shipping[gray] to %s[-] $%d.%02d\n",
2037 esc(to), u.shipping.Cents/100, u.shipping.Cents%100)
2038 }
2039 if total == 0 {
2040 b.WriteString("[gray]your cart is empty[-]\n")
2041 }
2042 fmt.Fprintf(&b, "\nTotal: $%d.%02d\n", total/100, total%100)
2043 if len(items) > 0 {
2044 b.WriteString("\n[#5fd7ff]ββ[-] line Β· [#5fd7ff]+ -[-] quantity Β· [#5fd7ff]r[-] remove")
2045 }
2046 b.WriteString("\n[#5fd7ff]s[-] shipping Β· [#5fd7ff]p[-] card")
2047 if coinOffered {
2048 b.WriteString(" Β· [#5fd7ff]k[-] coin")
2049 }
2050 b.WriteString(" Β· [#5fd7ff]x[-] empty Β· [#5fd7ff]v[-] close\n")
2051 fmt.Fprintf(&b, "[gray]checkout talks to the store server at %s[-]", esc(ordersURL()))
2052 content := b.String()
2053 u.commerce = &cartOverlay{ta: newTextArea(content, false), lines: strings.Count(content, "\n") + 1, sel: sel, items: items}
2054}
2055
2056func (c *cartOverlay) title() string { return "View Cart" }
2057func (c *cartOverlay) size() (int, int) {
2058 return 70, c.lines + 2
2059}
2060
2061func (c *cartOverlay) draw(u *ui, sc tcell.Screen, r rect) {
2062 c.ta.draw(sc, r)
2063}
2064
2065func (c *cartOverlay) key(u *ui, ev *tcell.EventKey) bool {
2066 line := ""
2067 if c.sel < len(c.items) {
2068 line = c.items[c.sel]
2069 }
2070 switch ev.Key() {
2071 case tcell.KeyUp:
2072 if c.sel > 0 {
2073 c.sel--
2074 u.openCart()
2075 }
2076 return true
2077 case tcell.KeyDown:
2078 if c.sel < len(c.items)-1 {
2079 c.sel++
2080 u.openCart()
2081 }
2082 return true
2083 case tcell.KeyDelete:
2084 if line != "" {
2085 u.setQty(line, 0)
2086 u.openCart()
2087 }
2088 return true
2089 }
2090 switch keyRune(ev) {
2091 case '+', '=':
2092 if line != "" {
2093 u.setQty(line, u.cartQty[line]+1)
2094 u.openCart()
2095 }
2096 return true
2097 case '-':
2098 if line != "" {
2099 u.setQty(line, u.cartQty[line]-1)
2100 u.openCart()
2101 }
2102 return true
2103 case 'r':
2104 if line != "" {
2105 u.setQty(line, 0)
2106 u.openCart()
2107 }
2108 return true
2109 case 'x':
2110 u.emptyCart()
2111 u.openCart()
2112 return true
2113 case 's':
2114 u.openShipping()
2115 return true
2116 case 'p':
2117 u.startCheckout()
2118 return true
2119 case 'k':
2120 if !coinOffered {
2121 return false
2122 }
2123 // The checkout a terminal can finish by itself; see crypto.go.
2124 u.startCoinCheckout()
2125 return true
2126 }
2127 return false
2128}
2129
2130// ---- the shipping form, from footer.html's Add Shipping Info ----
2131
2132type shippingOverlay struct {
2133 frm *form
2134 h int
2135}
2136
2137func (s *shippingOverlay) title() string { return "Add Shipping Info" }
2138func (s *shippingOverlay) size() (int, int) { return 62, s.h }
2139func (s *shippingOverlay) draw(u *ui, sc tcell.Screen, r rect) { s.frm.draw(sc, r) }
2140func (s *shippingOverlay) key(u *ui, ev *tcell.EventKey) bool { return s.frm.key(ev) }
2141
2142func (u *ui) openShipping() {
2143 prev := u.shipping
2144 if prev == nil {
2145 prev = &storepage.Shipping{Cents: storepage.MinShippingCents, Country: storepage.DefaultCountry}
2146 }
2147 // Built by walking the shared field list rather than naming each input,
2148 // so a field added to the form appears here and on the page together.
2149 // read[name] returns what that control holds, in the spelling the cart
2150 // line wants β a state picker shows "Texas" and yields "TX".
2151 read := map[string]func() string{}
2152 items := make([]formItem, 0, len(storepage.ShippingFields))
2153 for _, f := range storepage.ShippingFields {
2154 switch f.Kind {
2155 case storepage.FieldState:
2156 opts := storepage.StateNames()
2157 dd := &fdropdown{lbl: f.TUILabel, opts: opts}
2158 for i, n := range opts {
2159 if n == storepage.StateName(prev.State) {
2160 dd.sel = i
2161 }
2162 }
2163 items = append(items, dd)
2164 read[f.Name] = func() string { return storepage.StateCode(dd.value()) }
2165 case storepage.FieldCountry:
2166 dd := &fdropdown{lbl: f.TUILabel, opts: storepage.Countries}
2167 items = append(items, dd)
2168 read[f.Name] = dd.value
2169 default:
2170 val := prev.Get(f.Name)
2171 if f.Kind == storepage.FieldMoney {
2172 val = fmt.Sprintf("%d.%02d", prev.Cents/100, prev.Cents%100)
2173 }
2174 in := &finput{lbl: f.TUILabel, text: []rune(val), width: f.Width}
2175 in.cur = len(in.text)
2176 items = append(items, in)
2177 read[f.Name] = in.value
2178 }
2179 }
2180
2181 // Declared separately on purpose: the buttons below close over frm, so it
2182 // has to exist before newForm is called.
2183 var frm *form //nolint:staticcheck
2184 frm = newForm(items, []fbutton{
2185 {"Add Shipping to Cart", func() {
2186 sh := storepage.Shipping{Cents: priceCents(read["shipping-price"]())}
2187 for _, f := range storepage.ShippingFields {
2188 if f.Kind != storepage.FieldMoney {
2189 sh.Set(f.Name, read[f.Name]())
2190 }
2191 }
2192 if msg := sh.Validate(); msg != "" {
2193 u.notice = msg
2194 return
2195 }
2196 u.shipping = &sh
2197 u.cartChanged()
2198 u.openCart()
2199 }},
2200 {"Cancel", func() { u.openCart() }},
2201 }, func() { u.openCart() })
2202 u.commerce = &shippingOverlay{frm: frm, h: len(items)*2 + 3}
2203}
2204
2205// ---- checkout: the cart wasm's flow, against the same server ----
2206
2207type checkoutOverlay struct {
2208 ta *textArea
2209 // form is set while the card form is laid over the dialog (pay_js.go):
2210 // the dialog grows to hold it, keeps a line above it for what is being
2211 // paid and one below for the keys, and Enter pays.
2212 form *cardForm
2213}
2214
2215// cardForm is the card form's part of the dialog.
2216type cardForm struct {
2217 head string // what is being paid, above the form
2218 pay func() // confirms the payment, as the form's own Pay button does
2219}
2220
2221func (c *checkoutOverlay) title() string { return "Checkout" }
2222func (c *checkoutOverlay) size() (int, int) {
2223 if c.form != nil {
2224 return 72, 32
2225 }
2226 return 78, 14
2227}
2228
2229func (c *checkoutOverlay) draw(u *ui, sc tcell.Screen, r rect) {
2230 if c.form == nil || r.h < 6 {
2231 c.ta.draw(sc, r)
2232 return
2233 }
2234 printMarkup(sc, r.x+1, r.y, r.w-2, c.form.head, styleText)
2235 formR := rect{r.x, r.y + 1, r.w, r.h - 3}
2236 // The cells under the form, for a terminal that cannot place it: the
2237 // dialog's own message says what is missing there.
2238 c.ta.draw(sc, formR)
2239 u.want("card", placed{r: formR, widget: "stripe", input: true})
2240 printMarkup(sc, r.x+1, r.y+r.h-1, r.w-2, "[#5fd7ff]β[-] pay Β· [#5fd7ff]esc[-] cancel β the cart is kept Β· [gray]click the form to type in it[-]", styleText)
2241}
2242
2243func (c *checkoutOverlay) key(u *ui, ev *tcell.EventKey) bool {
2244 if c.form != nil && ev.Key() == tcell.KeyEnter {
2245 c.form.pay()
2246 return true
2247 }
2248 return c.ta.key(ev)
2249}
2250
2251// ordersURL is where checkout is served from: the store being browsed
2252// in client mode, else SITEORDERSURL when the config names one (as the
2253// website's cart uses it), else the local store server.
2254func ordersURL() string {
2255 if s := storeURL(); s != "" {
2256 return s
2257 }
2258 if f.Siteordersurl != "" {
2259 return f.Siteordersurl
2260 }
2261 port := f.WebPort
2262 if port == 0 {
2263 port = 9883
2264 }
2265 return fmt.Sprintf("http://127.0.0.1:%d", port)
2266}
2267
2268// cartItem matches the JSON the cart wasm POSTs to /create-payment-intent.
2269type cartItem struct {
2270 ID string `json:"ID"`
2271 Amount int64 `json:"Amount"`
2272}
2273
2274func (u *ui) startCheckout() {
2275 if len(u.cartOrder) == 0 {
2276 u.notice = "the cart is empty"
2277 return
2278 }
2279 if u.shipping == nil {
2280 u.notice = "add shipping info first (s in the cart)"
2281 return
2282 }
2283
2284 // The same payload the cart wasm sends, and the same one the coin
2285 // checkout sends: see cartItems in crypto.go.
2286 items := u.cartItems()
2287
2288 co := &checkoutOverlay{ta: newTextArea("\n contacting "+esc(ordersURL())+" β¦", true)}
2289 u.commerce = co
2290
2291 go func() {
2292 clientSecret, err := createPaymentIntent(items)
2293 u.post(func() {
2294 if u.commerce != co {
2295 return // the overlay was closed meanwhile
2296 }
2297 if err != nil {
2298 co.ta.setContent(fmt.Sprintf(
2299 "\n [red]could not create the payment:[-] %s\n\n [gray]is the store server running? (%s)\n Esc closes β the cart is kept[-]\n",
2300 esc(err.Error()), esc(ordersURL())))
2301 return
2302 }
2303 u.payWithElements(co, clientSecret)
2304 })
2305 }()
2306}
2307
2308// createPaymentIntent POSTs the cart to the store server, which
2309// validates every line against the catalog and answers with the payment
2310// intent's client secret β identical to the cart wasm's fetch.
2311func createPaymentIntent(items []cartItem) (string, error) {
2312 body, err := json.Marshal(map[string]interface{}{"items": items})
2313 if err != nil {
2314 return "", err
2315 }
2316 client := &http.Client{Timeout: 15 * time.Second}
2317 resp, err := client.Post(ordersURL()+"/create-payment-intent", "application/json", bytes.NewReader(body))
2318 if err != nil {
2319 return "", err
2320 }
2321 defer resp.Body.Close() //nolint:errcheck // read-side close
2322 data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
2323 if err != nil {
2324 return "", err
2325 }
2326 var out struct {
2327 ClientSecret string `json:"clientSecret"`
2328 Error string `json:"error"`
2329 }
2330 if err := json.Unmarshal(data, &out); err != nil {
2331 return "", fmt.Errorf("bad response (%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
2332 }
2333 if out.Error != "" {
2334 return "", fmt.Errorf("%s", out.Error)
2335 }
2336 if out.ClientSecret == "" {
2337 return "", fmt.Errorf("no client secret in response (%d)", resp.StatusCode)
2338 }
2339 return out.ClientSecret, nil
2340}
2341
2342
2343// ===== pkg/tui/crypto.go =====
2344// Package tui pkg/tui/crypto.go β paying in coin from the terminal.
2345//
2346// This is the checkout a terminal can actually complete. Stripe's card
2347// entry is a browser widget, which is why payWithElements tells a native
2348// terminal it cannot take card details; a payment address is text, and text
2349// is what a terminal has. The same /create-invoice endpoint the page uses
2350// answers here, so the terminal is not a second implementation of anything
2351// β it renders what the server already decided.
2352package tui
2353
2354import (
2355 "bytes"
2356 "encoding/json"
2357 "fmt"
2358 "io"
2359 "net/http"
2360 "net/url"
2361 "strings"
2362 "time"
2363
2364 "github.com/gdamore/tcell/v3"
2365)
2366
2367// invoiceView is the subset of an invoice the server hands a client.
2368type invoiceView struct {
2369 ID string `json:"id"`
2370 Coin string `json:"coin"`
2371 Address string `json:"address"`
2372 Amount string `json:"amount"`
2373 URI string `json:"uri"`
2374 FiatCents int64 `json:"fiat_cents"`
2375 Status string `json:"status"`
2376 Paid string `json:"paid"`
2377 Outstanding string `json:"outstanding"`
2378 Expires string `json:"expires"`
2379 TxID string `json:"txid"`
2380 Error string `json:"error"`
2381}
2382
2383type invoiceOverlay struct {
2384 ta *textArea
2385 inv *invoiceView
2386}
2387
2388func (o *invoiceOverlay) title() string { return "Pay in coin" }
2389func (o *invoiceOverlay) size() (int, int) { return 74, 16 }
2390func (o *invoiceOverlay) draw(u *ui, sc tcell.Screen, r rect) { o.ta.draw(sc, r) }
2391
2392func (o *invoiceOverlay) key(u *ui, ev *tcell.EventKey) bool {
2393 if keyRune(ev) == 'r' && o.inv != nil {
2394 u.refreshInvoice(o)
2395 return true
2396 }
2397 return o.ta.key(ev)
2398}
2399
2400// startCoinCheckout asks the store for an invoice and shows it.
2401func (u *ui) startCoinCheckout() {
2402 if len(u.cartOrder) == 0 {
2403 u.notice = "the cart is empty"
2404 return
2405 }
2406 if u.shipping == nil {
2407 u.notice = "add shipping info first (s in the cart)"
2408 return
2409 }
2410 items := u.cartItems()
2411
2412 ov := &invoiceOverlay{ta: newTextArea("\n asking "+esc(ordersURL())+" for an address β¦", true)}
2413 u.commerce = ov
2414
2415 go func() {
2416 inv, err := createInvoice(items)
2417 u.post(func() {
2418 if u.commerce != ov {
2419 return // closed meanwhile
2420 }
2421 if err != nil {
2422 ov.ta.setContent(fmt.Sprintf(
2423 "\n [red]could not create the invoice:[-] %s\n\n [gray]the store may not have crypto checkout configured\n Esc closes β the cart is kept[-]\n",
2424 esc(err.Error())))
2425 return
2426 }
2427 ov.inv = inv
2428 ov.ta.setContent(renderInvoice(inv))
2429 })
2430 }()
2431}
2432
2433// refreshInvoice re-reads one invoice; the server polls the chain when
2434// asked, so this is what turns "pending" into "paid" on screen.
2435func (u *ui) refreshInvoice(ov *invoiceOverlay) {
2436 id := ov.inv.ID
2437 go func() {
2438 inv, err := getInvoice(id)
2439 u.post(func() {
2440 if u.commerce != ov {
2441 return
2442 }
2443 if err != nil {
2444 u.notice = "could not refresh: " + esc(err.Error())
2445 return
2446 }
2447 ov.inv = inv
2448 ov.ta.setContent(renderInvoice(inv))
2449 })
2450 }()
2451}
2452
2453// cartItems builds the payload both checkouts send.
2454func (u *ui) cartItems() []cartItem {
2455 var items []cartItem
2456 for _, partno := range u.cartOrder {
2457 qty := u.cartQty[partno]
2458 p := u.cat.find(partno)
2459 if qty == 0 || p == nil {
2460 continue
2461 }
2462 items = append(items, cartItem{
2463 ID: fmt.Sprintf("%s X %d", partno, qty),
2464 Amount: int64(priceCents(p.Price) * qty),
2465 })
2466 }
2467 if u.shipping != nil {
2468 items = append(items, cartItem{ID: u.shipping.ID(), Amount: int64(u.shipping.Cents)})
2469 }
2470 return items
2471}
2472
2473func renderInvoice(inv *invoiceView) string {
2474 var b strings.Builder
2475 fmt.Fprintf(&b, "\n send [::b]%s %s[-:-:-]\n", esc(inv.Amount), esc(inv.Coin))
2476 fmt.Fprintf(&b, " to [aqua]%s[-]\n\n", esc(inv.Address))
2477 fmt.Fprintf(&b, " [gray]uri[-] %s\n", esc(inv.URI))
2478 fmt.Fprintf(&b, " [gray]order[-] $%d.%02d\n", inv.FiatCents/100, inv.FiatCents%100)
2479 if t, err := time.Parse(time.RFC3339, inv.Expires); err == nil {
2480 fmt.Fprintf(&b, " [gray]quote holds until[-] %s\n", esc(t.Local().Format("15:04:05")))
2481 }
2482 b.WriteString("\n")
2483 switch inv.Status {
2484 case "paid":
2485 b.WriteString(" [green]paid β thank you. the order is printing.[-]\n")
2486 if inv.TxID != "" {
2487 fmt.Fprintf(&b, " [gray]txid %s[-]\n", esc(inv.TxID))
2488 }
2489 case "underpaid":
2490 fmt.Fprintf(&b, " [yellow]part paid:[-] %s received, [::b]%s[-:-:-] still owed\n",
2491 esc(inv.Paid), esc(inv.Outstanding))
2492 case "expired":
2493 b.WriteString(" [red]the quote expired[-] β close and start again for a fresh price\n")
2494 default:
2495 b.WriteString(" [gray]waiting for the payment to confirmβ¦[-]\n")
2496 }
2497 b.WriteString("\n [#5fd7ff]r[-] check again Β· [#5fd7ff]esc[-] close\n")
2498 return b.String()
2499}
2500
2501func createInvoice(items []cartItem) (*invoiceView, error) {
2502 body, err := json.Marshal(map[string]any{"coin": "SKY", "items": items})
2503 if err != nil {
2504 return nil, err
2505 }
2506 client := &http.Client{Timeout: 30 * time.Second}
2507 resp, err := client.Post(ordersURL()+"/create-invoice", "application/json", bytes.NewReader(body))
2508 if err != nil {
2509 return nil, err
2510 }
2511 return readInvoice(resp)
2512}
2513
2514func getInvoice(id string) (*invoiceView, error) {
2515 client := &http.Client{Timeout: 30 * time.Second}
2516 resp, err := client.Get(ordersURL() + "/invoice/" + url.PathEscape(id))
2517 if err != nil {
2518 return nil, err
2519 }
2520 return readInvoice(resp)
2521}
2522
2523func readInvoice(resp *http.Response) (*invoiceView, error) {
2524 defer resp.Body.Close() //nolint:errcheck // read-side close
2525 data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
2526 if err != nil {
2527 return nil, err
2528 }
2529 var inv invoiceView
2530 if err := json.Unmarshal(data, &inv); err != nil {
2531 return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
2532 }
2533 if inv.Error != "" {
2534 return nil, fmt.Errorf("%s", inv.Error)
2535 }
2536 if resp.StatusCode != http.StatusOK {
2537 return nil, fmt.Errorf("%s", resp.Status)
2538 }
2539 return &inv, nil
2540}
2541
2542
2543// ===== pkg/tui/frame_dump_test.go =====
2544package tui
2545
2546import (
2547 "image/png"
2548 "os"
2549 "testing"
2550)
2551
2552func TestDumpFrame(t *testing.T) {
2553 out := os.Getenv("FRAMEOUT")
2554 if out == "" {
2555 t.Skip("set FRAMEOUT to dump a frame")
2556 }
2557 logo, err := loadImage(os.Getenv("FRAMELOGO"))
2558 if err != nil {
2559 t.Fatal(err)
2560 }
2561 bd := makeBackdrop(logo, 200, 100)
2562 fr := globeFrame(bd, 0.9, 2.3, -0.6)
2563 f, err := os.Create(out) //nolint:gosec
2564 if err != nil {
2565 t.Fatalf("create %s: %v", out, err)
2566 }
2567 defer f.Close() //nolint:errcheck,gosec
2568 if err := png.Encode(f, fr); err != nil {
2569 t.Fatal(err)
2570 }
2571}
2572
2573
2574// ===== pkg/tui/htmltable.go =====
2575// Package tui pkg/tui/htmltable.go β tables in a content page.
2576//
2577// htmlToText used to flatten a table: </td> became two spaces and </tr> a
2578// newline, so the Links page β which is a three-column table of supplier,
2579// location and notes β arrived as ragged prose with the columns running into
2580// each other. The site draws it as a table and so should this.
2581//
2582// The work is laid out here rather than in render.go because it needs its own
2583// wrapper: a cell's text carries the [aqua] and [link=N] tags the anchor pass
2584// already wrote into it, and those are zero width. Wrapping on len() would
2585// count them and wrapping without them would drop a link's styling β and its
2586// clickable region β at the break.
2587package tui
2588
2589import (
2590 "regexp"
2591 "strings"
2592
2593 xhtml "html"
2594)
2595
2596var (
2597 tagTable = regexp.MustCompile(`(?is)<table[^>]*>(.*?)</table>`)
2598 tagRow = regexp.MustCompile(`(?is)<tr[^>]*>(.*?)</tr>`)
2599 tagCellFull = regexp.MustCompile(`(?is)<(t[dh])[^>]*>(.*?)</t[dh]>`)
2600 runOfSpaces = regexp.MustCompile(`[ \t\n]+`)
2601)
2602
2603// colGap is the space between columns, in cells.
2604const colGap = 2
2605
2606// minCol is how narrow shrinking may make a column before it gives up and
2607// lets the table overflow instead. A column squeezed below this is unreadable,
2608// and an honestly too-wide table is easier to deal with than a shredded one.
2609const minCol = 6
2610
2611// cellText reduces one cell's HTML to its printed text, keeping the markup
2612// tags the anchor pass wrote and dropping everything else.
2613func cellText(s string) string {
2614 s = tagBreak.ReplaceAllString(s, " ")
2615 s = tagAny.ReplaceAllString(s, "")
2616 s = xhtml.UnescapeString(s)
2617 return strings.TrimSpace(runOfSpaces.ReplaceAllString(s, " "))
2618}
2619
2620// isResetTag reports whether a color tag turns styling off rather than on:
2621// "[-]", "[-:-:-]" and the like are built only from dashes and colons.
2622func isResetTag(body string) bool {
2623 if body == "" {
2624 return false
2625 }
2626 return strings.Trim(body, "-:") == ""
2627}
2628
2629// mtok is one piece of a tagged string: a zero-width tag, or one visible rune.
2630type mtok struct {
2631 tag string
2632 txt string
2633}
2634
2635// markupTokens splits a tagged string the way parseMarkup reads it, so the
2636// two agree on what is a tag and what is text. "[[" is a literal bracket.
2637func markupTokens(s string) []mtok {
2638 var out []mtok
2639 r := []rune(s)
2640 for i := 0; i < len(r); i++ {
2641 if r[i] != '[' {
2642 out = append(out, mtok{txt: string(r[i])})
2643 continue
2644 }
2645 if i+1 < len(r) && r[i+1] == '[' {
2646 out = append(out, mtok{txt: "["})
2647 i++
2648 continue
2649 }
2650 end := -1
2651 for j := i + 1; j < len(r) && j < i+24; j++ {
2652 if r[j] == ']' {
2653 end = j
2654 break
2655 }
2656 }
2657 if end < 0 {
2658 out = append(out, mtok{txt: "["})
2659 continue
2660 }
2661 body := string(r[i+1 : end])
2662 if _, ok := linkBody(body); ok || tagBody(body) {
2663 out = append(out, mtok{tag: "[" + body + "]"})
2664 i = end
2665 continue
2666 }
2667 out = append(out, mtok{txt: "["})
2668 }
2669 return out
2670}
2671
2672// wrapMarkup breaks a tagged string into lines of at most w printed columns.
2673//
2674// The color and link tags still open at a break are repeated at the start of
2675// the next line: each line is drawn on its own, so a wrapped link that did not
2676// carry its [link=N] across would stop being clickable half way through.
2677func wrapMarkup(s string, w int) []string {
2678 if w < 1 {
2679 w = 1
2680 }
2681 var (
2682 lines []string
2683 line strings.Builder
2684 lineW int
2685 color, link string
2686 word []mtok
2687 wordW int
2688 )
2689 startLine := func() {
2690 line.Reset()
2691 lineW = 0
2692 line.WriteString(color)
2693 line.WriteString(link)
2694 }
2695 pushLine := func() {
2696 lines = append(lines, line.String())
2697 startLine()
2698 }
2699 emitWord := func() {
2700 if len(word) == 0 {
2701 return
2702 }
2703 switch {
2704 case lineW > 0 && lineW+1+wordW > w:
2705 pushLine()
2706 case lineW > 0:
2707 line.WriteString(" ")
2708 lineW++
2709 }
2710 for _, t := range word {
2711 if t.tag != "" {
2712 body := t.tag[1 : len(t.tag)-1]
2713 if _, ok := linkBody(body); ok {
2714 if body == "/link" {
2715 link = ""
2716 } else {
2717 link = t.tag
2718 }
2719 } else if isResetTag(body) {
2720 color = ""
2721 } else {
2722 color = t.tag
2723 }
2724 line.WriteString(t.tag)
2725 continue
2726 }
2727 if lineW >= w { // a single word longer than the column
2728 pushLine()
2729 }
2730 if t.txt == "[" {
2731 line.WriteString("[[")
2732 } else {
2733 line.WriteString(t.txt)
2734 }
2735 lineW++
2736 }
2737 word = word[:0]
2738 wordW = 0
2739 }
2740
2741 for _, t := range markupTokens(s) {
2742 if t.tag == "" && t.txt == " " {
2743 emitWord()
2744 continue
2745 }
2746 word = append(word, t)
2747 if t.tag == "" {
2748 wordW++
2749 }
2750 }
2751 emitWord()
2752 if lineW > 0 || len(lines) == 0 {
2753 lines = append(lines, line.String())
2754 }
2755 return lines
2756}
2757
2758// fitCols shrinks the natural column widths until the row fits, always taking
2759// from the widest column so the notes column gives way before the two narrow
2760// ones do.
2761func fitCols(nat []int, width int) []int {
2762 cols := append([]int(nil), nat...)
2763 n := len(cols)
2764 if n == 0 {
2765 return cols
2766 }
2767 avail := width - colGap*(n-1)
2768 if avail < n {
2769 avail = n
2770 }
2771 total := 0
2772 for _, c := range cols {
2773 total += c
2774 }
2775 for total > avail {
2776 wi, wv := 0, cols[0]
2777 for i, c := range cols {
2778 if c > wv {
2779 wi, wv = i, c
2780 }
2781 }
2782 if wv <= minCol {
2783 break
2784 }
2785 cols[wi]--
2786 total--
2787 }
2788 return cols
2789}
2790
2791// renderTable draws one table's inner HTML as aligned columns.
2792func renderTable(inner string, width int) string {
2793 var (
2794 rows [][]string
2795 heads []bool
2796 )
2797 for _, r := range tagRow.FindAllStringSubmatch(inner, -1) {
2798 cells := tagCellFull.FindAllStringSubmatch(r[1], -1)
2799 if len(cells) == 0 {
2800 continue
2801 }
2802 row := make([]string, 0, len(cells))
2803 head := true
2804 for _, c := range cells {
2805 if !strings.EqualFold(c[1], "th") {
2806 head = false
2807 }
2808 row = append(row, cellText(c[2]))
2809 }
2810 rows = append(rows, row)
2811 heads = append(heads, head)
2812 }
2813 if len(rows) == 0 {
2814 return ""
2815 }
2816 n := 0
2817 for _, r := range rows {
2818 if len(r) > n {
2819 n = len(r)
2820 }
2821 }
2822 nat := make([]int, n)
2823 for _, r := range rows {
2824 for i, c := range r {
2825 if w := markupWidth(c); w > nat[i] {
2826 nat[i] = w
2827 }
2828 }
2829 }
2830 cols := fitCols(nat, width)
2831
2832 var b strings.Builder
2833 for ri, row := range rows {
2834 cellLines := make([][]string, n)
2835 h := 1
2836 for i := 0; i < n; i++ {
2837 txt := ""
2838 if i < len(row) {
2839 txt = row[i]
2840 }
2841 if heads[ri] && txt != "" {
2842 txt = "[::b]" + txt + "[-:-:-]"
2843 }
2844 cellLines[i] = wrapMarkup(txt, cols[i])
2845 if len(cellLines[i]) > h {
2846 h = len(cellLines[i])
2847 }
2848 }
2849 for li := 0; li < h; li++ {
2850 var line strings.Builder
2851 for i := 0; i < n; i++ {
2852 cell := ""
2853 if li < len(cellLines[i]) {
2854 cell = cellLines[i][li]
2855 }
2856 line.WriteString(cell)
2857 if i < n-1 {
2858 pad := cols[i] - markupWidth(cell) + colGap
2859 if pad < 1 {
2860 pad = 1
2861 }
2862 line.WriteString(strings.Repeat(" ", pad))
2863 }
2864 }
2865 b.WriteString(strings.TrimRight(line.String(), " "))
2866 b.WriteByte('\n')
2867 }
2868 if heads[ri] {
2869 var rule strings.Builder
2870 for i := 0; i < n; i++ {
2871 rule.WriteString(strings.Repeat("β", cols[i]))
2872 if i < n-1 {
2873 rule.WriteString(strings.Repeat(" ", colGap))
2874 }
2875 }
2876 b.WriteString("[gray]" + rule.String() + "[-]\n")
2877 }
2878 }
2879 return b.String()
2880}
2881
2882
2883// ===== pkg/tui/htmltable_test.go =====
2884package tui
2885
2886import (
2887 "strings"
2888 "testing"
2889)
2890
2891// A table is the Links page's whole content. Flattened to runs of spaces it
2892// stopped being a table at all, so what matters is that the columns line up
2893// and that a cell too wide for its column wraps inside it rather than
2894// pushing everything after it out of true.
2895func TestTableAlignsColumnsAndWraps(t *testing.T) {
2896 const in = `<table>
2897<thead><tr><th>Supplier</th><th>Where</th><th>Notes</th></tr></thead>
2898<tbody>
2899<tr><td>Alpha</td><td>Dallas, TX</td><td>one two three four five six seven</td></tr>
2900<tr><td>Beta</td><td>Lima, OH</td><td>short</td></tr>
2901</tbody></table>`
2902 text, _ := htmlToText(in, "2026", 46)
2903 lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
2904 for _, l := range lines {
2905 if markupWidth(l) > 46 {
2906 t.Errorf("line wider than the pane (%d): %q", markupWidth(l), l)
2907 }
2908 }
2909 // Every row starts its second column at the same place.
2910 if a, b := columnOf(lines, "Dallas, TX"), columnOf(lines, "Lima, OH"); a != b || a <= 0 {
2911 t.Errorf("second column not aligned: %d vs %d\n%s", a, b, text)
2912 }
2913 // The long notes cell wrapped instead of running off.
2914 if !strings.Contains(text, "seven") {
2915 t.Errorf("wrapped text lost:\n%s", text)
2916 }
2917 if columnOf(lines, "one") != columnOf(lines, "six") {
2918 t.Errorf("continuation not in the notes column:\n%s", text)
2919 }
2920}
2921
2922// A link that wraps has to stay a link on the second line: each line is
2923// drawn on its own, so the [link=N] tag must be repeated at the break.
2924func TestWrappedLinkKeepsItsTag(t *testing.T) {
2925 const in = `<table><tr><td><a href='https://example.com/'>a very long supplier name here</a></td></tr></table>`
2926 text, hrefs := htmlToText(in, "2026", 12)
2927 if len(hrefs) != 1 || hrefs[0] != "https://example.com/" {
2928 t.Fatalf("hrefs = %v", hrefs)
2929 }
2930 for i, l := range strings.Split(strings.TrimSpace(text), "\n") {
2931 if strings.TrimSpace(stripTagsLine(l)) == "" {
2932 continue
2933 }
2934 if !strings.Contains(l, "[link=1]") {
2935 t.Errorf("line %d dropped the link tag: %q", i, l)
2936 }
2937 }
2938}
2939
2940// Anchors inside a table are numbered with the rest of the page, in
2941// document order: the table is set aside and drawn last, which must not
2942// renumber anything.
2943func TestTableAnchorsKeepDocumentOrder(t *testing.T) {
2944 const in = `<p><a href='/one'>one</a></p>
2945<table><tr><td><a href='/two'>two</a></td><td><a href='/three'>three</a></td></tr></table>
2946<p><a href='/four'>four</a></p>`
2947 _, hrefs := htmlToText(in, "2026", 60)
2948 want := []string{"/one", "/two", "/three", "/four"}
2949 if len(hrefs) != len(want) {
2950 t.Fatalf("hrefs = %v", hrefs)
2951 }
2952 for i := range want {
2953 if hrefs[i] != want[i] {
2954 t.Errorf("hrefs[%d] = %q, want %q", i, hrefs[i], want[i])
2955 }
2956 }
2957}
2958
2959func stripTagsLine(s string) string {
2960 out := ""
2961 for _, sg := range parseMarkup(s, styleText) {
2962 out += string(sg.txt)
2963 }
2964 return out
2965}
2966
2967// columnOf reports the printed column a word starts at, on the first line
2968// that holds it.
2969func columnOf(lines []string, word string) int {
2970 for _, l := range lines {
2971 plain := stripTagsLine(l)
2972 if i := strings.Index(plain, word); i >= 0 {
2973 return i
2974 }
2975 }
2976 return -1
2977}
2978
2979
2980// ===== pkg/tui/kit.go =====
2981// Package tui pkg/tui/kit.go β drawing primitives on tcell: a small
2982// color-tag markup ([fg:bg:flags], as tview popularized), line printing
2983// with alignment and wrapping, and bordered boxes. The TUI is written
2984// straight on tcell v3 so it runs wherever the 0magnet ecosystem's
2985// terminal stack does β natively today, in the browser via
2986// tuiwasm/xtcell tomorrow.
2987package tui
2988
2989import (
2990 "fmt"
2991 "strconv"
2992 "strings"
2993
2994 "github.com/gdamore/tcell/v3"
2995)
2996
2997type rect struct{ x, y, w, h int }
2998
2999// esc quotes user data so it never parses as a color tag: "[" doubles,
3000// and the parser reads "[[" back as one literal bracket.
3001func esc(s string) string {
3002 return strings.ReplaceAll(s, "[", "[[")
3003}
3004
3005// seg is a run of text in one style, optionally inside a link.
3006type seg struct {
3007 txt []rune
3008 st tcell.Style
3009 // link is the 1-based index of the anchor this run belongs to, or 0
3010 // for ordinary text. What it indexes is the drawing code's business:
3011 // each page keeps its own table of what its links do. See linkTag.
3012 link int
3013}
3014
3015// namedColors are the palette names the markup uses; anything else goes
3016// to tcell.GetColor (which handles #rrggbb and the W3C names).
3017var namedColors = map[string]tcell.Color{
3018 "aqua": tcell.ColorAqua.TrueColor(),
3019 "white": tcell.ColorWhite.TrueColor(),
3020 "black": tcell.ColorBlack.TrueColor(),
3021 "gray": tcell.GetColor("#808080"),
3022 "grey": tcell.GetColor("#808080"),
3023 "yellow": tcell.ColorYellow.TrueColor(),
3024 "red": tcell.ColorRed.TrueColor(),
3025 "green": tcell.ColorGreen.TrueColor(),
3026 "orange": tcell.GetColor("#ffa500"),
3027}
3028
3029func markupColor(name string) (tcell.Color, bool) {
3030 if c, ok := namedColors[name]; ok {
3031 return c, true
3032 }
3033 c := tcell.GetColor(name)
3034 if c == tcell.ColorDefault && name != "default" {
3035 return c, false
3036 }
3037 return c, true
3038}
3039
3040// linkTag wraps text in an anchor: everything between [link=N] and
3041// [/link] carries link index N, which the drawing code turns into a
3042// clickable region. N is 1-based so that a zero index means "no link".
3043//
3044// The page's own markup is the only thing that may produce these β esc
3045// doubles the bracket in anything that came from the catalog or from a
3046// content file, so user data cannot open an anchor.
3047func linkTag(n int, s string) string {
3048 return fmt.Sprintf("[link=%d]%s[/link]", n, s)
3049}
3050
3051// linkBody reads a [link=N] / [/link] tag, returning the index it opens
3052// (0 for the closing tag) and whether the body was one of them at all.
3053func linkBody(s string) (int, bool) {
3054 if s == "/link" {
3055 return 0, true
3056 }
3057 rest, ok := strings.CutPrefix(s, "link=")
3058 if !ok {
3059 return 0, false
3060 }
3061 n, err := strconv.Atoi(rest)
3062 if err != nil || n < 1 {
3063 return 0, false
3064 }
3065 return n, true
3066}
3067
3068// tagBody reports whether the text between brackets looks like a color
3069// tag: colors and flags only, at most two colons.
3070func tagBody(s string) bool {
3071 if s == "" {
3072 return false
3073 }
3074 colons := 0
3075 for _, r := range s {
3076 switch {
3077 case r == ':':
3078 colons++
3079 case r == '#' || r == '-':
3080 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
3081 default:
3082 return false
3083 }
3084 }
3085 return colons <= 2
3086}
3087
3088// applyTag folds one [fg:bg:flags] spec into a style. Empty parts keep,
3089// "-" resets to the base.
3090func applyTag(cur, base tcell.Style, body string) tcell.Style {
3091 parts := strings.SplitN(body, ":", 3)
3092 if len(parts) > 0 && parts[0] != "" {
3093 if parts[0] == "-" {
3094 cur = cur.Foreground(base.GetForeground())
3095 } else if c, ok := markupColor(parts[0]); ok {
3096 cur = cur.Foreground(c)
3097 }
3098 }
3099 if len(parts) > 1 && parts[1] != "" {
3100 if parts[1] == "-" {
3101 cur = cur.Background(base.GetBackground())
3102 } else if c, ok := markupColor(parts[1]); ok {
3103 cur = cur.Background(c)
3104 }
3105 }
3106 if len(parts) > 2 {
3107 if parts[2] == "-" || parts[2] == "" {
3108 cur = cur.Attributes(base.GetAttributes())
3109 } else {
3110 for _, f := range parts[2] {
3111 switch f {
3112 case 'b':
3113 cur = cur.Bold(true)
3114 case 'u':
3115 cur = cur.Underline(true)
3116 }
3117 }
3118 }
3119 }
3120 return cur
3121}
3122
3123// parseMarkup splits a tagged string into styled segments.
3124func parseMarkup(s string, base tcell.Style) []seg {
3125 var out []seg
3126 cur := base
3127 link := 0
3128 var run []rune
3129 flush := func() {
3130 if len(run) > 0 {
3131 out = append(out, seg{run, cur, link})
3132 run = nil
3133 }
3134 }
3135 r := []rune(s)
3136 for i := 0; i < len(r); i++ {
3137 if r[i] != '[' {
3138 run = append(run, r[i])
3139 continue
3140 }
3141 if i+1 < len(r) && r[i+1] == '[' { // escaped literal bracket
3142 run = append(run, '[')
3143 i++
3144 continue
3145 }
3146 end := -1
3147 for j := i + 1; j < len(r) && j < i+24; j++ {
3148 if r[j] == ']' {
3149 end = j
3150 break
3151 }
3152 }
3153 if end < 0 {
3154 run = append(run, r[i])
3155 continue
3156 }
3157 body := string(r[i+1 : end])
3158 if n, ok := linkBody(body); ok {
3159 flush()
3160 link = n
3161 i = end
3162 continue
3163 }
3164 if !tagBody(body) {
3165 run = append(run, r[i])
3166 continue
3167 }
3168 flush()
3169 cur = applyTag(cur, base, body)
3170 i = end
3171 }
3172 flush()
3173 return out
3174}
3175
3176// markupWidth is the printed width of a tagged string.
3177func markupWidth(s string) int {
3178 n := 0
3179 for _, sg := range parseMarkup(s, tcell.StyleDefault) {
3180 n += len(sg.txt)
3181 }
3182 return n
3183}
3184
3185// printSegs draws segments on one row, clipped to maxW; returns cells drawn.
3186func printSegs(sc tcell.Screen, x, y, maxW int, segs []seg) int {
3187 return printSegsLinks(sc, x, y, maxW, segs, nil)
3188}
3189
3190// printSegsLinks is printSegs, reporting each anchor it drew: onLink is
3191// called once per run of link cells with the run's position, width and
3192// link index, which is what turns markup into something clickable.
3193//
3194// Per run of cells and not per segment: an anchor split by a color
3195// change inside it is still one link, and a link clipped away at maxW
3196// must not leave a region covering cells that were never drawn.
3197func printSegsLinks(sc tcell.Screen, x, y, maxW int, segs []seg, onLink func(x, y, w, link int)) int {
3198 n := 0
3199 run, runStart := 0, 0
3200 flush := func() {
3201 if run != 0 && onLink != nil {
3202 onLink(x+runStart, y, n-runStart, run)
3203 }
3204 run = 0
3205 }
3206 for _, sg := range segs {
3207 if sg.link != run {
3208 flush()
3209 run, runStart = sg.link, n
3210 }
3211 for _, r := range sg.txt {
3212 if n >= maxW {
3213 flush()
3214 return n
3215 }
3216 sc.SetContent(x+n, y, r, nil, sg.st)
3217 n++
3218 }
3219 }
3220 flush()
3221 return n
3222}
3223
3224// printMarkup draws one tagged line, clipped to maxW.
3225// The width it returns mirrors printSegs, which callers of that do use.
3226func printMarkup(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style) int { //nolint:unparam
3227 return printSegs(sc, x, y, maxW, parseMarkup(s, base))
3228}
3229
3230// printMarkupLinks draws one tagged line and reports its anchors.
3231func printMarkupLinks(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style, onLink func(x, y, w, link int)) {
3232 printSegsLinks(sc, x, y, maxW, parseMarkup(s, base), onLink)
3233}
3234
3235// printMarkupCenter draws one tagged line centered in w columns.
3236func printMarkupCenter(sc tcell.Screen, x, y, w int, s string, base tcell.Style) {
3237 pad := (w - markupWidth(s)) / 2
3238 if pad < 0 {
3239 pad = 0
3240 }
3241 printMarkup(sc, x+pad, y, w-pad, s, base)
3242}
3243
3244// wrapSegs word-wraps parsed lines to width w.
3245func wrapSegs(s string, w int, base tcell.Style, wrap bool) [][]seg {
3246 if w < 1 {
3247 w = 1
3248 }
3249 var out [][]seg
3250 for _, line := range strings.Split(s, "\n") {
3251 segs := parseMarkup(line, base)
3252 if !wrap {
3253 out = append(out, segs)
3254 continue
3255 }
3256 // Flatten to styled runes, then break into rows at spaces.
3257 var flat []styledRune
3258 for _, sg := range segs {
3259 for _, r := range sg.txt {
3260 flat = append(flat, styledRune{r, sg.st, sg.link})
3261 }
3262 }
3263 for len(flat) > w {
3264 cut := -1
3265 for i := w; i > 0; i-- {
3266 if flat[i].r == ' ' {
3267 cut = i
3268 break
3269 }
3270 }
3271 if cut <= 0 {
3272 cut = w
3273 }
3274 out = append(out, packSegs(flat[:cut]))
3275 for cut < len(flat) && flat[cut].r == ' ' {
3276 cut++
3277 }
3278 flat = flat[cut:]
3279 }
3280 out = append(out, packSegs(flat))
3281 }
3282 return out
3283}
3284
3285type styledRune struct {
3286 r rune
3287 st tcell.Style
3288 link int
3289}
3290
3291// keyRune reads the rune of a KeyRune event (v3 carries it as Str).
3292func keyRune(ev *tcell.EventKey) rune {
3293 if ev.Key() != tcell.KeyRune {
3294 return 0
3295 }
3296 rs := []rune(ev.Str())
3297 if len(rs) == 0 {
3298 return 0
3299 }
3300 return rs[0]
3301}
3302
3303func packSegs(flat []styledRune) []seg {
3304 var segs []seg
3305 for _, c := range flat {
3306 if n := len(segs); n > 0 && segs[n-1].st == c.st && segs[n-1].link == c.link {
3307 segs[n-1].txt = append(segs[n-1].txt, c.r)
3308 } else {
3309 segs = append(segs, seg{[]rune{c.r}, c.st, c.link})
3310 }
3311 }
3312 return segs
3313}
3314
3315// fillRect paints a rectangle with spaces in the given style.
3316func fillRect(sc tcell.Screen, r rect, st tcell.Style) {
3317 for y := r.y; y < r.y+r.h; y++ {
3318 for x := r.x; x < r.x+r.w; x++ {
3319 sc.SetContent(x, y, ' ', nil, st)
3320 }
3321 }
3322}
3323
3324// drawRule draws a horizontal rule, the terminal stand-in for the 1px
3325// table borders that close the page's header and footer.
3326func drawRule(sc tcell.Screen, x, y, w int, st tcell.Style) {
3327 for i := 0; i < w; i++ {
3328 sc.SetContent(x+i, y, 'β', nil, st)
3329 }
3330}
3331
3332// drawBox fills and frames a rectangle, with an optional title in the
3333// top border.
3334func drawBox(sc tcell.Screen, r rect, title string, border, fill tcell.Style) {
3335 if r.w < 2 || r.h < 2 {
3336 return
3337 }
3338 fillRect(sc, r, fill)
3339 for x := r.x + 1; x < r.x+r.w-1; x++ {
3340 sc.SetContent(x, r.y, 'β', nil, border)
3341 sc.SetContent(x, r.y+r.h-1, 'β', nil, border)
3342 }
3343 for y := r.y + 1; y < r.y+r.h-1; y++ {
3344 sc.SetContent(r.x, y, 'β', nil, border)
3345 sc.SetContent(r.x+r.w-1, y, 'β', nil, border)
3346 }
3347 sc.SetContent(r.x, r.y, 'β', nil, border)
3348 sc.SetContent(r.x+r.w-1, r.y, 'β', nil, border)
3349 sc.SetContent(r.x, r.y+r.h-1, 'β', nil, border)
3350 sc.SetContent(r.x+r.w-1, r.y+r.h-1, 'β', nil, border)
3351 if title != "" {
3352 printMarkup(sc, r.x+2, r.y, r.w-4, title, fill)
3353 }
3354}
3355
3356// inner is the area inside a box's border.
3357func (r rect) inner() rect {
3358 return rect{r.x + 1, r.y + 1, r.w - 2, r.h - 2}
3359}
3360
3361func (r rect) contains(x, y int) bool {
3362 return x >= r.x && y >= r.y && x < r.x+r.w && y < r.y+r.h
3363}
3364
3365
3366// ===== pkg/tui/kittyimg.go =====
3367package tui
3368
3369import (
3370 "bytes"
3371 "encoding/base64"
3372 "fmt"
3373 "image"
3374 "image/draw"
3375 "image/png"
3376 "os"
3377 "strconv"
3378
3379 "github.com/0magnet/websh/progressive"
3380)
3381
3382// Pictures in a terminal that is not websh: where the terminal speaks
3383// kitty's graphics protocol (kitty, WezTerm, Ghostty, Konsole β the probe
3384// asks), a product's photo is the photo itself, laid over the cells that
3385// draw it in half blocks, as websh lays it with a placement. Widgets need a
3386// page; in a terminal they stay cells.
3387//
3388// M2_IMAGES chooses: "place" (websh's placements), "kitty", or "cells".
3389
3390// imageMode is how pictures go beyond cells here: "place", "kitty" or "".
3391func imageMode() string {
3392 switch os.Getenv("M2_IMAGES") {
3393 case "cells":
3394 return ""
3395 case "kitty":
3396 return "kitty"
3397 case "place":
3398 return "place"
3399 }
3400 c := progressive.Current()
3401 if !c.Has("place") && c.Has("kitty-graphics") {
3402 return "kitty"
3403 }
3404 // A terminal that does not know OSC 7337 ignores it, so placements are
3405 // the default even where nothing answered.
3406 return "place"
3407}
3408
3409// kittyID is the image id a placement's pictures go by.
3410func (u *ui) kittyID(id string) uint32 {
3411 if u.kittyIDs == nil {
3412 u.kittyIDs = map[string]uint32{}
3413 }
3414 n, ok := u.kittyIDs[id]
3415 if !ok {
3416 n = uint32(len(u.kittyIDs) + 7337) //nolint:gosec // a few placements at most
3417 u.kittyIDs[id] = n
3418 }
3419 return n
3420}
3421
3422// kittyPlace lays p's photo over its cells, or does nothing for what is not
3423// a photo.
3424func (u *ui) kittyPlace(id string, p placed) {
3425 if p.url == "" {
3426 return
3427 }
3428 data, err := u.kittyPNG(p)
3429 if err != nil {
3430 return
3431 }
3432 enc := base64.StdEncoding.EncodeToString(data)
3433 var b bytes.Buffer
3434 fmt.Fprintf(&b, "\x1b7\x1b[%d;%dH", p.r.y+1, p.r.x+1) // save the cursor, go to the cells
3435 first := true
3436 for len(enc) > 0 || first {
3437 n := min(len(enc), 4096)
3438 more := "0"
3439 if n < len(enc) {
3440 more = "1"
3441 }
3442 if first {
3443 fmt.Fprintf(&b, "\x1b_Ga=T,f=100,i=%d,c=%d,r=%d,C=1,q=2,m=%s;%s\x1b\\", u.kittyID(id), p.r.w, p.r.h, more, enc[:n])
3444 first = false
3445 } else {
3446 fmt.Fprintf(&b, "\x1b_Gm=%s;%s\x1b\\", more, enc[:n])
3447 }
3448 enc = enc[n:]
3449 }
3450 b.WriteString("\x1b8") // and back
3451 u.writeTerm(b.String())
3452}
3453
3454// kittyRemove takes p's picture away.
3455func (u *ui) kittyRemove(id string) {
3456 u.writeTerm("\x1b_Ga=d,d=I,i=" + strconv.FormatUint(uint64(u.kittyID(id)), 10) + ",q=2\x1b\\")
3457}
3458
3459// kittyPNG is p's photo as a PNG the shape of its cells β kitty stretches a
3460// picture to the cells it is given β contained or, for "cover", cropped.
3461func (u *ui) kittyPNG(p placed) ([]byte, error) {
3462 key := fmt.Sprintf("%s|%dx%d|%s", p.url, p.r.w, p.r.h, p.fit)
3463 if b, ok := u.kittyCache[key]; ok {
3464 return b, nil
3465 }
3466 raw, err := getImageData(p.url)
3467 if err != nil {
3468 return nil, err
3469 }
3470 src, _, err := image.Decode(bytes.NewReader(raw))
3471 if err != nil {
3472 return nil, err
3473 }
3474 cw, ch := 1.0, 2.0 // a cell's shape where the terminal does not say
3475 if c := progressive.Current(); c != nil && c.Cell.W > 0 && c.Cell.H > 0 {
3476 cw, ch = c.Cell.W, c.Cell.H
3477 }
3478 aspect := float64(p.r.w) * cw / (float64(p.r.h) * ch)
3479 sb := src.Bounds()
3480 sw, sh := float64(sb.Dx()), float64(sb.Dy())
3481 var dst *image.RGBA
3482 if p.fit == "cover" { // crop the middle to the cells' shape
3483 w, h := sw, sh
3484 if sw/sh > aspect {
3485 w = sh * aspect
3486 } else {
3487 h = sw / aspect
3488 }
3489 r := image.Rect(0, 0, int(w), int(h))
3490 dst = image.NewRGBA(r)
3491 off := image.Pt(sb.Min.X+int((sw-w)/2), sb.Min.Y+int((sh-h)/2))
3492 draw.Draw(dst, r, src, off, draw.Src)
3493 } else { // the whole of it, transparent around it
3494 w, h := sw, sh
3495 if sw/sh > aspect {
3496 h = sw / aspect
3497 } else {
3498 w = sh * aspect
3499 }
3500 dst = image.NewRGBA(image.Rect(0, 0, int(w), int(h)))
3501 at := image.Pt(int((w-sw)/2), int((h-sh)/2))
3502 draw.Draw(dst, sb.Sub(sb.Min).Add(at), src, sb.Min, draw.Src)
3503 }
3504 var out bytes.Buffer
3505 if err := png.Encode(&out, dst); err != nil {
3506 return nil, err
3507 }
3508 if u.kittyCache == nil {
3509 u.kittyCache = map[string][]byte{}
3510 }
3511 u.kittyCache[key] = out.Bytes()
3512 return out.Bytes(), nil
3513}
3514
3515
3516// ===== pkg/tui/links_test.go =====
3517package tui
3518
3519import (
3520 "strings"
3521 "testing"
3522
3523 "github.com/gdamore/tcell/v3"
3524
3525 "github.com/0magnet/m2/pkg/product"
3526 "github.com/0magnet/m2/pkg/storepage"
3527)
3528
3529// nullScreen is somewhere for the printers to put cells. Only SetContent
3530// is implemented: tcell v3 ships no simulation screen, and the anchor
3531// bookkeeping under test needs nothing else. The embedded nil interface
3532// supplies the rest of the method set, so a test that reached for one
3533// would panic rather than quietly pass.
3534type nullScreen struct{ tcell.Screen }
3535
3536func (nullScreen) SetContent(int, int, rune, []rune, tcell.Style) {}
3537
3538// span is a drawn anchor, as printSegsLinks reports one.
3539type span struct{ x, y, w, link int }
3540
3541// recorder collects the anchors a print reports, which is the whole of
3542// what makes a link clickable.
3543type recorder struct{ got []span }
3544
3545func (r *recorder) sink(x, y, w, link int) {
3546 r.got = append(r.got, span{x, y, w, link})
3547}
3548
3549// A link tag marks its text and nothing else, and the tag itself takes
3550// up no room on the line.
3551func TestLinkTagMarksOnlyItsText(t *testing.T) {
3552 s := "Category: " + linkTag(1, "diode") + " end"
3553 if w := markupWidth(s); w != len("Category: diode end") {
3554 t.Errorf("width %d, want %d", w, len("Category: diode end"))
3555 }
3556 var linked string
3557 for _, sg := range parseMarkup(s, tcell.StyleDefault) {
3558 if sg.link == 1 {
3559 linked += string(sg.txt)
3560 }
3561 }
3562 if linked != "diode" {
3563 t.Errorf("link 1 covers %q, want %q", linked, "diode")
3564 }
3565}
3566
3567// Color changes inside an anchor do not split it: the region has to
3568// cover the whole of the link or half of it stops being clickable.
3569func TestLinkSurvivesAColorChangeInside(t *testing.T) {
3570 var r recorder
3571 printMarkupLinks(nullScreen{}, 3, 7, 80, linkTag(1, "[aqua]dio[white]de[-]"), styleText, r.sink)
3572 if len(r.got) != 1 {
3573 t.Fatalf("%d regions, want 1: %+v", len(r.got), r.got)
3574 }
3575 if want := (span{x: 3, y: 7, w: 5, link: 1}); r.got[0] != want {
3576 t.Errorf("region %+v, want %+v", r.got[0], want)
3577 }
3578}
3579
3580// A link clipped off the end of the line must not leave a region over
3581// cells that were never drawn.
3582func TestLinkRegionStopsAtTheClip(t *testing.T) {
3583 var r recorder
3584 printMarkupLinks(nullScreen{}, 0, 0, 6, "abc"+linkTag(1, "defghij"), styleText, r.sink)
3585 if len(r.got) != 1 {
3586 t.Fatalf("%d regions, want 1: %+v", len(r.got), r.got)
3587 }
3588 if got := r.got[0].x + r.got[0].w; got != 6 {
3589 t.Errorf("region ends at %d, want 6 (the clip)", got)
3590 }
3591}
3592
3593// Two anchors on one line are two regions, in the order they were
3594// numbered β which is what binds each to the right action.
3595func TestTwoLinksOnOneLine(t *testing.T) {
3596 var idx []int
3597 printMarkupLinks(nullScreen{}, 0, 0, 80, linkTag(1, "one")+", "+linkTag(2, "two"), styleText,
3598 func(_, _, _, link int) { idx = append(idx, link) })
3599 if len(idx) != 2 || idx[0] != 1 || idx[1] != 2 {
3600 t.Errorf("link indices %v, want [1 2]", idx)
3601 }
3602}
3603
3604// Catalog text is escaped before it reaches the parser, so a product
3605// named with a bracket cannot open an anchor of its own.
3606func TestEscapedTextCannotForgeALink(t *testing.T) {
3607 for _, sg := range parseMarkup(esc("[link=1]nope[/link]"), tcell.StyleDefault) {
3608 if sg.link != 0 {
3609 t.Fatalf("escaped text produced link %d", sg.link)
3610 }
3611 }
3612}
3613
3614// The stock pages are mostly anchors; htmlToText has to hand back where
3615// each one goes, in the order its tags are numbered.
3616func TestHTMLToTextReturnsHrefs(t *testing.T) {
3617 in := `<p>see <a title='x' href="https://example.com/a">A</a> and ` +
3618 `<a href='/cat/diode'>diodes</a> and <a>nowhere</a></p>`
3619 text, hrefs := htmlToText(in, "2026", 80)
3620 want := []string{"https://example.com/a", "/cat/diode", ""}
3621 if len(hrefs) != len(want) {
3622 t.Fatalf("%d hrefs, want %d: %q", len(hrefs), len(want), hrefs)
3623 }
3624 for i := range want {
3625 if hrefs[i] != want[i] {
3626 t.Errorf("href %d = %q, want %q", i, hrefs[i], want[i])
3627 }
3628 }
3629 if !strings.Contains(text, "[link=2]") {
3630 t.Errorf("text carries no anchor tags: %q", text)
3631 }
3632 if markupWidth(text) != len("see A and diodes and nowhere\n") {
3633 t.Errorf("anchor tags left width %d in %q", markupWidth(text), text)
3634 }
3635}
3636
3637// An href is routed the way the site routes it: a path this store serves
3638// is a page the TUI has, and anything else is a link out.
3639func TestFollowRoutesSitePaths(t *testing.T) {
3640 u := &ui{panels: map[string]panel{}, cat: newCatalogInfo(product.Products{
3641 {Partno: "1N4001", Category: "diode", Subcategory: "1N40XX"},
3642 {Partno: "1N4729", Category: "diode", Subcategory: "1N47XX"},
3643 })}
3644 if u.follow("") != nil {
3645 t.Error("an empty href is not a link")
3646 }
3647 u.current = "content:about"
3648 u.follow("/")()
3649 if u.current != "home" {
3650 t.Errorf("/ went to %q, want home", u.current)
3651 }
3652 u.follow("/cat/diode/1N47XX")()
3653 if want := "list:diode|1N47XX"; u.current != want {
3654 t.Errorf("subcategory path went to %q, want %q", u.current, want)
3655 }
3656 u.follow("/p/1N4001")()
3657 if want := "prod:1N4001"; u.current != want {
3658 t.Errorf("product path went to %q, want %q", u.current, want)
3659 }
3660 // A link out does not navigate; it hands over the address.
3661 before := u.current
3662 u.follow("https://example.com")()
3663 if u.current != before {
3664 t.Errorf("an external link navigated to %q", u.current)
3665 }
3666 if !strings.Contains(u.notice, "example.com") {
3667 t.Errorf("external link gave no address: %q", u.notice)
3668 }
3669}
3670
3671// The pointer lights up whichever of the page's links it is over: the
3672// header nav on the top row, the footer's cells on the bottom one, and
3673// the panel's own anchors in between.
3674func TestHoveredFindsEachKindOfLink(t *testing.T) {
3675 const h = 20
3676 u := &ui{hoverX: -1, hoverY: -1}
3677 u.navRegions = append(u.navRegions, struct {
3678 x1, x2 int
3679 act string
3680 }{4, 14, "cats"})
3681 u.footRegions = append(u.footRegions, struct {
3682 x1, x2 int
3683 act string
3684 }{2, 9, "cart"})
3685 u.links = []linkRegion{{x: 6, y: 7, w: 5, act: func() {}}}
3686
3687 for _, c := range []struct {
3688 name string
3689 x, y int
3690 wantX1, wantX2 int
3691 want bool
3692 }{
3693 {"nothing seen yet", -1, -1, 0, 0, false},
3694 {"header entry", 5, 0, 4, 14, true},
3695 {"header, past the entry", 30, 0, 0, 0, false},
3696 {"footer cell", 3, h - 1, 2, 9, true},
3697 {"panel anchor", 8, 7, 6, 11, true},
3698 {"one cell past the anchor", 11, 7, 0, 0, false},
3699 {"the row below it", 8, 8, 0, 0, false},
3700 } {
3701 u.hoverX, u.hoverY = c.x, c.y
3702 x1, x2, _, ok := u.hovered(h)
3703 if ok != c.want {
3704 t.Errorf("%s: hovered=%v, want %v", c.name, ok, c.want)
3705 continue
3706 }
3707 if ok && (x1 != c.wantX1 || x2 != c.wantX2) {
3708 t.Errorf("%s: span %d..%d, want %d..%d", c.name, x1, x2, c.wantX1, c.wantX2)
3709 }
3710 }
3711}
3712
3713// sampleTable is a two-column table whose first column is the row's link.
3714func sampleTable() *table {
3715 tbl := &table{cols: []tcol{
3716 {title: "Name", link: true},
3717 {title: "Price", width: 9, alignRight: true},
3718 }}
3719 tbl.setRows([]trow{
3720 {cells: []tcell_{{text: "one"}, {text: "$1"}}},
3721 {cells: []tcell_{{text: "two"}, {text: "$2"}}},
3722 })
3723 return tbl
3724}
3725
3726// Every drawn row offers its name as an anchor, covering the name and
3727// not the blank column beside it β that span is both what one click
3728// follows and what the pointer lights up.
3729func TestTableReportsEachRowsLinkCell(t *testing.T) {
3730 tbl := sampleTable()
3731 var got []span
3732 tbl.onLink = func(x, y, w, row, _ int) { got = append(got, span{x, y, w, row}) }
3733 tbl.draw(nullScreen{}, rect{x: 2, y: 4, w: 40, h: 10})
3734
3735 want := []span{{x: 2, y: 5, w: 3, link: 0}, {x: 2, y: 6, w: 3, link: 1}}
3736 if len(got) != len(want) {
3737 t.Fatalf("%d anchors, want %d: %+v", len(got), len(want), got)
3738 }
3739 for i := range want {
3740 if got[i] != want[i] {
3741 t.Errorf("anchor %d = %+v, want %+v", i, got[i], want[i])
3742 }
3743 }
3744}
3745
3746// A column that is not the link column offers nothing, and neither does
3747// the header row.
3748func TestTableReportsOnlyTheLinkColumn(t *testing.T) {
3749 tbl := sampleTable()
3750 tbl.cols[0].link = false
3751 n := 0
3752 tbl.onLink = func(int, int, int, int, int) { n++ }
3753 tbl.draw(nullScreen{}, rect{x: 0, y: 0, w: 40, h: 10})
3754 if n != 0 {
3755 t.Errorf("%d anchors from a table with no link column, want 0", n)
3756 }
3757}
3758
3759// A row has an anchor per link cell, and an empty one offers none β
3760// which is how a product with no stock has nothing to add to a cart,
3761// the page omitting its button for the same reason.
3762func TestTableSkipsAnEmptyLinkCell(t *testing.T) {
3763 tbl := &table{cols: []tcol{
3764 {title: "Name", link: true},
3765 {title: "Buy", width: 11, link: true},
3766 }}
3767 tbl.setRows([]trow{
3768 {cells: []tcell_{{text: "in stock"}, {text: storepage.AddToCart}}},
3769 {cells: []tcell_{{text: "sold out"}, {text: ""}}},
3770 })
3771 byCol := map[int]int{}
3772 tbl.onLink = func(_, _, _, _, col int) { byCol[col]++ }
3773 tbl.draw(nullScreen{}, rect{x: 0, y: 0, w: 40, h: 10})
3774
3775 if byCol[0] != 2 {
3776 t.Errorf("%d name anchors, want one per row", byCol[0])
3777 }
3778 if byCol[1] != 1 {
3779 t.Errorf("%d buy anchors, want one β the sold-out row has no button", byCol[1])
3780 }
3781}
3782
3783// The link cell never reaches the table's own mouse handling β the page
3784// follows the anchor first. What is left is the rest of the row: it
3785// selects, and a second click on the selected row opens it.
3786func TestTableClickSelectsThenOpens(t *testing.T) {
3787 tbl := sampleTable()
3788 opened := -1
3789 tbl.onActivate = func(i int) { opened = i }
3790 click := tcell.NewEventMouse(0, 0, tcell.Button1, 0)
3791
3792 tbl.mouse(2, click) // row 1, which is not selected
3793 if opened != -1 {
3794 t.Errorf("the first click opened %d, want nothing", opened)
3795 }
3796 if tbl.sel != 1 {
3797 t.Errorf("the first click selected row %d, want 1", tbl.sel)
3798 }
3799 tbl.mouse(2, click)
3800 if opened != 1 {
3801 t.Errorf("the second click opened %d, want 1", opened)
3802 }
3803}
3804
3805
3806// ===== pkg/tui/mirror.go =====
3807package tui
3808
3809import (
3810 "fmt"
3811 "html"
3812 "net/url"
3813 "sort"
3814 "strings"
3815
3816 "github.com/0magnet/m2/pkg/storepage"
3817)
3818
3819// The mirror (websh's PROTOCOL.md, Accessibility): where the host takes it,
3820// the store publishes the page it shows as structure β the product, the list,
3821// the categories β for a screen reader, which reads the cells as a grid of
3822// characters. Its links go to the site's own pages.
3823
3824// mirrorLimit is how many products a list's mirror names.
3825const mirrorLimit = 500
3826
3827// mirrorHTML is the page now showing, as an HTML fragment.
3828func (u *ui) mirrorHTML() string {
3829 esc := html.EscapeString
3830 site := storeURL()
3831 link := func(path, text string) string {
3832 return `<a href="` + esc(site+path) + `">` + esc(text) + `</a>`
3833 }
3834 catPath := func(cat, sub string) string {
3835 p := "/cat/" + url.PathEscape(cat)
3836 if sub != "" {
3837 p += "/" + url.PathEscape(storepage.EscapeSubcat(sub))
3838 }
3839 return p
3840 }
3841 var b strings.Builder
3842 u.mu.Lock()
3843 cur := u.current
3844 u.mu.Unlock()
3845 switch {
3846 case strings.HasPrefix(cur, "prod:"):
3847 p := u.cat.find(strings.TrimPrefix(cur, "prod:"))
3848 if p == nil {
3849 return ""
3850 }
3851 fmt.Fprintf(&b, "<h1>%s</h1><ul>", esc(p.Name))
3852 for _, kv := range [][2]string{{"Price", "$" + strings.TrimPrefix(p.Price, "$")}, {"In stock", p.Quantity}, {"Part number", p.Partno}, {"Package", p.Packagetype}} {
3853 if kv[1] != "" && kv[1] != "$" {
3854 fmt.Fprintf(&b, "<li>%s: %s</li>", kv[0], esc(kv[1]))
3855 }
3856 }
3857 if p.Category != "" {
3858 b.WriteString("<li>Category: " + link(catPath(p.Category, ""), p.Category))
3859 if p.Subcategory != "" {
3860 b.WriteString(", " + link(catPath(p.Category, p.Subcategory), p.Subcategory))
3861 }
3862 b.WriteString("</li>")
3863 }
3864 b.WriteString("</ul>")
3865 for _, d := range []string{p.Description1, p.Description2} {
3866 if d != "" {
3867 fmt.Fprintf(&b, "<p>%s</p>", esc(d))
3868 }
3869 }
3870 b.WriteString("<p>" + link("/p/"+url.PathEscape(p.Partno), "This product on the website") + "</p>")
3871 case strings.HasPrefix(cur, "list:"):
3872 cat, sub, _ := strings.Cut(strings.TrimPrefix(cur, "list:"), "|")
3873 title := "All products"
3874 if cat != "" {
3875 title = cat
3876 if sub != "" {
3877 title += ": " + sub
3878 }
3879 }
3880 prods := u.cat.products(cat, sub)
3881 fmt.Fprintf(&b, "<h1>%s</h1><p>%d products.</p><ul>", esc(title), len(prods))
3882 for i, p := range prods {
3883 if i == mirrorLimit {
3884 fmt.Fprintf(&b, "<li>and %d more</li>", len(prods)-i)
3885 break
3886 }
3887 fmt.Fprintf(&b, "<li>%s β $%s</li>", link("/p/"+url.PathEscape(p.Partno), p.Name), esc(strings.TrimPrefix(p.Price, "$")))
3888 }
3889 b.WriteString("</ul>")
3890 case strings.HasPrefix(cur, "content:"):
3891 name := strings.TrimPrefix(cur, "content:")
3892 fmt.Fprintf(&b, "<h1>%s</h1>%s", esc(name), fetchContent(name)) // the site's own page; the host keeps only structure
3893 default: // home
3894 long := f.Sitelongname
3895 if long == "" {
3896 long = u.sitedomain
3897 }
3898 fmt.Fprintf(&b, "<h1>%s</h1>", esc(long))
3899 if f.Sitetagline != "" {
3900 fmt.Fprintf(&b, "<p>%s</p>", esc(f.Sitetagline))
3901 }
3902 cats := make([]string, 0, len(u.cat.catCounts))
3903 for c := range u.cat.catCounts {
3904 cats = append(cats, c)
3905 }
3906 sort.Strings(cats)
3907 b.WriteString("<h2>Categories</h2><ul>")
3908 for _, c := range cats {
3909 fmt.Fprintf(&b, "<li>%s (%d)</li>", link(catPath(c, ""), c), u.cat.catCounts[c])
3910 }
3911 b.WriteString("</ul>")
3912 }
3913 return b.String()
3914}
3915
3916
3917// ===== pkg/tui/open_js.go =====
3918//go:build js && wasm
3919
3920// Package tui pkg/tui/open_js.go β following a link in the browser
3921// build. The TUI is a page, so a link that leaves the store can be
3922// opened the way the page's own would be: in another tab.
3923package tui
3924
3925import "syscall/js"
3926
3927// openURL opens a link the store cannot render itself β an external
3928// site, or the PDF behind a datasheet.
3929func (u *ui) openURL(href string) {
3930 if href == "" {
3931 return
3932 }
3933 w := js.Global().Get("window")
3934 if !w.Truthy() {
3935 u.notice = "[aqua]" + esc(href) + "[-]"
3936 return
3937 }
3938 // noopener, or the opened page gets a handle on this one.
3939 if opened := w.Call("open", href, "_blank", "noopener"); !opened.Truthy() {
3940 // A blocked popup is silent otherwise; show the address instead
3941 // so the link is still followable by hand.
3942 u.notice = "[aqua]" + esc(href) + "[-]"
3943 }
3944}
3945
3946
3947// ===== pkg/tui/open_native.go =====
3948//go:build !js || !wasm
3949
3950// Package tui pkg/tui/open_native.go β following a link in a native
3951// terminal, where there is no browser to hand it to.
3952package tui
3953
3954// openURL shows a link the store cannot render itself β an external
3955// site, or the PDF behind a datasheet. A terminal has nowhere to open
3956// one, so it displays the address, which is what a terminal can do with
3957// a link that a person can then act on.
3958func (u *ui) openURL(href string) {
3959 if href == "" {
3960 return
3961 }
3962 u.notice = "[aqua]" + esc(href) + "[-]"
3963}
3964
3965
3966// ===== pkg/tui/pay_js.go =====
3967//go:build js && wasm
3968
3969// Package tui pkg/tui/pay_js.go β the browser half of the payment seam.
3970// The card form is Stripe's Payment Element: Stripe's fields in Stripe's
3971// frame, so the card never passes through this program, which is why a
3972// terminal cannot have one. Here the terminal is in a page, so the form is
3973// laid over the checkout dialog's cells as a websh placement β a widget this
3974// package offers as "stripe" β as the website's cart wasm opens the same
3975// form in the page's dialog.
3976//
3977// A card is paid without leaving the terminal (redirect "if_required"):
3978// the order goes to /submit-order, as /complete would send it, and the cart
3979// is emptied. A payment method that must leave the page goes to /complete
3980// as the website's does, and /complete submits the order it finds in
3981// localStorage: so for as long as a payment is being confirmed, and only
3982// then, the order is put there too (handOff). The cart itself is a file
3983// (cartsave.go).
3984package tui
3985
3986import (
3987 "bytes"
3988 "encoding/json"
3989 "fmt"
3990 "io"
3991 "net/http"
3992 "strings"
3993 "sync"
3994 "syscall/js"
3995 "time"
3996
3997 "github.com/gdamore/tcell/v3"
3998
3999 "github.com/0magnet/websh/widget"
4000)
4001
4002func init() {
4003 widget.Register("stripe", mountCardForm)
4004}
4005
4006// cardPay is the checkout the card form is for.
4007type cardPay struct {
4008 u *ui
4009 co *checkoutOverlay
4010 secret string
4011 lines []cartLine // the cart the payment intent was made for
4012
4013 mu sync.Mutex
4014 elements js.Value // the mounted form's Elements, undefined while none is
4015 stripe js.Value // Stripe.js, for that key
4016 say func(s string, bad bool) // the message under the form
4017 busy bool
4018}
4019
4020var (
4021 payMu sync.Mutex
4022 paying *cardPay
4023)
4024
4025func (u *ui) payWithElements(co *checkoutOverlay, clientSecret string) {
4026 if f.StripePK == "" {
4027 co.ta.setContent("\n [yellow]this store has no Stripe key configured[-] β the card form\n cannot open. Esc closes; the cart is kept.\n")
4028 return
4029 }
4030 if _, inPage := u.screen.(interface{ WriteRaw([]byte) }); !inPage {
4031 co.ta.setContent("\n [yellow]the card form opens over the store's own terminal in its page[-]\n β complete this order at [aqua]https://" + esc(u.sitedomain) + "[-]\n")
4032 return
4033 }
4034 st := &cardPay{u: u, co: co, secret: clientSecret, lines: u.cartLines()}
4035 payMu.Lock()
4036 paying = st
4037 payMu.Unlock()
4038 total, _ := u.cartTotal()
4039 head := fmt.Sprintf("Pay [::b]$%d.%02d[-:-:-] by card", total/100, total%100)
4040 if f.Teststripekey {
4041 head += " [yellow]test mode: 4242 4242 4242 4242, any future date[-]"
4042 }
4043 co.form = &cardForm{head: head, pay: func() { go st.confirm() }}
4044 // Under the form, seen only where it is not laid over these cells.
4045 co.ta.setContent("\n [gray]the card form goes here, over these cells.\n If the page's parts are hidden, w shows them again.[-]\n")
4046}
4047
4048// mountCardForm fills a placement with the card form for the checkout in
4049// progress.
4050func mountCardForm(el js.Value) func() {
4051 payMu.Lock()
4052 st := paying
4053 payMu.Unlock()
4054 if st == nil {
4055 return nil
4056 }
4057 doc := js.Global().Get("document")
4058 mk := func(tag, css string) js.Value {
4059 e := doc.Call("createElement", tag)
4060 e.Get("style").Set("cssText", css)
4061 return e
4062 }
4063 box := mk("div", "position:absolute;inset:0;overflow:auto;background:#000;padding:10px 14px;box-sizing:border-box")
4064 form := mk("div", "min-height:120px")
4065 btn := mk("button", "margin-top:12px;padding:6px 18px;font:inherit;color:#000;background:#00ffff;border:0;cursor:pointer")
4066 btn.Set("textContent", "Pay now")
4067 msg := mk("div", "margin-top:10px;color:#bbb;white-space:pre-wrap")
4068 box.Call("append", form, btn, msg)
4069 el.Call("append", box)
4070
4071 click := js.FuncOf(func(_ js.Value, args []js.Value) any {
4072 args[0].Call("preventDefault")
4073 go st.confirm()
4074 return nil
4075 })
4076 btn.Call("addEventListener", "click", click)
4077
4078 var (
4079 gone bool
4080 pe js.Value
4081 )
4082 say := func(s string, bad bool) {
4083 color := "#bbb"
4084 if bad {
4085 color = "#ff6b6b"
4086 }
4087 msg.Get("style").Set("color", color)
4088 msg.Set("textContent", s)
4089 }
4090 say("loading the card form β¦", false)
4091 withStripe(f.StripePK, func(stripe js.Value, err string) {
4092 if gone {
4093 return
4094 }
4095 if err != "" {
4096 say(err, true)
4097 return
4098 }
4099 elements := stripe.Call("elements", map[string]any{
4100 "clientSecret": st.secret,
4101 "appearance": map[string]any{
4102 "theme": "night",
4103 "variables": map[string]any{
4104 "colorPrimary": "#00ffff",
4105 "colorBackground": "#000000",
4106 "fontFamily": "ui-monospace, Menlo, Consolas, monospace",
4107 },
4108 },
4109 })
4110 pe = elements.Call("create", "payment", map[string]any{"layout": "tabs"})
4111 pe.Call("mount", form)
4112 st.mu.Lock()
4113 st.stripe, st.elements, st.say = stripe, elements, say
4114 st.mu.Unlock()
4115 say("", false)
4116 })
4117 return func() {
4118 gone = true
4119 st.mu.Lock()
4120 st.elements, st.say = js.Undefined(), nil
4121 st.mu.Unlock()
4122 if pe.Truthy() {
4123 pe.Call("destroy")
4124 }
4125 btn.Call("removeEventListener", "click", click)
4126 click.Release()
4127 el.Set("textContent", "")
4128 }
4129}
4130
4131// confirm pays with what is in the form. It waits on Stripe, so it runs on
4132// its own goroutine, never the event loop's.
4133func (st *cardPay) confirm() {
4134 st.mu.Lock()
4135 if st.busy || !st.elements.Truthy() {
4136 st.mu.Unlock()
4137 return
4138 }
4139 st.busy = true
4140 stripe, elements := st.stripe, st.elements
4141 st.mu.Unlock()
4142 defer func() {
4143 st.mu.Lock()
4144 st.busy = false
4145 st.mu.Unlock()
4146 }()
4147 // What is said goes under whichever form is mounted when it is said.
4148 tell := func(s string, bad bool) {
4149 st.mu.Lock()
4150 say := st.say
4151 st.mu.Unlock()
4152 if say != nil {
4153 say(s, bad)
4154 }
4155 }
4156 tell("paying β¦", false)
4157 // If Stripe takes the page to /complete, that page submits this.
4158 restore := handOff(st.lines)
4159 origin := js.Global().Get("location").Get("origin").String()
4160 res, err := await(stripe.Call("confirmPayment", map[string]any{
4161 "elements": elements,
4162 "confirmParams": map[string]any{"return_url": origin + "/complete"},
4163 "redirect": "if_required",
4164 }))
4165 // Still here: the page did not leave, so /complete will not need it.
4166 restore()
4167 if err != "" {
4168 tell("could not reach Stripe: "+err, true)
4169 return
4170 }
4171 if e := res.Get("error"); e.Truthy() {
4172 tell(e.Get("message").String(), true)
4173 return
4174 }
4175 pi := res.Get("paymentIntent")
4176 id, status := pi.Get("id").String(), pi.Get("status").String()
4177 if status != "succeeded" {
4178 tell("the payment is "+status+" β it has not gone through yet ("+id+")", true)
4179 return
4180 }
4181 tell("paid β sending the order β¦", false)
4182 serr := submitOrder(st.lines, id)
4183 st.u.postWait(func() {
4184 payMu.Lock()
4185 if paying == st {
4186 paying = nil
4187 }
4188 payMu.Unlock()
4189 st.co.form = nil
4190 if serr != nil {
4191 // Paid, and the order not recorded: the cart stays, as the
4192 // record of what was bought, and the payment's id is the way
4193 // to find it.
4194 st.co.ta.setContent(fmt.Sprintf("\n [green]payment succeeded[-] (%s)\n\n [red]but the order could not be sent:[-] %s\n\n please contact the store with the payment id above.\n The cart is kept.\n", esc(id), esc(serr.Error())))
4195 return
4196 }
4197 st.u.emptyCart()
4198 st.co.ta.setContent(fmt.Sprintf("\n [green]Payment succeeded β thank you for your order.[-]\n\n [gray]payment[-] %s\n\n The order has been sent to the store. Esc closes.\n", esc(id)))
4199 })
4200}
4201
4202// submitOrder sends the order as the website's /complete does: the cart as
4203// it was saved, beside the payment's id, which the server checks with
4204// Stripe before it records anything.
4205func submitOrder(lines []cartLine, paymentIntentID string) error {
4206 body, err := json.Marshal(map[string]any{
4207 "localStorageData": map[string]any{completeKey: lines},
4208 "paymentIntentId": paymentIntentID,
4209 })
4210 if err != nil {
4211 return err
4212 }
4213 client := &http.Client{Timeout: 30 * time.Second}
4214 resp, err := client.Post(ordersURL()+"/submit-order", "application/json", bytes.NewReader(body))
4215 if err != nil {
4216 return err
4217 }
4218 defer resp.Body.Close() //nolint:errcheck // read-side close
4219 if resp.StatusCode != http.StatusOK {
4220 data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) //nolint:errcheck // the status says enough without it
4221 return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
4222 }
4223 return nil
4224}
4225
4226// await waits for a promise, from a goroutine that is not the event loop's.
4227func await(p js.Value) (js.Value, string) {
4228 type result struct {
4229 v js.Value
4230 err string
4231 }
4232 ch := make(chan result, 1)
4233 var then, catch js.Func
4234 then = js.FuncOf(func(_ js.Value, args []js.Value) any {
4235 ch <- result{v: args[0]}
4236 return nil
4237 })
4238 catch = js.FuncOf(func(_ js.Value, args []js.Value) any {
4239 ch <- result{err: js.Global().Get("String").Invoke(args[0]).String()}
4240 return nil
4241 })
4242 p.Call("then", then, catch)
4243 r := <-ch
4244 then.Release()
4245 catch.Release()
4246 return r.v, r.err
4247}
4248
4249// Stripe.js is loaded once, from Stripe (it may not be bundled or served
4250// from elsewhere), the first time a card form is shown.
4251var (
4252 stripeMu sync.Mutex
4253 stripeByKey = map[string]js.Value{}
4254 stripeLoading bool
4255 stripeWaiting []func(string)
4256)
4257
4258// withStripe calls fn with Stripe.js for key, once it has loaded, on the
4259// event loop.
4260func withStripe(key string, fn func(stripe js.Value, err string)) {
4261 ready := func(err string) {
4262 if err != "" {
4263 fn(js.Value{}, err)
4264 return
4265 }
4266 stripeMu.Lock()
4267 s, ok := stripeByKey[key]
4268 if !ok {
4269 s = js.Global().Get("Stripe").Invoke(key)
4270 stripeByKey[key] = s
4271 }
4272 stripeMu.Unlock()
4273 fn(s, "")
4274 }
4275 if js.Global().Get("Stripe").Type() == js.TypeFunction {
4276 ready("")
4277 return
4278 }
4279 stripeMu.Lock()
4280 stripeWaiting = append(stripeWaiting, ready)
4281 if stripeLoading {
4282 stripeMu.Unlock()
4283 return
4284 }
4285 stripeLoading = true
4286 stripeMu.Unlock()
4287
4288 doc := js.Global().Get("document")
4289 script := doc.Call("createElement", "script")
4290 script.Set("src", "https://js.stripe.com/v3/")
4291 var onload, onerror js.Func
4292 done := func(err string) {
4293 onload.Release()
4294 onerror.Release()
4295 stripeMu.Lock()
4296 waiting := stripeWaiting
4297 stripeWaiting, stripeLoading = nil, false
4298 stripeMu.Unlock()
4299 for _, w := range waiting {
4300 w(err)
4301 }
4302 }
4303 onload = js.FuncOf(func(js.Value, []js.Value) any { done(""); return nil })
4304 onerror = js.FuncOf(func(js.Value, []js.Value) any {
4305 done("could not load Stripe.js β is js.stripe.com blocked?")
4306 return nil
4307 })
4308 script.Call("addEventListener", "load", onload)
4309 script.Call("addEventListener", "error", onerror)
4310 doc.Get("head").Call("append", script)
4311}
4312
4313// postWait is post for what must not be dropped β a payment's outcome:
4314// it waits for room in the queue rather than giving up, until shutdown.
4315func (u *ui) postWait(fn func()) {
4316 for {
4317 u.postMu.Lock()
4318 if u.finished {
4319 u.postMu.Unlock()
4320 return
4321 }
4322 select {
4323 case u.screen.EventQ() <- tcell.NewEventInterrupt(fn):
4324 u.postMu.Unlock()
4325 return
4326 default:
4327 }
4328 u.postMu.Unlock()
4329 time.Sleep(20 * time.Millisecond)
4330 }
4331}
4332
4333// completeKey is where the website's /complete page finds the order it
4334// submits after a payment that left the page.
4335const completeKey = "cartItems"
4336
4337// handOff puts the order where /complete looks for it, and returns what puts
4338// back what was there β the website's own cart uses the same key.
4339func handOff(lines []cartLine) (restore func()) {
4340 restore = func() {}
4341 defer func() { _ = recover() }() //nolint:errcheck // storage the browser refuses: /complete then finds nothing, as before
4342 st := js.Global().Get("localStorage")
4343 b, err := json.Marshal(lines)
4344 if !st.Truthy() || err != nil {
4345 return restore
4346 }
4347 was := st.Call("getItem", completeKey)
4348 st.Call("setItem", completeKey, string(b))
4349 return func() {
4350 defer func() { _ = recover() }() //nolint:errcheck // as above
4351 if was.Type() == js.TypeString {
4352 st.Call("setItem", completeKey, was)
4353 } else {
4354 st.Call("removeItem", completeKey)
4355 }
4356 }
4357}
4358
4359
4360// ===== pkg/tui/pay_native.go =====
4361//go:build !js || !wasm
4362
4363// Package tui pkg/tui/pay_native.go β the native half of the payment
4364// seam. Card details can never be typed into a terminal (they would
4365// pass through this program, which PCI forbids); the browser build of
4366// this TUI lays the Stripe Payment Element over the checkout dialog
4367// (pay_js.go). Here the native flow proves the whole pipeline β cart,
4368// shipping, server validation, payment intent β and says where the last
4369// step lives.
4370package tui
4371
4372// payWithElements takes over the checkout dialog once the store server
4373// has created the payment intent for this cart.
4374func (u *ui) payWithElements(co *checkoutOverlay, clientSecret string) {
4375 preview := clientSecret
4376 if len(preview) > 12 {
4377 preview = preview[:12] + "β¦"
4378 }
4379 co.ta.setContent("\n [green]the store server accepted the cart[-] and created the payment\n intent (" +
4380 esc(preview) + ").\n\n" +
4381 " [yellow]card entry needs the Stripe Payment Element[-], which a terminal\n" +
4382 " cannot host. In the browser build of this TUI it opens right here,\n" +
4383 " over the terminal β in a native terminal, complete this order at\n" +
4384 " [aqua]https://" + esc(u.sitedomain) + "[-]\n\n" +
4385 " [gray]Esc closes β the cart is kept[-]\n")
4386}
4387
4388
4389// ===== pkg/tui/place.go =====
4390package tui
4391
4392import (
4393 "encoding/base64"
4394 "encoding/json"
4395 "strings"
4396)
4397
4398// The real thing over the cells, where the terminal can show it: websh's
4399// placements (OSC 7337). The home panel's logo and turning globe have the
4400// website's own β its logo.svg and its globe, a widget this program offers
4401// as "globe" (widgets_js.go) β laid over the cells that draw them, and a product's photo has
4402// the photo itself. A terminal without placements ignores the sequences, so
4403// they are written wherever this runs, and the cells are drawn as ever; an
4404// overlay (a menu, the cart, a notice) keeps the cells it crosses.
4405
4406// placementsShown is set where websh told this program, run as a process of
4407// its own, that its terminal shows placements (widgets_js.go).
4408var placementsShown = func() bool { return false }
4409
4410// A placed is what one placement shows over r.
4411type placed struct {
4412 r rect
4413 url string // an image, or
4414 widget string // a widget the page registered
4415 fit string
4416 input bool // the mouse over it is its own: the globe is turned by hand
4417 // above is how many of the frame's overlays were drawn when it was
4418 // asked for: one an overlay asks for lies on that overlay, and only
4419 // what is drawn after it covers it.
4420 above int
4421}
4422
4423// want asks for p over its cells as id this frame.
4424func (u *ui) want(id string, p placed) {
4425 if p.r.w <= 0 || p.r.h <= 0 {
4426 return
4427 }
4428 if p.url != "" && !strings.HasPrefix(p.url, "https://") && !strings.HasPrefix(p.url, "http://") {
4429 return // a file here, not an image the terminal can fetch
4430 }
4431 p.above = u.overlaysDrawn
4432 if u.placeWant == nil {
4433 u.placeWant = map[string]placed{}
4434 }
4435 u.placeWant[id] = p
4436}
4437
4438// placeSync, after a frame, tells the terminal what changed: what was wanted
4439// and is not covered by an overlay is laid (or moved), and the rest taken
4440// away β all of it when the person keeps to the cells (w).
4441func (u *ui) placeSync(overlays []rect) {
4442 if u.cellsOnly {
4443 clear(u.placeWant)
4444 }
4445 for id, p := range u.placeWant {
4446 for _, o := range overlays[min(p.above, len(overlays)):] {
4447 if p.r.overlaps(o) {
4448 delete(u.placeWant, id)
4449 break
4450 }
4451 }
4452 }
4453 mode := imageMode()
4454 for id := range u.placeSent {
4455 if _, ok := u.placeWant[id]; !ok {
4456 if mode == "kitty" {
4457 u.kittyRemove(id)
4458 } else {
4459 u.writeTerm("\x1b]7337;remove;" + id + "\x1b\\")
4460 }
4461 delete(u.placeSent, id)
4462 }
4463 }
4464 for id, p := range u.placeWant {
4465 if s, ok := u.placeSent[id]; ok && s == p {
4466 continue
4467 }
4468 if mode == "" {
4469 continue
4470 }
4471 if mode == "kitty" {
4472 u.kittyPlace(id, p)
4473 if u.placeSent == nil {
4474 u.placeSent = map[string]placed{}
4475 }
4476 u.placeSent[id] = p
4477 continue
4478 }
4479 b, err := json.Marshal(struct {
4480 Row int `json:"row"`
4481 Col int `json:"col"`
4482 W int `json:"w"`
4483 H int `json:"h"`
4484 URL string `json:"url,omitempty"`
4485 Widget string `json:"widget,omitempty"`
4486 Fit string `json:"fit,omitempty"`
4487 Input bool `json:"input,omitempty"`
4488 }{p.r.y, p.r.x, p.r.w, p.r.h, p.url, p.widget, p.fit, p.input})
4489 if err != nil {
4490 continue
4491 }
4492 u.writeTerm("\x1b]7337;place;" + id + ";" + base64.StdEncoding.EncodeToString(b) + "\x1b\\")
4493 if u.placeSent == nil {
4494 u.placeSent = map[string]placed{}
4495 }
4496 u.placeSent[id] = p
4497 }
4498 // The page's own terminal (a screen written straight into its emulator)
4499 // surely shows placements, and so does a websh that says so to a program
4500 // it runs; elsewhere the cells are the picture.
4501 _, inPage := u.screen.(interface{ WriteRaw([]byte) })
4502 u.homeCovered.Store((inPage || placementsShown()) && u.placeSent["home"].widget != "")
4503 clear(u.placeWant)
4504}
4505
4506// placeClear takes every placement away, as the program ends.
4507func (u *ui) placeClear() {
4508 if imageMode() == "kitty" {
4509 for id := range u.placeSent {
4510 u.kittyRemove(id)
4511 }
4512 return
4513 }
4514 if len(u.placeSent) > 0 {
4515 u.writeTerm("\x1b]7337;clear\x1b\\")
4516 }
4517}
4518
4519// writeTerm writes s to the terminal itself, outside the screen's frames:
4520// to the tty on a real terminal, or straight to the emulator in a page.
4521func (u *ui) writeTerm(s string) {
4522 if w, ok := u.screen.(interface{ WriteRaw([]byte) }); ok {
4523 w.WriteRaw([]byte(s))
4524 return
4525 }
4526 if tty, ok := u.screen.Tty(); ok {
4527 _, _ = tty.Write([]byte(s)) //nolint:errcheck // a terminal that is gone shows nothing either
4528 }
4529}
4530
4531// overlaps reports whether r and o share a cell.
4532func (r rect) overlaps(o rect) bool {
4533 return r.x < o.x+o.w && o.x < r.x+r.w && r.y < o.y+o.h && o.y < r.y+r.h
4534}
4535
4536
4537// ===== pkg/tui/probe_js.go =====
4538//go:build js
4539
4540package tui
4541
4542// probeTerminal: in a browser the host is asked otherwise β the page hands a
4543// program in it the answer (storepane), and a program run from websh's
4544// filesystem asks through its terminal (websh's childtty).
4545func probeTerminal() {}
4546
4547
4548// ===== pkg/tui/probe_other.go =====
4549//go:build !js
4550
4551package tui
4552
4553import (
4554 "os"
4555 "time"
4556
4557 "golang.org/x/term"
4558
4559 "github.com/0magnet/websh/progressive"
4560)
4561
4562// probeTerminal asks the terminal what it can do beyond cells β kitty
4563// graphics, say β before tcell takes it, so the store can show real
4564// pictures where the terminal can. It asks on /dev/tty, in raw mode, and
4565// closes it after, which ends the read the probe may still have waiting:
4566// no key is taken from tcell.
4567func probeTerminal() {
4568 tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
4569 if err != nil {
4570 return
4571 }
4572 defer tty.Close() //nolint:errcheck // done with it either way
4573 fd := int(tty.Fd()) //nolint:gosec // a file descriptor fits an int
4574 old, err := term.MakeRaw(fd)
4575 if err != nil {
4576 return
4577 }
4578 defer term.Restore(fd, old) //nolint:errcheck // back as it was, as far as it goes
4579 progressive.Probe(tty, tty, 1500*time.Millisecond) //nolint:errcheck,gosec // no answer is cells
4580}
4581
4582
4583// ===== pkg/tui/remote.go =====
4584// Package tui pkg/tui/remote.go β the TUI as a store client. With
4585// STOREURL set (m2 tui --storeurl https://magnetosphere.net) nothing is
4586// read from disk: the catalog comes from /api/products, images from /i,
4587// the logo from /logo.jpg, and checkout from the same origin β exactly
4588// what the browser build of this TUI will do from the page the store
4589// serves it on. Without it, the TUI reads the deployment's own files as
4590// before.
4591package tui
4592
4593import (
4594 "encoding/json"
4595 "fmt"
4596 "io"
4597 "net/http"
4598 "net/url"
4599 "os"
4600 "strings"
4601 "sync"
4602 "time"
4603
4604 "github.com/0magnet/m2/pkg/product"
4605)
4606
4607// storeURL is the remote store, normalized; empty means local files.
4608func storeURL() string {
4609 return strings.TrimRight(f.Storeurl, "/")
4610}
4611
4612// FetchCatalog loads the product catalog from a store's /api/products.
4613func FetchCatalog(store string) (product.Products, error) {
4614 client := &http.Client{Timeout: 30 * time.Second}
4615 resp, err := client.Get(strings.TrimRight(store, "/") + "/api/products")
4616 if err != nil {
4617 return nil, err
4618 }
4619 defer resp.Body.Close() //nolint:errcheck // read-side close
4620 if resp.StatusCode != http.StatusOK {
4621 return nil, fmt.Errorf("%s from %s/api/products", resp.Status, store)
4622 }
4623 var prods product.Products
4624 if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&prods); err != nil {
4625 return nil, err
4626 }
4627 return prods, nil
4628}
4629
4630// FetchSite fills the site identity (name, tagline, telegram links) from
4631// a store's /api/site β the browser build has no MENV file to source.
4632func FetchSite(store string) error {
4633 client := &http.Client{Timeout: 15 * time.Second}
4634 resp, err := client.Get(strings.TrimRight(store, "/") + "/api/site")
4635 if err != nil {
4636 return err
4637 }
4638 defer resp.Body.Close() //nolint:errcheck // read-side close
4639 if resp.StatusCode != http.StatusOK {
4640 return fmt.Errorf("%s from %s/api/site", resp.Status, store)
4641 }
4642 var site struct {
4643 Sitename, Siteext, Sitelongname, Sitetagline string
4644 Tgcontact, Tgchannel string
4645 Teststripekey bool
4646 Sitemeta, Sitedomain string
4647 Siteprettyname, Siteprettynamecap string
4648 Siteprettynamecaps, SiteASCIILogo string
4649 Stripepk string
4650 NavLinks []navLink
4651 Coin string
4652 }
4653 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&site); err != nil {
4654 return err
4655 }
4656 f.Sitename, f.Siteext = site.Sitename, site.Siteext
4657 f.Sitelongname, f.Sitetagline = site.Sitelongname, site.Sitetagline
4658 f.Tgcontact, f.Tgchannel = site.Tgcontact, site.Tgchannel
4659 f.Teststripekey = site.Teststripekey
4660 f.Sitemeta, f.Sitedomain = site.Sitemeta, site.Sitedomain
4661 f.Siteprettyname, f.Siteprettynamecap = site.Siteprettyname, site.Siteprettynamecap
4662 f.Siteprettynamecaps, f.SiteASCIILogo = site.Siteprettynamecaps, site.SiteASCIILogo
4663 if site.Stripepk != "" {
4664 f.StripePK = site.Stripepk
4665 }
4666 navLinks = site.NavLinks
4667 coinOffered = site.Coin != ""
4668 return nil
4669}
4670
4671// navLink is an entry a server-side drop-in added to the site navigation β
4672// pkg/web's NavLink, as /api/site serves it.
4673//
4674// The Etc menu was written out twice, once in htmpl/header.html and once in
4675// openEtc, and only the template knew about these: the store's Software page
4676// was in the page's menu and missing from the terminal's. A store client
4677// cannot see pkg/web's registry, so the server hands it over instead.
4678type navLink struct {
4679 Title, Href, Desc string
4680}
4681
4682// coinOffered is whether the store takes coin, which the cart offers as k.
4683// A store reports it in /api/site; the local TUI, beside its own server,
4684// offers it and lets the server answer.
4685var coinOffered = true
4686
4687// navLinks is what /api/site last reported. Empty in a deployment with no
4688// drop-ins, and empty in a local TUI with no store to ask.
4689var navLinks []navLink
4690
4691// fetchContent reads a stock page (about/policy/links): from the store's
4692// /api/content in client mode, from the deployment's content/ otherwise.
4693func fetchContent(name string) string {
4694 s := storeURL()
4695 if s == "" {
4696 return contentFile("content/" + name + ".html")
4697 }
4698 client := &http.Client{Timeout: 15 * time.Second}
4699 resp, err := client.Get(s + "/api/content/" + url.PathEscape(name))
4700 if err != nil {
4701 return ""
4702 }
4703 defer resp.Body.Close() //nolint:errcheck // read-side close
4704 if resp.StatusCode != http.StatusOK {
4705 return ""
4706 }
4707 data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
4708 if err != nil {
4709 return ""
4710 }
4711 return string(data)
4712}
4713
4714// imageCache holds fetched remote images; the render worker re-reads an
4715// image at every pane size, and the network should pay only once.
4716var (
4717 imageCacheMu sync.Mutex
4718 imageCache = map[string][]byte{}
4719)
4720
4721// getImageData reads an image by path or URL.
4722func getImageData(path string) ([]byte, error) {
4723 if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
4724 return os.ReadFile(path) //nolint:gosec // paths come from the catalog, not the user
4725 }
4726 imageCacheMu.Lock()
4727 data, ok := imageCache[path]
4728 imageCacheMu.Unlock()
4729 if ok {
4730 return data, nil
4731 }
4732 client := &http.Client{Timeout: 20 * time.Second}
4733 // Fetching the URL it was asked for is the point of this function.
4734 resp, err := client.Get(path) //nolint:gosec
4735 if err != nil {
4736 return nil, err
4737 }
4738 defer resp.Body.Close() //nolint:errcheck // read-side close
4739 if resp.StatusCode != http.StatusOK {
4740 return nil, fmt.Errorf("%s", resp.Status)
4741 }
4742 data, err = io.ReadAll(io.LimitReader(resp.Body, 16<<20))
4743 if err != nil {
4744 return nil, err
4745 }
4746 imageCacheMu.Lock()
4747 if len(imageCache) > 64 {
4748 imageCache = map[string][]byte{}
4749 }
4750 imageCache[path] = data
4751 imageCacheMu.Unlock()
4752 return data, nil
4753}
4754
4755
4756// ===== pkg/tui/render.go =====
4757// Package tui pkg/tui/render.go β images, the globe, and text conversion.
4758// Everything renders to cells: product photos become pixel buffers
4759// painted as β half-blocks (or libcaca canvases blitted glyph by glyph),
4760// and tcell diffs frames so only changed cells reach the terminal.
4761package tui
4762
4763import (
4764 "bytes"
4765 "fmt"
4766 "image"
4767 "image/color"
4768 _ "image/gif"
4769 _ "image/jpeg"
4770 _ "image/png"
4771 "net/url"
4772 "os"
4773 "path/filepath"
4774 "regexp"
4775 "strconv"
4776 "strings"
4777
4778 xhtml "html"
4779
4780 "github.com/gdamore/tcell/v3"
4781 "golang.org/x/image/draw"
4782
4783 "github.com/0magnet/chaosrack/pkg/geom"
4784 "github.com/0magnet/chaosrack/pkg/rasterview"
4785 "github.com/0magnet/img2txt-go/caca"
4786
4787 "github.com/0magnet/m2/pkg/product"
4788)
4789
4790// imgMode selects how product photos are painted.
4791type imgMode int
4792
4793const (
4794 modeHalfBlock imgMode = iota // truecolor, two pixels per cell via β
4795 modeCaca // libcaca ANSI art, as /logo renders it
4796)
4797
4798// artwork is a rendered product photo: a pixel buffer (half-block mode),
4799// a cell canvas (caca mode), or a message when there is nothing to show.
4800type artwork struct {
4801 rgba *image.RGBA
4802 cells *cellArt
4803 msg string
4804}
4805
4806// cellArt is a libcaca canvas as terminal cells.
4807type cellArt struct {
4808 w, h int
4809 ch []rune
4810 st []tcell.Style
4811}
4812
4813// imagePath resolves a product photo the way the templates do:
4814// img/<category>/<image1> (from the store's /i in client mode), falling
4815// back locally to img/<image1>. A nil product (nothing selected yet β
4816// an empty table's preview) has no image.
4817func imagePath(p *product.Product) string {
4818 if p == nil || p.Image1 == "" {
4819 return ""
4820 }
4821 if s := storeURL(); s != "" {
4822 return s + "/i/" + url.PathEscape(p.Category) + "/" + url.PathEscape(p.Image1)
4823 }
4824 for _, path := range []string{
4825 filepath.Join("img", p.Category, p.Image1),
4826 filepath.Join("img", p.Image1),
4827 } {
4828 if _, err := os.Stat(path); err == nil {
4829 return path
4830 }
4831 }
4832 return ""
4833}
4834
4835// datasheetURL resolves a product datasheet the way product.html does:
4836// <ImgSRC>/pdf/<name>, which is /i/pdf/<name> on the store's own origin.
4837// Without a store there is no origin to build on, so the local path the
4838// file would be served from is what a click has to offer instead.
4839func datasheetURL(name string) string {
4840 if name == "" {
4841 return ""
4842 }
4843 if s := storeURL(); s != "" {
4844 return s + "/i/pdf/" + url.PathEscape(name)
4845 }
4846 return filepath.Join("img", "pdf", name)
4847}
4848
4849func loadImage(path string) (image.Image, error) {
4850 data, err := getImageData(path)
4851 if err != nil {
4852 return nil, err
4853 }
4854 img, _, err := image.Decode(bytes.NewReader(data))
4855 return img, err
4856}
4857
4858// scaleToFit scales img into a pixel buffer no larger than maxW x maxH,
4859// preserving aspect. Half-block pixels are roughly square.
4860func scaleToFit(img image.Image, maxW, maxH int) *image.RGBA {
4861 b := img.Bounds()
4862 pw, ph := b.Dx(), b.Dy()
4863 if pw*maxH > ph*maxW {
4864 ph = ph * maxW / pw
4865 pw = maxW
4866 } else {
4867 pw = pw * maxH / ph
4868 ph = maxH
4869 }
4870 if pw < 1 {
4871 pw = 1
4872 }
4873 if ph < 1 {
4874 ph = 1
4875 }
4876 dst := image.NewRGBA(image.Rect(0, 0, pw, ph))
4877 draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
4878 return dst
4879}
4880
4881// renderArt produces a product photo sized to fit cols x rows cells.
4882func renderArt(path string, cols, rows int, mode imgMode) artwork {
4883 if path == "" {
4884 return artwork{msg: "no image"}
4885 }
4886 if cols < 2 || rows < 2 {
4887 return artwork{msg: "pane too small"}
4888 }
4889 if mode == modeCaca {
4890 cells, err := renderCaca(path, cols, rows)
4891 if err != nil {
4892 return artwork{msg: err.Error()}
4893 }
4894 return artwork{cells: cells}
4895 }
4896 img, err := loadImage(path)
4897 if err != nil {
4898 return artwork{msg: err.Error()}
4899 }
4900 if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
4901 return artwork{msg: "empty image"}
4902 }
4903 return artwork{rgba: scaleToFit(img, cols, rows*2)}
4904}
4905
4906// renderCaca draws an image as libcaca ANSI art β the same renderer
4907// behind the /logo endpoint, via the pure-Go img2txt port β and reads
4908// the canvas cells straight into terminal cells.
4909func renderCaca(path string, cols, rows int) (*cellArt, error) {
4910 data, err := getImageData(path)
4911 if err != nil {
4912 return nil, err
4913 }
4914 im, err := caca.DecodeImage(bytes.NewReader(data))
4915 if err != nil {
4916 return nil, err
4917 }
4918 if im.W == 0 || im.H == 0 {
4919 return nil, fmt.Errorf("bad dimensions")
4920 }
4921 // img2txt's 6x10 font-cell aspect, constrained by both axes.
4922 lines := cols * im.H * 6 / im.W / 10
4923 if lines > rows {
4924 lines = rows
4925 cols = lines * im.W * 10 / im.H / 6
4926 }
4927 if lines < 1 || cols < 1 {
4928 return nil, fmt.Errorf("pane too small")
4929 }
4930 cv := caca.NewCanvas(cols, lines)
4931 cv.SetColorANSI(caca.Default, caca.Transparent)
4932 cv.Clear()
4933 im.Dither.SetAlgorithm("fstein")
4934 cv.DitherBitmap(0, 0, cols, lines, im.Dither, im.Pixels)
4935
4936 art := &cellArt{w: cv.Width, h: cv.Height,
4937 ch: make([]rune, cv.Width*cv.Height), st: make([]tcell.Style, cv.Width*cv.Height)}
4938 for i, ch := range cv.Chars {
4939 art.ch[i] = ch
4940 art.st[i] = tcell.StyleDefault.
4941 Foreground(rgb12(caca.AttrToRGB12Fg(cv.Attrs[i]))).
4942 Background(rgb12(caca.AttrToRGB12Bg(cv.Attrs[i])))
4943 }
4944 return art, nil
4945}
4946
4947// rgb12 expands libcaca's 12-bit 0xRGB into a tcell color.
4948func rgb12(v uint16) tcell.Color {
4949 return tcell.NewRGBColor(
4950 int32((v>>8&0xF)*17), int32((v>>4&0xF)*17), int32((v&0xF)*17))
4951}
4952
4953// drawArtwork paints a rendered photo centered in r, and says which cells
4954// the photo took (false when there was no photo to draw).
4955func drawArtwork(sc tcell.Screen, r rect, art *artwork) (rect, bool) {
4956 switch {
4957 case art == nil:
4958 case art.rgba != nil:
4959 rows := (art.rgba.Bounds().Dy() + 1) / 2
4960 yoff := (r.h - rows) / 2
4961 if yoff < 0 {
4962 yoff = 0
4963 }
4964 drawRGBA(sc, r.x, r.y+yoff, r.w, r.h-yoff, art.rgba)
4965 pw := art.rgba.Bounds().Dx()
4966 xoff := 0
4967 if r.w > pw {
4968 xoff = (r.w - pw) / 2
4969 }
4970 return rect{r.x + xoff, r.y + yoff, min(pw, r.w), min(rows, r.h-yoff)}, true
4971 case art.cells != nil:
4972 xoff := (r.w - art.cells.w) / 2
4973 yoff := (r.h - art.cells.h) / 2
4974 if xoff < 0 {
4975 xoff = 0
4976 }
4977 if yoff < 0 {
4978 yoff = 0
4979 }
4980 for y := 0; y < art.cells.h && y < r.h; y++ {
4981 for x := 0; x < art.cells.w && x < r.w; x++ {
4982 i := y*art.cells.w + x
4983 sc.SetContent(r.x+xoff+x, r.y+yoff+y, art.cells.ch[i], nil, art.cells.st[i])
4984 }
4985 }
4986 return rect{r.x + xoff, r.y + yoff, min(art.cells.w, r.w), min(art.cells.h, r.h)}, true
4987 case art.msg != "":
4988 printMarkupCenter(sc, r.x, r.y+r.h/2, r.w, "[gray]"+esc(art.msg)+"[-]", styleText)
4989 }
4990 return rect{}, false
4991}
4992
4993// The home panel: a wireframe globe turning over the site logo, as the
4994// website's landing view renders its wasm globe over the svg logo.
4995
4996// makeBackdrop scales the logo to one panel size.
4997func makeBackdrop(logo image.Image, cols, rows int) *image.RGBA {
4998 if logo == nil || cols <= 0 || rows <= 0 {
4999 if cols < 1 {
5000 cols = 1
5001 }
5002 if rows < 1 {
5003 rows = 1
5004 }
5005 return image.NewRGBA(image.Rect(0, 0, cols, rows*2))
5006 }
5007 // Full brightness, as the website shows it: the svg behind the canvas
5008 // is not dimmed there, so its white stays white here too. The globe
5009 // reads on top by its own saturation, the way it does on the page.
5010 return scaleToFit(logo, cols, rows*2)
5011}
5012
5013// globeLines is the globe the website's attractor engine uploads to
5014// WebGL β same geometry, from the same package.
5015var globeLines = geom.Globe(18, 36, 60)
5016
5017// globeFrame composites one animation frame: the dimmed logo with the
5018// chaosrack globe drawn over it by chaosrack's own software renderer, in
5019// its default look β random pose, redβblue gradient along model z (see
5020// rasterview.DefaultGradient). BackDim is the one departure from the
5021// WebGL look: an unantialiased raster needs the depth cue.
5022func globeFrame(backdrop *image.RGBA, ax, ay, az float64) *image.RGBA {
5023 frame := image.NewRGBA(backdrop.Bounds())
5024 copy(frame.Pix, backdrop.Pix)
5025 view := rasterview.View{AngleX: ax, AngleY: ay, AngleZ: az, Dist: 3.2, BackDim: 0.5}
5026 view.Render(frame, globeLines.Vertices, globeLines.Indices, rasterview.DefaultGradient())
5027 return frame
5028}
5029
5030// drawRGBA paints a pixel buffer into terminal cells at x0,y0, two pixels
5031// per cell via β, centered in cols columns. Cells unchanged since the
5032// last frame cost nothing: tcell diffs them away.
5033func drawRGBA(sc tcell.Screen, x0, y0, cols, maxRows int, img *image.RGBA) {
5034 pw, ph := img.Bounds().Dx(), img.Bounds().Dy()
5035 xoff := 0
5036 if cols > pw {
5037 xoff = (cols - pw) / 2
5038 }
5039 for y := 0; y < ph; y += 2 {
5040 if y/2 >= maxRows {
5041 break
5042 }
5043 for x := 0; x < pw; x++ {
5044 if xoff+x >= cols {
5045 break
5046 }
5047 top := img.RGBAAt(x, y)
5048 var bot color.RGBA
5049 if y+1 < ph {
5050 bot = img.RGBAAt(x, y+1)
5051 }
5052 st := tcell.StyleDefault.
5053 Foreground(tcell.NewRGBColor(step16(top.R), step16(top.G), step16(top.B))).
5054 Background(tcell.NewRGBColor(step16(bot.R), step16(bot.G), step16(bot.B)))
5055 sc.SetContent(x0+xoff+x, y0+y/2, 'β', nil, st)
5056 }
5057 }
5058}
5059
5060var (
5061 tmplAction = regexp.MustCompile(`\{\{[^}]*\}\}`)
5062 tagAnchor = regexp.MustCompile(`(?is)<a\b([^>]*)>(.*?)</a>`)
5063 attrHref = regexp.MustCompile(`(?is)\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
5064 tagBreak = regexp.MustCompile(`(?i)<br\s*/?>`)
5065 tagBlock = regexp.MustCompile(`(?i)</(p|h1|h2|h3|div|tr|li|ul|table|pre)>`)
5066 tagHeading = regexp.MustCompile(`(?is)<h[1-3][^>]*>(.*?)</h[1-3]>`)
5067 tagLi = regexp.MustCompile(`(?i)<li[^>]*>`)
5068 tagCell = regexp.MustCompile(`(?i)</t[dh]>`)
5069 tagAny = regexp.MustCompile(`(?s)<[^>]*>`)
5070 manyBlank = regexp.MustCompile(`\n{3,}`)
5071)
5072
5073// htmlToText renders a stock-page fragment (about/policy/links) as
5074// terminal text: links cyan like the site, headings bold, tags dropped.
5075//
5076// It also returns each anchor's href, in the order the [link=N] tags it
5077// wrote number them, so the page can make them followable. The Links
5078// page is a page of nothing but links; cyan text that does nothing is
5079// not a rendering of it.
5080//
5081// width is the column count the caller will draw into; tables are laid out
5082// to fit it. See htmltable.go.
5083func htmlToText(s, year string, width int) (string, []string) {
5084 s = strings.ReplaceAll(s, "{{.Page.Year}}", year)
5085 s = tmplAction.ReplaceAllString(s, "")
5086 s = strings.ReplaceAll(s, "[", "[[") // user HTML must not become tags
5087 s = tagHeading.ReplaceAllString(s, "\n[::b]$1[-:-:-]\n")
5088 var hrefs []string
5089 s = tagAnchor.ReplaceAllStringFunc(s, func(m string) string {
5090 g := tagAnchor.FindStringSubmatch(m)
5091 href := ""
5092 if a := attrHref.FindStringSubmatch(g[1]); a != nil {
5093 href = xhtml.UnescapeString(a[1] + a[2] + a[3]) // only one group matches
5094 }
5095 hrefs = append(hrefs, href)
5096 return linkTag(len(hrefs), "[aqua]"+g[2]+"[-]")
5097 })
5098 // Tables come out whole and are drawn last. The flattening below would
5099 // otherwise reduce their cells to runs of spaces, and drawing them first
5100 // would feed the finished columns straight back into it.
5101 var tables []string
5102 s = tagTable.ReplaceAllStringFunc(s, func(m string) string {
5103 tables = append(tables, tagTable.FindStringSubmatch(m)[1])
5104 return "\x00T" + strconv.Itoa(len(tables)-1) + "\x00"
5105 })
5106 s = tagBreak.ReplaceAllString(s, "\n")
5107 s = tagLi.ReplaceAllString(s, " * ")
5108 s = tagCell.ReplaceAllString(s, " ")
5109 s = tagBlock.ReplaceAllString(s, "\n")
5110 s = tagAny.ReplaceAllString(s, "")
5111 s = xhtml.UnescapeString(s)
5112 s = manyBlank.ReplaceAllString(s, "\n\n")
5113 for i, inner := range tables {
5114 s = strings.Replace(s, "\x00T"+strconv.Itoa(i)+"\x00", "\n"+renderTable(inner, width), 1)
5115 }
5116 return strings.TrimSpace(s) + "\n", hrefs
5117}
5118
5119// step16 snaps a channel to 16 levels, spanning the full range: 0x00 stays
5120// 0x00 and 0xFF stays 0xFF, so the logo's black and white are untouched and
5121// only the interpolated greys move, by at most 1/32 of the range.
5122//
5123// This is a rendering cost, not an aesthetic choice. The WebGL renderer
5124// caches rasterised glyphs under a key that includes both colors, so a
5125// truecolor half-block is a distinct glyph per color pair. A full-screen
5126// gradient at ~11k cells hands it ~11k keys a frame against an atlas of
5127// ~18k slots; it fills, clears, and re-rasterises everything, every frame.
5128// Quantising collapses the key space enough for the cache to hold, which
5129// measured out at roughly half the CPU and less than half the worst-case
5130// frame time.
5131func step16(v uint8) int32 { return int32(v&0xF0 | v>>4) }
5132
5133
5134// ===== pkg/tui/tui.go =====
5135// Package tui pkg/tui/tui.go β a terminal view of the web store, derived
5136// from the website itself: the same masthead, the catsubcats navigation
5137// tree, one panel at a time like the front page's :target panels, the
5138// category tables, product pages in template order, the stock content
5139// pages, and the footer bar. The landing view is the site's landing
5140// view: a wireframe globe turning over the logo.
5141//
5142// Written directly on tcell v3 with the small widget kit in widgets.go β
5143// the same terminal stack as the rest of the 0magnet ecosystem, so the
5144// browser build can ride tuiwasm/xtcell.
5145package tui
5146
5147import (
5148 "context"
5149 "embed"
5150 "fmt"
5151 "image"
5152 "math"
5153 "math/rand"
5154 "os"
5155 "strconv"
5156 "strings"
5157 "sync"
5158 "sync/atomic"
5159 "time"
5160
5161 "github.com/gdamore/tcell/v3"
5162
5163 "github.com/0magnet/calvin"
5164
5165 "github.com/0magnet/m2/pkg/config"
5166 "github.com/0magnet/m2/pkg/product"
5167 "github.com/0magnet/m2/pkg/storepage"
5168)
5169
5170//go:embed *.go
5171var Source embed.FS
5172
5173// f aliases the shared configuration, as in pkg/web.
5174var f = &config.F
5175
5176// site styles, from content/style.css
5177var (
5178 styleText = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.ColorBlack.TrueColor())
5179 styleLink = styleText.Foreground(tcell.ColorAqua.TrueColor()) // a{color:cyan}
5180 colorHover = tcell.ColorGreen.TrueColor() // a:hover{color:green}
5181 styleSel = tcell.StyleDefault.Foreground(tcell.GetColor("#ffa500")).Background(tcell.ColorWhite.TrueColor()) // a.cur
5182 // .β thead{background-color:#6a1b9a;color:white} β a filled band, not
5183 // underlined text. Underlined purple read as a link, which those
5184 // column titles are not.
5185 styleThead = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#6a1b9a")).Bold(true)
5186 styleBorder = styleText.Foreground(tcell.ColorBlue.TrueColor())
5187 styleField = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#000087"))
5188 styleButton = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#005f87"))
5189
5190 // The header and footer tables are closed by a 1px blue border on the
5191 // page; their cell edges are white. Terminal rules stand in for both.
5192 ruleBlue = styleText.Foreground(tcell.GetColor("#0000ff"))
5193 ruleWhite = styleText.Foreground(tcell.ColorWhite.TrueColor())
5194)
5195
5196// panel is one of the site's :target panels.
5197type panel interface {
5198 draw(u *ui, sc tcell.Screen, r rect)
5199 key(u *ui, ev *tcell.EventKey) bool
5200 mouse(u *ui, r rect, ev *tcell.EventMouse) bool
5201}
5202
5203// overlayW is a commerce overlay (cart / shipping / checkout).
5204type overlayW interface {
5205 draw(u *ui, sc tcell.Screen, r rect)
5206 key(u *ui, ev *tcell.EventKey) bool
5207 size() (w, h int)
5208 title() string
5209}
5210
5211// linkRegion is one anchor on the drawn page: the cells it occupies and
5212// what following it does.
5213type linkRegion struct {
5214 x, y, w int
5215 act func()
5216}
5217
5218// dropState is an open header dropdown.
5219type dropState struct {
5220 kind string // "cats" | "etc"
5221 lst *list
5222 targets []navTarget // cats only
5223 width int
5224}
5225
5226// artHolder carries one asynchronously rendered product photo.
5227type artHolder struct {
5228 key string // what art holds
5229 want string // what was last requested
5230 art *artwork
5231}
5232
5233type artReq struct {
5234 key string
5235 path string
5236 w, h int
5237 mode imgMode
5238 holder *artHolder
5239}
5240
5241type ui struct {
5242 screen tcell.Screen
5243 cat *catalogInfo
5244 quit bool
5245
5246 panels map[string]panel
5247 history []string
5248 fwd []string
5249
5250 drop *dropState
5251 commerce overlayW
5252 notice string
5253
5254 // postMu gates post against RunOn's shutdown closing the queue.
5255 postMu sync.Mutex
5256 finished bool
5257
5258 // header nav and footer summary click regions, rebuilt each draw
5259 navRegions []struct {
5260 x1, x2 int
5261 act string
5262 }
5263 footRegions []struct {
5264 x1, x2 int
5265 act string
5266 }
5267
5268 // links are the anchors the visible page drew, rebuilt every draw and
5269 // consulted before the panel sees a click. On the website every one of
5270 // these is an <a>; here it is a run of cells with something to do.
5271 links []linkRegion
5272
5273 // hoverX, hoverY is the cell the pointer was last seen over, which is
5274 // what a:hover needs. -1 means the pointer has not been anywhere yet.
5275 hoverX, hoverY int
5276
5277 // bare drops the pisano frame around the product table, giving its
5278 // rows back to the table. Toggled with d.
5279 bare bool
5280
5281 // image rendering
5282 mode imgMode
5283 reqCh chan artReq
5284
5285 // expanded holds which categories' branches are open in the
5286 // Categories dropdown, like the template's <details open>.
5287 expanded map[string]bool
5288
5289 // home animation. The ticker goroutine computes frame (a pixel
5290 // buffer) and the home panel paints it; both sides go through mu.
5291 // current names the visible panel; the goroutine reads it too.
5292 mu sync.Mutex
5293 current string
5294 logo image.Image
5295 homeW int
5296 homeH int
5297 frame *image.RGBA
5298 backdrop *image.RGBA
5299 bdW, bdH int
5300 // pose randomized at startup like chaosrack's randomizeOrientation,
5301 // then tumbled by a random rate on every axis.
5302 poseX, poseY, poseZ float64
5303 rateX, rateY, rateZ float64
5304 done chan struct{}
5305 // Placements (place.go): what this frame wants over its cells, and
5306 // what the terminal has.
5307 placeWant, placeSent map[string]placed
5308 // cellsOnly keeps to the cells: no placements, as in a terminal without
5309 // them. Toggled with w.
5310 cellsOnly bool
5311 // overlaysDrawn counts the overlays drawn so far this frame; see placed.above.
5312 overlaysDrawn int
5313 // homeCovered: the page's globe is over the home panel's cells (set by
5314 // placeSync, read by animateHome), so they need not turn.
5315 homeCovered atomic.Bool
5316
5317 asciiLines []string
5318 caps string
5319
5320 // cart, as the footer's View Cart
5321 cartOrder []string
5322 cartQty map[string]int
5323 shipping *storepage.Shipping
5324
5325 sitedomain string
5326 // announced is the page path last told to the host (address.go).
5327 announced string
5328 // kittyIDs and kittyCache: pictures for kitty's graphics protocol
5329 // (kittyimg.go).
5330 kittyIDs map[string]uint32
5331 kittyCache map[string][]byte
5332 masthead [3]string
5333}
5334
5335// screenKey carries an alternative way of getting a screen.
5336type screenKey struct{}
5337
5338// WithScreen tells Run how to obtain its screen, for a host where
5339// tcell's own answer is the wrong one.
5340//
5341// This is what lets `m2 tui` be the same command in both places. In a
5342// terminal it opens a tcell screen on the tty and there is nothing to
5343// say; in a browser the screen has to be an xtcell one bound to the
5344// particular terminal and element the command was typed into, which
5345// only the page knows. Rather than a second command for the browser β
5346// which is what there used to be, and which promptly drifted β the page
5347// puts the answer here and runs the real one.
5348//
5349// A function and not a screen: most commands never need one, and
5350// building it takes the terminal over.
5351func WithScreen(ctx context.Context, newScreen func() (tcell.Screen, error)) context.Context {
5352 return context.WithValue(ctx, screenKey{}, newScreen)
5353}
5354
5355// screenFrom returns the screen ctx asks for, or nil for tcell's own.
5356func screenFrom(ctx context.Context) func() (tcell.Screen, error) {
5357 if ctx == nil {
5358 return nil
5359 }
5360 fn, _ := ctx.Value(screenKey{}).(func() (tcell.Screen, error))
5361 return fn
5362}
5363
5364// Run browses the store in a full-screen terminal UI until the user
5365// quits, on the screen ctx names or on a new tcell one.
5366func Run(ctx context.Context, prods product.Products) error {
5367 if newScreen := screenFrom(ctx); newScreen != nil {
5368 sc, err := newScreen()
5369 if err != nil {
5370 return err
5371 }
5372 return RunOn(sc, prods)
5373 }
5374 probeTerminal() // what this terminal can do beyond cells (probe_other.go)
5375 sc, err := tcell.NewScreen()
5376 if err != nil {
5377 return err
5378 }
5379 if err := sc.Init(); err != nil {
5380 return err
5381 }
5382 return RunOn(sc, prods)
5383}
5384
5385// RunOn drives the store UI on an initialized screen until the user
5386// quits, then finalizes it.
5387func RunOn(sc tcell.Screen, prods product.Products) error {
5388 u := &ui{
5389 cat: newCatalogInfo(prods),
5390 panels: map[string]panel{},
5391 mode: modeHalfBlock,
5392 reqCh: make(chan artReq, 1),
5393 expanded: map[string]bool{},
5394 done: make(chan struct{}),
5395 cartQty: map[string]int{},
5396 hoverX: -1,
5397 hoverY: -1,
5398 }
5399 u.sitedomain = f.Sitename + f.Siteext
5400 if u.sitedomain == "" {
5401 u.sitedomain = "m2"
5402 }
5403 u.loadCart()
5404 u.offerWidgets()
5405 u.asciiLines = strings.Split(strings.TrimRight(calvin.AsciiFont(u.sitedomain), "\n"), "\n")
5406 // The page sets this line in blackboard bold, and the terminal cannot:
5407 // the double-struck capitals live outside the BMP and no monospace font
5408 // here carries them, so every one of them falls back to a face whose
5409 // glyph is nearly twice the cell it is given and collides with its
5410 // neighbor. Plain capitals are the same words, drawn on the grid.
5411 u.caps = strings.ToUpper(u.sitedomain)
5412 long := f.Sitelongname
5413 if long == "" {
5414 long = u.sitedomain
5415 }
5416 tag := ""
5417 if f.Sitetagline != "" {
5418 tag = "- " + esc(f.Sitetagline) + " -"
5419 }
5420 u.masthead = [3]string{"[::b]" + esc(long) + "[-:-:-]", "[gray]" + tag + "[-]", checkerboard(u.sitedomain)}
5421
5422 // A fresh random starting pose each run, as the website gives each
5423 // page load β and a random spin rate per axis, each with a floor so
5424 // all three visibly contribute, the vector normalized so the overall
5425 // speed matches auto-rotate's.
5426 u.poseX = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
5427 u.poseY = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
5428 u.poseZ = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
5429 randRate := func() float64 {
5430 r := 0.35 + 0.65*rand.Float64() //nolint:gosec
5431 if rand.Intn(2) == 0 { //nolint:gosec
5432 return -r
5433 }
5434 return r
5435 }
5436 u.rateX, u.rateY, u.rateZ = randRate(), randRate(), randRate()
5437 norm := math.Sqrt(u.rateX*u.rateX + u.rateY*u.rateY + u.rateZ*u.rateZ)
5438 const pace = 0.042 // auto-rotate's 0.3 rad/s at the 140ms ticker
5439 u.rateX, u.rateY, u.rateZ = u.rateX/norm*pace, u.rateY/norm*pace, u.rateZ/norm*pace
5440
5441 logos := []string{"logo.jpg", "logo.png"}
5442 if s := storeURL(); s != "" {
5443 logos = []string{s + "/logo.jpg", s + "/logo.png"}
5444 }
5445 for _, p := range logos {
5446 if img, err := loadImage(p); err == nil {
5447 u.logo = img
5448 break
5449 }
5450 }
5451
5452 u.screen = sc
5453 sc.SetStyle(styleText)
5454 sc.EnableMouse()
5455
5456 u.panels["home"] = &homePanel{}
5457 u.current = "home"
5458
5459 go u.renderWorker()
5460 go u.animateHome()
5461 if p := u.pageFromPath(StartPath); p != "" && p != "home" {
5462 u.show(p) // a link opened the store at this page
5463 }
5464 StartPath = "" // once: the store run again starts at home
5465
5466 defer func() {
5467 // Stop accepting posts before the screen closes the event queue
5468 // under them β a ticker mid-post must not race the close.
5469 u.postMu.Lock()
5470 u.finished = true
5471 u.postMu.Unlock()
5472 u.placeClear() // the placements go with the program
5473 sc.Fini()
5474 close(u.done)
5475 }()
5476 events := sc.EventQ()
5477 u.draw()
5478 for !u.quit {
5479 ev, ok := <-events
5480 if !ok {
5481 break
5482 }
5483 switch e := ev.(type) {
5484 case *tcell.EventInterrupt:
5485 if fn, ok := e.Data().(func()); ok && fn != nil {
5486 fn()
5487 }
5488 case *tcell.EventResize:
5489 sc.Sync()
5490 clear(u.placeSent) // the full redraw erased what was laid over the cells; lay it again
5491 case *tcell.EventKey:
5492 // v3 reports key releases too where the terminal can; a
5493 // shortcut must fire once, on the press.
5494 if e.Pressed() {
5495 u.handleKey(e)
5496 }
5497 case *tcell.EventMouse:
5498 u.handleMouse(e)
5499 }
5500 u.draw()
5501 }
5502 return nil
5503}
5504
5505// animation ticker use it. Non-blocking and refused after shutdown, so
5506// a producer can never race the queue's close nor wedge against a loop
5507// that has already exited.
5508func (u *ui) post(fn func()) {
5509 u.postMu.Lock()
5510 defer u.postMu.Unlock()
5511 if u.finished {
5512 return
5513 }
5514 select {
5515 case u.screen.EventQ() <- tcell.NewEventInterrupt(fn):
5516 default:
5517 }
5518}
5519
5520// ---- drawing ----
5521
5522// linkSink returns the callback the markup printers report anchors to,
5523// binding each link index to the page's own table of what its links do.
5524// A page builds that table as it builds its markup; see linkTag.
5525func (u *ui) linkSink(acts []func()) func(x, y, w, link int) {
5526 return func(x, y, w, link int) {
5527 if w <= 0 || link < 1 || link > len(acts) || acts[link-1] == nil {
5528 return
5529 }
5530 u.links = append(u.links, linkRegion{x, y, w, acts[link-1]})
5531 }
5532}
5533
5534// hovered is the run of cells the pointer is over, if it is over one of
5535// the page's links: the header nav, the footer's two cells, or an anchor
5536// the visible panel drew.
5537func (u *ui) hovered(h int) (x1, x2, y int, ok bool) {
5538 switch {
5539 case u.hoverY < 0:
5540 case u.hoverY == 0:
5541 for _, reg := range u.navRegions {
5542 if u.hoverX >= reg.x1 && u.hoverX < reg.x2 {
5543 return reg.x1, reg.x2, 0, true
5544 }
5545 }
5546 case u.hoverY == h-1:
5547 for _, reg := range u.footRegions {
5548 if u.hoverX >= reg.x1 && u.hoverX < reg.x2 {
5549 return reg.x1, reg.x2, h - 1, true
5550 }
5551 }
5552 default:
5553 for _, l := range u.links {
5554 if u.hoverY == l.y && u.hoverX >= l.x && u.hoverX < l.x+l.w {
5555 return l.x, l.x + l.w, l.y, true
5556 }
5557 }
5558 }
5559 return 0, 0, 0, false
5560}
5561
5562// paintHover recolors the link under the pointer, as a:hover{color:green}
5563// does on the page.
5564//
5565// It repaints cells the page has already drawn rather than styling them
5566// as they go, because which cells belong to a link is only known once it
5567// is drawn β and this way no drawing code has to know where the pointer
5568// is. It runs before the overlays, so an open dropdown covers it rather
5569// than being recolored by it.
5570func (u *ui) paintHover(sc tcell.Screen, h int) {
5571 x1, x2, y, ok := u.hovered(h)
5572 if !ok {
5573 return
5574 }
5575 for x := x1; x < x2; x++ {
5576 str, st, _ := sc.Get(x, y)
5577 sc.Put(x, y, str, st.Foreground(colorHover))
5578 }
5579}
5580
5581func (u *ui) draw() {
5582 sc := u.screen
5583 w, h := sc.Size()
5584 if w < 4 || h < 8 {
5585 sc.Show()
5586 return
5587 }
5588 sc.HideCursor()
5589 u.links = u.links[:0]
5590 u.overlaysDrawn = 0
5591 fillRect(sc, rect{0, 0, w, h}, styleText)
5592 u.drawHeader(sc, w)
5593 // Row 1 is the header table's bottom border, so the masthead starts
5594 // below it, as the page's title block sits under the nav rule.
5595 for i, line := range u.masthead {
5596 printMarkupCenter(sc, 0, 2+i, w, line, styleText)
5597 }
5598 content := rect{0, 5, w, h - 8}
5599 u.mu.Lock()
5600 cur := u.current
5601 u.mu.Unlock()
5602 if p := u.panels[cur]; p != nil {
5603 p.draw(u, sc, content)
5604 }
5605 u.drawFooter(sc, w, h)
5606 u.paintHover(sc, h)
5607 var overlays []rect // what is drawn over the panel, which no placement may cover
5608 if u.commerce != nil {
5609 r := u.commerceRect()
5610 overlays = append(overlays, r)
5611 drawBox(sc, r, "[white] "+u.commerce.title()+" [-]", styleText.Foreground(tcell.ColorWhite.TrueColor()), styleText)
5612 u.overlaysDrawn = len(overlays)
5613 u.commerce.draw(u, sc, r.inner())
5614 }
5615 if u.drop != nil {
5616 r := u.dropRect()
5617 overlays = append(overlays, r)
5618 drawBox(sc, r, "[white] "+map[string]string{"cats": "Categories", "etc": "Etc..."}[u.drop.kind]+" [-]", styleBorder, styleText)
5619 u.drop.lst.draw(sc, r.inner())
5620 }
5621 if u.notice != "" {
5622 nw := markupWidth(u.notice) + 6
5623 if nw > w-4 {
5624 nw = w - 4
5625 }
5626 r := rect{(w - nw) / 2, h/2 - 2, nw, 5}
5627 overlays = append(overlays, r)
5628 drawBox(sc, r, "", styleText.Foreground(tcell.ColorWhite.TrueColor()), styleText)
5629 printMarkupCenter(sc, r.x+1, r.y+2, r.w-2, u.notice, styleText)
5630 }
5631 sc.Show()
5632 u.placeSync(overlays)
5633 u.announcePage()
5634}
5635
5636// drawHeader lays the nav out as the website's header table does: three
5637// equal columns β Categories, Home, Etc... β each label centered in its
5638// third, with its dropdown opening beneath it. The current section is
5639// cyan and underlined and the rest are white, as the page renders its
5640// links, and the row is closed by the header table's blue bottom border.
5641func (u *ui) drawHeader(sc tcell.Screen, w int) {
5642 entries := []struct{ label, act string }{
5643 {"Categories", "cats"}, {"Home", "home"}, {"Etc...", "etc"},
5644 }
5645 active := u.navActive()
5646 u.navRegions = u.navRegions[:0]
5647 third := w / 3
5648 for i, e := range entries {
5649 x := i*third + (third-len(e.label))/2
5650 if x < 0 {
5651 x = 0
5652 }
5653 tag := "[white]"
5654 if e.act == active {
5655 tag = "[aqua::u]"
5656 }
5657 printMarkup(sc, x, 0, w-x, tag+e.label+"[-:-:-]", styleText)
5658 u.navRegions = append(u.navRegions, struct {
5659 x1, x2 int
5660 act string
5661 }{x, x + len(e.label), e.act})
5662 }
5663 drawRule(sc, 0, 1, w, ruleBlue)
5664}
5665
5666// navActive names the header entry for the page on screen, the way the
5667// site marks the section you are in: the catalog pages belong to
5668// Categories, the stock pages to Etc..., everything else to Home.
5669func (u *ui) navActive() string {
5670 u.mu.Lock()
5671 cur := u.current
5672 u.mu.Unlock()
5673 switch {
5674 case strings.HasPrefix(cur, "list:"), strings.HasPrefix(cur, "prod:"):
5675 return "cats"
5676 case strings.HasPrefix(cur, "content:"):
5677 return "etc"
5678 }
5679 return "home"
5680}
5681
5682// drawFooter is the website's fixed footer: the status flash, address
5683// and key hints on one row, then the footer table itself β View Cart on
5684// the left half and Add Shipping Info on the right, each centered in its
5685// cell, ruled above and divided down the middle as the page's is.
5686func (u *ui) drawFooter(sc tcell.Screen, w, h int) {
5687 status := "[#FF10F0:#009EFF] Open For Business [-:-]"
5688 if f.Teststripekey {
5689 status = "[yellow:red] Test Mode Active - No Orders Processed [-:-]"
5690 }
5691 arrows := ""
5692 if len(u.history) > 0 {
5693 arrows += "β"
5694 }
5695 if len(u.fwd) > 0 {
5696 arrows += "β"
5697 }
5698 if arrows != "" {
5699 arrows = "[gray]" + arrows + "[-] "
5700 }
5701 line := fmt.Sprintf(" %s %s[aqua]%s[-] [gray]β« back Β· f fwd Β· c cats Β· h home Β· e etc Β· b buy Β· v cart Β· i img Β· d deco Β· w page Β· q quit[-]",
5702 status, arrows, u.pagePath())
5703 printMarkup(sc, 0, h-3, w, line, styleText)
5704
5705 // The footer table: a white rule for its top edge, then two equal
5706 // cells split by the divider the page draws between them.
5707 drawRule(sc, 0, h-2, w, ruleWhite)
5708 total, count := u.cartTotal()
5709 cart := fmt.Sprintf("β΄ View Cart Total: $%d.%02d", total/100, total%100)
5710 if count > 0 {
5711 cart = fmt.Sprintf("β΄ View Cart (%d) Total: $%d.%02d", count, total/100, total%100)
5712 }
5713 ship := "β΄ Add Shipping Info"
5714 u.footRegions = u.footRegions[:0]
5715 half := w / 2
5716 for i, e := range []struct{ label, act string }{{cart, "cart"}, {ship, "shipping"}} {
5717 x := i*half + (half-len([]rune(e.label)))/2
5718 if x < 0 {
5719 x = 0
5720 }
5721 printMarkup(sc, x, h-1, w-x, "[aqua]"+esc(e.label)+"[-]", styleText)
5722 u.footRegions = append(u.footRegions, struct {
5723 x1, x2 int
5724 act string
5725 }{x, x + len([]rune(e.label)), e.act})
5726 }
5727 sc.SetContent(half, h-1, 'β', nil, ruleWhite)
5728}
5729
5730func (u *ui) cartTotal() (cents, count int) {
5731 for partno, qty := range u.cartQty {
5732 if p := u.cat.find(partno); p != nil {
5733 cents += priceCents(p.Price) * qty
5734 count += qty
5735 }
5736 }
5737 if u.shipping != nil {
5738 cents += u.shipping.Cents
5739 }
5740 return cents, count
5741}
5742
5743func priceCents(s string) int {
5744 s = strings.TrimPrefix(s, "$")
5745 fl, err := strconv.ParseFloat(s, 64)
5746 if err != nil {
5747 return 0
5748 }
5749 return int(fl*100 + 0.5)
5750}
5751
5752// ---- input routing ----
5753
5754func (u *ui) handleKey(ev *tcell.EventKey) {
5755 if ev.Key() == tcell.KeyCtrlC {
5756 u.quit = true
5757 return
5758 }
5759 if u.notice != "" {
5760 u.notice = ""
5761 return
5762 }
5763 if u.drop != nil {
5764 if u.drop.lst.key(ev) {
5765 return
5766 }
5767 r := keyRune(ev)
5768 if ev.Key() == tcell.KeyEscape ||
5769 (r == 'c' && u.drop.kind == "cats") || (r == 'e' && u.drop.kind == "etc") {
5770 u.drop = nil
5771 return
5772 }
5773 }
5774 if u.commerce != nil {
5775 if u.commerce.key(u, ev) {
5776 return
5777 }
5778 if ev.Key() == tcell.KeyEscape {
5779 u.closeCommerce()
5780 return
5781 }
5782 }
5783 switch ev.Key() {
5784 case tcell.KeyEscape:
5785 u.back()
5786 return
5787 case tcell.KeyBackspace, tcell.KeyBackspace2:
5788 u.back()
5789 return
5790 }
5791 if ev.Key() == tcell.KeyRune {
5792 switch keyRune(ev) {
5793 case 'q':
5794 u.quit = true
5795 return
5796 case 'c':
5797 u.openCategories()
5798 return
5799 case 'h':
5800 u.show("home")
5801 return
5802 case 'e':
5803 u.openEtc()
5804 return
5805 case 'f':
5806 u.forward()
5807 return
5808 case 'v':
5809 u.toggleCart()
5810 return
5811 case 'i':
5812 if u.mode == modeHalfBlock {
5813 u.mode = modeCaca
5814 } else {
5815 u.mode = modeHalfBlock
5816 }
5817 return // the changed mode re-keys art requests at next draw
5818 case 'w':
5819 u.cellsOnly = !u.cellsOnly
5820 return
5821 case 'd':
5822 // The frame is drawn out of the table's own area, so it
5823 // costs rows of products. Worth having and worth being able
5824 // to put away, which the page never needs to offer.
5825 u.bare = !u.bare
5826 return
5827 }
5828 }
5829 if u.drop != nil || u.commerce != nil {
5830 return
5831 }
5832 u.mu.Lock()
5833 cur := u.current
5834 u.mu.Unlock()
5835 if p := u.panels[cur]; p != nil {
5836 p.key(u, ev)
5837 }
5838}
5839
5840func (u *ui) handleMouse(ev *tcell.EventMouse) {
5841 x, y := ev.Position()
5842 w, h := u.screen.Size()
5843 click := ev.Buttons()&tcell.Button1 != 0
5844 // Where the pointer is, for a:hover. Every event carries it, so bare
5845 // motion is not the only thing that keeps it current.
5846 u.hoverX, u.hoverY = x, y
5847 if u.notice != "" {
5848 if click {
5849 u.notice = ""
5850 }
5851 return
5852 }
5853 wasOpen := ""
5854 if u.drop != nil {
5855 r := u.dropRect().inner()
5856 if r.contains(x, y) {
5857 u.drop.lst.mouse(y-r.y, ev)
5858 return
5859 }
5860 if !click {
5861 return
5862 }
5863 // Close, and let the click land: clicking the other header label
5864 // switches menus in one click, as details dropdowns do β but a
5865 // click on this menu's own label must collapse it, not reopen it.
5866 wasOpen = u.drop.kind
5867 u.drop = nil
5868 }
5869 if click && y == 0 {
5870 for _, reg := range u.navRegions {
5871 if x >= reg.x1 && x < reg.x2 {
5872 switch reg.act {
5873 case "cats":
5874 if wasOpen != "cats" {
5875 u.openCategories()
5876 }
5877 case "home":
5878 u.show("home")
5879 case "etc":
5880 if wasOpen != "etc" {
5881 u.openEtc()
5882 }
5883 }
5884 return
5885 }
5886 }
5887 return
5888 }
5889 if click && y == h-1 {
5890 for _, reg := range u.footRegions {
5891 if x >= reg.x1 && x < reg.x2 {
5892 switch reg.act {
5893 case "cart":
5894 u.toggleCart()
5895 case "shipping":
5896 if _, open := u.commerce.(*shippingOverlay); open {
5897 u.closeCommerce()
5898 } else {
5899 u.openShipping()
5900 }
5901 }
5902 return
5903 }
5904 }
5905 return
5906 }
5907 if u.commerce != nil {
5908 r := u.commerceRect()
5909 if !r.contains(x, y) && click {
5910 u.closeCommerce()
5911 }
5912 return
5913 }
5914 // An anchor first: on the page these are <a>, and a click on one
5915 // follows it rather than reaching the widget it was drawn over.
5916 if click {
5917 for _, l := range u.links {
5918 if y == l.y && x >= l.x && x < l.x+l.w {
5919 l.act()
5920 return
5921 }
5922 }
5923 }
5924 content := rect{0, 5, w, h - 8}
5925 if content.contains(x, y) {
5926 u.mu.Lock()
5927 cur := u.current
5928 u.mu.Unlock()
5929 if p := u.panels[cur]; p != nil {
5930 p.mouse(u, content, ev)
5931 }
5932 }
5933}
5934
5935// ---- navigation ----
5936
5937func (u *ui) show(page string) {
5938 u.drop = nil
5939 u.closeCommerce()
5940 u.mu.Lock()
5941 if page == u.current {
5942 u.mu.Unlock()
5943 return
5944 }
5945 u.history = append(u.history, u.current)
5946 u.current = page
5947 u.mu.Unlock()
5948 u.fwd = nil
5949 u.ensurePanel(page)
5950}
5951
5952func (u *ui) back() {
5953 u.mu.Lock()
5954 if len(u.history) == 0 {
5955 u.mu.Unlock()
5956 return
5957 }
5958 page := u.history[len(u.history)-1]
5959 u.history = u.history[:len(u.history)-1]
5960 u.fwd = append(u.fwd, u.current)
5961 u.current = page
5962 u.mu.Unlock()
5963}
5964
5965func (u *ui) forward() {
5966 u.mu.Lock()
5967 if len(u.fwd) == 0 {
5968 u.mu.Unlock()
5969 return
5970 }
5971 page := u.fwd[len(u.fwd)-1]
5972 u.fwd = u.fwd[:len(u.fwd)-1]
5973 u.history = append(u.history, u.current)
5974 u.current = page
5975 u.mu.Unlock()
5976 u.ensurePanel(page)
5977}
5978
5979func (u *ui) ensurePanel(page string) {
5980 if _, ok := u.panels[page]; ok {
5981 return
5982 }
5983 switch {
5984 case strings.HasPrefix(page, "list:"):
5985 spec := strings.TrimPrefix(page, "list:")
5986 parts := strings.SplitN(spec, "|", 2)
5987 cat, sub := parts[0], ""
5988 if len(parts) == 2 {
5989 sub = parts[1]
5990 }
5991 u.panels[page] = newListPanel(u, page, cat, sub)
5992 case strings.HasPrefix(page, "prod:"):
5993 u.panels[page] = newProductPanel(u, strings.TrimPrefix(page, "prod:"))
5994 case strings.HasPrefix(page, "content:"):
5995 u.panels[page] = newContentPanel(u, strings.TrimPrefix(page, "content:"))
5996 }
5997}
5998
5999func listPageID(t navTarget) string {
6000 if t.cat == "" {
6001 return "list:"
6002 }
6003 if t.subcat == "" {
6004 return "list:" + t.cat
6005 }
6006 return "list:" + t.cat + "|" + t.subcat
6007}
6008
6009// pagePath renders the current panel as the URL path the website would
6010// give it β the footer shows it like an address bar.
6011func (u *ui) pagePath() string {
6012 u.mu.Lock()
6013 cur := u.current
6014 u.mu.Unlock()
6015 switch {
6016 case cur == "home":
6017 return "/"
6018 case strings.HasPrefix(cur, "content:"):
6019 return "/#" + strings.TrimPrefix(cur, "content:")
6020 case strings.HasPrefix(cur, "prod:"):
6021 return "/p/" + strings.TrimPrefix(cur, "prod:")
6022 case strings.HasPrefix(cur, "list:"):
6023 spec := strings.TrimPrefix(cur, "list:")
6024 if spec == "" {
6025 return "/cat"
6026 }
6027 return "/cat/" + strings.ReplaceAll(spec, "|", "/")
6028 }
6029 return "/"
6030}
6031
6032// ---- header dropdowns ----
6033
6034// dropRect puts an open header menu against the edge its entry is
6035// nearest: Categories at the left, Etc at the right.
6036//
6037// The page does this as a consequence of how its header is built β each
6038// menu is absolutely positioned inside its own cell of a full-width
6039// table, so the first lands at the far left and the last at the far
6040// right β and the result reads as deliberate: a pair of menus bracketing
6041// the header rather than two labels with boxes hanging under them. The
6042// terminal now does it on purpose.
6043func (u *ui) dropRect() rect {
6044 sw, sh := u.screen.Size()
6045 x := 0
6046 if u.drop.kind == "etc" {
6047 x = sw - u.drop.width
6048 }
6049 if x < 0 {
6050 x = 0
6051 }
6052 h := len(u.drop.lst.items) + 2
6053 if max := sh - 4; h > max {
6054 h = max
6055 }
6056 return rect{x, 2, u.drop.width, h}
6057}
6058
6059// openCategories drops down the catsubcats tree. Like the template's
6060// nested <details>, categories open and close: the current category
6061// starts open, Enter on a closed category opens it (Enter again
6062// navigates), β/space toggle, β closes.
6063func (u *ui) openCategories() {
6064 if u.drop != nil && u.drop.kind == "cats" {
6065 u.drop = nil
6066 return
6067 }
6068 if cat, _ := u.currentTarget(); cat != "" {
6069 u.expanded[cat] = true
6070 }
6071 // Width from the fully expanded tree, so the box doesn't resize as
6072 // branches open and close.
6073 width := 20
6074 for _, it := range u.cat.buildTree(nil) {
6075 if w := len([]rune(it.label)) + 4; w > width {
6076 width = w
6077 }
6078 }
6079 d := &dropState{kind: "cats", lst: &list{}, width: width}
6080 u.drop = d
6081 rebuild := func(selCat string) {
6082 items := u.cat.buildTree(u.expanded)
6083 d.lst.items = d.lst.items[:0]
6084 d.targets = d.targets[:0]
6085 sel := 0
6086 for i, it := range items {
6087 d.lst.items = append(d.lst.items, esc(it.label))
6088 d.targets = append(d.targets, it.target)
6089 if it.target.cat == selCat && it.target.subcat == "" {
6090 sel = i
6091 }
6092 }
6093 d.lst.sel = sel
6094 }
6095 rebuild("")
6096 itemAt := func(i int) (navTarget, bool) {
6097 if i < 0 || i >= len(d.targets) {
6098 return navTarget{}, false
6099 }
6100 return d.targets[i], true
6101 }
6102 hasSubs := func(t navTarget) bool {
6103 return t.cat != "" && t.subcat == "" && len(u.cat.subcatsByCat[t.cat]) > 0
6104 }
6105 d.lst.onPick = func(i int) {
6106 t, ok := itemAt(i)
6107 if !ok {
6108 return
6109 }
6110 if hasSubs(t) && !u.expanded[t.cat] {
6111 u.expanded[t.cat] = true
6112 rebuild(t.cat)
6113 return
6114 }
6115 u.show(listPageID(t))
6116 }
6117 d.lst.onKey = func(ev *tcell.EventKey, sel int) bool {
6118 t, ok := itemAt(sel)
6119 if !ok {
6120 return false
6121 }
6122 switch {
6123 case ev.Key() == tcell.KeyRight:
6124 if hasSubs(t) && !u.expanded[t.cat] {
6125 u.expanded[t.cat] = true
6126 rebuild(t.cat)
6127 }
6128 return true
6129 case ev.Key() == tcell.KeyLeft:
6130 if t.cat != "" && (u.expanded[t.cat] || t.subcat != "") {
6131 u.expanded[t.cat] = false
6132 rebuild(t.cat)
6133 }
6134 return true
6135 case keyRune(ev) == ' ':
6136 if t.cat == "" {
6137 return true
6138 }
6139 if t.subcat != "" {
6140 u.expanded[t.cat] = false
6141 } else if hasSubs(t) {
6142 u.expanded[t.cat] = !u.expanded[t.cat]
6143 }
6144 rebuild(t.cat)
6145 return true
6146 }
6147 return false
6148 }
6149}
6150
6151// currentTarget reports which category/subcategory panel is showing.
6152func (u *ui) currentTarget() (string, string) {
6153 u.mu.Lock()
6154 cur := u.current
6155 u.mu.Unlock()
6156 if !strings.HasPrefix(cur, "list:") {
6157 return "", ""
6158 }
6159 parts := strings.SplitN(strings.TrimPrefix(cur, "list:"), "|", 2)
6160 if len(parts) == 2 {
6161 return parts[0], parts[1]
6162 }
6163 return parts[0], ""
6164}
6165
6166// openEtc drops down the Etc... menu: About, Policy, Telegram, Contact, Links.
6167func (u *ui) openEtc() {
6168 if u.drop != nil && u.drop.kind == "etc" {
6169 u.drop = nil
6170 return
6171 }
6172 type entry struct {
6173 label string
6174 fn func()
6175 }
6176 var entries []entry
6177 add := func(label string, fn func()) { entries = append(entries, entry{label, fn}) }
6178 add("About", func() { u.show("content:about") })
6179 add("Policy", func() { u.show("content:policy") })
6180 // The Telegram entries are links out, and now follow like any other:
6181 // the browser build opens the chat, and in a terminal openURL shows
6182 // the address, which is what this did in both places before.
6183 if f.Tgchannel != "" {
6184 href := "https://t.me/" + f.Tgchannel
6185 add("Telegram", func() { u.drop = nil; u.openURL(href) })
6186 }
6187 if f.Tgcontact != "" {
6188 href := "https://t.me/" + f.Tgcontact
6189 add("Contact", func() { u.drop = nil; u.openURL(href) })
6190 }
6191 add("Links", func() { u.show("content:links") })
6192 // Whatever the deployment's drop-ins added to the page's menu, in the
6193 // same place and the same order the header puts them: after Links.
6194 // follow decides what each one means here β a page the TUI has of its
6195 // own, or a link out.
6196 for _, nl := range navLinks {
6197 act := u.follow(nl.Href)
6198 if act == nil {
6199 continue
6200 }
6201 add(nl.Title, func() { u.drop = nil; act() })
6202 }
6203 lst := &list{}
6204 for _, e := range entries {
6205 lst.items = append(lst.items, esc(e.label))
6206 }
6207 lst.onPick = func(i int) { entries[i].fn() }
6208 u.drop = &dropState{kind: "etc", lst: lst, width: 30}
6209}
6210
6211// ---- the render worker ----
6212
6213func (u *ui) requestArt(h *artHolder, path string, w, ht int) {
6214 key := fmt.Sprintf("%s|%d|%d|%d", path, w, ht, u.mode)
6215 if h.key == key || h.want == key {
6216 return
6217 }
6218 h.want = key
6219 req := artReq{key: key, path: path, w: w, h: ht, mode: u.mode, holder: h}
6220 select {
6221 case u.reqCh <- req:
6222 default:
6223 select { // replace the stale pending request
6224 case <-u.reqCh:
6225 default:
6226 }
6227 u.reqCh <- req
6228 }
6229}
6230
6231func (u *ui) renderWorker() {
6232 cache := map[string]artwork{}
6233 for req := range u.reqCh {
6234 art, ok := cache[req.key]
6235 if !ok {
6236 art = renderArt(req.path, req.w, req.h, req.mode)
6237 if len(cache) > 128 {
6238 cache = map[string]artwork{}
6239 }
6240 cache[req.key] = art
6241 }
6242 r := req
6243 a := art
6244 u.post(func() {
6245 r.holder.key = r.key
6246 r.holder.art = &a
6247 })
6248 }
6249}
6250
6251// ---- the home panel ----
6252
6253type homePanel struct{}
6254
6255func (hp *homePanel) draw(u *ui, sc tcell.Screen, r rect) {
6256 u.mu.Lock()
6257 u.homeW, u.homeH = r.w, r.h
6258 frame := u.frame
6259 u.mu.Unlock()
6260 if frame == nil {
6261 return
6262 }
6263 rows := (frame.Bounds().Dy() + 1) / 2
6264 drawRGBA(sc, r.x, r.y, r.w, r.h, frame)
6265 // The website's own logo and globe over these cells, where the page
6266 // offers its globe (place.go).
6267 // Across the panel's whole width, the black either side of the logo
6268 // included: the picture there is the widget's, so a click on it is too
6269 // (it stops the globe). The logo is letterboxed to the same place.
6270 u.want("home", placed{r: rect{r.x, r.y, r.w, min(rows, r.h)}, widget: "globe", input: true})
6271 if rows < r.h {
6272 ty := r.y + rows
6273 for i, l := range u.asciiLines {
6274 if ty+i >= r.y+r.h {
6275 break
6276 }
6277 printMarkupCenter(sc, r.x, ty+i, r.w, esc(l), styleText)
6278 }
6279 if ty+len(u.asciiLines)+1 < r.y+r.h {
6280 printMarkupCenter(sc, r.x, ty+len(u.asciiLines)+1, r.w, esc(u.caps), styleText)
6281 // The page's double-struck capitals over them, where a page can
6282 // set them (the "caps" widget, widgets_js.go); a terminal keeps these.
6283 u.want("caps", placed{r: rect{r.x, ty + len(u.asciiLines) + 1, r.w, 1}, widget: "caps"})
6284 }
6285 }
6286}
6287
6288func (hp *homePanel) key(*ui, *tcell.EventKey) bool { return false }
6289func (hp *homePanel) mouse(*ui, rect, *tcell.EventMouse) bool { return false }
6290
6291// animateHome turns the globe over the logo while the home panel is
6292// shown, like the website's landing animation over the svg.
6293func (u *ui) animateHome() {
6294 ticker := time.NewTicker(140 * time.Millisecond)
6295 defer ticker.Stop()
6296 for {
6297 select {
6298 case <-u.done:
6299 return
6300 case <-ticker.C:
6301 }
6302 u.mu.Lock()
6303 cur, w, h := u.current, u.homeW, u.homeH
6304 u.mu.Unlock()
6305 if cur != "home" || w <= 0 || h <= 0 {
6306 continue
6307 }
6308 globeRows := h - len(u.asciiLines) - 2
6309 if globeRows < 4 {
6310 globeRows = h
6311 }
6312 if u.backdrop == nil || u.bdW != w || u.bdH != globeRows {
6313 u.backdrop = makeBackdrop(u.logo, w, globeRows)
6314 u.bdW, u.bdH = w, globeRows
6315 }
6316 if u.homeCovered.Load() {
6317 // The page's globe turns over these cells: drawing ours under
6318 // it would cost the tab a core for nothing seen. The logo alone,
6319 // once, which then costs nothing.
6320 u.mu.Lock()
6321 if u.frame != u.backdrop {
6322 u.frame = u.backdrop
6323 u.mu.Unlock()
6324 u.post(nil)
6325 } else {
6326 u.mu.Unlock()
6327 }
6328 continue
6329 }
6330 u.poseX += u.rateX
6331 u.poseY += u.rateY
6332 u.poseZ += u.rateZ
6333 frame := globeFrame(u.backdrop, u.poseX, u.poseY, u.poseZ)
6334 u.mu.Lock()
6335 u.frame = frame
6336 u.mu.Unlock()
6337 u.post(nil) // just redraw
6338 }
6339}
6340
6341// ---- category / subcategory listings ----
6342
6343// listPanel renders a category page: category.html's h1, the sidebar's
6344// list of subcategories, and the product table, with the selected row's
6345// image beside it (the terminal's stand-in for the table's image column).
6346type listPanel struct {
6347 page, cat, sub string
6348 heading []string
6349 links []func() // what this page's anchors do; see linkTag
6350 cols []storepage.Col // the shared column set, image included
6351 shown []storepage.Col // the ones this view draws, in table order
6352 prods []*product.Product // panel's full set
6353 rows []*product.Product // filtered
6354 tbl *table
6355 art artHolder
6356 filterOn bool
6357 filter finput
6358}
6359
6360// link registers an action for this page and returns the markup that
6361// wraps s in it.
6362func (lp *listPanel) link(s string, act func()) string {
6363 lp.links = append(lp.links, act)
6364 return linkTag(len(lp.links), s)
6365}
6366
6367func newListPanel(u *ui, page, cat, sub string) *listPanel {
6368 lp := &listPanel{page: page, cat: cat, sub: sub}
6369 all := u.cat.products(cat, sub)
6370 for i := range all {
6371 lp.prods = append(lp.prods, &all[i])
6372 }
6373 // category.html's h1, carrying the same links: the category name goes
6374 // to the category β which is how a subcategory page climbs back β and
6375 // each name links to its own page as the template's do.
6376 catLink := func(t navTarget, label string) string {
6377 return lp.link("[aqua]"+esc(label)+"[-]", func() { u.show(listPageID(t)) })
6378 }
6379 title := "All Products"
6380 switch {
6381 case sub != "":
6382 title = "Category: " + catLink(navTarget{cat: cat}, cat) +
6383 " | Subcategory: " + catLink(navTarget{cat: cat, subcat: sub}, sub)
6384 case cat != "":
6385 title = "Category: " + catLink(navTarget{cat: cat}, cat)
6386 }
6387 lp.heading = []string{"[::b]" + title + "[-:-:-]"}
6388 // The page's aside lists the category's subcategories under a
6389 // "Subcategories:" heading; the Categories dropdown is the terminal's
6390 // aside, so the list goes on the page where it can be reached.
6391 if cat != "" && sub == "" {
6392 if subs := u.cat.subcatsByCat[cat]; len(subs) > 0 {
6393 var parts []string
6394 for _, s := range subs {
6395 target := navTarget{cat: cat, subcat: s}
6396 parts = append(parts, fmt.Sprintf("%s (%d)",
6397 lp.link("[aqua]"+esc(s)+"[-]", func() { u.show(listPageID(target)) }),
6398 u.cat.subcatCounts[cat][s]))
6399 }
6400 lp.heading = append(lp.heading, "Subcategories: "+strings.Join(parts, ", "))
6401 }
6402 }
6403 // The columns are storepage's, so the page and the terminal list the
6404 // same things in the same order. Image is the one column a terminal
6405 // answers differently β it has one picture to spend, beside the
6406 // table, not a thumbnail per row β so it is dropped here and the art
6407 // panel stands in for it. Subcategory only where the rows can differ
6408 // in it, which is what storepage is being asked.
6409 lp.cols = storepage.Columns(sub == "")
6410 var cols []tcol
6411 for _, c := range lp.cols {
6412 if c.Kind == storepage.ColImage {
6413 continue
6414 }
6415 cols = append(cols, tcol{
6416 title: c.Title,
6417 width: c.Width,
6418 alignRight: c.Align == storepage.AlignRight,
6419 link: c.Kind == storepage.ColName || c.Kind == storepage.ColSubcategory || c.Kind == storepage.ColBuy,
6420 })
6421 lp.shown = append(lp.shown, c)
6422 }
6423 lp.tbl = &table{cols: cols}
6424 lp.tbl.onActivate = func(i int) {
6425 if i >= 0 && i < len(lp.rows) {
6426 u.show("prod:" + lp.rows[i].Partno)
6427 }
6428 }
6429 lp.fill("")
6430 return lp
6431}
6432
6433func (lp *listPanel) fill(query string) {
6434 lp.rows = lp.rows[:0]
6435 var rows []trow
6436 query = strings.ToLower(query)
6437 for _, p := range lp.prods {
6438 if query != "" && !strings.Contains(strings.ToLower(
6439 p.Partno+" "+p.Name+" "+p.Subcategory+" "+p.Description1), query) {
6440 continue
6441 }
6442 row := trow{}
6443 for _, c := range lp.shown {
6444 cell := storepage.CellFor(c, p)
6445 row.cells = append(row.cells, tcell_{cell.Text, cellStyle(c, p)})
6446 }
6447 rows = append(rows, row)
6448 lp.rows = append(lp.rows, p)
6449 }
6450 lp.tbl.setRows(rows)
6451}
6452
6453// cellStyle dresses a shared cell in the site's colors: links cyan, the
6454// buy button a button, and an out-of-stock name grey β which is the page
6455// greying a row it will not sell.
6456func cellStyle(c storepage.Col, p *product.Product) tcell.Style {
6457 switch c.Kind {
6458 case storepage.ColName:
6459 if !storepage.InStock(p) {
6460 return styleText.Foreground(tcell.GetColor("#808080"))
6461 }
6462 return styleLink
6463 case storepage.ColSubcategory:
6464 return styleLink
6465 case storepage.ColBuy:
6466 return styleButton
6467 }
6468 return styleText
6469}
6470
6471func (lp *listPanel) selected() *product.Product {
6472 if lp.tbl.sel >= 0 && lp.tbl.sel < len(lp.rows) {
6473 return lp.rows[lp.tbl.sel]
6474 }
6475 return nil
6476}
6477
6478// layout places the panel's parts. tblR is where the table's cells go,
6479// which is inside the frame when there is room for one β brd and brdR
6480// carry that frame and where it is drawn. Both draw and mouse take the
6481// table's rectangle from here, or a click would land a row or two off
6482// whatever the frame pushed the table to.
6483func (lp *listPanel) layout(u *ui, r rect) (head, tblR, artR, filtR, brdR rect, brd *borderArt) {
6484 hh := len(lp.heading)
6485 head = rect{r.x + 1, r.y, r.w - 2, hh}
6486 body := rect{r.x, r.y + hh + 1, r.w, r.h - hh - 1}
6487 fh := 0
6488 if lp.filterOn {
6489 fh = 1
6490 }
6491 tw := body.w * 3 / 5
6492 tblR = rect{body.x + 1, body.y, tw - 2, body.h - fh}
6493 artR = rect{body.x + tw, body.y, body.w - tw, body.h - fh}
6494 filtR = rect{body.x + 1, body.y + body.h - 1, body.w - 2, 1}
6495 if !u.bare {
6496 tblR, brd, brdR = framed(tblR)
6497 }
6498 return
6499}
6500
6501func (lp *listPanel) draw(u *ui, sc tcell.Screen, r rect) {
6502 head, tblR, artR, filtR, brdR, brd := lp.layout(u, r)
6503 if brd != nil {
6504 brd.draw(sc, brdR)
6505 }
6506 sink := u.linkSink(lp.links)
6507 for i, line := range lp.heading {
6508 printMarkupLinks(sc, head.x, head.y+i, head.w, line, styleText, sink)
6509 }
6510 // A row has the two the page's row has: the name opens the product,
6511 // as the product image does there, and Buy adds it to the cart.
6512 // Registering them as anchors is what makes one click do it and the
6513 // pointer light them up. Both select the row first, so the photo
6514 // beside the table shows what was just acted on.
6515 lp.tbl.onLink = func(x, y, w, row, col int) {
6516 if col >= len(lp.shown) {
6517 return
6518 }
6519 kind := lp.shown[col].Kind
6520 u.links = append(u.links, linkRegion{x, y, w, func() {
6521 lp.tbl.sel = row
6522 p := lp.selected()
6523 if p == nil {
6524 return
6525 }
6526 // What the cell says it is, rather than what column it
6527 // landed in: the shared cell already carries where it goes
6528 // and whether it is the row's buy.
6529 cell := storepage.CellFor(storepage.Col{Kind: kind}, p)
6530 switch {
6531 case cell.Buy:
6532 u.addToCart(p)
6533 case cell.Href != "":
6534 if act := u.follow(cell.Href); act != nil {
6535 act()
6536 }
6537 }
6538 }})
6539 }
6540 lp.tbl.draw(sc, tblR)
6541 if p := lp.selected(); p != nil {
6542 u.requestArt(&lp.art, imagePath(p), artR.w, artR.h)
6543 }
6544 if ar, ok := drawArtwork(sc, artR, lp.art.art); ok && lp.selected() != nil {
6545 u.want("photo", placed{r: ar, url: imagePath(lp.selected()), fit: "cover"})
6546 }
6547 if lp.filterOn {
6548 printMarkup(sc, filtR.x, filtR.y, 3, "[aqua]/ [-]", styleText)
6549 curX, _ := lp.filter.drawValue(sc, filtR.x+2, filtR.y, filtR.w-2, true)
6550 sc.ShowCursor(curX, filtR.y)
6551 }
6552}
6553
6554func (lp *listPanel) key(u *ui, ev *tcell.EventKey) bool {
6555 if lp.filterOn {
6556 switch ev.Key() {
6557 case tcell.KeyEscape:
6558 lp.filterOn = false
6559 lp.filter.text = nil
6560 lp.filter.cur = 0
6561 lp.fill("")
6562 return true
6563 case tcell.KeyEnter:
6564 lp.filterOn = false
6565 return true
6566 }
6567 if lp.filter.key(ev) {
6568 lp.fill(string(lp.filter.text))
6569 return true
6570 }
6571 return ev.Key() == tcell.KeyRune
6572 }
6573 if ev.Key() == tcell.KeyRune {
6574 switch keyRune(ev) {
6575 case '/':
6576 lp.filterOn = true
6577 lp.filter = finput{width: 40}
6578 return true
6579 case 'b':
6580 if p := lp.selected(); p != nil {
6581 u.addToCart(p)
6582 }
6583 return true
6584 }
6585 }
6586 return lp.tbl.key(ev)
6587}
6588
6589func (lp *listPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
6590 _, tblR, _, _, _, _ := lp.layout(u, r)
6591 x, y := ev.Position()
6592 if ev.Buttons()&(tcell.WheelUp|tcell.WheelDown) != 0 {
6593 // the wheel drives the table wherever it is rolled, the picture
6594 // side included
6595 return lp.tbl.mouse(1, ev)
6596 }
6597 if tblR.contains(x, y) {
6598 return lp.tbl.mouse(y-tblR.y, ev)
6599 }
6600 return false
6601}
6602
6603// ---- the product panel ----
6604
6605type productPanel struct {
6606 prod *product.Product
6607 ta *textArea
6608 art artHolder
6609 links []func() // what this page's anchors do; see linkTag
6610}
6611
6612func newProductPanel(u *ui, partno string) panel {
6613 p := u.cat.find(partno)
6614 if p == nil {
6615 return newContentPanel(u, "") // empty
6616 }
6617 pp := &productPanel{prod: p}
6618 pp.ta = newTextArea(productLines(u, p, func(text string, act func()) string {
6619 pp.links = append(pp.links, act)
6620 return linkTag(len(pp.links), text)
6621 }), true)
6622 return pp
6623}
6624
6625func (pp *productPanel) draw(u *ui, sc tcell.Screen, r rect) {
6626 ih := r.h * 3 / 5
6627 imgR := rect{r.x, r.y, r.w, ih}
6628 txtR := rect{r.x + 1, r.y + ih + 1, r.w - 2, r.h - ih - 1}
6629 u.requestArt(&pp.art, imagePath(pp.prod), imgR.w, imgR.h)
6630 if ar, ok := drawArtwork(sc, imgR, pp.art.art); ok {
6631 u.want("photo", placed{r: ar, url: imagePath(pp.prod), fit: "cover"})
6632 }
6633 pp.ta.onLink = u.linkSink(pp.links)
6634 pp.ta.draw(sc, txtR)
6635}
6636
6637func (pp *productPanel) key(u *ui, ev *tcell.EventKey) bool {
6638 if ev.Key() == tcell.KeyEnter || keyRune(ev) == 'b' {
6639 u.addToCart(pp.prod)
6640 return true
6641 }
6642 return pp.ta.key(ev)
6643}
6644
6645func (pp *productPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
6646 switch {
6647 case ev.Buttons()&tcell.WheelUp != 0:
6648 pp.ta.wheel(true)
6649 case ev.Buttons()&tcell.WheelDown != 0:
6650 pp.ta.wheel(false)
6651 default:
6652 return false
6653 }
6654 return true
6655}
6656
6657func (u *ui) addToCart(p *product.Product) {
6658 if p.Quantity == "0" {
6659 u.notice = esc(p.Name) + " is out of stock"
6660 return
6661 }
6662 if _, ok := u.cartQty[p.Partno]; !ok {
6663 u.cartOrder = append(u.cartOrder, p.Partno)
6664 }
6665 u.cartQty[p.Partno]++
6666 u.cartChanged()
6667}
6668
6669// ---- content pages: about, policy, links ----
6670
6671type contentPanel struct {
6672 ta *textArea
6673 links []func() // what this page's anchors do; see linkTag
6674 // The page is re-rendered when the pane changes width, because the
6675 // tables in it are laid out to a column count. raw is kept so that
6676 // costs a re-render and not a re-fetch.
6677 u *ui
6678 raw string
6679 year string
6680 lastW int
6681}
6682
6683// contentFile mirrors pkg/web: the deployment-local file when present,
6684// falling back to the committed .example.
6685func contentFile(path string) string {
6686 if data, err := os.ReadFile(path); err == nil { //nolint
6687 return string(data)
6688 }
6689 data, err := os.ReadFile(path + ".example") //nolint
6690 if err != nil {
6691 return ""
6692 }
6693 return string(data)
6694}
6695
6696func newContentPanel(u *ui, name string) *contentPanel {
6697 cp := &contentPanel{
6698 u: u,
6699 raw: fetchContent(name),
6700 year: fmt.Sprintf("%d", time.Now().Year()),
6701 ta: newTextArea("[gray]no content/"+esc(name)+".html[-]", true),
6702 }
6703 return cp
6704}
6705
6706// render lays the page out for a pane w columns wide, keeping the reader
6707// where they were: setContent rewinds to the top, which is the right thing
6708// on a new page and the wrong thing on a resize.
6709func (cp *contentPanel) render(w int) {
6710 cp.lastW = w
6711 if cp.raw == "" {
6712 return
6713 }
6714 text, hrefs := htmlToText(cp.raw, cp.year, w)
6715 at := cp.ta.scroll
6716 cp.ta.setContent(text)
6717 cp.ta.scroll = at
6718 cp.links = cp.links[:0]
6719 for _, href := range hrefs {
6720 cp.links = append(cp.links, cp.u.follow(href))
6721 }
6722}
6723
6724// follow is what clicking an href does: a path this store serves is a
6725// page the TUI has of its own, and anything else is a link out.
6726func (u *ui) follow(href string) func() {
6727 switch {
6728 case href == "", href == "#":
6729 return nil
6730 case href == "/", href == "/#", href == "#home", href == "/#home":
6731 return func() { u.show("home") }
6732 // The site's own menu spells these "/#about" so that they work from a
6733 // page other than the front one, where a bare "#about" would only
6734 // append a fragment to the path already there. Both forms are the
6735 // same page here.
6736 case href == "#about", href == "#policy", href == "#links",
6737 href == "/#about", href == "/#policy", href == "/#links":
6738 page := "content:" + strings.TrimPrefix(strings.TrimPrefix(href, "/"), "#")
6739 return func() { u.show(page) }
6740 case strings.HasPrefix(href, "/p/"):
6741 if p := u.cat.find(strings.TrimPrefix(href, "/p/")); p != nil {
6742 partno := p.Partno
6743 return func() { u.show("prod:" + partno) }
6744 }
6745 abs := storeURL() + href
6746 return func() { u.openURL(abs) }
6747 case strings.HasPrefix(href, "/cat/"):
6748 // The site spells a subcategory in a URL through escapesubcat
6749 // ("ΒΌ watt 5%" is quarter-watt-5-pct), so a path only names a
6750 // page here when the catalog has those names as written. When it
6751 // does not, the store's own URL resolves it and the link goes
6752 // out rather than landing on an empty table.
6753 if t, ok := u.cat.resolve(strings.TrimPrefix(href, "/cat/")); ok {
6754 return func() { u.show(listPageID(t)) }
6755 }
6756 abs := storeURL() + href
6757 return func() { u.openURL(abs) }
6758 case strings.HasPrefix(href, "/"):
6759 // Some other page of this site β /logo, /sitemap.xml. The TUI has
6760 // no view of it, so hand over the address on the store's origin.
6761 abs := storeURL() + href
6762 return func() { u.openURL(abs) }
6763 }
6764 return func() { u.openURL(href) }
6765}
6766
6767func (cp *contentPanel) draw(u *ui, sc tcell.Screen, r rect) {
6768 inner := rect{r.x + 1, r.y, r.w - 2, r.h}
6769 if inner.w > 0 && inner.w != cp.lastW {
6770 cp.render(inner.w)
6771 }
6772 cp.ta.onLink = u.linkSink(cp.links)
6773 cp.ta.draw(sc, inner)
6774}
6775
6776func (cp *contentPanel) key(u *ui, ev *tcell.EventKey) bool {
6777 return cp.ta.key(ev)
6778}
6779
6780func (cp *contentPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
6781 switch {
6782 case ev.Buttons()&tcell.WheelUp != 0:
6783 cp.ta.wheel(true)
6784 case ev.Buttons()&tcell.WheelDown != 0:
6785 cp.ta.wheel(false)
6786 default:
6787 return false
6788 }
6789 return true
6790}
6791
6792
6793// ===== pkg/tui/widgets.go =====
6794// Package tui pkg/tui/widgets.go β the widget kit: a table, a list, a
6795// scrollable text area, and a form, written directly on tcell. Small on
6796// purpose: these are the site's shapes (the category table, the
6797// catsubcats dropdown, product pages, Add Shipping Info), not a general
6798// toolkit.
6799package tui
6800
6801import (
6802 "strings"
6803
6804 "github.com/gdamore/tcell/v3"
6805)
6806
6807// ---- textArea: scrollable, optionally word-wrapped markup text ----
6808
6809type textArea struct {
6810 content string
6811 base tcell.Style
6812 wrap bool
6813 scroll int
6814 lines [][]seg
6815 lastW int
6816 // onLink, when set, is told where each anchor in the text landed on
6817 // the screen, so the page can make it clickable. Set before every
6818 // draw: what is on screen changes with the scroll.
6819 onLink func(x, y, w, link int)
6820}
6821
6822func newTextArea(content string, wrap bool) *textArea {
6823 return &textArea{content: content, base: styleText, wrap: wrap}
6824}
6825
6826func (t *textArea) setContent(s string) {
6827 t.content = s
6828 t.lastW = 0
6829 t.scroll = 0
6830}
6831
6832func (t *textArea) draw(sc tcell.Screen, r rect) {
6833 if r.w != t.lastW || t.lines == nil {
6834 t.lines = wrapSegs(t.content, r.w, t.base, t.wrap)
6835 t.lastW = r.w
6836 }
6837 if max := len(t.lines) - r.h; t.scroll > max {
6838 t.scroll = max
6839 }
6840 if t.scroll < 0 {
6841 t.scroll = 0
6842 }
6843 for i := 0; i < r.h; i++ {
6844 li := t.scroll + i
6845 if li >= len(t.lines) {
6846 break
6847 }
6848 printSegsLinks(sc, r.x, r.y+i, r.w, t.lines[li], t.onLink)
6849 }
6850}
6851
6852func (t *textArea) key(ev *tcell.EventKey) bool {
6853 switch ev.Key() {
6854 case tcell.KeyUp:
6855 t.scroll--
6856 case tcell.KeyDown:
6857 t.scroll++
6858 case tcell.KeyPgUp:
6859 t.scroll -= 10
6860 case tcell.KeyPgDn:
6861 t.scroll += 10
6862 case tcell.KeyHome:
6863 t.scroll = 0
6864 case tcell.KeyEnd:
6865 t.scroll = len(t.lines)
6866 default:
6867 return false
6868 }
6869 return true
6870}
6871
6872func (t *textArea) wheel(up bool) {
6873 if up {
6874 t.scroll -= 3
6875 } else {
6876 t.scroll += 3
6877 }
6878}
6879
6880// ---- table: the site's product table ----
6881
6882type tcol struct {
6883 title string
6884 width int // 0 = expands
6885 alignRight bool
6886 // link marks the column the row's <a> lives in. Clicking a cell there
6887 // opens the row, as clicking the link on the page does; clicking any
6888 // other column selects the row, which is what shows its picture.
6889 link bool
6890}
6891
6892type tcell_ struct {
6893 text string
6894 style tcell.Style
6895}
6896
6897type trow struct {
6898 cells []tcell_
6899}
6900
6901type table struct {
6902 cols []tcol
6903 rows []trow
6904 sel int
6905 offset int
6906 onSelect func(int)
6907 onActivate func(int)
6908 // onLink, when set, is told where each drawn link cell landed and
6909 // which row and column it belongs to, so the page can register it as
6910 // an anchor β which is what makes one click act on it and the pointer
6911 // light it up. A row has more than one: its name opens the product
6912 // and its Buy cell adds it to the cart, as the page's row does. Set
6913 // before every draw, since the rows move as the table scrolls.
6914 onLink func(x, y, w, row, col int)
6915}
6916
6917func (t *table) setRows(rows []trow) {
6918 t.rows = rows
6919 t.offset = 0
6920 if len(rows) == 0 {
6921 t.sel = -1
6922 return
6923 }
6924 t.sel = 0
6925 if t.onSelect != nil {
6926 t.onSelect(0)
6927 }
6928}
6929
6930// colWidths distributes r.w across the columns; width-0 columns share
6931// the remainder.
6932func (t *table) colWidths(w int) []int {
6933 ws := make([]int, len(t.cols))
6934 fixed, flex := 0, 0
6935 for i, c := range t.cols {
6936 ws[i] = c.width
6937 if c.width == 0 {
6938 flex++
6939 } else {
6940 fixed += c.width + 1
6941 }
6942 }
6943 if flex > 0 {
6944 share := (w - fixed - flex) / flex
6945 if share < 4 {
6946 share = 4
6947 }
6948 for i, c := range t.cols {
6949 if c.width == 0 {
6950 ws[i] = share
6951 }
6952 }
6953 }
6954 return ws
6955}
6956
6957func (t *table) draw(sc tcell.Screen, r rect) {
6958 if r.h < 2 {
6959 return
6960 }
6961 ws := t.colWidths(r.w)
6962 // The page's thead is a filled purple band, so the band is the header
6963 // rather than only the words in it.
6964 fillRect(sc, rect{r.x, r.y, r.w, 1}, styleThead)
6965 x := r.x
6966 for i, c := range t.cols {
6967 st := styleThead
6968 txt := c.title
6969 if len(txt) > ws[i] {
6970 txt = txt[:ws[i]]
6971 }
6972 pad := ws[i] - len(txt)
6973 if c.alignRight {
6974 printMarkup(sc, x+pad, r.y, ws[i], esc(txt), st)
6975 } else {
6976 printMarkup(sc, x, r.y, ws[i], esc(txt), st)
6977 }
6978 x += ws[i] + 1
6979 }
6980 visible := r.h - 1
6981 if t.sel >= 0 {
6982 if t.sel < t.offset {
6983 t.offset = t.sel
6984 }
6985 if t.sel >= t.offset+visible {
6986 t.offset = t.sel - visible + 1
6987 }
6988 }
6989 for row := 0; row < visible; row++ {
6990 ri := t.offset + row
6991 if ri >= len(t.rows) {
6992 break
6993 }
6994 y := r.y + 1 + row
6995 selected := ri == t.sel
6996 if selected {
6997 fillRect(sc, rect{r.x, y, r.w, 1}, styleSel)
6998 }
6999 x = r.x
7000 for i, c := range t.rows[ri].cells {
7001 if i >= len(ws) {
7002 break
7003 }
7004 st := c.style
7005 if selected {
7006 st = styleSel
7007 }
7008 txt := c.text
7009 if len(txt) > ws[i] {
7010 txt = txt[:ws[i]]
7011 }
7012 pad := 0
7013 if t.cols[i].alignRight {
7014 pad = ws[i] - len(txt)
7015 }
7016 n := printMarkup(sc, x+pad, y, ws[i]-pad, esc(txt), st)
7017 // The anchor covers the text, not the empty column beside
7018 // it: a link that lights up out to the price would say the
7019 // blank cells were clickable too. An empty cell offers
7020 // nothing, which is how an out-of-stock row has no Buy.
7021 if t.cols[i].link && t.onLink != nil && n > 0 {
7022 t.onLink(x+pad, y, n, ri, i)
7023 }
7024 x += ws[i] + 1
7025 }
7026 }
7027}
7028
7029func (t *table) move(d int) {
7030 if len(t.rows) == 0 {
7031 return
7032 }
7033 t.sel += d
7034 if t.sel < 0 {
7035 t.sel = 0
7036 }
7037 if t.sel >= len(t.rows) {
7038 t.sel = len(t.rows) - 1
7039 }
7040 if t.onSelect != nil {
7041 t.onSelect(t.sel)
7042 }
7043}
7044
7045func (t *table) key(ev *tcell.EventKey) bool {
7046 switch ev.Key() {
7047 case tcell.KeyUp:
7048 t.move(-1)
7049 case tcell.KeyDown:
7050 t.move(1)
7051 case tcell.KeyPgUp:
7052 t.move(-10)
7053 case tcell.KeyPgDn:
7054 t.move(10)
7055 case tcell.KeyHome:
7056 t.move(-len(t.rows))
7057 case tcell.KeyEnd:
7058 t.move(len(t.rows))
7059 case tcell.KeyEnter:
7060 if t.sel >= 0 && t.onActivate != nil {
7061 t.onActivate(t.sel)
7062 }
7063 default:
7064 return false
7065 }
7066 return true
7067}
7068
7069// mouse handles a click or wheel at a position local to the table rect.
7070//
7071// A click on the row's link cell never gets here β that cell is an anchor
7072// and the page follows it first. What is left is the rest of the row:
7073// clicking it selects, which is what puts the row's photo beside the
7074// table, and clicking the selected row again opens it.
7075func (t *table) mouse(localY int, ev *tcell.EventMouse) bool {
7076 btn := ev.Buttons()
7077 switch {
7078 // One product per notch. Three is the usual step for scrolling TEXT
7079 // and wrong here, because the wheel moves the SELECTION: the row
7080 // picked is the one whose photo is shown and whose name a second
7081 // click opens, so three at a time skips two products rather than
7082 // covering ground faster. The dropdown has always moved by one.
7083 case btn&tcell.WheelUp != 0:
7084 t.move(-1)
7085 case btn&tcell.WheelDown != 0:
7086 t.move(1)
7087 case btn&tcell.Button1 != 0:
7088 ri := t.offset + localY - 1 // row 0 is the header
7089 if localY < 1 || ri < 0 || ri >= len(t.rows) {
7090 return false
7091 }
7092 if ri == t.sel {
7093 if t.onActivate != nil {
7094 t.onActivate(ri)
7095 }
7096 break
7097 }
7098 t.sel = ri
7099 if t.onSelect != nil {
7100 t.onSelect(ri)
7101 }
7102 default:
7103 return false
7104 }
7105 return true
7106}
7107
7108// ---- list: the dropdown menus ----
7109
7110type list struct {
7111 items []string // markup labels
7112 sel int
7113 offset int
7114 onPick func(int)
7115 // onKey sees keys first β the categories tree uses it for its
7116 // expand/collapse keys.
7117 onKey func(ev *tcell.EventKey, sel int) bool
7118}
7119
7120func (l *list) draw(sc tcell.Screen, r rect) {
7121 if l.sel >= 0 {
7122 if l.sel < l.offset {
7123 l.offset = l.sel
7124 }
7125 if l.sel >= l.offset+r.h {
7126 l.offset = l.sel - r.h + 1
7127 }
7128 }
7129 for i := 0; i < r.h; i++ {
7130 li := l.offset + i
7131 if li >= len(l.items) {
7132 break
7133 }
7134 st := styleLink
7135 if li == l.sel {
7136 st = styleSel
7137 fillRect(sc, rect{r.x, r.y + i, r.w, 1}, styleSel)
7138 }
7139 printMarkup(sc, r.x, r.y+i, r.w, l.items[li], st)
7140 }
7141}
7142
7143func (l *list) key(ev *tcell.EventKey) bool {
7144 if l.onKey != nil && l.onKey(ev, l.sel) {
7145 return true
7146 }
7147 switch ev.Key() {
7148 case tcell.KeyUp:
7149 if l.sel > 0 {
7150 l.sel--
7151 }
7152 case tcell.KeyDown:
7153 if l.sel < len(l.items)-1 {
7154 l.sel++
7155 }
7156 case tcell.KeyHome:
7157 l.sel = 0
7158 case tcell.KeyEnd:
7159 l.sel = len(l.items) - 1
7160 case tcell.KeyEnter:
7161 if l.onPick != nil && l.sel >= 0 {
7162 l.onPick(l.sel)
7163 }
7164 default:
7165 return false
7166 }
7167 return true
7168}
7169
7170func (l *list) mouse(localY int, ev *tcell.EventMouse) bool {
7171 btn := ev.Buttons()
7172 switch {
7173 case btn&tcell.WheelUp != 0:
7174 if l.sel > 0 {
7175 l.sel--
7176 }
7177 case btn&tcell.WheelDown != 0:
7178 if l.sel < len(l.items)-1 {
7179 l.sel++
7180 }
7181 case btn&tcell.Button1 != 0:
7182 li := l.offset + localY
7183 if li < 0 || li >= len(l.items) {
7184 return false
7185 }
7186 l.sel = li
7187 if l.onPick != nil {
7188 l.onPick(li)
7189 }
7190 default:
7191 return false
7192 }
7193 return true
7194}
7195
7196// ---- form: Add Shipping Info ----
7197
7198type formItem interface {
7199 label() string
7200 // drawValue paints the value cell; the form places the cursor.
7201 drawValue(sc tcell.Screen, x, y, w int, focused bool) (curX int, showCur bool)
7202 key(ev *tcell.EventKey) bool
7203 value() string
7204}
7205
7206type finput struct {
7207 lbl string
7208 text []rune
7209 cur int
7210 width int
7211}
7212
7213func (f *finput) label() string { return f.lbl }
7214func (f *finput) value() string { return strings.TrimSpace(string(f.text)) }
7215
7216func (f *finput) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
7217 if f.width < w {
7218 w = f.width
7219 }
7220 st := styleField
7221 fillRect(sc, rect{x, y, w, 1}, st)
7222 start := 0
7223 if f.cur >= w {
7224 start = f.cur - w + 1
7225 }
7226 for i := 0; i < w && start+i < len(f.text); i++ {
7227 sc.SetContent(x+i, y, f.text[start+i], nil, st)
7228 }
7229 return x + f.cur - start, focused
7230}
7231
7232func (f *finput) key(ev *tcell.EventKey) bool {
7233 switch ev.Key() {
7234 case tcell.KeyRune:
7235 ins := []rune(ev.Str())
7236 f.text = append(f.text[:f.cur], append(ins, f.text[f.cur:]...)...)
7237 f.cur += len(ins)
7238 case tcell.KeyBackspace, tcell.KeyBackspace2:
7239 if f.cur > 0 {
7240 f.text = append(f.text[:f.cur-1], f.text[f.cur:]...)
7241 f.cur--
7242 }
7243 case tcell.KeyDelete:
7244 if f.cur < len(f.text) {
7245 f.text = append(f.text[:f.cur], f.text[f.cur+1:]...)
7246 }
7247 case tcell.KeyLeft:
7248 if f.cur > 0 {
7249 f.cur--
7250 }
7251 case tcell.KeyRight:
7252 if f.cur < len(f.text) {
7253 f.cur++
7254 }
7255 case tcell.KeyHome:
7256 f.cur = 0
7257 case tcell.KeyEnd:
7258 f.cur = len(f.text)
7259 default:
7260 return false
7261 }
7262 return true
7263}
7264
7265// fdropdown selects among options with β/β, or by typing a prefix (so
7266// "t","x" lands on TX).
7267type fdropdown struct {
7268 lbl string
7269 opts []string
7270 sel int
7271}
7272
7273func (f *fdropdown) label() string { return f.lbl }
7274func (f *fdropdown) value() string { return f.opts[f.sel] }
7275
7276func (f *fdropdown) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
7277 st := styleField
7278 label := f.opts[f.sel]
7279 if label == "" {
7280 label = "β"
7281 }
7282 txt := "β " + label + " βΈ"
7283 if len(txt) > w {
7284 txt = txt[:w]
7285 }
7286 fillRect(sc, rect{x, y, w, 1}, st)
7287 printMarkup(sc, x, y, w, esc(txt), st)
7288 return x, false
7289}
7290
7291func (f *fdropdown) key(ev *tcell.EventKey) bool {
7292 switch {
7293 case ev.Key() == tcell.KeyLeft:
7294 if f.sel > 0 {
7295 f.sel--
7296 }
7297 case ev.Key() == tcell.KeyRight:
7298 if f.sel < len(f.opts)-1 {
7299 f.sel++
7300 }
7301 case ev.Key() == tcell.KeyRune:
7302 want := strings.ToUpper(ev.Str())
7303 for i := 1; i <= len(f.opts); i++ {
7304 o := f.opts[(f.sel+i)%len(f.opts)]
7305 if strings.HasPrefix(strings.ToUpper(o), want) {
7306 f.sel = (f.sel + i) % len(f.opts)
7307 break
7308 }
7309 }
7310 default:
7311 return false
7312 }
7313 return true
7314}
7315
7316type fbutton struct {
7317 lbl string
7318 fn func()
7319}
7320
7321// form lays items out one per row with a button row below, inside a
7322// bordered box drawn by its owner.
7323type form struct {
7324 items []formItem
7325 buttons []fbutton
7326 focus int // 0..len(items)-1, then buttons
7327 cancel func()
7328 labelCol int
7329}
7330
7331func newForm(items []formItem, buttons []fbutton, cancel func()) *form {
7332 f := &form{items: items, buttons: buttons, cancel: cancel}
7333 for _, it := range items {
7334 if n := len(it.label()); n > f.labelCol {
7335 f.labelCol = n
7336 }
7337 }
7338 return f
7339}
7340
7341func (f *form) draw(sc tcell.Screen, r rect) {
7342 sc.HideCursor()
7343 for i, it := range f.items {
7344 y := r.y + i*2
7345 if y >= r.y+r.h-1 {
7346 break
7347 }
7348 printMarkup(sc, r.x, y, r.w, esc(it.label()), styleLink)
7349 curX, show := it.drawValue(sc, r.x+f.labelCol+1, y, r.w-f.labelCol-1, f.focus == i)
7350 if show && f.focus == i {
7351 sc.ShowCursor(curX, y)
7352 }
7353 }
7354 by := r.y + len(f.items)*2
7355 x := r.x + 2
7356 for i, b := range f.buttons {
7357 st := styleButton
7358 if f.focus == len(f.items)+i {
7359 st = styleSel
7360 }
7361 lbl := " " + b.lbl + " "
7362 printMarkup(sc, x, by, r.w, esc(lbl), st)
7363 x += len(lbl) + 2
7364 }
7365}
7366
7367func (f *form) key(ev *tcell.EventKey) bool {
7368 switch ev.Key() {
7369 case tcell.KeyEscape:
7370 if f.cancel != nil {
7371 f.cancel()
7372 }
7373 return true
7374 case tcell.KeyTab, tcell.KeyDown:
7375 f.focus = (f.focus + 1) % (len(f.items) + len(f.buttons))
7376 return true
7377 case tcell.KeyBacktab, tcell.KeyUp:
7378 f.focus--
7379 if f.focus < 0 {
7380 f.focus = len(f.items) + len(f.buttons) - 1
7381 }
7382 return true
7383 case tcell.KeyEnter:
7384 if f.focus >= len(f.items) {
7385 f.buttons[f.focus-len(f.items)].fn()
7386 } else {
7387 f.focus++
7388 }
7389 return true
7390 }
7391 if f.focus < len(f.items) {
7392 if f.items[f.focus].key(ev) {
7393 return true
7394 }
7395 }
7396 // Swallow stray runes so shortcuts never fire while a form is up.
7397 return ev.Key() == tcell.KeyRune
7398}
7399
7400
7401// ===== pkg/tui/widgets_js.go =====
7402//go:build js && wasm
7403
7404package tui
7405
7406import (
7407 "image"
7408 "math"
7409 "strconv"
7410 "strings"
7411 "syscall/js"
7412
7413 "github.com/0magnet/calvin"
7414 "github.com/0magnet/chaosrack/pkg/rasterview"
7415 "github.com/0magnet/chaosrack/pkg/stlview"
7416 "github.com/0magnet/websh/widget"
7417)
7418
7419// The parts of the store that a page can show and a terminal cannot are this
7420// program's own widgets, registered by the program itself (here, and the card
7421// form in pay_js.go) rather than by the page it runs in: any websh that runs
7422// this TUI in its page has them, and a terminal without placements shows the
7423// cells drawn under them instead.
7424
7425func init() {
7426 registerGlobe()
7427 placementsShown = widget.Shown // asked each frame: the host answers childtty's probe after init
7428}
7429
7430// offerWidgets registers the widgets that need to know the store.
7431func (u *ui) offerWidgets() { registerCaps(u.sitedomain) }
7432
7433// siteURL is the store's address: the one being browsed, else the page's own.
7434func siteURL() string {
7435 if s := storeURL(); s != "" {
7436 return s
7437 }
7438 return js.Global().Get("location").Get("origin").String()
7439}
7440
7441// registerGlobe offers the website's landing picture as the widget "globe":
7442// the home panel lays it over the cells where it draws its own logo and globe
7443// (websh's placements). From the bottom up:
7444//
7445// - the panel's own cells β the half-block logo β where nothing above
7446// covers them;
7447// - the site's logo.svg;
7448// - the globe above it, drawn again by the panel's own code in its cells'
7449// half blocks: the same mesh, pose and camera, so it lies exactly behind
7450// that globe and shows through its wireframe;
7451// - the landing view's globe β the same renderer, shaders and sphere
7452// (chaosrack's stlview) β which the person turns by dragging and zooms
7453// with the wheel.
7454//
7455// And it answers the pointer (see look):
7456//
7457// - a click on the black around the logo stops it all β no frames, so no
7458// work on the CPU or the GPU β and a click on the logo or the globe
7459// starts it again;
7460// - a double click (or tap) on the logo hides it, showing the panel's own
7461// half-block logo under it, and another brings it back;
7462// - a double click on the globe steps through: the wireframe alone; the
7463// panel's globe alone, turning; no globe; and back to both.
7464func registerGlobe() {
7465 widget.Register("globe", mountGlobe)
7466}
7467
7468// look is how the person has left the picture. It is the page's, not one
7469// placement's: the TUI takes the placement away whenever a menu or the cart
7470// crosses it, and puts a new one back after.
7471var look struct {
7472 paused bool
7473 logoHidden bool
7474 stage int // globeBoth, globeWire, globeCells, globeNone
7475}
7476
7477const (
7478 globeBoth = iota
7479 globeWire
7480 globeCells
7481 globeNone
7482)
7483
7484func mountGlobe(el js.Value) func() {
7485 doc := js.Global().Get("document")
7486 layer := func(tag, css string) js.Value {
7487 e := doc.Call("createElement", tag)
7488 e.Get("style").Set("cssText", "position:absolute;inset:0;width:100%;height:100%;"+css)
7489 el.Call("append", e)
7490 return e
7491 }
7492 logo := layer("img", "object-fit:contain")
7493 logo.Set("src", siteURL()+"/i/logo.svg")
7494 logo.Set("alt", "")
7495
7496 // The panel's globe: one pixel per half cell, shown as blocks, as the
7497 // panel draws it.
7498 shadow := layer("canvas", "image-rendering:pixelated;opacity:.85")
7499 ctx := shadow.Call("getContext", "2d")
7500
7501 canvas := layer("canvas", "")
7502 dpr := js.Global().Get("devicePixelRatio").Float()
7503 if dpr <= 0 {
7504 dpr = 1
7505 }
7506 canvas.Set("width", int(el.Get("clientWidth").Float()*dpr))
7507 canvas.Set("height", int(el.Get("clientHeight").Float()*dpr))
7508 g, err := stlview.NewGlobe(canvas)
7509 if err != nil {
7510 js.Global().Get("console").Call("warn", "globe: "+err.Error())
7511 return func() { el.Set("textContent", "") }
7512 }
7513 g.Interact(canvas)
7514
7515 var img *image.RGBA
7516 var buf js.Value
7517 drawShadow := func() {
7518 cols, _ := strconv.Atoi(el.Get("dataset").Get("cols").String()) //nolint:errcheck // websh sets it; zero draws nothing
7519 rows, _ := strconv.Atoi(el.Get("dataset").Get("rows").String()) //nolint:errcheck // as above
7520 if cols <= 0 || rows <= 0 {
7521 return
7522 }
7523 w, h := cols, rows*2
7524 if img == nil || img.Bounds().Dx() != w || img.Bounds().Dy() != h {
7525 img = image.NewRGBA(image.Rect(0, 0, w, h))
7526 buf = js.Global().Get("Uint8ClampedArray").New(len(img.Pix))
7527 shadow.Set("width", w)
7528 shadow.Set("height", h)
7529 }
7530 // A half cell is as wide as the terminal's font makes it, not
7531 // square: its width over its height on the page.
7532 aspect := 1.0
7533 if ew, eh := el.Get("clientWidth").Float(), el.Get("clientHeight").Float(); ew > 0 && eh > 0 {
7534 aspect = (ew / float64(w)) / (eh / float64(h))
7535 }
7536 // The globe's size on the page: its share of half the height, in a
7537 // frame whose shorter side, measured on the page, sets the scale.
7538 scale := g.Radius() * float64(h) / math.Min(float64(w)*aspect, float64(h))
7539 ax, ay, az := g.Pose()
7540 verts, lines := g.Mesh()
7541 globeAt(img, verts, lines, ax, ay, az, g.Distance(), scale, aspect)
7542 js.CopyBytesToJS(buf, img.Pix)
7543 ctx.Call("putImageData", js.Global().Get("ImageData").New(buf, w, h), 0, 0)
7544 }
7545
7546 // The panel's globe turns at the panel's own pace (its 140ms tick): it
7547 // is the panel's globe, and every display frame would cost the tab for
7548 // it. Its frames run only while it is seen and turning.
7549 var (
7550 gone bool
7551 shadowOn bool
7552 raf js.Value
7553 frame js.Func
7554 last float64
7555 )
7556 frame = js.FuncOf(func(_ js.Value, args []js.Value) any {
7557 if gone || !shadowOn {
7558 return nil
7559 }
7560 raf = js.Global().Call("requestAnimationFrame", frame)
7561 if now := args[0].Float(); now-last >= 140 {
7562 last = now
7563 drawShadow()
7564 }
7565 return nil
7566 })
7567 runShadow := func(on bool) {
7568 if on == shadowOn {
7569 return
7570 }
7571 shadowOn = on
7572 if on {
7573 raf = js.Global().Call("requestAnimationFrame", frame)
7574 } else {
7575 js.Global().Call("cancelAnimationFrame", raf)
7576 }
7577 }
7578
7579 shown := func(e js.Value, on bool) {
7580 v := "hidden"
7581 if on {
7582 v = ""
7583 }
7584 e.Get("style").Set("visibility", v)
7585 }
7586 apply := func() {
7587 wire := look.stage == globeBoth || look.stage == globeWire
7588 cells := look.stage == globeBoth || look.stage == globeCells
7589 shown(logo, !look.logoHidden)
7590 shown(canvas, wire)
7591 shown(shadow, cells)
7592 // Not drawn, the wireframe still turns, for the panel's globe to
7593 // follow; with no globe at all, nothing runs.
7594 g.SetDrawn(wire)
7595 if look.paused || look.stage == globeNone {
7596 g.Pause()
7597 } else {
7598 g.Resume()
7599 }
7600 runShadow(cells && !look.paused)
7601 if cells {
7602 drawShadow() // as it is now, turning or not
7603 }
7604 }
7605
7606 // Where on the picture a point (in the element's own pixels) is.
7607 const (
7608 onBlack = iota
7609 onLogo
7610 onGlobe
7611 )
7612 region := func(x, y float64) int {
7613 w, h := el.Get("clientWidth").Float(), el.Get("clientHeight").Float()
7614 if r := g.Radius() * h / 2; math.Hypot(x-w/2, y-h/2) <= r {
7615 return onGlobe
7616 }
7617 lx, ly, lw, lh := 0.0, 0.0, w, h
7618 if nw, nh := logo.Get("naturalWidth").Float(), logo.Get("naturalHeight").Float(); nw > 0 && nh > 0 {
7619 s := math.Min(w/nw, h/nh) // object-fit: contain
7620 lw, lh = nw*s, nh*s
7621 lx, ly = (w-lw)/2, (h-lh)/2
7622 }
7623 if x >= lx && x < lx+lw && y >= ly && y < ly+lh {
7624 return onLogo
7625 }
7626 return onBlack
7627 }
7628
7629 // Clicks are counted here rather than taken from dblclick, which a
7630 // double tap does not reliably give; and a drag that turned the globe is
7631 // not a click.
7632 var (
7633 downX, downY float64
7634 lastT float64
7635 lastAt = -1
7636 )
7637 type handler struct {
7638 name string
7639 fn js.Func
7640 }
7641 var handlers []handler
7642 on := func(name string, f func(e js.Value)) {
7643 fn := js.FuncOf(func(_ js.Value, args []js.Value) any {
7644 f(args[0])
7645 return nil
7646 })
7647 el.Call("addEventListener", name, fn, true)
7648 handlers = append(handlers, handler{name, fn})
7649 }
7650 on("pointerdown", func(e js.Value) {
7651 downX, downY = e.Get("clientX").Float(), e.Get("clientY").Float()
7652 })
7653 on("click", func(e js.Value) {
7654 cx, cy := e.Get("clientX").Float(), e.Get("clientY").Float()
7655 if math.Hypot(cx-downX, cy-downY) > 5 {
7656 return // a drag
7657 }
7658 box := el.Call("getBoundingClientRect")
7659 at := region(cx-box.Get("left").Float(), cy-box.Get("top").Float())
7660 look.paused = at == onBlack
7661 if t := e.Get("timeStamp").Float(); at == lastAt && t-lastT < 400 {
7662 switch at {
7663 case onGlobe:
7664 look.stage = (look.stage + 1) % 4
7665 case onLogo:
7666 look.logoHidden = !look.logoHidden
7667 }
7668 lastAt = -1
7669 } else {
7670 lastT, lastAt = t, at
7671 }
7672 apply()
7673 })
7674 apply()
7675
7676 return func() {
7677 gone = true
7678 runShadow(false)
7679 frame.Release()
7680 for _, h := range handlers {
7681 el.Call("removeEventListener", h.name, h.fn, true)
7682 h.fn.Release()
7683 }
7684 g.Stop()
7685 el.Set("textContent", "")
7686 }
7687}
7688
7689// registerCaps offers the domain in the page's double-struck capitals as the
7690// widget "caps", for the line the home panel prints it on. A terminal has
7691// them only in fonts that draw them twice the width of a cell, so the panel
7692// prints plain capitals; a page can set them as the website does, over them.
7693func registerCaps(domain string) {
7694 text := calvin.BlackboardBold(strings.ToUpper(domain))
7695 widget.Register("caps", func(el js.Value) func() {
7696 el.Get("style").Set("containerType", "size")
7697 d := js.Global().Get("document").Call("createElement", "div")
7698 d.Get("style").Set("cssText", "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;"+
7699 "background:#000;color:#fff;white-space:nowrap;line-height:1;font-size:85cqh;font-family:inherit")
7700 d.Set("textContent", text)
7701 el.Call("append", d)
7702 return func() { el.Set("textContent", "") }
7703 })
7704}
7705
7706// globeAt draws a globe in the home panel's manner β its depth cue, and its
7707// colors in the panel's sixteen steps β over a clear dst. The lines are the
7708// mesh given (vertices as xyz, indices as endpoint pairs), posed by the angles
7709// of RxΒ·RyΒ·Rz, seen from dist (0 for none) and filling scale of half the frame,
7710// in pixels aspect as wide as they are tall, colored by y from red to blue: stlview's Globe gives all of these as it
7711// draws itself (Mesh, Pose, Distance, Radius), so a page draws the panel's
7712// version exactly behind its own β the "globe" widget does (widgets_js.go).
7713func globeAt(dst *image.RGBA, vertices []float32, indices []uint16, ax, ay, az, dist, scale, aspect float64) {
7714 clear(dst.Pix)
7715 view := rasterview.View{AngleX: ax, AngleY: ay, AngleZ: az, Dist: dist, BackDim: 0.5, Scale: scale, PixelAspect: aspect}
7716 g := rasterview.DefaultGradient()
7717 g.Source = 1 // y, as the page's shader colors it
7718 view.Render(dst, vertices, indices, g)
7719 for i := 0; i+3 < len(dst.Pix); i += 4 {
7720 if dst.Pix[i+3] != 0 {
7721 for k := range 3 {
7722 dst.Pix[i+k] = dst.Pix[i+k]&0xF0 | dst.Pix[i+k]>>4 // step16
7723 }
7724 }
7725 }
7726}
7727
7728
7729// ===== pkg/tui/widgets_native.go =====
7730//go:build !js || !wasm
7731
7732package tui
7733
7734// offerWidgets has nothing to offer where there is no page: the cells are
7735// the picture. See widgets_js.go.
7736func (u *ui) offerWidgets() {}
7737
7738
7739// ===== pkg/web/app.go =====
7740// Package web pkg/web/app.go β the fiber application, buildable for two hosts.
7741//
7742// Serve (native) and the in-tab site server (js/wasm β pkg/storepane's
7743// `serve` command, listening on the bottle vnet loopback) construct the same
7744// application from the same templates and handlers. AppOpts carries the only
7745// differences: where the request log goes, and the origin to lean on for
7746// everything a browser tab must not or cannot hold β the Stripe secret key,
7747// files on disk, and the toolchain that compiles wasm drop-ins.
7748package web
7749
7750import (
7751 "context"
7752 "errors"
7753 "fmt"
7754 "io"
7755 "net/http"
7756 "os"
7757 "strings"
7758 "time"
7759
7760 "github.com/gofiber/fiber/v3"
7761 "github.com/gofiber/fiber/v3/middleware/static"
7762
7763 "github.com/0magnet/bottle"
7764)
7765
7766// AppOpts selects the host the app is being built for.
7767type AppOpts struct {
7768 // LogOutput receives the request log; nil keeps the native default
7769 // (stdout). The in-tab server points this at its shell's terminal, so
7770 // browsing the vnet site scrolls an access log like any server.
7771 LogOutput io.Writer
7772
7773 // ProxyOrigin, when non-empty, marks the in-tab role. Routes that need
7774 // the host machine (wasm compilation, source tarballs, images and fonts
7775 // on disk, the logo pipeline, CUPS printing) are not registered β a GET
7776 // that matches nothing is fetched from this origin instead β and the
7777 // payment endpoints forward there, so the tab renders the store but
7778 // never holds a secret key.
7779 ProxyOrigin string
7780}
7781
7782// NewApp builds the store's fiber application. It does not listen; Serve
7783// (native) and the in-tab server each own their listener.
7784func NewApp(o AppOpts) *fiber.App {
7785 initTMPL()
7786 logOut := o.LogOutput
7787 if logOut == nil {
7788 logOut = os.Stdout
7789 }
7790 inTab := o.ProxyOrigin != ""
7791 // In the tab the drop-ins are named from the origin, not from a disk
7792 // that carries none of them.
7793 if inTab {
7794 serveFromOrigin(o.ProxyOrigin)
7795 }
7796
7797 r := fiber.New(fiber.Config{
7798 ErrorHandler: func(c fiber.Ctx, err error) error {
7799 code := fiber.StatusInternalServerError
7800 var e *fiber.Error
7801 if errors.As(err, &e) {
7802 code = e.Code
7803 }
7804 c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
7805 return c.Status(code).SendString(err.Error())
7806 },
7807 })
7808
7809 r.Use(func(c fiber.Ctx) error {
7810 start := time.Now()
7811 err := c.Next()
7812 status := c.Response().StatusCode()
7813 // Unmatched routes and handler errors are turned into their real
7814 // status by the app ErrorHandler after this middleware returns, so
7815 // c.Response() still reads 200 here. Recover the true code from the
7816 // error so 404s (and 5xx) are logged accurately rather than as 200.
7817 if err != nil {
7818 var fe *fiber.Error
7819 if errors.As(err, &fe) {
7820 status = fe.Code
7821 } else {
7822 status = fiber.StatusInternalServerError
7823 }
7824 }
7825 lat := time.Since(start)
7826 colors := c.App().Config().ColorScheme
7827 ip := fmt.Sprintf("%*s", 15, c.IP())
7828 ipsStr := strings.Join(c.IPs(), ", ")
7829 ips := fmt.Sprintf("%*s", 15, ipsStr)
7830 method := fmt.Sprintf("%-*s", 6, c.Method())
7831 statCol := statusColor(status, colors) + fmt.Sprintf("%3d", status) + colors.Reset
7832 methCol := methodColor(c.Method(), colors) + method + colors.Reset
7833 fmt.Fprintf(logOut, "%s | %s | %12s | %s | %s | %s | %s\n", time.Now().Format("2006-01-02 15:04:05"), statCol, lat, ip, ips, methCol, c.Path()) //nolint:errcheck,gosec
7834 return err
7835 })
7836
7837 // What anyone may read β the catalog, the site, its pages, its images and
7838 // the store program β any origin may read too: a websh served elsewhere
7839 // runs the store TUI against this site (wasm/store). Nothing that writes,
7840 // and nothing behind a cookie, is shared this way.
7841 r.Use(func(c fiber.Ctx) error {
7842 if m := c.Method(); (m == fiber.MethodGet || m == fiber.MethodHead) && sharedRead(c.Path()) {
7843 c.Set(fiber.HeaderAccessControlAllowOrigin, "*")
7844 }
7845 return c.Next()
7846 })
7847
7848 if !inTab {
7849 // The desk page's OS layer and browser engine, served ahead of any
7850 // wasm module: jsfs/vnet must exist before Go captures globalThis.fs.
7851 jsAsset := func(body []byte) fiber.Handler {
7852 return func(c fiber.Ctx) error {
7853 c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
7854 c.Set(fiber.HeaderCacheControl, "public, max-age=3600")
7855 return c.Send(body)
7856 }
7857 }
7858 r.Get("/bottle/jsfs.js", jsAsset(bottle.JSFS()))
7859 r.Get("/bottle/vnet.js", jsAsset(bottle.VNetJS()))
7860 r.Get("/bottle/proc.js", jsAsset(bottle.ProcJS()))
7861 serveSourceCode(r)
7862 serveWASM(r)
7863 serveBrowseResponder(r)
7864 r.Get("/logo", logo)
7865 r.Get("/logo/:width", logo)
7866 r.Get("/logo/:width/:height", logo)
7867 // The ports that draw: a product image as ANSI art turned into HTML, and
7868 // a heading as a FIGlet banner. Nothing on the site links to either β
7869 // they are here for the decorator on /test to call.
7870 r.Get("/art/:cat/:name", art)
7871 r.Get("/banner", banner)
7872 r.Get("/logo.png", sendFile)
7873 r.Get("/logo.html", sendFile)
7874 r.Get("/mobilelogo.html", sendFile)
7875 r.Get("/logolarge.html", sendFile)
7876 r.Get("/favicon.ico", sendImage)
7877 if f.Siteimagesrc == "" {
7878 r.Use("/i", static.New("./img"))
7879 r.Use("/img", static.New("./img"))
7880 }
7881 r.Use("/font", static.New("./font"))
7882 r.Get("/stl/:filename", func(c fiber.Ctx) error {
7883 name := c.Params("filename")
7884 if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
7885 return c.SendStatus(fiber.StatusBadRequest)
7886 }
7887 return c.SendFile("./img/stl/" + name)
7888 })
7889 r.Get("/stl/base64/:filename", stlbase64)
7890 r.Get("/tui", tuipage)
7891 r.Get("/desk", deskpage)
7892 }
7893
7894 r.Get("/robots.txt", robots)
7895 r.Get("/site.webmanifest", func(c fiber.Ctx) error {
7896 return c.JSON(fiber.Map{
7897 "name": f.Sitelongname,
7898 "short_name": f.Sitename,
7899 "icons": []fiber.Map{
7900 {"src": f.Siteimagesrc + "/i/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"},
7901 {"src": f.Siteimagesrc + "/i/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"},
7902 },
7903 "theme_color": "#ffffff",
7904 "background_color": "#ffffff",
7905 "display": "standalone",
7906 })
7907 })
7908 r.Get("/api/products", apiproducts)
7909 r.Get("/api/site", apisite)
7910 r.Get("/api/content/:name", apicontent)
7911 r.Get("/sitemap", sitemap)
7912 r.Get("/sitemap.xml", sitemap)
7913 r.Get("/", homepage)
7914 r.Get("/p/:partno", productpage)
7915 r.Get("/post/:partno", handlecat)
7916 r.Get("/p", handlecat)
7917 r.Get("/cat", handlecat)
7918 r.Get("/cat/:cat", handlecat)
7919 r.Get("/cat/:cat/:subcat", handlecat)
7920 r.Get("/style.css", style)
7921 r.Get("/font.css", fontcss)
7922
7923 if !inTab {
7924 for _, register := range extraRoutes {
7925 register(r)
7926 }
7927 handleOrder(r)
7928 // Crypto checkout, if a wallet is configured; the poller that prints
7929 // a receipt when a payment confirms starts with it.
7930 handleInvoice(r)
7931 go WatchInvoices(context.Background())
7932 } else {
7933 // The drop-ins this binary carries, ahead of the catch-all so they are
7934 // answered from the embed rather than fetched back off the origin.
7935 serveCarriedWASM(r)
7936 // Money crosses back to the real server: the tab renders the cart,
7937 // the origin holds the Stripe key and the order book.
7938 r.Post("/create-payment-intent", proxyToOrigin(o.ProxyOrigin))
7939 r.Post("/submit-order", proxyToOrigin(o.ProxyOrigin))
7940 // Everything the tab does not carry β images, fonts, STL models,
7941 // the logo pipeline, /complete's Stripe lookup β reads through to
7942 // the origin. Registered last, so it is the 404 path.
7943 r.Use(proxyToOrigin(o.ProxyOrigin))
7944 }
7945 return r
7946}
7947
7948// proxyToOrigin relays the request to the real origin and copies the answer
7949// back. Under js/wasm net/http rides the browser's fetch, so a same-origin
7950// relay needs no credentials or CORS ceremony.
7951func proxyToOrigin(origin string) fiber.Handler {
7952 return func(c fiber.Ctx) error {
7953 method := c.Method()
7954 if method != fiber.MethodGet && method != fiber.MethodPost {
7955 return fiber.ErrNotFound
7956 }
7957 var body io.Reader
7958 if method == fiber.MethodPost {
7959 body = strings.NewReader(string(c.Body()))
7960 }
7961 req, err := http.NewRequest(method, origin+c.OriginalURL(), body)
7962 if err != nil {
7963 return fiber.ErrBadGateway
7964 }
7965 if ct := c.Get(fiber.HeaderContentType); ct != "" {
7966 req.Header.Set(fiber.HeaderContentType, ct)
7967 }
7968 resp, err := http.DefaultClient.Do(req)
7969 if err != nil {
7970 return fiber.ErrBadGateway
7971 }
7972 defer resp.Body.Close() //nolint:errcheck,gosec
7973 payload, err := io.ReadAll(resp.Body)
7974 if err != nil {
7975 return fiber.ErrBadGateway
7976 }
7977 if ct := resp.Header.Get("Content-Type"); ct != "" {
7978 c.Set(fiber.HeaderContentType, ct)
7979 }
7980 return c.Status(resp.StatusCode).Send(payload)
7981 }
7982}
7983
7984// sharedRead reports whether path is public, read-only data any origin may
7985// fetch.
7986func sharedRead(path string) bool {
7987 if path == "/api/products" || path == "/api/site" || path == "/font.css" || path == "/store.wasm" || path == "/store-tiny.wasm" {
7988 return true
7989 }
7990 for _, p := range []string{"/api/content/", "/i/", "/img/"} {
7991 if strings.HasPrefix(path, p) {
7992 return true
7993 }
7994 }
7995 return false
7996}
7997
7998
7999// ===== pkg/web/art.go =====
8000package web
8001
8002// Two endpoints that draw with the ports: a product image as ANSI art turned
8003// into HTML, and a heading as a FIGlet banner.
8004//
8005// Server side rather than in the wasm decorator, for two different reasons.
8006// The image art needs a JPEG decode and a dither per product, which is real
8007// work to do fourteen times in a browser and is the same answer every time β
8008// so it is done once here and cached. The banner needs the FIGlet fonts, and
8009// there are eight hundred kilobytes of them; embedding that in a decorator to
8010// draw a dozen headings is the wrong trade, while here they are already in the
8011// binary.
8012//
8013// Nothing on the site links to either. They exist for the decorator on /test to
8014// call, so the served HTML is unchanged and a browser that does not run the
8015// wasm sees the page it always saw.
8016
8017import (
8018 "bytes"
8019 "fmt"
8020 "io"
8021 "net/http"
8022 "os"
8023 "path/filepath"
8024 "strconv"
8025 "strings"
8026 "sync"
8027 "time"
8028
8029 "github.com/gofiber/fiber/v3"
8030
8031 "github.com/0magnet/ansifilter-go/ansifilter"
8032 "github.com/0magnet/img2txt-go/caca"
8033 "github.com/0magnet/toilet-go/figlet"
8034 "github.com/0magnet/toilet-go/fonts"
8035)
8036
8037// artCache keys rendered art by everything that changes it. Both renders are
8038// pure functions of their inputs and neither is cheap, so nothing is drawn
8039// twice.
8040var (
8041 artCache = map[string]string{}
8042 artMu sync.Mutex
8043)
8044
8045func cached(key string, make func() (string, error)) (string, error) {
8046 artMu.Lock()
8047 hit, ok := artCache[key]
8048 artMu.Unlock()
8049 if ok {
8050 return hit, nil
8051 }
8052 out, err := make()
8053 if err != nil {
8054 return "", err
8055 }
8056 artMu.Lock()
8057 artCache[key] = out
8058 artMu.Unlock()
8059 return out, nil
8060}
8061
8062// artWidth keeps a request from asking for a canvas big enough to be a denial
8063// of service. Eighty is a terminal; two hundred is already wider than the
8064// column the page gives it.
8065const (
8066 artMin = 8
8067 artMax = 200
8068)
8069
8070// art renders one product image as ANSI art and exports it as HTML.
8071//
8072// The chain is the one the store already had most of: img2txt-go decodes and
8073// dithers the image into a libcaca canvas, which exports ANSI β the same
8074// renderer the terminal UI draws products with β and ansifilter-go turns that
8075// ANSI into HTML. Going straight from the canvas to caca's own HTML export
8076// would skip the middle step and lose the point: the ANSI is what the terminal
8077// shows, so passing it through is what makes the two agree.
8078func art(c fiber.Ctx) error {
8079 cols := 60
8080 if v, err := strconv.Atoi(c.Query("cols")); err == nil {
8081 cols = v
8082 }
8083 cols = min(max(cols, artMin), artMax)
8084 cat, name := c.Params("cat"), c.Params("name")
8085 if !safeSegment(cat) || !safeSegment(name) {
8086 return c.Status(fiber.StatusBadRequest).SendString("bad path")
8087 }
8088 src := imgPath(cat, name)
8089 out, err := cached(fmt.Sprintf("art\x00%s\x00%d", src, cols), func() (string, error) {
8090 return renderArt(src, cols)
8091 })
8092 if err != nil {
8093 return c.Status(fiber.StatusNotFound).SendString(err.Error())
8094 }
8095 c.Set("Content-Type", "text/html;charset=utf-8")
8096 c.Set("Cache-Control", "public, max-age=86400")
8097 return c.SendString(out)
8098}
8099
8100func renderArt(src string, cols int) (string, error) {
8101 data, err := fetch(src)
8102 if err != nil {
8103 return "", err
8104 }
8105 im, err := caca.DecodeImage(bytes.NewReader(data))
8106 if err != nil {
8107 return "", err
8108 }
8109 if im.W == 0 || im.H == 0 {
8110 return "", fmt.Errorf("image has no size")
8111 }
8112 // img2txt's 6x10 font cell, so the art keeps the picture's proportions
8113 // instead of coming out squashed by the shape of a character.
8114 rows := cols * im.H * 6 / im.W / 10
8115 if rows < 1 {
8116 rows = 1
8117 }
8118 cv := caca.NewCanvas(cols, rows)
8119 cv.SetColorANSI(caca.Default, caca.Transparent)
8120 cv.Clear()
8121 im.Dither.SetAlgorithm("fstein")
8122 cv.DitherBitmap(0, 0, cols, rows, im.Dither, im.Pixels)
8123 ansi, ok := cv.Export("ansi")
8124 if !ok {
8125 return "", fmt.Errorf("canvas will not export ansi")
8126 }
8127 g := ansifilter.New(ansifilter.HTML)
8128 g.SetFragmentCode(true)
8129 return g.GenerateString(string(ansi)), nil
8130}
8131
8132// banner renders a line of text as a FIGlet banner.
8133func banner(c fiber.Ctx) error {
8134 // A query parameter, not a path segment. Fiber hands back path params still
8135 // percent-encoded, and rendering those gives a banner reading
8136 // DIODE%20%E2%86%92%201N40XX, which is exactly what went live.
8137 text := c.Query("text")
8138 if text == "" {
8139 return c.Status(fiber.StatusBadRequest).SendString("no text")
8140 }
8141 if len(text) > 64 {
8142 text = text[:64]
8143 }
8144 font := c.Query("font")
8145 if font == "" {
8146 font = "future"
8147 }
8148 width := 200
8149 if v, err := strconv.Atoi(c.Query("width")); err == nil && v > 0 {
8150 width = min(v, 400)
8151 }
8152 out, err := cached(fmt.Sprintf("banner\x00%s\x00%s\x00%d", text, font, width), func() (string, error) {
8153 return renderBanner(text, font, width)
8154 })
8155 if err != nil {
8156 return c.Status(fiber.StatusNotFound).SendString(err.Error())
8157 }
8158 c.Set("Content-Type", "text/plain;charset=utf-8")
8159 c.Set("Cache-Control", "public, max-age=86400")
8160 return c.SendString(out)
8161}
8162
8163func renderBanner(text, font string, width int) (string, error) {
8164 raw, ok := fonts.Get(font)
8165 if !ok {
8166 return "", fmt.Errorf("no font %q", font)
8167 }
8168 fnt, err := figlet.ParseFont(raw)
8169 if err != nil {
8170 return "", err
8171 }
8172 r := figlet.NewRenderer(fnt)
8173 r.SetWidth(width)
8174 r.SetMode(figlet.ParseMode("smush"))
8175 for _, ch := range text {
8176 r.PutChar(ch)
8177 }
8178 cv := r.Flush()
8179 if cv == nil {
8180 return "", fmt.Errorf("nothing rendered")
8181 }
8182 utf8, ok := cv.Export("utf8")
8183 if !ok {
8184 return "", fmt.Errorf("canvas will not export utf8")
8185 }
8186 return strings.TrimRight(string(utf8), "\n"), nil
8187}
8188
8189// safeSegment rejects anything that could climb out of the image directory.
8190// The two path parts come off the wire, so they are the one thing here a
8191// visitor controls.
8192func safeSegment(s string) bool {
8193 if s == "" || s == "." || s == ".." {
8194 return false
8195 }
8196 return !strings.ContainsAny(s, "/\\") && !strings.Contains(s, "..")
8197}
8198
8199// imgPath is where a product image lives: on disk under ./img when the site
8200// serves its own, and behind Siteimagesrc when it does not β the same two cases
8201// the template's ImgSRC covers.
8202func imgPath(cat, name string) string {
8203 if f.Siteimagesrc == "" {
8204 return filepath.Join("img", cat, name)
8205 }
8206 return f.Siteimagesrc + "/i/" + cat + "/" + name
8207}
8208
8209// fetch reads an image from wherever imgPath put it, on disk or over http.
8210func fetch(src string) ([]byte, error) {
8211 if !strings.HasPrefix(src, "http://") && !strings.HasPrefix(src, "https://") {
8212 return os.ReadFile(src) //nolint:gosec // safeSegment has already refused anything with a separator in it
8213 }
8214 client := &http.Client{Timeout: 20 * time.Second}
8215 resp, err := client.Get(src) //nolint:gosec // ImgSRC is the operator's own
8216 if err != nil {
8217 return nil, err
8218 }
8219 defer resp.Body.Close() //nolint:errcheck // read-side close
8220 if resp.StatusCode != http.StatusOK {
8221 return nil, fmt.Errorf("%s", resp.Status)
8222 }
8223 return io.ReadAll(io.LimitReader(resp.Body, 16<<20))
8224}
8225
8226
8227// ===== pkg/web/art_test.go =====
8228package web
8229
8230import (
8231 "os"
8232 "path/filepath"
8233 "strings"
8234 "testing"
8235)
8236
8237// The two renders have to work end to end, since nothing else exercises the
8238// chain: img2txt decodes and dithers, the canvas exports ANSI, ansifilter turns
8239// that into HTML.
8240func TestRenderArt(t *testing.T) {
8241 var sample string
8242 err := filepath.WalkDir("../../img", func(p string, d os.DirEntry, err error) error {
8243 if err != nil || d.IsDir() || sample != "" {
8244 return nil //nolint:nilerr // a missing image directory is not this test's business
8245 }
8246 switch strings.ToLower(filepath.Ext(p)) {
8247 case ".jpg", ".jpeg", ".png", ".gif":
8248 sample = p
8249 }
8250 return nil
8251 })
8252 if err != nil || sample == "" {
8253 t.Skip("no product images on disk to render")
8254 }
8255 out, err := renderArt(sample, 40)
8256 if err != nil {
8257 t.Fatalf("rendering %s: %v", sample, err)
8258 }
8259 if !strings.Contains(out, "<span") {
8260 t.Errorf("no colored spans in the output; ansifilter produced %.80q", out)
8261 }
8262 if n := strings.Count(out, "\n"); n < 4 {
8263 t.Errorf("only %d lines of art, expected the image to fill a few", n)
8264 }
8265}
8266
8267// A banner has to come back as several lines of art, not as the text itself.
8268func TestRenderBanner(t *testing.T) {
8269 out, err := renderBanner("resistor", "future", 200)
8270 if err != nil {
8271 t.Fatal(err)
8272 }
8273 lines := strings.Split(out, "\n")
8274 if len(lines) < 3 {
8275 t.Fatalf("a banner should be several rows tall, got %d: %q", len(lines), out)
8276 }
8277 if strings.Contains(out, "resistor") {
8278 t.Error("the banner is the literal text, so no font was applied")
8279 }
8280 t.Logf("banner is %d lines, widest %d", len(lines), widest(lines))
8281 for _, l := range lines {
8282 t.Log(l)
8283 }
8284}
8285
8286func widest(lines []string) int {
8287 w := 0
8288 for _, l := range lines {
8289 if n := len([]rune(l)); n > w {
8290 w = n
8291 }
8292 }
8293 return w
8294}
8295
8296// The path guard is the only place a visitor's input reaches the filesystem.
8297func TestSafeSegment(t *testing.T) {
8298 for _, bad := range []string{"", ".", "..", "../etc", "a/b", `a\b`, "x..y"} {
8299 if safeSegment(bad) {
8300 t.Errorf("%q was accepted", bad)
8301 }
8302 }
8303 for _, good := range []string{"diode", "1N4148.jpg", "a-b_c.png"} {
8304 if !safeSegment(good) {
8305 t.Errorf("%q was refused", good)
8306 }
8307 }
8308}
8309
8310
8311// ===== pkg/web/browse.go =====
8312package web
8313
8314// The real-origin browse substrate: giving the desk's nested browser a genuine
8315// origin to render the in-tab store at.
8316//
8317// netscrape renders a fetched page into a sandboxed srcdoc with an opaque
8318// origin, which cannot reach the tab's virtual loopback at all; its relay
8319// carries stylesheets and images and nothing else. That is enough for a page
8320// of text and pictures and is structurally unable to run a wasm drop-in: a
8321// <script src> is never fetched, and an in-page WebAssembly.instantiateStreaming
8322// could not reach vnet even if it were. So the store inside the desk had a
8323// cart that could not add and a logo the attractor never drew over.
8324//
8325// github.com/0magnet/realorigin fixes the class of problem rather than the
8326// symptom. The store gets its own browser origin B, distinct from the site's
8327// origin V, whose network layer is a service worker relaying every fetch back
8328// to a responder on V β which answers it from the in-tab server over vnet. The
8329// page is then an ordinary document: native subresource loading, real fetch,
8330// real wasm, cookies and storage of its own.
8331//
8332// Two things make it cheap here where it is expensive in general:
8333//
8334// - There is exactly ONE target, the tab's own store, so there is exactly one
8335// browse origin. realorigin content-addresses origins precisely so a
8336// wildcard can cover a whole address space; one fixed target hashes to one
8337// fixed hostname, which needs one ordinary certificate and no wildcard, no
8338// DNS-01 and no DNS provider credentials.
8339// - B must be a different REGISTRABLE domain from V, not a subdomain, so that
8340// untrusted content is fully cross-site from the site holding the keys.
8341// That is a deployment fact, and BrowseSuffix is where it is stated.
8342//
8343// Everything here is inert until BrowseSuffix and BrowseAddr are both set.
8344
8345import (
8346 "context"
8347 "github.com/gofiber/fiber/v3"
8348 "log"
8349 "net/url"
8350 "strconv"
8351 "strings"
8352
8353 "github.com/0magnet/realorigin"
8354
8355 "github.com/0magnet/m2/pkg/config"
8356)
8357
8358// browseResponderPath is where the app origin serves realorigin's responder β
8359// the trust boundary, which must run first-party on V. The desk page loads it
8360// before the wasm that configures it.
8361const browseResponderPath = "/browse-responder.js"
8362
8363// browseEnabled reports whether the deployment configured the substrate.
8364func browseEnabled() bool {
8365 return config.F.BrowseSuffix != "" && config.F.BrowseAddr != ""
8366}
8367
8368// browseScheme and browsePort describe how a browse origin is reached.
8369//
8370// Hosted, that is https on 443 and the port is not in the URL. The local
8371// model is the exception the platform itself makes: *.localhost resolves to
8372// the loopback without any DNS and counts as a secure context, so a service
8373// worker runs there over plain http. It is how realorigin and skywire are
8374// developed against (.mesh.localhost), and it is the only way to exercise the
8375// whole path without a wildcard certificate.
8376func browseScheme() string {
8377 if isLoopbackSuffix(config.F.BrowseSuffix) {
8378 return "http"
8379 }
8380 return "https"
8381}
8382
8383// browsePort is the port a browse origin is served on, empty when it is the
8384// scheme's default.
8385func browsePort() string {
8386 if !isLoopbackSuffix(config.F.BrowseSuffix) {
8387 return ""
8388 }
8389 if i := strings.LastIndex(config.F.BrowseAddr, ":"); i >= 0 {
8390 return config.F.BrowseAddr[i+1:]
8391 }
8392 return ""
8393}
8394
8395// isLoopbackSuffix reports whether the browse domain is the browser's own
8396// loopback namespace rather than a real one.
8397func isLoopbackSuffix(suffix string) bool {
8398 return strings.HasSuffix(strings.TrimSuffix(suffix, "."), ".localhost")
8399}
8400
8401// appOrigin is V: the origin the responder runs on, and the exact string the
8402// browse origin posts its handshake to. It has to be what the BROWSER sees,
8403// not what the site calls itself.
8404//
8405// Those are the same thing hosted, where SITEDOMAIN is both the brand and the
8406// address. They are not the same under the loopback model: SITEDOMAIN is
8407// assembled from SITENAME and SITEEXT for the masthead, so a development run
8408// still calls itself magnetosphere.net while the browser is on
8409// localhost:<WEBPORT>. Posting the handshake to the brand would silently drop
8410// it and hang the frame on its interstitial forever, which is a bad way to
8411// find out.
8412func appOrigin() string {
8413 if isLoopbackSuffix(config.F.BrowseSuffix) {
8414 return "http://localhost:" + strconv.Itoa(config.F.WebPort)
8415 }
8416 raw := config.F.Sitedomain
8417 if raw == "" {
8418 return ""
8419 }
8420 if !strings.Contains(raw, "://") {
8421 raw = "https://" + raw
8422 }
8423 u, err := url.Parse(raw)
8424 if err != nil || u.Host == "" {
8425 return ""
8426 }
8427 return u.Scheme + "://" + u.Host
8428}
8429
8430// serveBrowseResponder puts realorigin's responder β the trust boundary β on
8431// the app origin, where it has to run first-party. An earlier design in that
8432// project put it in a cross-origin helper instead and Storage Partitioning
8433// stranded it in another partition, which is worth not rediscovering.
8434func serveBrowseResponder(r *fiber.App) {
8435 if !browseEnabled() {
8436 return
8437 }
8438 body := realorigin.ResponderJS()
8439 r.Get(browseResponderPath, func(c fiber.Ctx) error {
8440 c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
8441 return c.Send(body)
8442 })
8443}
8444
8445// StartBrowseOrigin runs the browse-origin bootstrap listener.
8446//
8447// It serves two static files and never proxies anything: the service worker at
8448// its own path and the bootstrap shell at every other. All content travels the
8449// other way, through the visitor's own tab, so this listener sees none of it
8450// and nothing about it scales with use.
8451//
8452// Returns immediately when the substrate is not configured, which is the
8453// state of any deployment that has not set up the second domain.
8454func StartBrowseOrigin(ctx context.Context) {
8455 if !browseEnabled() {
8456 return
8457 }
8458 app := appOrigin()
8459 if app == "" {
8460 log.Printf("browse: SITEURL is not a URL; the browse origin needs one to name its app origin")
8461 return
8462 }
8463 cfg := realorigin.Config{
8464 Addr: config.F.BrowseAddr,
8465 Suffix: config.F.BrowseSuffix,
8466 AppOrigin: app,
8467 }
8468 if _, err := realorigin.Handler(cfg); err != nil {
8469 log.Printf("browse: %v", err)
8470 return
8471 }
8472 log.Printf("browse: serving %s on %s for %s β each target the tab reaches gets its own origin",
8473 config.F.BrowseSuffix, config.F.BrowseAddr, app)
8474 go func() {
8475 if err := cfg.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
8476 log.Printf("browse: %v", err)
8477 }
8478 }()
8479}
8480
8481
8482// ===== pkg/web/browse_test.go =====
8483package web
8484
8485import (
8486 "strings"
8487 "testing"
8488)
8489
8490// The substrate is off unless BOTH the domain and the listener are named. A
8491// deployment with neither must behave exactly as it did before.
8492func TestBrowseIsOffUntilConfigured(t *testing.T) {
8493 saved := *f
8494 t.Cleanup(func() { *f = saved })
8495
8496 for _, tc := range []struct {
8497 suffix, addr string
8498 want bool
8499 }{
8500 {"", "", false},
8501 {".haltingstate.net", "", false},
8502 {"", "127.0.0.1:7997", false},
8503 {".haltingstate.net", "127.0.0.1:7997", true},
8504 } {
8505 f.BrowseSuffix, f.BrowseAddr = tc.suffix, tc.addr
8506 if got := browseEnabled(); got != tc.want {
8507 t.Errorf("browseEnabled(%q,%q) = %v, want %v", tc.suffix, tc.addr, got, tc.want)
8508 }
8509 }
8510}
8511
8512// Hosted is https on the default port; the loopback model is plain http on
8513// the listener's own port, which is the only way to exercise the whole path
8514// without a wildcard certificate.
8515func TestBrowseSchemeAndPort(t *testing.T) {
8516 saved := *f
8517 t.Cleanup(func() { *f = saved })
8518 for _, tc := range []struct {
8519 suffix, addr string
8520 scheme, port string
8521 }{
8522 {".haltingstate.net", "127.0.0.1:7997", "https", ""},
8523 {".mesh.localhost", "127.0.0.1:7997", "http", "7997"},
8524 {".mesh.localhost", ":7997", "http", "7997"},
8525 } {
8526 f.BrowseSuffix, f.BrowseAddr = tc.suffix, tc.addr
8527 if got := browseScheme(); got != tc.scheme {
8528 t.Errorf("browseScheme(%q) = %q, want %q", tc.suffix, got, tc.scheme)
8529 }
8530 if got := browsePort(); got != tc.port {
8531 t.Errorf("browsePort(%q,%q) = %q, want %q", tc.suffix, tc.addr, got, tc.port)
8532 }
8533 }
8534}
8535
8536// B must be a different registrable domain from V, not a subdomain of it:
8537// different hosts are merely cross-origin, and only different registrable
8538// domains make untrusted content fully cross-site from the site holding the
8539// Stripe key.
8540func TestBrowseSuffixIsNotUnderTheSiteDomain(t *testing.T) {
8541 saved := *f
8542 t.Cleanup(func() { *f = saved })
8543 f.Sitedomain = "magnetosphere.net"
8544 f.BrowseSuffix, f.BrowseAddr = ".haltingstate.net", "127.0.0.1:7997"
8545
8546 if got := appOrigin(); got != "https://magnetosphere.net" {
8547 t.Errorf("appOrigin() = %q", got)
8548 }
8549 if strings.HasSuffix(f.BrowseSuffix, "."+f.Sitedomain) {
8550 t.Error("the browse domain is under the site's own; it must be a separate registrable domain")
8551 }
8552}
8553
8554func TestAppOriginFromSiteDomain(t *testing.T) {
8555 saved := *f
8556 t.Cleanup(func() { *f = saved })
8557 f.BrowseSuffix = ".haltingstate.net" // hosted: the brand IS the address
8558 for _, tc := range []struct{ in, want string }{
8559 {"magnetosphere.net", "https://magnetosphere.net"},
8560 {"https://magnetosphere.net", "https://magnetosphere.net"},
8561 {"http://localhost:8099", "http://localhost:8099"},
8562 {"", ""},
8563 } {
8564 f.Sitedomain = tc.in
8565 if got := appOrigin(); got != tc.want {
8566 t.Errorf("appOrigin(%q) = %q, want %q", tc.in, got, tc.want)
8567 }
8568 }
8569}
8570
8571// Under the loopback model V is wherever the browser actually has it, not
8572// what the masthead says. SITEDOMAIN is assembled from SITENAME and SITEEXT
8573// for branding, so a development run calls itself magnetosphere.net while
8574// being served from localhost β and a handshake posted to the brand is
8575// silently dropped.
8576func TestAppOriginIsTheServingOriginUnderLoopback(t *testing.T) {
8577 saved := *f
8578 t.Cleanup(func() { *f = saved })
8579 f.Sitedomain, f.WebPort = "magnetosphere.net", 8099
8580 f.BrowseSuffix, f.BrowseAddr = ".mesh.localhost", "127.0.0.1:7997"
8581
8582 if got, want := appOrigin(), "http://localhost:8099"; got != want {
8583 t.Errorf("appOrigin() = %q, want %q β the brand is not the address here", got, want)
8584 }
8585 f.BrowseSuffix = ".haltingstate.net"
8586 if got, want := appOrigin(), "https://magnetosphere.net"; got != want {
8587 t.Errorf("hosted appOrigin() = %q, want %q", got, want)
8588 }
8589}
8590
8591
8592// ===== pkg/web/catalog.go =====
8593// Package web pkg/web/catalog.go β the in-memory catalog the server sells from.
8594package web
8595
8596import (
8597 "log"
8598 "os"
8599 "sync"
8600 "time"
8601
8602 p "github.com/0magnet/m2/pkg/product"
8603)
8604
8605var (
8606 allproducts p.Products
8607 allproductsMu sync.RWMutex
8608)
8609
8610var lastModTime time.Time
8611
8612// LoadCatalog reads the products CSV named in the config into memory,
8613// logging any data-integrity warnings.
8614func LoadCatalog() error {
8615 fileInfo, err := os.Stat(f.ProductsCSV)
8616 if err != nil {
8617 return err
8618 }
8619 lastModTime = fileInfo.ModTime()
8620 prods := p.ReadCSV(f.ProductsCSV)
8621 if warnings := p.ValidateCSV(prods); len(warnings) > 0 {
8622 for _, w := range warnings {
8623 log.Println("CSV warning:", w)
8624 }
8625 }
8626 allproductsMu.Lock()
8627 allproducts = prods
8628 allproductsMu.Unlock()
8629 return nil
8630}
8631
8632// WatchCatalog polls the products CSV and reloads it when it changes.
8633// Run it in a goroutine; it never returns.
8634func WatchCatalog() {
8635 for {
8636 fileInfo, err := os.Stat(f.ProductsCSV)
8637 if err != nil {
8638 log.Println("Error getting file info:", err)
8639 time.Sleep(10 * time.Second)
8640 continue
8641 }
8642
8643 currentModTime := fileInfo.ModTime()
8644 if currentModTime != lastModTime {
8645 log.Println("CSV file has been modified!")
8646 if err := LoadCatalog(); err != nil {
8647 log.Println("Error reloading catalog:", err)
8648 }
8649 }
8650
8651 time.Sleep(10 * time.Second)
8652 }
8653}
8654
8655// SetCatalog replaces the in-memory catalog directly, for hosts that have no
8656// CSV on disk to load β the in-tab site server seeds this from /api/products,
8657// which is already the public projection (no cost, location or source info).
8658func SetCatalog(prods p.Products) {
8659 allproductsMu.Lock()
8660 allproducts = prods
8661 allproductsMu.Unlock()
8662}
8663
8664
8665// ===== pkg/web/catsubcats_test.go =====
8666package web
8667
8668import (
8669 "bytes"
8670 "flag"
8671 htmpl "html/template"
8672 "os"
8673 "strings"
8674 "testing"
8675
8676 p "github.com/0magnet/m2/pkg/product"
8677)
8678
8679// sampleProducts is a catalog small enough to write the expected menu out by
8680// hand, and shaped to exercise the alignment: counts of one, two and three
8681// digits, a category with subcategories and one without.
8682func sampleProducts() p.Products {
8683 var out p.Products
8684 add := func(cat, sub string, n int) {
8685 for i := 0; i < n; i++ {
8686 out = append(out, p.Product{Enable: "TRUE", Category: cat, Subcategory: sub,
8687 Partno: cat + sub + string(rune('a'+i%26))})
8688 }
8689 }
8690 add("resistor", "quarter watt 5%", 67)
8691 add("resistor", "ceramic", 1)
8692 add("inductor", "", 34)
8693 return out
8694}
8695
8696// renderMenu runs just the category menu, the way a page does.
8697func renderMenu(t *testing.T, prods p.Products) string {
8698 t.Helper()
8699 saved := allproducts
8700 allproducts = prods
8701 defer func() { allproducts = saved }()
8702 data := htmlTemplateData{}
8703 data.LenAllProducts = len(prods)
8704 data.CatsCounts, data.Cats, data.SubCatsCounts, data.SubCatsByCat = getcategories(prods)
8705 data.Page = "front"
8706 tmpl := htmpl.New("catsubcats").Funcs(funcs)
8707 // Read by path rather than through h: the accessors resolve relative to
8708 // the server's working directory, which is not a test's.
8709 src, err := os.ReadFile("../../htmpl/catsubcats.html")
8710 if err != nil {
8711 t.Fatalf("reading catsubcats: %v", err)
8712 }
8713 if _, err := tmpl.Parse(string(src)); err != nil {
8714 t.Fatalf("parsing catsubcats: %v", err)
8715 }
8716 var b bytes.Buffer
8717 if err := tmpl.Execute(&b, map[string]interface{}{"Page": data, "Category": "", "Subcategory": ""}); err != nil {
8718 t.Fatalf("executing catsubcats: %v", err)
8719 }
8720 return b.String()
8721}
8722
8723// The menu's counts are right-aligned by padding the branch rule itself, so
8724// every count ends in the same column. These glyph runs are what the site drew
8725// before the layout moved to tallytree.
8726func TestCatSubCatsDrawing(t *testing.T) {
8727 out := renderMenu(t, sampleProducts())
8728 for _, want := range []string{
8729 "βββ102 ", // All Products
8730 "βββ¬β68 ", // resistor, has subcategories
8731 "β ββ67 ", // quarter watt 5%
8732 "β βββ1 ", // ceramic, last subcategory, rule stretched two
8733 "ββββ34 ", // inductor, no subcategories, last category
8734 } {
8735 if !strings.Contains(out, want) {
8736 t.Errorf("menu is missing %q\n\n%s", want, out)
8737 }
8738 }
8739}
8740
8741// TestCatSubCatsGolden holds the menu's whole markup, not just its glyphs.
8742// The layout moved out to tallytree; the anchors, titles, classes and nesting
8743// around it must not have moved with it.
8744//
8745// Regenerate with: go test ./pkg/web -run CatSubCatsGolden -update
8746func TestCatSubCatsGolden(t *testing.T) {
8747 got := renderMenu(t, sampleProducts())
8748 const golden = "testdata/catsubcats.golden"
8749 if *update {
8750 if err := os.WriteFile(golden, []byte(got), 0o600); err != nil {
8751 t.Fatal(err)
8752 }
8753 t.Log("golden rewritten")
8754 return
8755 }
8756 want, err := os.ReadFile(golden)
8757 if err != nil {
8758 t.Fatalf("%v (run with -update to create it)", err)
8759 }
8760 if got != string(want) {
8761 t.Errorf("menu markup changed.\n got:\n%s\nwant:\n%s", got, want)
8762 }
8763}
8764
8765var update = flag.Bool("update", false, "rewrite the golden files")
8766
8767
8768// ===== pkg/web/dropinfs.go =====
8769package web
8770
8771// The drop-ins the binary carries, for a server whose disk has none.
8772//
8773// The store runs in three places and only two of them have the binaries on a
8774// filesystem. On a host, CompileWASM writes them next to the working directory
8775// and the routes read them from there. In a browser tab β pkg/storepane's
8776// `serve`, behind the desk's netscrape window β there is no toolchain to build
8777// them with and no disk that carries them, and the sources are not there
8778// either, so nothing will ever produce them locally.
8779//
8780// So the build stages them into dropins/ and they ride along inside the
8781// binary. A tab then serves the same cart, attractor and border decoration the
8782// origin serves, out of itself, with no round trip.
8783//
8784// The page binaries (term, and the separate tui/desk) are deliberately NOT
8785// staged. term.wasm is the binary doing the embedding, and a file cannot
8786// contain itself; the tab does not serve /tui or /desk anyway β those routes
8787// belong to the host role.
8788//
8789// An empty stage is fine and is what a fresh checkout builds: the directive
8790// matches dropins/README.md, nothing is carried, and the lookup below simply
8791// says no. The disk and then the origin answer instead.
8792
8793import (
8794 "embed"
8795 "io/fs"
8796 "path"
8797 "strings"
8798)
8799
8800//go:embed dropins
8801var dropinFS embed.FS
8802
8803// embeddedDropin returns the staged bytes of one drop-in, by the file name a
8804// page asks for ("stl2-tiny.wasm").
8805func embeddedDropin(file string) ([]byte, bool) {
8806 // Only the binaries, by a bare name. The directory also holds its own
8807 // README, which is not something a page may ask this for.
8808 if file == "" || !strings.HasSuffix(file, ".wasm") || strings.ContainsAny(file, `/\`) {
8809 return nil, false
8810 }
8811 b, err := dropinFS.ReadFile(path.Join("dropins", file))
8812 if err != nil {
8813 return nil, false
8814 }
8815 return b, true
8816}
8817
8818// embeddedDropinNames lists the staged binaries, so the routes can be
8819// registered for exactly what the build carried.
8820func embeddedDropinNames() []string {
8821 ents, err := fs.ReadDir(dropinFS, "dropins")
8822 if err != nil {
8823 return nil
8824 }
8825 var out []string
8826 for _, e := range ents {
8827 if !e.IsDir() && strings.HasSuffix(e.Name(), ".wasm") {
8828 out = append(out, e.Name())
8829 }
8830 }
8831 return out
8832}
8833
8834// lookupDropin is embeddedDropin behind a variable so a test can say what the
8835// build carried. Whether a binary carries a drop-in is fixed at compile time,
8836// which makes every test of the pick order depend on what happened to be
8837// staged in the tree it was compiled from; this makes that an input.
8838var lookupDropin = embeddedDropin
8839
8840
8841// ===== pkg/web/dropinfs_test.go =====
8842package web
8843
8844import (
8845 "os"
8846 "path/filepath"
8847 "testing"
8848)
8849
8850// A fresh checkout stages nothing, and that has to build and behave β the
8851// directive matches only the README, and every lookup says no.
8852func TestEmbeddedDropinOnAnEmptyStage(t *testing.T) {
8853 // Whatever this working tree has staged, README.md is never a drop-in.
8854 if _, ok := embeddedDropin("README.md"); ok {
8855 t.Error("README.md should not be reachable as a drop-in")
8856 }
8857 for _, bad := range []string{"", "../wasmpick.go", "sub/x.wasm", `a\b.wasm`} {
8858 if _, ok := embeddedDropin(bad); ok {
8859 t.Errorf("embeddedDropin(%q) should be refused", bad)
8860 }
8861 }
8862 for _, n := range embeddedDropinNames() {
8863 if filepath.Ext(n) != ".wasm" {
8864 t.Errorf("staged name %q is not a .wasm", n)
8865 }
8866 }
8867}
8868
8869// pageWasm is what keeps term.wasm from being staged into itself.
8870func TestPageBinariesAreNotStaged(t *testing.T) {
8871 for _, name := range []string{"term", "tui", "desk"} {
8872 if !pageWasm[name] {
8873 t.Errorf("%s must be treated as a page binary, not a drop-in", name)
8874 }
8875 }
8876 for _, name := range []string{"cart", "stl2", "deco"} {
8877 if pageWasm[name] {
8878 t.Errorf("%s is a drop-in and must be stageable", name)
8879 }
8880 }
8881}
8882
8883// stageDropins takes the smallest build of each drop-in and clears what a
8884// previous run left, so a source dropped from WasmSRC stops being carried.
8885func TestStageDropinsTakesTheSmallBuildAndClearsStale(t *testing.T) {
8886 t.Chdir(t.TempDir())
8887 dir := filepath.Join("pkg", "web", "dropins")
8888 if err := os.MkdirAll(dir, 0o750); err != nil {
8889 t.Fatal(err)
8890 }
8891 write := func(name, body string) {
8892 if err := os.WriteFile(name, []byte(body), 0o600); err != nil {
8893 t.Fatal(err)
8894 }
8895 }
8896 // Left over from a run when "gone" was still configured.
8897 write(filepath.Join(dir, "gone-tiny.wasm"), "stale")
8898
8899 write("cart.wasm", "cart-go")
8900 write("cart-tiny.wasm", "cart-tiny")
8901 write("deco.wasm", "deco-go") // no tinygo build of this one
8902
8903 stageDropins([]string{"wasm/cart", "wasm/deco", "wasm/never"})
8904
8905 got := map[string]string{}
8906 ents, err := os.ReadDir(dir)
8907 if err != nil {
8908 t.Fatal(err)
8909 }
8910 for _, e := range ents {
8911 b, rerr := os.ReadFile(filepath.Join(dir, e.Name())) //nolint:gosec // test tree
8912 if rerr != nil {
8913 t.Fatal(rerr)
8914 }
8915 got[e.Name()] = string(b)
8916 }
8917 want := map[string]string{
8918 "cart-tiny.wasm": "cart-tiny", // small build preferred
8919 "deco.wasm": "deco-go", // only build there is
8920 }
8921 if len(got) != len(want) {
8922 t.Fatalf("staged %v, want %v", got, want)
8923 }
8924 for k, v := range want {
8925 if got[k] != v {
8926 t.Errorf("staged %s = %q, want %q", k, got[k], v)
8927 }
8928 }
8929}
8930
8931
8932// ===== pkg/web/invoice.go =====
8933package web
8934
8935// Crypto checkout: the other button beside Stripe.
8936//
8937// The two paths meet where it matters. Both validate every line against the
8938// server's own catalog before believing a total, and both end by writing an
8939// order into orders/ and printing a receipt β so a paid order looks the same
8940// however it was paid, and the page in the tray keeps meaning what it means.
8941//
8942// Where they differ is when. A card authorizes while the reader waits; a
8943// chain payment arrives minutes later, from a page that may already be
8944// closed. So the receipt prints on CONFIRMATION, not on placement β
8945// otherwise every abandoned checkout would put paper in the tray and the
8946// glance-at-the-printer workflow would stop working.
8947
8948import (
8949 "context"
8950 "crypto/rand"
8951 "encoding/hex"
8952 "encoding/json"
8953 "fmt"
8954 "log"
8955 "os"
8956 "path/filepath"
8957 "time"
8958
8959 "github.com/gofiber/fiber/v3"
8960
8961 "github.com/0magnet/m2/pkg/pay"
8962 "github.com/0magnet/m2/pkg/pay/sky"
8963 "github.com/0magnet/m2/pkg/storepage"
8964)
8965
8966// cryptoPay is the configured crypto checkout, or nil when the deployment
8967// has no wallet set. Nil means the button is not offered at all, which is
8968// what a store with nothing configured should show rather than an error.
8969var cryptoPay *cryptoConfig
8970
8971type cryptoConfig struct {
8972 store *pay.Store
8973 rater *pay.Rater
8974 backend pay.Backend
8975 window time.Duration
8976 ordersIn string
8977}
8978
8979// initCrypto builds the crypto checkout from the configuration, and reports
8980// whether it is on.
8981func initCrypto(ordersDir string) *cryptoConfig {
8982 if f.SkyXpub == "" {
8983 return nil
8984 }
8985 store, err := pay.NewStore(ordersDir)
8986 if err != nil {
8987 log.Printf("crypto checkout off: %v", err)
8988 return nil
8989 }
8990
8991 // A fixed rate REPLACES the exchange rather than joining it: the
8992 // lowest-wins rule settles a disagreement between exchanges quoting one
8993 // market, and a manual figure mixed in just wins whenever it is lower.
8994 var sources []pay.RateSource
8995 switch {
8996 case f.SkyRateFix != "":
8997 sources = []pay.RateSource{pay.Fixed{Label: "operator", USD: f.SkyRateFix}}
8998 case f.SkyRatePair != "":
8999 sources = []pay.RateSource{pay.LBank{Pair: f.SkyRatePair}}
9000 default:
9001 log.Printf("crypto checkout off: set a rate pair or a fixed rate")
9002 return nil
9003 }
9004
9005 mins := f.InvoiceMins
9006 if mins <= 0 {
9007 mins = 15
9008 }
9009 return &cryptoConfig{
9010 store: store,
9011 rater: &pay.Rater{Sources: sources, Min: f.RateMin, Max: f.RateMax},
9012 backend: &sky.Backend{Xpub: f.SkyXpub, NodeURL: f.SkyNodeURL},
9013 window: time.Duration(mins) * time.Minute,
9014 ordersIn: ordersDir,
9015 }
9016}
9017
9018// handleInvoice registers the crypto endpoints, if crypto is configured.
9019func handleInvoice(r *fiber.App) {
9020 cryptoPay = initCrypto("./orders")
9021 if cryptoPay == nil {
9022 return
9023 }
9024 r.Post("/create-invoice", createInvoice)
9025 r.Get("/invoice/:id", invoiceStatus)
9026 log.Printf("crypto checkout on: skycoin, %s window", cryptoPay.window)
9027}
9028
9029// invoiceRequest is what the cart posts: the same items it sends Stripe.
9030type invoiceRequest struct {
9031 Coin string `json:"coin"`
9032 Items []pay.Item `json:"items"`
9033}
9034
9035// createInvoice prices an order in coin and hands back an address.
9036func createInvoice(c fiber.Ctx) error {
9037 var req invoiceRequest
9038 if err := json.Unmarshal(c.Body(), &req); err != nil {
9039 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid JSON"})
9040 }
9041 if len(req.Items) == 0 {
9042 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
9043 }
9044 if req.Coin != "" && pay.Coin(req.Coin) != cryptoPay.backend.Coin() {
9045 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Unsupported coin"})
9046 }
9047
9048 total, err := validateOrderItems(req.Items)
9049 if err != nil {
9050 log.Printf("invoice validation failed: %v", err)
9051 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
9052 }
9053 if total < 50 {
9054 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
9055 }
9056
9057 ctx, cancel := context.WithTimeout(c.Context(), 20*time.Second)
9058 defer cancel()
9059
9060 rate, source, err := cryptoPay.rater.Quote(ctx)
9061 if err != nil {
9062 // No usable price means no sale. Quoting anyway is the mistake the
9063 // rate layer exists to prevent.
9064 log.Printf("invoice refused, no rate: %v", err)
9065 return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "No price available right now"})
9066 }
9067 coin := cryptoPay.backend.Coin()
9068 atomic, err := pay.AtomicFor(total, rate, coin.Decimals())
9069 if err != nil {
9070 log.Printf("invoice pricing failed: %v", err)
9071 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to price the order"})
9072 }
9073
9074 now := time.Now()
9075 taken, err := cryptoPay.store.TakenAddresses(coin, now)
9076 if err != nil {
9077 log.Printf("invoice address reservation failed: %v", err)
9078 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to allocate an address"})
9079 }
9080 addr, err := cryptoPay.backend.NextAddress(ctx, taken)
9081 if err != nil {
9082 log.Printf("invoice address allocation failed: %v", err)
9083 return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Unable to allocate an address"})
9084 }
9085
9086 inv := &pay.Invoice{
9087 ID: newInvoiceID(), Coin: coin, Address: addr, Atomic: atomic,
9088 FiatCents: total, Rate: rate.FloatString(10), RateSource: source,
9089 Items: req.Items, Created: now, Expires: now.Add(cryptoPay.window),
9090 Status: pay.StatusPending,
9091 }
9092 if err := cryptoPay.store.Put(inv); err != nil {
9093 log.Printf("invoice save failed: %v", err)
9094 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save the invoice"})
9095 }
9096 return c.JSON(invoiceView(inv))
9097}
9098
9099// invoiceStatus reports one invoice, polling the chain first so a reader
9100// watching the page sees the payment as soon as it confirms.
9101func invoiceStatus(c fiber.Ctx) error {
9102 inv, err := cryptoPay.store.Get(c.Params("id"))
9103 if err != nil {
9104 return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "No such invoice"})
9105 }
9106 if inv.Status != pay.StatusPaid {
9107 ctx, cancel := context.WithTimeout(c.Context(), 15*time.Second)
9108 defer cancel()
9109 if err := refreshInvoice(ctx, inv); err != nil {
9110 log.Printf("invoice %s refresh failed: %v", inv.ID, err)
9111 }
9112 }
9113 return c.JSON(invoiceView(inv))
9114}
9115
9116// invoiceView is what a client is told: never the whole record, because the
9117// items and the rate source are the store's business, not the page's.
9118func invoiceView(inv *pay.Invoice) fiber.Map {
9119 return fiber.Map{
9120 "id": inv.ID,
9121 "coin": string(inv.Coin),
9122 "address": inv.Address,
9123 "amount": pay.AmountString(inv.Atomic, inv.Coin.Decimals()),
9124 "uri": inv.URI(),
9125 "fiat_cents": inv.FiatCents,
9126 "status": string(inv.Status),
9127 "paid": pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()),
9128 "outstanding": pay.AmountString(inv.Outstanding(), inv.Coin.Decimals()),
9129 "expires": inv.Expires.Format(time.RFC3339),
9130 "txid": inv.TxID,
9131 }
9132}
9133
9134// refreshInvoice asks the chain, records what it finds, and β the first time
9135// an invoice settles β writes the order and prints the receipt.
9136func refreshInvoice(ctx context.Context, inv *pay.Invoice) error {
9137 atomic, txid, confirmed, err := cryptoPay.backend.Received(ctx, inv.Address)
9138 if err != nil {
9139 return err
9140 }
9141 changed := inv.Apply(atomic, txid, confirmed, time.Now())
9142 settled := inv.Status == pay.StatusPaid && !inv.Printed
9143 if settled {
9144 if err := completeInvoice(inv); err != nil {
9145 // The money arrived; failing to print must not lose that.
9146 log.Printf("invoice %s settled but could not be completed: %v", inv.ID, err)
9147 } else {
9148 inv.Printed = true
9149 changed = true
9150 }
9151 }
9152 if changed {
9153 return cryptoPay.store.Put(inv)
9154 }
9155 return nil
9156}
9157
9158// completeInvoice writes the order beside the card orders and prints it.
9159func completeInvoice(inv *pay.Invoice) error {
9160 items := make([]map[string]any, 0, len(inv.Items))
9161 for _, it := range inv.Items {
9162 items = append(items, map[string]any{"id": it.ID, "amount": it.Amount, "quantity": 1})
9163 }
9164 local := map[string]any{"items": items}
9165 orderData := map[string]any{
9166 "clientData": local,
9167 "verifiedCents": inv.FiatCents,
9168 "currency": "usd",
9169 "cryptoStatus": string(inv.Status),
9170 "coin": string(inv.Coin),
9171 "coinAmount": pay.AmountString(inv.Atomic, inv.Coin.Decimals()),
9172 "coinPaid": pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()),
9173 "rateUSD": inv.Rate,
9174 "rateSource": inv.RateSource,
9175 "address": inv.Address,
9176 "txid": inv.TxID,
9177 "submittedAt": time.Now().Format(time.RFC3339),
9178 }
9179 data, err := json.MarshalIndent(orderData, "", " ")
9180 if err != nil {
9181 return err
9182 }
9183 id := string(inv.Coin) + "-" + inv.ID
9184 path := filepath.Join(cryptoPay.ordersIn, id+".json")
9185 if err := os.WriteFile(path, data, 0o600); err != nil {
9186 return err
9187 }
9188 receipt, err := buildReceipt(local, id)
9189 if err != nil {
9190 return err
9191 }
9192 if err := sendToCUPS(receipt, "Order "+id); err != nil {
9193 _ = os.WriteFile(filepath.Join(cryptoPay.ordersIn, id+".print_failed"), []byte(err.Error()), 0o600) //nolint:errcheck,gosec // best-effort marker; the failure is logged
9194 return err
9195 }
9196 return nil
9197}
9198
9199// WatchInvoices polls live invoices until ctx is done.
9200//
9201// The page polls too, but a reader who closes the tab still bought
9202// something: without this, an order paid after the last poll would sit in
9203// orders/ unprinted and unnoticed.
9204func WatchInvoices(ctx context.Context) {
9205 if cryptoPay == nil {
9206 return
9207 }
9208 t := time.NewTicker(time.Minute)
9209 defer t.Stop()
9210 for {
9211 select {
9212 case <-ctx.Done():
9213 return
9214 case <-t.C:
9215 live, err := cryptoPay.store.Live()
9216 if err != nil {
9217 log.Printf("invoice poll failed: %v", err)
9218 continue
9219 }
9220 for _, inv := range live {
9221 if err := refreshInvoice(ctx, inv); err != nil {
9222 log.Printf("invoice %s poll failed: %v", inv.ID, err)
9223 }
9224 }
9225 }
9226 }
9227}
9228
9229// validateOrderItems totals an order against the server's own catalog.
9230//
9231// Shared with the Stripe path: a client is never believed about a price in
9232// either. The shipping line is the one the client sets, so it is held to the
9233// same minimum the form enforces β otherwise a hand-written request could
9234// ship for a cent.
9235func validateOrderItems(items []pay.Item) (int64, error) {
9236 total := int64(0)
9237 for _, it := range items {
9238 if it.Amount <= 0 {
9239 return 0, fmt.Errorf("non-positive amount for %q", it.ID)
9240 }
9241 if storepage.IsShippingID(it.ID) {
9242 if it.Amount < int64(storepage.MinShippingCents) {
9243 return 0, fmt.Errorf("shipping below the minimum: %d cents", it.Amount)
9244 }
9245 total += it.Amount
9246 continue
9247 }
9248 expected, err := validateItemAmount(it.ID, it.Amount)
9249 if err != nil {
9250 return 0, err
9251 }
9252 total += expected
9253 }
9254 return total, nil
9255}
9256
9257// newInvoiceID is a random id: it names a file and appears in a URL, and
9258// must not be guessable from an order number.
9259func newInvoiceID() string {
9260 var b [12]byte
9261 if _, err := rand.Read(b[:]); err != nil {
9262 return fmt.Sprintf("%d", time.Now().UnixNano())
9263 }
9264 return hex.EncodeToString(b[:])
9265}
9266
9267
9268// ===== pkg/web/invoice_test.go =====
9269package web
9270
9271import (
9272 "testing"
9273
9274 "github.com/0magnet/m2/pkg/pay"
9275 "github.com/0magnet/m2/pkg/storepage"
9276)
9277
9278// Both payment paths total an order through this, so a client is never
9279// believed about a price in one and checked in the other.
9280func TestValidateOrderItemsHoldsShippingToTheMinimum(t *testing.T) {
9281 ship := storepage.Shipping{
9282 Cents: storepage.MinShippingCents, Name: "A", City: "Dallas",
9283 State: "TX", Country: storepage.DefaultCountry,
9284 }
9285
9286 // A shipping line at the minimum is fine.
9287 total, err := validateOrderItems([]pay.Item{
9288 {ID: ship.ID(), Amount: int64(storepage.MinShippingCents)},
9289 })
9290 if err != nil {
9291 t.Fatalf("the minimum was refused: %v", err)
9292 }
9293 if total != int64(storepage.MinShippingCents) {
9294 t.Errorf("total = %d, want %d", total, storepage.MinShippingCents)
9295 }
9296
9297 // A cent of shipping is not. The form enforces this in the browser;
9298 // a hand-written request does not go through the form.
9299 if _, err := validateOrderItems([]pay.Item{
9300 {ID: ship.ID(), Amount: 1},
9301 }); err == nil {
9302 t.Error("a one-cent shipping line was accepted")
9303 }
9304}
9305
9306func TestValidateOrderItemsRejectsNonPositive(t *testing.T) {
9307 if _, err := validateOrderItems([]pay.Item{{ID: "X-1 X 1", Amount: 0}}); err == nil {
9308 t.Error("a zero-amount line was accepted")
9309 }
9310 if _, err := validateOrderItems([]pay.Item{{ID: "X-1 X 1", Amount: -500}}); err == nil {
9311 t.Error("a negative line was accepted")
9312 }
9313}
9314
9315// An unknown part cannot be priced, so it cannot be sold.
9316func TestValidateOrderItemsRejectsUnknownPart(t *testing.T) {
9317 if _, err := validateOrderItems([]pay.Item{
9318 {ID: "NO-SUCH-PART-12345 X 1", Amount: 100},
9319 }); err == nil {
9320 t.Error("an unknown part was priced")
9321 }
9322}
9323
9324// Crypto is off unless a wallet is configured: a store with nothing set
9325// should show no button rather than one that errors.
9326func TestCryptoOffWithoutAnXpub(t *testing.T) {
9327 saved := f.SkyXpub
9328 f.SkyXpub = ""
9329 defer func() { f.SkyXpub = saved }()
9330 if got := initCrypto(t.TempDir()); got != nil {
9331 t.Error("crypto checkout came up with no xpub configured")
9332 }
9333}
9334
9335// ...and off when there is a wallet but no way to price it, rather than
9336// quoting from a guess.
9337func TestCryptoOffWithoutARate(t *testing.T) {
9338 savedX, savedP, savedF := f.SkyXpub, f.SkyRatePair, f.SkyRateFix
9339 f.SkyXpub = "xpub6DH2sNie8sDh7cUbZRXeMEHaJrU72g8UZ45Z8VS26oUc8NMZrfbgpWH1U1osnJDisro6sUDVNL6MbnsAmfDNXDLjC8UfRLuM2YSoPMeY4DS"
9340 f.SkyRatePair, f.SkyRateFix = "", ""
9341 defer func() { f.SkyXpub, f.SkyRatePair, f.SkyRateFix = savedX, savedP, savedF }()
9342 if got := initCrypto(t.TempDir()); got != nil {
9343 t.Error("crypto checkout came up with no rate source")
9344 }
9345}
9346
9347
9348// ===== pkg/web/navmenu.go =====
9349package web
9350
9351// The category menu's layout.
9352//
9353// The drawing β β/β branches, a β¬ where a category opens into subcategories,
9354// and the counts right-aligned by padding the rule itself β is tallytree's. It
9355// used to be written out twice: once here in htmpl/catsubcats.html with the
9356// repeat and sub template functions, and once again in pkg/tui for the terminal,
9357// whose comment read "reproduces htmpl/catsubcats.html". Two implementations of
9358// one picture, kept in agreement by hand and tested by neither.
9359//
9360// What stays here is the markup. The template asks for the rows and wraps each
9361// one in the disclosure elements and anchors it needs; it no longer works out
9362// where anything goes.
9363
9364import (
9365 htmpl "html/template"
9366
9367 "github.com/0magnet/tallytree"
9368)
9369
9370// navRow is one line of the menu: the glyphs that lead up to it, its count,
9371// and what it points at.
9372//
9373// Prefix is HTML because its blanks are non-breaking spaces. An ordinary space
9374// there would collapse and the tree would lose its columns.
9375type navRow struct {
9376 Prefix htmpl.HTML
9377 Count int
9378 Label string
9379 HasSubs bool
9380}
9381
9382// navCat is a category row together with the subcategory rows beneath it, which
9383// is the shape the markup needs: each category is a <details> holding its own
9384// subcategories.
9385type navCat struct {
9386 navRow
9387 Subs []navRow
9388}
9389
9390// navMenu is the whole menu: the "everything" row, then the categories.
9391type navMenu struct {
9392 All navRow
9393 Cats []navCat
9394}
9395
9396// navmenu lays the menu out for a page.
9397//
9398// Depth is pinned to two because the menu always COULD have subcategories, so
9399// it reserves room for them even in a catalog where nothing does β otherwise a
9400// flat catalog would draw a narrower tree than the same shop with one
9401// subcategory added, and the menu would change shape as stock came and went.
9402//
9403// Every branch is drawn open: the disclosure elements do the hiding, so the
9404// rows are always all there.
9405func navmenu(d htmlTemplateData) navMenu {
9406 forest := []tallytree.Node{{Label: "All Products", Count: d.LenAllProducts}}
9407 for _, cat := range d.Cats {
9408 n := tallytree.Node{Label: cat, Count: d.CatsCounts[cat]}
9409 for _, sub := range d.SubCatsByCat[cat] {
9410 n.Children = append(n.Children, tallytree.Node{Label: sub, Count: d.SubCatsCounts[cat][sub]})
9411 }
9412 forest = append(forest, n)
9413 }
9414 rows := tallytree.Rows(forest, tallytree.Options{
9415 Depth: 2,
9416 Glyphs: tallytree.GlyphSet{Blank: " "},
9417 })
9418 var m navMenu
9419 for i, r := range rows {
9420 row := navRow{Prefix: htmpl.HTML(r.Prefix), Count: r.Count, Label: r.Label, HasSubs: r.HasChildren} //nolint:gosec // the prefix is glyphs this package just produced
9421 switch {
9422 case i == 0:
9423 m.All = row
9424 case r.Depth == 0:
9425 m.Cats = append(m.Cats, navCat{navRow: row})
9426 default:
9427 m.Cats[len(m.Cats)-1].Subs = append(m.Cats[len(m.Cats)-1].Subs, row)
9428 }
9429 }
9430 return m
9431}
9432
9433
9434// ===== pkg/web/order.go =====
9435// Package web pkg/web/order.go β checkout, order persistence, receipt printing.
9436package web
9437
9438import (
9439 "bytes"
9440 "encoding/json"
9441 "fmt"
9442 htmpl "html/template"
9443 "log"
9444 "os"
9445 "path/filepath"
9446 "regexp"
9447 "strconv"
9448 "strings"
9449 "time"
9450
9451 "github.com/bitfield/script"
9452 "github.com/gofiber/fiber/v3"
9453 "github.com/stripe/stripe-go/v81"
9454 "github.com/stripe/stripe-go/v81/paymentintent"
9455
9456 "github.com/0magnet/m2/pkg/pay"
9457)
9458
9459// validPIID matches Stripe PaymentIntent IDs: "pi_" followed by alphanumeric chars.
9460// Also allows plain alphanumeric+underscore+hyphen for test order IDs.
9461var validPIID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
9462
9463func handleOrder(r *fiber.App) {
9464 r.Get("/checkout.css", func(c fiber.Ctx) error {
9465 c.Set("Content-Type", "text/css;charset=utf-8")
9466 _, err := c.Status(fiber.StatusOK).Write([]byte(h.CheckoutCSS()))
9467 return err
9468 })
9469
9470 r.Get("/complete", func(c fiber.Ctx) error {
9471 // Complete template
9472 completetmpl := htmpl.New("index")
9473 if _, err := completetmpl.Parse(h.CompletePage()); err != nil {
9474 msg := fmt.Sprintf("Error parsing complete page template: %v", err)
9475 log.Println(msg)
9476 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9477 }
9478 if _, err := completetmpl.New("wasm").Parse(h.Wasm()); err != nil {
9479 log.Println("Error parsing wasm template:", err)
9480 msg := fmt.Sprintf("Error parsing wasm template: %v", err)
9481 log.Println(msg)
9482 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9483 }
9484 h1 := htmlPageTemplateData
9485 // The checkout completion page is what the cart wasm drives: it
9486 // fills in the payment status icon, text and intent id.
9487 h1.WasmBinary = wasmFor("cart")
9488 /*
9489 proto := "http"
9490 if c.Secure() {
9491 proto += "s"
9492 }
9493 */
9494 proto := "https"
9495 h1.Canonical = proto + `://` + c.Hostname() + c.OriginalURL()
9496 h1.BaseURL = proto + `://` + c.Hostname()
9497 h1.RequestHost = c.Hostname()
9498 h1.Protocol = proto
9499 h1.Time = time.Now().Format(time.RFC3339Nano)
9500 h1.Year = fmt.Sprintf("%v", time.Now().Year())
9501 tmplData := map[string]interface{}{
9502 "Page": h1,
9503 }
9504 var result bytes.Buffer
9505 err := completetmpl.Execute(&result, tmplData)
9506 if err != nil {
9507 msg := fmt.Sprintf("Could not execute html template %v", err)
9508 log.Println(msg)
9509 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9510 }
9511 c.Set("Content-Type", "text/html;charset=utf-8")
9512 return c.Status(fiber.StatusOK).Send(result.Bytes())
9513 })
9514
9515 r.Get("/order/:piid", func(c fiber.Ctx) error {
9516 piid := c.Params("piid")
9517 if !validPIID.MatchString(piid) {
9518 return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
9519 }
9520 order, err := script.File("orders/" + piid + ".json").Bytes()
9521 if err != nil {
9522 return c.Status(fiber.StatusNotFound).SendString("Order not found")
9523 }
9524 return c.Status(fiber.StatusOK).Send(order)
9525 })
9526
9527 r.Get("/order/:piid/html", func(c fiber.Ctx) error {
9528 piid := c.Params("piid")
9529 if !validPIID.MatchString(piid) {
9530 return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
9531 }
9532 order, err := script.File("orders/" + piid + ".json").Bytes()
9533 if err != nil {
9534 return c.Status(fiber.StatusNotFound).SendString("Order not found")
9535 }
9536 var m map[string]interface{}
9537 if err := json.Unmarshal(order, &m); err != nil {
9538 return c.Status(500).SendString("failed to unmarshal order json: " + err.Error())
9539 }
9540 receipt, err := buildReceipt(m, piid)
9541 if err != nil {
9542 return c.Status(500).SendString("failed to build receipt: " + err.Error())
9543 }
9544 return c.Status(200).SendString(string(receipt))
9545 })
9546
9547 r.Post("/create-payment-intent", func(c fiber.Ctx) error {
9548 rawBody := c.Body()
9549 if rawBody == nil {
9550 log.Printf("Failed to read raw request body")
9551 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to read request body"})
9552 }
9553
9554 var req struct {
9555 Items []item `json:"items"`
9556 }
9557 if err := json.Unmarshal(rawBody, &req); err != nil {
9558 log.Printf("Failed to parse JSON: %v", err)
9559 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
9560 }
9561
9562 if len(req.Items) == 0 {
9563 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
9564 }
9565
9566 // Validate each item's amount against the server-side product
9567 // catalog. Shared with the crypto path (see invoice.go) so a client
9568 // is never believed about a price in one and checked in the other.
9569 items := make([]pay.Item, 0, len(req.Items))
9570 for _, it := range req.Items {
9571 items = append(items, pay.Item{ID: it.ID, Amount: it.Amount})
9572 }
9573 total, err := validateOrderItems(items)
9574 if err != nil {
9575 log.Printf("Item validation failed: %v", err)
9576 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
9577 }
9578
9579 if total < 50 {
9580 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
9581 }
9582
9583 params := &stripe.PaymentIntentParams{
9584 Amount: stripe.Int64(total),
9585 Currency: stripe.String(string(stripe.CurrencyUSD)),
9586 }
9587 pi, err := paymentintent.New(params)
9588 if err != nil {
9589 log.Printf("Failed to create PaymentIntent: %v", err)
9590 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
9591 }
9592
9593 log.Printf("Created PaymentIntent %s for %d cents", pi.ID, total)
9594 return c.Status(fiber.StatusOK).JSON(fiber.Map{
9595 "clientSecret": pi.ClientSecret,
9596 "dpmCheckerLink": fmt.Sprintf("https://dashboard.stripe.com/settings/payment_methods/review?transaction_id=%s", pi.ID),
9597 })
9598 })
9599
9600 r.Post("/submit-order", func(c fiber.Ctx) error {
9601 var requestData struct {
9602 LocalStorageData map[string]interface{} `json:"localStorageData"`
9603 PaymentIntentID string `json:"paymentIntentId"`
9604 }
9605
9606 if err := c.Bind().Body(&requestData); err != nil {
9607 log.Println(err)
9608 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request data"})
9609 }
9610
9611 if !validPIID.MatchString(requestData.PaymentIntentID) {
9612 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payment intent ID"})
9613 }
9614
9615 log.Printf("Received payment intent ID: %s\n", requestData.PaymentIntentID)
9616
9617 paymentIntent, err := paymentintent.Get(requestData.PaymentIntentID, nil)
9618 if err != nil {
9619 log.Printf("Error retrieving payment intent: %v", err)
9620 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to verify payment"})
9621 }
9622 if paymentIntent.Status != stripe.PaymentIntentStatusSucceeded {
9623 log.Printf("Payment was not successful, status: %s", paymentIntent.Status)
9624 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Payment not successful"})
9625 }
9626
9627 ordersDir := "./orders"
9628 if err := os.MkdirAll(ordersDir, 0o750); err != nil {
9629 log.Printf("Error creating orders directory: %v", err)
9630 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
9631 }
9632
9633 filePath := filepath.Join(ordersDir, fmt.Sprintf("%s.json", requestData.PaymentIntentID))
9634
9635 // Idempotency: if the order file already exists, don't overwrite or reprint
9636 if _, err := os.Stat(filePath); err == nil {
9637 log.Printf("Order %s already exists, skipping duplicate submission", requestData.PaymentIntentID)
9638 return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order already submitted"})
9639 }
9640
9641 // Include the verified Stripe amount alongside the client-supplied data
9642 orderData := map[string]interface{}{
9643 "clientData": requestData.LocalStorageData,
9644 "verifiedCents": paymentIntent.Amount,
9645 "currency": string(paymentIntent.Currency),
9646 "stripeStatus": string(paymentIntent.Status),
9647 "submittedAt": time.Now().Format(time.RFC3339),
9648 }
9649
9650 data, err := json.MarshalIndent(orderData, "", " ")
9651 if err != nil {
9652 log.Printf("Error marshaling data to json: %v", err)
9653 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
9654 }
9655 if err := os.WriteFile(filePath, data, 0o600); err != nil {
9656 log.Printf("Error writing data to file: %v", err)
9657 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
9658 }
9659
9660 // ---- Print receipt via CUPS (non-blocking so your response is snappy)
9661 go func(pid string, local map[string]interface{}) {
9662 receipt, err := buildReceipt(local, pid)
9663 if err != nil {
9664 log.Printf("build receipt failed: %v", err)
9665 return
9666 }
9667 if err := sendToCUPS(receipt, "Order "+pid); err != nil {
9668 log.Printf("print failed: %v", err)
9669 _ = os.WriteFile(filepath.Join(ordersDir, pid+".print_failed"), []byte(err.Error()), 0o600) //nolint:errcheck // a best-effort marker that the receipt did not print; the failure is already logged
9670 }
9671 }(requestData.PaymentIntentID, requestData.LocalStorageData)
9672
9673 return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order submitted successfully"})
9674 })
9675
9676 /*
9677 r.Post("/reprint/:pid", func(c fiber.Ctx) error {
9678 pid := c.Params("pid")
9679 b, err := os.ReadFile(filepath.Join("./orders", pid+".json"))
9680 if err != nil { return c.Status(404).SendString("not found") }
9681 var m map[string]interface{}
9682 if err := json.Unmarshal(b, &m); err != nil { return c.Status(500).SendString(err.Error()) }
9683 receipt, err := buildReceipt(m, pid)
9684 if err != nil { return c.Status(500).SendString(err.Error()) }
9685 if err := sendToCUPS(receipt, "Order "+pid); err != nil {
9686 return c.Status(500).SendString(err.Error())
9687 }
9688 return c.SendStatus(204)
9689 })
9690 */
9691}
9692
9693func buildReceipt(local map[string]interface{}, paymentIntentID string) ([]byte, error) {
9694 // Pretty JSON body from what you already persisted
9695 body, err := json.MarshalIndent(local, "", " ")
9696 if err != nil {
9697 return nil, err
9698 }
9699 // Simple text receipt header
9700 ts := time.Now().Format("2006-01-02 15:04:05")
9701 hdr := fmt.Sprintf(
9702 "==================== ORDER ====================\n"+
9703 "PaymentIntent: %s\nTime: %s\n===============================================\n\n",
9704 paymentIntentID, ts,
9705 )
9706 // Footer (optional)
9707 ftr := "\n\n---------------------- END ---------------------\n"
9708 receipt := append([]byte(hdr), body...)
9709 receipt = append(receipt, []byte(ftr)...)
9710 return receipt, nil
9711}
9712
9713// serverPriceCents looks up a product's price from the server-side catalog by part number.
9714func serverPriceCents(partno string) (int64, error) {
9715 allproductsMu.RLock()
9716 prods := allproducts
9717 allproductsMu.RUnlock()
9718 for _, prod := range prods {
9719 if prod.Partno == partno {
9720 return parsePriceCents(prod.Price), nil
9721 }
9722 }
9723 return 0, fmt.Errorf("product %q not found in catalog", partno)
9724}
9725
9726// parsePriceCents converts a price string like "$1.23" or "1.23" to cents.
9727func parsePriceCents(s string) int64 {
9728 if s == "" {
9729 return 0
9730 }
9731 s = strings.TrimPrefix(s, "$")
9732 f, err := strconv.ParseFloat(s, 64)
9733 if err != nil {
9734 return 0
9735 }
9736 if f < 0 {
9737 return -int64(-f*100 + 0.5)
9738 }
9739 return int64(f*100 + 0.5)
9740}
9741
9742// validateItemAmount parses a client item ID ("partno X qty"), looks up the
9743// server-side price, computes the expected total, and returns it. If the
9744// client-supplied amount doesn't match, an error is returned.
9745func validateItemAmount(itemID string, clientAmount int64) (int64, error) {
9746 // Parse "partno X qty"
9747 parts := strings.SplitN(itemID, " X ", 2)
9748 if len(parts) != 2 {
9749 return 0, fmt.Errorf("unexpected item ID format: %q", itemID)
9750 }
9751 partno := parts[0]
9752 qty, err := strconv.Atoi(parts[1])
9753 if err != nil || qty <= 0 {
9754 return 0, fmt.Errorf("invalid quantity in item ID %q", itemID)
9755 }
9756
9757 unitCents, err := serverPriceCents(partno)
9758 if err != nil {
9759 return 0, err
9760 }
9761 expected := unitCents * int64(qty)
9762 if expected != clientAmount {
9763 return 0, fmt.Errorf("amount mismatch for %q: client sent %d cents, server expects %d cents", partno, clientAmount, expected)
9764 }
9765 return expected, nil
9766}
9767
9768// escape for inclusion inside *double quotes* in a bash command string
9769func bashEscapeDoubleQuoted(s string) string {
9770 s = strings.ReplaceAll(s, `\`, `\\`)
9771 s = strings.ReplaceAll(s, `"`, `\"`)
9772 s = strings.ReplaceAll(s, "$", `\$`)
9773 s = strings.ReplaceAll(s, "`", "\\`")
9774 return s
9775}
9776
9777func sendToCUPS(receipt []byte, title string) error {
9778 if title == "" {
9779 title = "Order"
9780 }
9781 var cmd strings.Builder
9782 cmd.WriteString("lp")
9783
9784 if f.PrinterName != "" {
9785 cmd.WriteString(` -d "`)
9786 cmd.WriteString(bashEscapeDoubleQuoted(f.PrinterName))
9787 cmd.WriteString(`"`)
9788 }
9789
9790 cmd.WriteString(` -t "`)
9791 cmd.WriteString(bashEscapeDoubleQuoted(title))
9792 cmd.WriteString(`"`)
9793
9794 if f.CupsOptions != "" {
9795 for _, opt := range strings.Split(f.CupsOptions, ",") {
9796 opt = strings.TrimSpace(opt)
9797 if opt == "" {
9798 continue
9799 }
9800 cmd.WriteString(` -o "`)
9801 cmd.WriteString(bashEscapeDoubleQuoted(opt))
9802 cmd.WriteString(`"`)
9803 }
9804 }
9805
9806 full := fmt.Sprintf(`bash -lc %q`, cmd.String())
9807
9808 _, err := script.Echo(string(receipt)).Exec(full).Stdout()
9809 if err != nil {
9810 return fmt.Errorf("lp failed: %v", err)
9811 }
9812 return nil
9813}
9814
9815
9816// ===== pkg/web/other.go =====
9817// Package web pkg/web/other.go β site-specific drop-in routes (not
9818// committed). Registers via the extraRoutes registry in server.go;
9819// deleting this file removes these routes with no other code changes.
9820// See other.go.example.
9821package web
9822
9823import (
9824 "bytes"
9825 "fmt"
9826 "log"
9827 "strconv"
9828 "strings"
9829
9830 "github.com/gofiber/fiber/v3"
9831)
9832
9833func init() {
9834 extraRoutes = append(extraRoutes, handleOthers)
9835 // The software page is the only one here worth crawling; see demospage.
9836 // /demos is kept as the older spelling of the same page.
9837 extraSitemapURLs = append(extraSitemapURLs, "/software")
9838 extraNavLinks = append(extraNavLinks, NavLink{
9839 Title: "Software",
9840 Href: "/software",
9841 Desc: "programs written here, running in your browser",
9842 })
9843}
9844
9845func handleOthers(r *fiber.App) {
9846 r.Get("/coffee", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusTeapot) })
9847 r.Get("/clock", clock)
9848 r.Get("/attractors", attractorspage)
9849 r.Get("/COVID", covidpage)
9850 r.Get("/test", testpage)
9851 r.Get("/software", demospage)
9852 r.Get("/software/", demospage)
9853 // The page was published as /demos first and is linked from the READMEs
9854 // of several repositories, so that spelling keeps working.
9855 r.Get("/demos", demospage)
9856 r.Get("/demos/", demospage)
9857}
9858
9859func clock(c fiber.Ctx) error {
9860 c.Set("Content-Type", "text/html;charset=utf-8")
9861 _, err := c.Status(fiber.StatusOK).Write([]byte(mustReadFileToString("content/clock.html")))
9862 return err
9863}
9864
9865func covidpage(c fiber.Ctx) error {
9866 tmpl, err := mainTmpl()
9867 if err != nil {
9868 msg := fmt.Sprintf("Error parse html template: %v", err)
9869 log.Println(msg)
9870 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9871 }
9872 tmpl0, err := tmpl.Clone()
9873 if err != nil {
9874 msg := fmt.Sprintf("Error cloning template: %v", err)
9875 log.Println(msg)
9876 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9877 }
9878 _, err = tmpl0.New("main").Parse(mustReadFileToString("content/mementomori.html"))
9879 if err != nil {
9880 msg := fmt.Sprintf("Error parsing main template: %v", err)
9881 log.Println(msg)
9882 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9883 }
9884 tmpl = tmpl0
9885 log.Println(c.Get("User-Agent"))
9886 c.Set("Content-Type", "text/html;charset=utf-8")
9887 h1 := pageMeta(c, htmlPageTemplateData)
9888 h1.Page = "hidden"
9889 h1.MetaDesc = "The COVID ΜΆvΜΆaΜΆcΜΆcΜΆiΜΆnΜΆeΜΆ bioweapon injection genocide and the new dark age of humanity"
9890 // h1.Mobile = strings.Contains(strings.ToLower(c.Get("User-Agent")), "mobile")
9891 tmplData := map[string]interface{}{
9892 "Page": h1,
9893 "Prods": allproducts,
9894 }
9895 var result bytes.Buffer
9896 err = tmpl.Execute(&result, tmplData)
9897 if err != nil {
9898 msg := fmt.Sprintf("Error executing template: %v", err)
9899 log.Println(msg)
9900 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9901 }
9902 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
9903 return err
9904}
9905
9906// attractorspage renders a chromeless fullscreen page for the
9907// strange-attractor visualizer (no header, footer, cart, or store
9908// nav). Reuses the existing stl2 wasm β its URL-path dispatcher
9909// sees "/attractors" and falls into the default branch which
9910// invokes attractor.Run(). Loads the TinyGo or stdlib wasm based
9911// on f.UseTinygo.
9912func attractorspage(c fiber.Ctx) error {
9913 // Newest build wins, with the shim that matches it β the same rule the
9914 // template pages use. Hardcoding "-tiny.wasm" here meant this page served
9915 // whatever the last tinygo run produced even when the Go build was newer,
9916 // and paired it with the site-global shim rather than its own.
9917 pick, ok := pickWasm("stl2")
9918 if !ok {
9919 return c.Status(fiber.StatusNotFound).SendString("no stl2 wasm build available")
9920 }
9921 wasmFile := pick.File
9922 html := fmt.Sprintf(`<!DOCTYPE html>
9923<html lang="en">
9924<head>
9925<meta charset="utf-8">
9926<meta name="viewport" content="width=device-width, initial-scale=1">
9927<title>Strange Attractors β %s</title>
9928<meta name="description" content="Interactive 3D strange-attractor visualizer with mouse-drag rotation. Lorenz, Rossler, Chua, Aizawa, Sprott, Lissajous, Thomas, Halvorsen, Chen, Dadras, Rabinovich-Fabrikant, Burke-Shaw, Platonic solids, globe, sphere, torus, magnetosphere.">
9929<meta name="robots" content="index, follow">
9930<style>html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;color:#fff;overflow:hidden;}#gocanvas{position:fixed;top:0;left:0;width:100%%;height:100%%;display:block;}</style>
9931<script src="%s"></script>
9932<script>
9933if (!WebAssembly.instantiateStreaming) {
9934 WebAssembly.instantiateStreaming = async (resp, importObject) => {
9935 const source = await (await resp).arrayBuffer();
9936 return await WebAssembly.instantiate(source, importObject);
9937 };
9938}
9939const go = new Go();
9940WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject).then((result) => {
9941 go.run(result.instance);
9942}).catch((err) => { console.error("Failed to run WASM:", err); });
9943</script>
9944</head>
9945<body>
9946<canvas id="gocanvas"></canvas>
9947</body>
9948</html>`, f.Sitelongname, f.WasmExecPath, wasmFile)
9949 c.Set("Content-Type", "text/html; charset=utf-8")
9950 return c.Status(fiber.StatusOK).SendString(html)
9951}
9952
9953// ---------------------------------------------------------------------------
9954// /demos β the live demos hosted on magnetosphere.net subdomains.
9955//
9956// Every one of these projects publishes its demo from GitHub Pages under a
9957// CNAME of this domain (chaosrack.magnetosphere.net and so on), and until now
9958// nothing on the web linked to them. A page nobody links to is a page a
9959// crawler reaches only through the certificate transparency logs, if at all β
9960// so the subdomains existed but were effectively unfindable.
9961//
9962// This is the index that fixes that. It is a real page at a real URL rather
9963// than another anchor on the front page, for two reasons: an anchor section
9964// shares the front page's <title> and description, and this content is not
9965// about electronic surplus; and the front page hides every non-target section
9966// with CSS, so the text would be in the markup but never rendered to a reader.
9967//
9968// It lives in the drop-in rather than in the m2 repo because it is specific to
9969// this deployment β no other m2 site has these subdomains. The one piece that
9970// did go upstream is extraSitemapURLs, which is the generic half: a drop-in
9971// route that wants to be crawled needs to be in the sitemap, and there was no
9972// way to say so.
9973// ---------------------------------------------------------------------------
9974
9975// demoAlt is a second build of the same demo, linked beside the first. Several
9976// of these ship a TinyGo build and a standard-Go build of the same program;
9977// the difference is size against completeness, and both are worth reaching.
9978type demoAlt struct {
9979 Label string
9980 Path string
9981}
9982
9983// demoSite is one demo: where it runs, where its source is, and one sentence
9984// saying what it actually does. The sentence is the part that matters β it is
9985// what a search result shows and the only description of these subdomains that
9986// exists anywhere.
9987type demoSite struct {
9988 Name string
9989 Host string
9990 Repo string
9991 What string
9992 Alts []demoAlt
9993
9994 // Pending holds a demo back until its subdomain actually answers over
9995 // HTTPS. GitHub issues a Pages certificate some time after the DNS lands,
9996 // and until it does the link is a TLS error rather than a page.
9997 //
9998 // The entry stays here rather than being deleted and rewritten later,
9999 // because the description is the part that took the thought. Clearing this
10000 // flag is the whole of the work when the certificate arrives β and a page
10001 // whose entire purpose is to be crawled must not be the thing that teaches
10002 // a crawler these hostnames are broken.
10003 Pending bool
10004}
10005
10006// demoGroup is a heading over a run of demos.
10007type demoGroup struct {
10008 Title string
10009 Note string
10010 Sites []demoSite
10011}
10012
10013var demoGroups = []demoGroup{{
10014 Title: "Applications",
10015 Note: "Whole programs, running in the tab. Nothing is uploaded and nothing runs on a server.",
10016 Sites: []demoSite{
10017 {
10018 Name: "chaosrack",
10019 Host: "chaosrack.magnetosphere.net",
10020 What: "An analog computer in the browser: 66 dynamical-system models β Lorenz, RΓΆssler, Chua, all twenty Sprott cases, HΓ©non and the other maps β on an instrument rack with knobs, seven-segment readouts and toggle switches. Live audio as a spectrogram, an XY scope or a delay embedding, and a ripple tank to look through.",
10021 },
10022 {
10023 Name: "shipyard",
10024 Host: "shipyard.magnetosphere.net",
10025 What: "A Go workstation in a browser tab. The Go toolchain is on the PATH: type go build in the shell, against the same in-memory filesystem the compiler reads, and run what comes out.",
10026 },
10027 {
10028 Name: "shipwright",
10029 Host: "shipwright.magnetosphere.net",
10030 What: "The Go toolchain itself, running in the tab. Edit a program and press build and run: cmd/compile and cmd/link do the work client-side, compiling the standard library from source, and nothing after page load touches the server.",
10031 },
10032 {
10033 Name: "netscrape",
10034 Host: "netscrape.magnetosphere.net",
10035 What: "A web browser written in Go and wasm β tab strip, address bar, history, back and forward β rendering a site served beside it, with the stylesheet inlined and images relayed back as data URIs. The same seam dials the dmsg mesh in skywire's wasm visor.",
10036 },
10037 {
10038 Name: "desk",
10039 Host: "desk.magnetosphere.net",
10040 What: "A desktop shell for the browser β windows, a taskbar, an applications menu β where a window is any Go program that renders into a DOM element.",
10041 Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10042 },
10043 {
10044 Name: "websh",
10045 Host: "websh.magnetosphere.net",
10046 What: "A bash-like shell running entirely in your browser. Real pipes, redirections, globbing, functions, heredocs and arithmetic, over a filesystem persisted to IndexedDB, so your files survive a reload. No server, no container, no emulator.",
10047 Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10048 },
10049 {
10050 Name: "dict",
10051 Host: "dict.magnetosphere.net",
10052 What: "Dictionary lookup and spell-check in a terminal, with Webster's 1913, WordNet, Wiktionary and Wikipedia. It finds the word you meant when a transposition or a doubled letter means a fuzzy matcher finds nothing at all.",
10053 },
10054 {
10055 Name: "pisano",
10056 Host: "pisano.magnetosphere.net",
10057 What: "Designs made by reducing an integer sequence modulo m β the Pisano period made visible β with a shell to drive the renderer.",
10058 Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10059 },
10060 },
10061}, {
10062 Title: "Libraries ported to Go and WebAssembly",
10063 Note: "JavaScript libraries reimplemented in Go, so a Go program in a tab can use them without a JavaScript dependency.",
10064 Sites: []demoSite{
10065 {
10066 Name: "xterm-go",
10067 Host: "xterm-go.magnetosphere.net",
10068 What: "A Go port of xterm.js 6.0.0 β the terminal component behind VS Code and Hyper. The full escape-sequence machinery, a WebGL2 renderer, scrollback with reflow, and IME support. The VT core is pure Go and runs headless anywhere.",
10069 },
10070 {
10071 Name: "winbox-go",
10072 Host: "winbox-go.magnetosphere.net",
10073 What: "A Go port of WinBox.js, the HTML5 window manager: drag, eight-direction resize, minimize into a split-screen taskbar, maximize, fullscreen, modals and every lifecycle callback. The stylesheet is embedded in the binary, so there is nothing else to serve.",
10074 },
10075 {
10076 Name: "cosmos-go",
10077 Host: "cosmos-go.magnetosphere.net",
10078 What: "A Go port of cosmos.gl 2.6.3, the GPU force-directed graph engine behind Cosmograph and the Skywire network visualizer. The simulation and the drawing both run in shaders, which is how it holds hundreds of thousands of points at frame rate.",
10079 },
10080 {
10081 Name: "tuiwasm",
10082 Host: "tuiwasm.magnetosphere.net",
10083 What: "Go terminal-UI libraries running in the browser on top of xterm-go β tcell, lipgloss, glamour, go-pretty, pterm and asciigraph β plus 21 terminal animations and a tcell space shooter, each in a window of its own.",
10084 Alts: []demoAlt{{Label: "TinyGo build", Path: "/tinygo/"}},
10085 },
10086 },
10087}, {
10088 Title: "Command-line tools ported to Go",
10089 Note: "Each of these is a faithful port of a C or Go program, and each demo runs the port itself rather than an approximation of it.",
10090 Sites: []demoSite{
10091 {
10092 Name: "plot-go",
10093 Host: "plot-go.magnetosphere.net",
10094 What: "Line graphs on the command line. The demo follows two counters a browser tab actually has β the gap between animation frames, and how late a zero-millisecond timeout runs β and redraws them live in a terminal.",
10095 },
10096 {
10097 Name: "img2txt-go",
10098 Host: "img2txt-go.magnetosphere.net",
10099 What: "libcaca's img2txt in pure Go, with no cgo and no Imlib2: images to colored ASCII and ANSI art, in every text format libcaca supports. Drop in a picture, pick a width and a dither; the image never leaves the tab.",
10100 },
10101 {
10102 Name: "ansifilter-go",
10103 Host: "ansifilter-go.magnetosphere.net",
10104 What: "A Go port of ansifilter 2.23: text with ANSI terminal escape codes converted to HTML, LaTeX, RTF, SVG, Pango markup or BBCode. The demo shows the same input in a real terminal and in a real browser at once.",
10105 },
10106 {
10107 Name: "calvin",
10108 Host: "calvin.magnetosphere.net",
10109 What: "Text converted to the Calvin S box-drawing font. Type in the box and watch the letters turn.",
10110 },
10111 },
10112}, {
10113 Title: "Interface libraries written here",
10114 Note: "Not ports of anything β original libraries, each with a demo that is the library doing its one job.",
10115 Sites: []demoSite{
10116 {
10117 Name: "rack-go",
10118 Host: "rack-go.magnetosphere.net",
10119 What: "Panel modules laid out the way a physical equipment rack does: side by side, each a whole number of slots wide, wrapping into rows and reflowing when one is taken out. Drag a module by its header to move it, or switch one away and watch the rest close up.",
10120 },
10121 },
10122}, {
10123 // Not written here: these are upstream projects this site's author is a
10124 // contributor to. They are on the software page because the page is the
10125 // honest answer to "what does whoever runs this store work on", and
10126 // leaving them off made that answer look smaller than it is.
10127 //
10128 // Repo is set on both β demoRepo defaults to github.com/0magnet/<Name>,
10129 // which is wrong for anything upstream.
10130 Title: "Upstream projects contributed to",
10131 Note: "Not written here, and not WebAssembly. Listed because a good deal of the work behind this site went into them.",
10132 Sites: []demoSite{{
10133 Name: "skycoin",
10134 Host: "skycoin.magnetosphere.net",
10135 Repo: "https://github.com/skycoin/skycoin",
10136 What: "A cryptocurrency with no mining and no transaction fees, and the consensus algorithm behind it. The link goes to a Skycoin block explorer running on this host, against a full node it keeps in sync.",
10137 }, {
10138 Name: "skywire",
10139 Host: "theskywirenetwork.net",
10140 Repo: "https://github.com/skycoin/skywire",
10141 What: "A mesh network that routes traffic over its own transports rather than the public internet, paying node operators for uptime. The browser pane in this site's desktop runs over it.",
10142 }},
10143}}
10144
10145// demoRepo is the source URL for a demo. All of these live under the same
10146// GitHub account and the repository is named after the program.
10147func (d demoSite) demoRepo() string {
10148 if d.Repo != "" {
10149 return d.Repo
10150 }
10151 return "https://github.com/0magnet/" + d.Name
10152}
10153
10154// demosBody renders the page. It is plain markup in the vocabulary the rest of
10155// the site already uses β the α table and the β€ heading from the links page β
10156// so it inherits the stylesheet rather than carrying one of its own.
10157func demosBody() string {
10158 var b strings.Builder
10159 // A column of its own. main is full-bleed and centered, which is right for
10160 // the store front but runs prose to both edges of a wide window and puts the
10161 // last table column under the scrollbar.
10162 b.WriteString("<div style='max-width:1200px;margin:0 auto;padding:0 14px'>\n")
10163 b.WriteString("<h1>Software</h1>\n")
10164 b.WriteString("<p>Software written here, running in your browser. ")
10165 b.WriteString("Every one of these is Go compiled to WebAssembly, served from a subdomain of this site, ")
10166 b.WriteString("and open source. Nothing below needs an account, an install, or a round trip to a server.</p>\n")
10167
10168 for _, g := range demoGroups {
10169 // A group every one of whose demos is still waiting on a certificate
10170 // would render as a heading over nothing.
10171 if live(g.Sites) == 0 {
10172 continue
10173 }
10174 b.WriteString("<h2 class='β€'>" + g.Title + "</h2>\n")
10175 if g.Note != "" {
10176 b.WriteString("<p>" + g.Note + "</p>\n")
10177 }
10178 b.WriteString("<table class='α'><thead><tr><th style='width:12%'><pre>Demo</pre></th><th style='width:62%'><pre>What it is</pre></th><th style='width:26%'><pre>Source</pre></th></tr></thead>\n<tbody>\n")
10179 for _, d := range g.Sites {
10180 if d.Pending {
10181 continue
10182 }
10183 b.WriteString("<tr><td style='width:12%'><pre><a title='" + d.Host + "' href='https://" + d.Host + "/'>" + d.Name + "</a>")
10184 for _, a := range d.Alts {
10185 b.WriteString("<br><a title='" + d.Host + a.Path + "' href='https://" + d.Host + a.Path + "'>" + a.Label + "</a>")
10186 }
10187 b.WriteString("</pre></td>")
10188 b.WriteString("<td style='width:62%;padding-right:16px'><pre style='white-space:pre-wrap'>" + d.What + "</pre></td>")
10189 b.WriteString("<td style='width:26%'><pre><a title='source code for " + d.Name + "' href='" + d.demoRepo() + "'>" + strings.TrimPrefix(d.demoRepo(), "https://") + "</a></pre></td></tr>\n")
10190 }
10191 b.WriteString("</tbody></table>\n")
10192 }
10193 b.WriteString(deepBody())
10194 b.WriteString("<p><a href='/'>Back to the store</a></p>\n")
10195 b.WriteString("</div>\n")
10196 return b.String()
10197}
10198
10199// demosLD is the structured data for the page: an ItemList of the demos, each
10200// a SoftwareApplication at its own URL.
10201//
10202// This is the half a crawler reads. The list gives every subdomain a name, a
10203// description and a URL in one document, which is the fastest way to get a set
10204// of never-before-linked hostnames into an index.
10205func demosLD(canonical string) string {
10206 var b strings.Builder
10207 b.WriteString("<script type='application/ld+json'>\n{\n")
10208 b.WriteString("\"@context\": \"https://schema.org\",\n")
10209 b.WriteString("\"@type\": \"ItemList\",\n")
10210 b.WriteString("\"name\": \"Demos\",\n")
10211 b.WriteString("\"url\": \"" + canonical + "\",\n")
10212 b.WriteString("\"itemListElement\": [\n")
10213 pos := 0
10214 for _, g := range demoGroups {
10215 for _, d := range g.Sites {
10216 if d.Pending {
10217 continue
10218 }
10219 if pos > 0 {
10220 b.WriteString(",\n")
10221 }
10222 pos++
10223 b.WriteString("{\"@type\": \"ListItem\", \"position\": " + strconv.Itoa(pos) + ", \"item\": {")
10224 b.WriteString("\"@type\": \"SoftwareApplication\", ")
10225 b.WriteString("\"name\": \"" + d.Name + "\", ")
10226 b.WriteString("\"url\": \"https://" + d.Host + "/\", ")
10227 b.WriteString("\"applicationCategory\": \"DeveloperApplication\", ")
10228 b.WriteString("\"operatingSystem\": \"Any (WebAssembly)\", ")
10229 b.WriteString("\"codeRepository\": \"" + d.demoRepo() + "\", ")
10230 b.WriteString("\"description\": \"" + jsonString(d.What) + "\"}}")
10231 }
10232 }
10233 b.WriteString("\n]\n}\n</script>\n")
10234 return b.String()
10235}
10236
10237// jsonString escapes the characters a description could plausibly contain and
10238// JSON cannot carry raw. The blurbs above are written by hand and hold none of
10239// them today; this is here so that editing one cannot silently produce invalid
10240// structured data, which a crawler drops without reporting.
10241func jsonString(s string) string {
10242 r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", " ", "\t", " ")
10243 return r.Replace(s)
10244}
10245
10246// demospage serves /demos.
10247//
10248// It borrows the site chrome β head, header, footer β and replaces the "schema"
10249// partial with the ItemList above, rather than the front page's Organization
10250// block.
10251//
10252// It states WasmBinary as empty. That is the default now β a page opts in
10253// with wasmFor β but saying so here keeps the intent readable: this page is
10254// text, and a crawler meant to read it quickly should not be handed the STL
10255// viewer. Emptying the list is how the templates ask for that; overriding the
10256// "wasm" partial with an empty body does NOT work, because Go templates treat
10257// a whitespace-only body as no definition at all and leave the existing one
10258// standing.
10259func demospage(c fiber.Ctx) error {
10260 tmpl, err := mainTmpl()
10261 if err != nil {
10262 msg := fmt.Sprintf("Error parsing html template: %v", err)
10263 log.Println(msg)
10264 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10265 }
10266 tmpl0, err := tmpl.Clone()
10267 if err != nil {
10268 msg := fmt.Sprintf("Error cloning template: %v", err)
10269 log.Println(msg)
10270 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10271 }
10272 h1 := pageMeta(c, htmlPageTemplateData)
10273 h1.Page = "software"
10274 // One canonical URL for two routes. /demos was the first spelling and is
10275 // linked from several repository READMEs, so it still answers β but it
10276 // must not compete with /software for the same content, which is exactly
10277 // what a self-referencing canonical on each would set up.
10278 h1.Canonical = h1.BaseURL + "/software"
10279 h1.Title = "Software β Go and WebAssembly in the browser | " + f.Sitelongname
10280 h1.MetaDesc = "Live demos: a shell, a desktop, a Go toolchain, a dictionary, a terminal emulator, a window manager, a GPU force graph and an analog computer β all Go compiled to WebAssembly, running in the browser."
10281 h1.KeyWords = "Go, WebAssembly, wasm, TinyGo, browser demo, xterm.js port, WinBox port, cosmos.gl port, in-browser shell, strange attractor, libcaca img2txt, ansifilter"
10282 h1.WasmBinary = nil
10283
10284 for _, part := range []struct{ name, body string }{
10285 {"main", demosBody()},
10286 {"schema", demosLD(h1.Canonical)},
10287 } {
10288 if _, err := tmpl0.New(part.name).Parse(part.body); err != nil {
10289 msg := fmt.Sprintf("Error parsing %s template: %v", part.name, err)
10290 log.Println(msg)
10291 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10292 }
10293 }
10294
10295 c.Set("Content-Type", "text/html;charset=utf-8")
10296 tmplData := map[string]interface{}{
10297 "Page": h1,
10298 "Prods": allproducts,
10299 }
10300 var result bytes.Buffer
10301 if err := tmpl0.Execute(&result, tmplData); err != nil {
10302 msg := fmt.Sprintf("Error executing template: %v", err)
10303 log.Println(msg)
10304 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10305 }
10306 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
10307 return err
10308}
10309
10310// ---------------------------------------------------------------------------
10311// Deep links.
10312//
10313// Three of the demos take a parameter, so a link can open on a particular
10314// thing rather than on the front of the app. tuiwasm is the striking case: one
10315// wasm binary holds 31 separate programs, and without this the page links to
10316// the binary and every program inside it is invisible.
10317//
10318// This is the cheapest indexable surface here by a distance. Each of these is
10319// a distinct URL that renders distinct content, and the link text is the
10320// demo's own name β which is what someone searching for "terminal matrix
10321// animation" or "Langton's ant in the browser" actually types.
10322// ---------------------------------------------------------------------------
10323
10324// deepLink is one parameterized entry point: what to call it, what it opens,
10325// and the query that gets there.
10326type deepLink struct {
10327 Label string
10328 Desc string
10329 Query string
10330}
10331
10332// deepGroup is a run of deep links into one demo.
10333type deepGroup struct {
10334 Host string
10335 Title string
10336 Note string
10337 Links []deepLink
10338}
10339
10340var deepGroups = []deepGroup{{
10341 Host: "tuiwasm.magnetosphere.net",
10342 Title: "tuiwasm β 43 terminal-UI programs, one per link",
10343 Note: "Terminal animations and TUI library demos, each drawn into a real terminal emulator in the page. The animations come from termanim; the rest exercise tcell, lipgloss, glamour, go-pretty and asciigraph.",
10344 Links: []deepLink{
10345 {"aquarium", "fish swimming past swaying seaweed", "demo=aquarium"},
10346 {"aurora", "curtains of light, folding, with the creases where they turn", "demo=aurora"},
10347 {"boids", "flocking by separation, alignment and cohesion", "demo=boids"},
10348 {"bonsai", "a bonsai tree growing branch by branch", "demo=bonsai"},
10349 {"bounce", "the screensaver logo, and the wait for it to hit a corner", "demo=bounce"},
10350 {"boxes", "tcell's own boxes demo β random boxes, timed", "demo=boxes"},
10351 {"charts", "asciigraph β a line plot in cells", "demo=charts"},
10352 {"clock", "an analog clock, after aclock", "demo=clock"},
10353 {"colors", "tcell's own colors demo β boxes cycling through the palette", "demo=colors"},
10354 {"cube", "a rotating wireframe solid, shaded by depth", "demo=cube"},
10355 {"donut", "a lit torus with a z-buffer", "demo=donut"},
10356 {"fire", "a heat grid seeded with noise and cooled upward", "demo=fire"},
10357 {"fireworks", "shells that rise, burst and droop into willows", "demo=fireworks"},
10358 {"flow", "particles carried through a divergence-free curl-noise field", "demo=flow"},
10359 {"frost", "a crystal growing by diffusion-limited aggregation", "demo=frost"},
10360 {"julia", "a Julia set morphing as its parameter walks the cardioid", "demo=julia"},
10361 {"langton", "Langton's ants: chaos, then the highway", "demo=langton"},
10362 {"lavalamp", "wax that heats, rises, cools and sinks", "demo=lavalamp"},
10363 {"life", "Conway's life, colored by how long a cell has lived", "demo=life"},
10364 {"lightning", "a branching discharge: leader, return stroke, afterglow", "demo=lightning"},
10365 {"matrix", "falling columns of glyphs", "demo=matrix"},
10366 {"maze", "a maze carved by backtracking, then solved", "demo=maze"},
10367 {"metaballs", "blobs that bulge and merge as they approach", "demo=metaballs"},
10368 {"moire", "two drifting ripples interfering", "demo=moire"},
10369 {"parrot", "the party parrot, rolling once around the hue wheel", "demo=parrot"},
10370 {"pendulum", "double pendulums released together, shearing apart", "demo=pendulum"},
10371 {"physarum", "slime mold laying trails and building a transport network", "demo=physarum"},
10372 {"pipes", "pipes growing and turning, with correct elbows", "demo=pipes"},
10373 {"plasma", "summed sine waves of position, offset by time", "demo=plasma"},
10374 {"proxima", "Escape from Proxima 5 β gdamore's tcell space shooter", "demo=proxima"},
10375 {"proxima2", "Escape from Proxima 5 on tcell v2 β the same game, one major back", "demo=proxima2"},
10376 {"rain", "drops with depth, slant, streaks and splashes", "demo=rain"},
10377 {"reaction", "Gray-Scott reaction-diffusion: spots that divide into a labyrinth", "demo=reaction"},
10378 {"ripple", "a ripple tank: rain falling, rings meeting and interfering", "demo=ripple"},
10379 {"sand", "grains heaping at their angle of repose", "demo=sand"},
10380 {"snow", "flakes that sway, settle and drift into banks", "demo=snow"},
10381 {"starfield", "stars streaming past the viewer", "demo=starfield"},
10382 {"styles", "lipgloss β borders, color, alignment", "demo=styles"},
10383 {"tables", "go-pretty β the wasm compatibility matrix", "demo=tables"},
10384 {"tunnel", "flying down a textured tube", "demo=tunnel"},
10385 {"unicode", "tcell's own unicode demo β wide, combining and emoji glyphs", "demo=unicode"},
10386 {"widgets", "tview β a flexbox of lists, tables and text views on tcell v2", "demo=widgets"},
10387 {"wolfram", "elementary cellular automata scrolling upward - 30, 90, 110", "demo=wolfram"},
10388 },
10389}, {
10390 Host: "plot-go.magnetosphere.net",
10391 Title: "plot-go β the same counters through different pipelines",
10392 Note: "The processing pipeline comes from the query, exactly as the command line takes it. Each of these plots the same two live counters a different way.",
10393 Links: []deepLink{
10394 {"avg:5", "a five-sample moving average β the default", "p=avg:5"},
10395 {"roc:5", "rate of change over five samples", "p=roc:5"},
10396 {"avg:5|roc:5", "smoothed first, then differentiated", "p=avg:5%7Croc:5"},
10397 {"cma", "a cumulative moving average over the whole run", "p=cma"},
10398 },
10399}, {
10400 Host: "dict.magnetosphere.net",
10401 Title: "dict β a word, looked up on arrival",
10402 Note: "The terminal runs whatever the link asks for, so each of these opens on a finished lookup rather than a prompt. One of the words is misspelled on purpose β that is the point of it.",
10403 Links: []deepLink{
10404 {"serendipity", "Webster's 1913 on serendipity", "run=dict%20serendipity"},
10405 {"rain", "every sense of rain, across all four sources", "run=dict%20%3Arain"},
10406 {"recieve", "a transposition: the misspelling a subsequence matcher cannot find", "run=dict%20recieve"},
10407 {"aluminium", "the British spelling, folded to the American entry", "run=dict%20aluminium"},
10408 },
10409}}
10410
10411// deepBody renders the deep-link section: one paragraph of links per demo,
10412// rather than a table. A table of 31 one-word rows is mostly borders.
10413func deepBody() string {
10414 var b strings.Builder
10415 b.WriteString("<h2 class='β€'>Straight to one thing</h2>\n")
10416 b.WriteString("<p>Some of these take a parameter, so a link can open on a particular demo, ")
10417 b.WriteString("pipeline or word instead of the front of the app.</p>\n")
10418 for _, g := range deepGroups {
10419 b.WriteString("<h3 style='font-size:10pt'>" + g.Title + "</h3>\n")
10420 b.WriteString("<p style='max-width:70em;margin:0 auto'>" + g.Note + "</p>\n")
10421 b.WriteString("<p style='max-width:70em;margin:6px auto;line-height:1.9'>")
10422 for i, l := range g.Links {
10423 if i > 0 {
10424 b.WriteString(" · ")
10425 }
10426 b.WriteString("<a title=\"" + l.Desc + "\" href='https://" + g.Host + "/?" + l.Query + "'>" + l.Label + "</a>")
10427 }
10428 b.WriteString("</p>\n")
10429 }
10430 return b.String()
10431}
10432
10433// live counts the demos in a group that are ready to be linked.
10434func live(sites []demoSite) int {
10435 n := 0
10436 for _, d := range sites {
10437 if !d.Pending {
10438 n++
10439 }
10440 }
10441 return n
10442}
10443
10444// ---------------------------------------------------------------------------
10445// /test β the site with the decoration wasm loaded.
10446//
10447// Deliberately not a gallery. The thing to judge is the site, so this parses
10448// exactly what homepage parses and differs from it in one line: the wasm list.
10449// Diffing /test against / therefore shows the decoration and nothing else, and
10450// when a piece of it is worth keeping the change is to add it to homepage.
10451
10452// testpage serves /test.
10453func testpage(c fiber.Ctx) error {
10454 tmpl, err := mainTmpl()
10455 if err != nil {
10456 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("template: %v", err))
10457 }
10458 tmpl0, err := tmpl.Clone()
10459 if err != nil {
10460 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("clone: %v", err))
10461 }
10462 for _, part := range []struct{ name, body string }{
10463 {"main", h.FrontPage()},
10464 {"about", h.AboutPage()},
10465 {"policy", h.PolicyPage()},
10466 {"links", h.LinksPage()},
10467 } {
10468 if _, err := tmpl0.New(part.name).Parse(part.body); err != nil {
10469 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("%s: %v", part.name, err))
10470 }
10471 }
10472 h1 := pageMeta(c, htmlPageTemplateData)
10473 h1.Page = "front"
10474 h1.Canonical = h1.BaseURL + "/test"
10475 h1.Title = "sandbox β " + f.Sitelongname
10476 // The front page's own canvas, plus the decorator. This is the whole of
10477 // the difference from homepage.
10478 h1.WasmBinary = wasmFor("stl2", "deco")
10479 c.Set("Content-Type", "text/html;charset=utf-8")
10480 c.Set("Cache-Control", "no-store")
10481 var out bytes.Buffer
10482 if err := tmpl0.Execute(&out, map[string]interface{}{"Page": h1, "Prods": allproducts}); err != nil {
10483 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("execute: %v", err))
10484 }
10485 body := collapseNewlines.ReplaceAll(out.Bytes(), []byte("\n"))
10486 // Injected rather than added to style.css: the rules are generated from the
10487 // live category list, which a static file cannot be. If this graduates it
10488 // wants a template hook rather than a string splice.
10489 body = bytes.Replace(body, []byte("</head>"), []byte(catTargetCSS()+"</head>"), 1)
10490 _, err = c.Status(fiber.StatusOK).Write(body)
10491 return err
10492}
10493
10494// catTargetCSS makes a targeted category show its subcategories' sections too.
10495//
10496// The problem it solves: sections are flat siblings under .β and only the one
10497// matching :target is shown, so #cat-resistor displays its heading, its list of
10498// subcategory links, and the "Other Products" table β which holds only the
10499// products that have no subcategory. Every resistor has one, so the category
10500// view is a page with no products on it.
10501//
10502// The fix is one rule per category, naming that category's subcategory
10503// sections by the prefix their ids already share:
10504//
10505// [id="cat-resistor"]:target ~ [id^="cat-resistor-"]{display:block}
10506//
10507// Nesting the sections and using :has() would also work and reads better, but
10508// :has() only reached Firefox in December 2023 while :target has worked since
10509// about 2010 β and a browser without :has() would find the nested section
10510// inside a display:none parent, which breaks navigation rather than degrading
10511// it. This uses :target, ~ and [attr^=] only, so it works wherever the
10512// existing navigation does, and needs no second copy of the markup.
10513func catTargetCSS() string {
10514 cats := htmlPageTemplateData.Cats
10515 var b strings.Builder
10516 b.WriteString("<style>/* a targeted category shows its subcategories too */\n")
10517 for _, cat := range cats {
10518 if len(htmlPageTemplateData.SubCatsByCat[cat]) == 0 {
10519 continue
10520 }
10521 // A category whose name plus a dash begins another category's name
10522 // would have its rule match that one's sections as well. Skip it
10523 // rather than emit a rule that reveals the wrong products.
10524 clash := false
10525 for _, other := range cats {
10526 if other != cat && strings.HasPrefix(other, cat+"-") {
10527 clash = true
10528 break
10529 }
10530 }
10531 if clash {
10532 b.WriteString("/* skipped " + cat + ": prefixes another category */\n")
10533 continue
10534 }
10535 id := "cat-" + cat
10536 b.WriteString(`[id="` + id + `"]:target ~ [id^="` + id + `-"]{display:block}` + "\n")
10537 }
10538 b.WriteString("</style>")
10539 return b.String()
10540}
10541
10542
10543// ===== pkg/web/server.go =====
10544// Package web pkg/web/server.go β the store server: routes and page handlers.
10545package web
10546
10547import (
10548 "bytes"
10549 "context"
10550 "encoding/base64"
10551 "fmt"
10552 "log"
10553 "os"
10554 "path/filepath"
10555 "regexp"
10556 "sort"
10557 "strconv"
10558 "strings"
10559 "sync"
10560
10561 "github.com/gofiber/fiber/v3"
10562
10563 "github.com/bitfield/script"
10564
10565 "github.com/0magnet/m2/pkg/config"
10566 p "github.com/0magnet/m2/pkg/product"
10567 "github.com/0magnet/m2/pkg/storepage"
10568)
10569
10570// f aliases the shared configuration; the name keeps the handler code
10571// identical to its pre-refactor form at the repo root, and drop-in
10572// other.go files keep working unchanged.
10573var f = &config.F
10574
10575var collapseNewlines = regexp.MustCompile(`\n{2,}`)
10576
10577func methodColor(method string, colors fiber.Colors) string {
10578 switch method {
10579 case fiber.MethodGet:
10580 return colors.Cyan
10581 case fiber.MethodPost:
10582 return colors.Green
10583 case fiber.MethodPut:
10584 return colors.Yellow
10585 case fiber.MethodDelete:
10586 return colors.Red
10587 case fiber.MethodPatch:
10588 return colors.White
10589 case fiber.MethodHead:
10590 return colors.Magenta
10591 case fiber.MethodOptions:
10592 return colors.Blue
10593 default:
10594 return colors.Reset
10595 }
10596}
10597
10598func statusColor(code int, colors fiber.Colors) string {
10599 switch {
10600 case code >= fiber.StatusOK && code < fiber.StatusMultipleChoices:
10601 return colors.Green
10602 case code >= fiber.StatusMultipleChoices && code < fiber.StatusBadRequest:
10603 return colors.Blue
10604 case code >= fiber.StatusBadRequest && code < fiber.StatusInternalServerError:
10605 return colors.Yellow
10606 default:
10607 return colors.Red
10608 }
10609}
10610
10611// Serve runs the web store: templates, routes, wasm compilation, and the
10612// http listener. It blocks for the life of the server.
10613func Serve() {
10614 wg := new(sync.WaitGroup)
10615 wg.Add(1)
10616 r := NewApp(AppOpts{})
10617 // The browse origin the desk's nested browser renders the store at. Its
10618 // own listener, because it is a different domain: a wholly static pair of
10619 // files that never sees any of the content it makes renderable.
10620 StartBrowseOrigin(context.Background())
10621 go func() {
10622 err := r.Listen(fmt.Sprintf(":%d", f.WebPort))
10623 if err != nil {
10624 log.Println("Error serving http: ", err)
10625 }
10626 wg.Done()
10627 }()
10628 CompileWASM()
10629 wg.Wait()
10630}
10631
10632func sitemap(c fiber.Ctx) error {
10633 c.Type("xml", "utf-8")
10634 return c.SendString(generateSitemapXML())
10635}
10636
10637// apisite serves the site identity for store clients β the browser tui
10638// has no MENV file to source its masthead from.
10639func apisite(c fiber.Ctx) error {
10640 return c.JSON(fiber.Map{
10641 "Sitename": f.Sitename,
10642 "Siteext": f.Siteext,
10643 "Sitelongname": f.Sitelongname,
10644 "Sitetagline": f.Sitetagline,
10645 "Tgcontact": f.Tgcontact,
10646 "Tgchannel": f.Tgchannel,
10647 "Teststripekey": f.Teststripekey,
10648 // The identity fields below appear verbatim in every rendered page,
10649 // so serving them here reveals nothing new; they let the in-tab site
10650 // server render the same masthead and metadata the origin does.
10651 "Sitemeta": f.Sitemeta,
10652 "Sitedomain": f.Sitedomain,
10653 "Siteprettyname": f.Siteprettyname,
10654 "Siteprettynamecap": f.Siteprettynamecap,
10655 "Siteprettynamecaps": f.Siteprettynamecaps,
10656 "SiteASCIILogo": f.SiteASCIILogo,
10657 "Stripepk": f.StripePK,
10658 // The drop-in navigation entries, so a client β the terminal build,
10659 // and the TUI served into a browser tab β can offer the same Etc menu
10660 // the page does instead of a shorter one missing every drop-in page.
10661 "NavLinks": extraNavLinks,
10662 // The coin the store takes, or "" where it takes none: the page offers
10663 // "Pay in <coin>" only then, and so does the terminal.
10664 "Coin": htmlTemplateData{}.CoinName(),
10665 })
10666}
10667
10668// apicontent serves the stock-page fragments (about/policy/links) raw,
10669// for the tui to render as text.
10670func apicontent(c fiber.Ctx) error {
10671 name := c.Params("name")
10672 switch name {
10673 case "about", "policy", "links":
10674 default:
10675 return c.SendStatus(fiber.StatusNotFound)
10676 }
10677 c.Set("Content-Type", "text/html;charset=utf-8")
10678 return c.SendString(contentFile("content/" + name + ".html"))
10679}
10680
10681// tuipage serves the terminal storefront: the same store TUI `m2 tui`
10682// runs natively, compiled to wasm (the wasm/tui drop-in, listed in
10683// WASMSRC) and drawn into an xterm-go terminal filling the page.
10684func tuipage(c fiber.Ctx) error {
10685 // The joint build where the deployment has one, else this page's own.
10686 // See pageBinary.
10687 binary, execPath := pageBinary("term", "tui")
10688 html := fmt.Sprintf(`<!DOCTYPE html>
10689<html lang="en">
10690<head>
10691<meta charset="utf-8">
10692<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
10693<title>%s β terminal</title>
10694<meta name="description" content="%s as a terminal: browse the catalog, view products, and fill a cart in a TUI running in your browser.">
10695<meta name="robots" content="index, follow">
10696<link rel="stylesheet" href="/font.css">
10697<style>html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;}#terminal{position:fixed;inset:0;}</style>
10698<script src="/bottle/jsfs.js"></script>
10699<script src="%s"></script>
10700`+procScripts()+`
10701<script>
10702if (!WebAssembly.instantiateStreaming) {
10703 WebAssembly.instantiateStreaming = async (resp, importObject) => {
10704 const source = await (await resp).arrayBuffer();
10705 return await WebAssembly.instantiate(source, importObject);
10706 };
10707}
10708const go = new Go();
10709`+homeReadyJS+`
10710homeReady.then(() => WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject)).then((result) => {
10711 go.run(result.instance);
10712}).catch((err) => { console.error("Failed to run WASM:", err); });
10713</script>
10714</head>
10715<body>
10716<div id="terminal"></div>
10717</body>
10718</html>`, f.Sitedomain, f.Sitelongname, execPath, binary)
10719 c.Set("Content-Type", "text/html; charset=utf-8")
10720 return c.Status(fiber.StatusOK).SendString(html)
10721}
10722
10723// procScripts lets a page's shells run programs from their filesystem as
10724// processes of their own (bottle's proc): the store program, wasm/store, or
10725// any other, built by Go or by TinyGo.
10726func procScripts() string {
10727 return fmt.Sprintf(`<script src="/bottle/proc.js"></script>
10728<script>proc.assets.wasmExecGo = %q; proc.assets.wasmExecTinyGo = %q;</script>`, f.WasmExecPathGo, f.WasmExecPathTinyGo)
10729}
10730
10731// deskpage serves the site as a desktop: the website in a maximized window
10732// and the storefront terminal in another, both launchable from the panel.
10733// Same drop-in mechanism as tuipage, against the 'wasm/desk' source.
10734func deskpage(c fiber.Ctx) error {
10735 // The joint build where the deployment has one, else this page's own.
10736 binary, execPath := pageBinary("term", "desk")
10737 // ?bare / ?fsonly / ?nonet split the page's script layer for debugging:
10738 // bare = none of jsfs/vnet, fsonly = jsfs alone, nonet = no browser.
10739 //
10740 // netscrape is no longer among these. It used to be a JS engine served at
10741 // /netscrape.js; it is now a Go browser compiled INTO the desk wasm, so
10742 // there is no script to leave out β ?nonet is honored by the desk itself,
10743 // which reads it from location.search and does not open the window.
10744 scripts := `<script src="/bottle/jsfs.js"></script>
10745<script src="/bottle/vnet.js"></script>` + "\n" + procScripts()
10746 // The browse substrate, when the deployment has a second domain for it.
10747 // The responder must be first-party here and must exist before the wasm
10748 // configures it, so it goes in with the rest of the page scripts; the
10749 // config object tells the desk which origin to render the store at.
10750 if browseEnabled() {
10751 scripts += fmt.Sprintf("\n<script>window.__M2_BROWSE__={suffix:%q,scheme:%q,port:%q};</script>"+
10752 "\n<script src=%q></script>", config.F.BrowseSuffix, browseScheme(), browsePort(), browseResponderPath)
10753 }
10754 switch {
10755 case c.Query("bare") != "":
10756 scripts = ""
10757 case c.Query("fsonly") != "":
10758 scripts = `<script src="/bottle/jsfs.js"></script>`
10759 }
10760 html := fmt.Sprintf(`<!DOCTYPE html>
10761<html lang="en">
10762<head>
10763<meta charset="utf-8">
10764<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
10765<title>%s β desktop</title>
10766<meta name="description" content="%s as a desktop: the website and the storefront terminal, each in a window.">
10767<meta name="robots" content="index, follow">
10768<link rel="stylesheet" href="/font.css">
10769<style>
10770html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;
10771 font-family:mononokiregular,ui-monospace,monospace;color:#fff;}
10772/* Windows are positioned against this, so it is the element that fills the
10773 page rather than the body. */
10774#desktop{position:fixed;inset:0;}
10775#boot{padding:14px;color:#777;font-size:13px;}
10776</style>
10777%s
10778<script>
10779// Channel providers for the netscrape browser window the desk opens. The
10780// clearnet channel is the page's own fetch β enough for this origin and for
10781// anything CORS permits; there is no skysocks here. dmsg has no transport in
10782// this page at all, so it answers 502 rather than hanging.
10783globalThis.__m2Glue = {
10784 fetchClearnet: async function (exit, method, url, body, winId, headers) {
10785 try {
10786 const r = await fetch(url, { method: method || "GET", body: body || undefined, headers: headers || undefined });
10787 const buf = new Uint8Array(await r.arrayBuffer());
10788 const hs = {};
10789 r.headers.forEach(function (v, k) { hs[k] = v; });
10790 return { status: r.status, body: buf, headers: hs };
10791 } catch (e) {
10792 return { status: 502, body: new Uint8Array(), headers: {} };
10793 }
10794 },
10795 fetchDmsg: async function () { return { status: 502, body: new Uint8Array(), headers: {} }; },
10796};
10797
10798// netscrape asks globalThis.__netscrapeFetch(url) for every page and
10799// subresource, and expects a Response. Without one it falls back to a
10800// same-origin /fetch proxy this server does not have, so the transport is
10801// wired here rather than left to that default.
10802//
10803// Note the argument order: netscrape's own loader.js calls
10804// fetchClearnet(url, method, body), but __m2Glue predates it and takes
10805// (exit, method, url, ...). The glue is what the rest of this page uses, so
10806// the shim adapts to the glue rather than the other way round.
10807globalThis.__netscrapeFetch = function (url) {
10808 var u;
10809 try { u = new URL(url, location.href); } catch (e) { return fetch(url); }
10810 var path = (u.pathname || "/") + (u.search || "");
10811 var mesh = /\.(dmsg|skysocks|skynet)$/i.test(u.hostname) || /^[0-9a-f]{66}$/i.test(u.hostname);
10812 // vnet:<port> is the site running INSIDE this tab, on the page's virtual
10813 // loopback. It needs its own name: the desk's own origin is a real
10814 // 127.0.0.1:<port>, so addressing the in-tab server as 127.0.0.1 too sent
10815 // it to the page's ordinary fetch and out to the host, which answered
10816 // ERR_CONNECTION_REFUSED. Nothing outside this page can be reached at
10817 // "vnet", so the routing is unambiguous.
10818 var call;
10819 if (u.hostname === "vnet") {
10820 call = globalThis.vnet.httpFetch(parseInt(u.port || "80", 10), "GET", path, null);
10821 } else if (mesh) {
10822 call = globalThis.__m2Glue.fetchDmsg(u.hostname, "GET", path, null);
10823 } else {
10824 call = globalThis.__m2Glue.fetchClearnet(null, "GET", u.href, null, null, null);
10825 }
10826 return Promise.resolve(call).then(function (r) {
10827 var h = new Headers();
10828 if (r && r.headers) { for (var k in r.headers) { try { h.set(k, r.headers[k]); } catch (e) {} } }
10829 return new Response((r && r.body) || new Uint8Array(0), { status: (r && r.status) || 200, headers: h });
10830 });
10831};
10832</script>
10833<script src="%s"></script>
10834<script>
10835if (!WebAssembly.instantiateStreaming) {
10836 WebAssembly.instantiateStreaming = async (resp, importObject) => {
10837 const source = await (await resp).arrayBuffer();
10838 return await WebAssembly.instantiate(source, importObject);
10839 };
10840}
10841const go = new Go();
10842`+homeReadyJS+`
10843homeReady.then(() => WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject)).then((result) => {
10844 const b = document.getElementById("boot"); if (b) b.remove();
10845 go.run(result.instance);
10846}).catch((err) => { console.error("Failed to run WASM:", err); });
10847</script>
10848</head>
10849<body>
10850<div id="desktop"><div id="boot">loadingβ¦</div></div>
10851</body>
10852</html>`, f.Sitedomain, f.Sitelongname, scripts, execPath, binary)
10853 c.Set("Content-Type", "text/html; charset=utf-8")
10854 return c.Status(fiber.StatusOK).SendString(html)
10855}
10856
10857// apiproducts serves the catalog as JSON for store clients (the tui's
10858// --store mode, and the browser tui to come). The business-sensitive
10859// columns the HTML never renders stay private.
10860func apiproducts(c fiber.Ctx) error {
10861 allproductsMu.RLock()
10862 prods := make(p.Products, len(allproducts))
10863 copy(prods, allproducts)
10864 allproductsMu.RUnlock()
10865 for i := range prods {
10866 prods[i].Cost = ""
10867 prods[i].Location = ""
10868 prods[i].Sourceinfo = ""
10869 }
10870 return c.JSON(prods)
10871}
10872
10873// extraRoutes collects route registrars from optional drop-in files
10874// (see other.go.example). A drop-in appends its registrar from init();
10875// deleting the file removes its routes with no other code changes.
10876var extraRoutes []func(*fiber.App)
10877
10878// extraSitemapURLs collects site-relative paths that drop-in routes want
10879// listed in /sitemap.xml. A route that is not linked from anywhere and not
10880// in the sitemap is one a crawler has no way to reach, which for a page that
10881// exists to be found is the whole of the problem.
10882//
10883// Paths only, each beginning with "/": the sitemap is per-host and the base
10884// URL is the deployment's own, so a drop-in cannot and should not name it.
10885var extraSitemapURLs []string
10886
10887// NavLink is an entry a drop-in adds to the site navigation.
10888type NavLink struct {
10889 // Title is the link text, Href where it goes, and Desc the tooltip.
10890 Title, Href, Desc string
10891}
10892
10893// extraNavLinks collects navigation entries from drop-in files, the way
10894// extraRoutes collects their routes.
10895//
10896// A drop-in route that nothing links to is reachable only by someone who
10897// already knows the path, and the stock header cannot name it: the header is
10898// shared by every deployment, and a hardcoded entry would be a broken link
10899// everywhere the drop-in is absent. Registering it here keeps the link and the
10900// route in the same file, so deleting that file removes both.
10901var extraNavLinks []NavLink
10902
10903func logo(c fiber.Ctx) error {
10904 tmpl, err := auxTmpl()
10905 if err != nil {
10906 msg := fmt.Sprintf("Error parsing html template: %v", err)
10907 log.Println(msg)
10908 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10909 }
10910 tmpl0, err := tmpl.Clone()
10911 if err != nil {
10912 msg := fmt.Sprintf("Error cloning template: %v", err)
10913 log.Println(msg)
10914 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10915 }
10916 _, err = tmpl0.New("main").Parse(h.Logo())
10917 if err != nil {
10918 msg := fmt.Sprintf("Error parsing product page template: %v", err)
10919 log.Println(msg)
10920 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10921 }
10922 tmpl = tmpl0
10923 c.Set("Content-Type", "text/html;charset=utf-8")
10924
10925 img2txtFlags := ""
10926 if w, err := strconv.Atoi(c.Params("width")); err == nil {
10927 img2txtFlags = fmt.Sprintf("--width=%d ", w)
10928 }
10929 if h, err := strconv.Atoi(c.Params("height")); err == nil {
10930 img2txtFlags = fmt.Sprintf("--height=%d ", h)
10931 }
10932
10933 logoHTMLslice, err := script.Exec(fmt.Sprintf("bash -c 'img2txt %s logo.jpg | ansifilter -H'", img2txtFlags)).Slice()
10934 if err != nil {
10935 log.Println("error: ", err)
10936 _, err = c.Status(fiber.StatusInternalServerError).Write([]byte(err.Error() + "/n" + strings.Join(logoHTMLslice, "\n")))
10937 return err
10938 }
10939 if len(logoHTMLslice) > 2 {
10940 logoHTMLslice = logoHTMLslice[:len(logoHTMLslice)-3]
10941 }
10942 if len(logoHTMLslice) > 18 {
10943 logoHTMLslice = logoHTMLslice[19:]
10944 }
10945
10946 var result bytes.Buffer
10947 h1 := pageMeta(c, htmlTemplateData{})
10948 h1.Page = "logo"
10949 h1.Title = "logo"
10950 tmplData := map[string]interface{}{
10951 "Content": strings.Join(logoHTMLslice, "\n"),
10952 }
10953 err = tmpl.Execute(&result, tmplData)
10954 if err != nil {
10955 log.Println("error: ", err)
10956 _, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
10957 return err
10958 }
10959 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
10960 return err
10961}
10962
10963func robots(c fiber.Ctx) error {
10964 c.Set("Content-Type", "text/plain;charset=utf-8")
10965 // "User-agent: *" alone is a group with no rules in it. That is legal and
10966 // means allow everything, but it says so by omission β and a group that
10967 // states nothing is one whose meaning a later edit can change by accident.
10968 // Allow: / states it. The trailing newline is so the Sitemap line is a
10969 // line, which some parsers care about.
10970 _, err := c.Status(fiber.StatusOK).Write([]byte(fmt.Sprintf(
10971 "User-agent: *\nAllow: /\n\nSitemap: https://%s/sitemap.xml\n", c.Hostname())))
10972 return err
10973}
10974
10975func style(c fiber.Ctx) error {
10976 c.Set("Content-Type", "text/css;charset=utf-8")
10977 _, err := c.Status(fiber.StatusOK).Write([]byte(h.StyleCSS()))
10978 return err
10979}
10980
10981// fontcss serves just the @font-face block, so the terminal page can have
10982// the site's face without pulling in the whole stylesheet. The TUI measures
10983// its cell from this font when it opens, which is why it is a separate,
10984// cacheable route rather than something inlined per page load.
10985func fontcss(c fiber.Ctx) error {
10986 c.Set("Content-Type", "text/css;charset=utf-8")
10987 c.Set("Cache-Control", "public, max-age=86400")
10988 _, err := c.Status(fiber.StatusOK).Write([]byte(h.FontCSS()))
10989 return err
10990}
10991
10992func serveWASM(r *fiber.App) {
10993 if f.WasmExecPath != "" {
10994 _, err := script.File(f.WasmExecPath).Bytes()
10995 if err != nil {
10996 log.Printf("Error reading %s: %v\n", f.WasmExecPath, err)
10997 } else { //the wasm exec must be present or none of the webassembly stuff will work ; provided by the golang installaton
10998 r.Get(f.WasmExecPathTinyGo, func(c fiber.Ctx) error {
10999 wasmExecData, err := script.File(f.WasmExecPathTinyGo).Bytes()
11000 if err != nil {
11001 log.Printf("Error reading %s: %v\n", f.WasmExecPathTinyGo, err)
11002 return c.SendStatus(fiber.StatusNotFound)
11003 }
11004 c.Set("Content-Type", "application/js")
11005 _, err = c.Status(fiber.StatusOK).Write(wasmExecData)
11006 return err
11007 })
11008
11009 r.Get(f.WasmExecPathGo, func(c fiber.Ctx) error {
11010 wasmExecData, err := script.File(f.WasmExecPathGo).Bytes()
11011 if err != nil {
11012 log.Printf("Error reading %s: %v\n", f.WasmExecPathGo, err)
11013 return c.SendStatus(fiber.StatusNotFound)
11014 }
11015 c.Set("Content-Type", "application/js")
11016 _, err = c.Status(fiber.StatusOK).Write(wasmExecData)
11017 return err
11018 })
11019
11020 // Register both variants per source: a drop-in tinygo cannot
11021 // compile still serves its Go build, and pages pick whichever
11022 // binary exists.
11023 for _, wasmSRC := range f.WasmSRC {
11024 base := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
11025 for _, suffix := range []string{".wasm", "-tiny.wasm"} {
11026 outputFile := base + suffix
11027 r.Get("/"+outputFile, func(c fiber.Ctx) error {
11028 // A binary this size must revalidate, not cache
11029 // blindly: a browser holding yesterday's wasm
11030 // makes a deploy look like nothing changed.
11031 fi, err := os.Stat(outputFile)
11032 if err != nil {
11033 return c.SendStatus(fiber.StatusInternalServerError)
11034 }
11035 etag := fmt.Sprintf(`"%x-%x"`, fi.ModTime().Unix(), fi.Size())
11036 c.Set("Cache-Control", "no-cache")
11037 c.Set("ETag", etag)
11038 if c.Get("If-None-Match") == etag {
11039 return c.SendStatus(fiber.StatusNotModified)
11040 }
11041 data, err := script.File(outputFile).Bytes()
11042 if err != nil {
11043 script.File(outputFile).Stdout() //nolint
11044 return c.SendStatus(fiber.StatusInternalServerError)
11045 }
11046 c.Set("Content-Type", "application/wasm")
11047 return c.Status(fiber.StatusOK).Send(data)
11048 })
11049 }
11050 }
11051 }
11052 }
11053}
11054
11055func sendFile(c fiber.Ctx) error {
11056 return c.SendFile("." + c.Path())
11057}
11058func sendImage(c fiber.Ctx) error {
11059 c.Set("Content-Type", "image/jpeg")
11060 return c.SendFile("./img" + c.Path())
11061}
11062
11063func stlbase64(c fiber.Ctx) error {
11064 name := c.Params("filename")
11065 if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
11066 return c.SendStatus(fiber.StatusBadRequest)
11067 }
11068 stlfile, err := script.File("img/stl/" + name).Bytes()
11069 if err != nil {
11070 return c.SendStatus(fiber.StatusNotFound)
11071 }
11072 _, err = c.Status(fiber.StatusOK).Write([]byte("data:model/stl;base64," + base64.StdEncoding.EncodeToString(stlfile)))
11073 return err
11074}
11075
11076type item struct {
11077 ID string
11078 Amount int64
11079}
11080
11081func cathtmlfunc(c fiber.Ctx) error {
11082 tmpl, err := mainTmpl()
11083 if err != nil {
11084 msg := fmt.Sprintf("Error parsing html template: %v", err)
11085 log.Println(msg)
11086 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11087 }
11088 tmpl0, err := tmpl.Clone()
11089 if err != nil {
11090 msg := fmt.Sprintf("Error cloning html template: %v", err)
11091 log.Println(msg)
11092 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11093 }
11094 _, err = tmpl0.New("main").Parse(h.CategoryPage())
11095 if err != nil {
11096 msg := fmt.Sprintf("Error parsing Category page template: %v", err)
11097 log.Println(msg)
11098 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11099 }
11100 tmpl = tmpl0
11101 var tmplData map[string]interface{}
11102 var result bytes.Buffer
11103 var categoryproducts p.Products
11104 c.Set("Content-Type", "text/html;charset=utf-8")
11105 h1 := pageMeta(c, htmlPageTemplateData)
11106 h1.Title = fmt.Sprintf("%s | %s", func() string {
11107 var str string
11108 if c.Params("partno") != "" {
11109 return "No product matching partno.: " + c.Params("partno") + " | Showing All Products"
11110 }
11111 if c.Params("cat") == "" {
11112 return "All Products"
11113 }
11114 str = fmt.Sprintf("Category: %s", c.Params("cat"))
11115 if c.Params("subcat") != "" {
11116 str += fmt.Sprintf("; Subcategory: %s", c.Params("subcat"))
11117 }
11118 return str
11119 }(), h1.Title)
11120 h1.Page = "category"
11121 // The listing has an Add to cart on every row, and the cart is what
11122 // defines addToCart. Without it every one of those buttons was inert
11123 // β the page rendered them, the click found no such function, and
11124 // nothing reached the cart from a category page at all. The small
11125 // build, because this is carried by every listing view.
11126 h1.WasmBinary = wasmSmallFor("cart")
11127 if c.Params("cat") == "" && c.Params("subcat") == "" {
11128 tmplData = map[string]interface{}{
11129 "Products": allproducts,
11130 "Page": h1,
11131 "Category": c.Params("cat"),
11132 "Subcategory": c.Params("subcat"),
11133 "Prods": allproducts,
11134 "Product": c.Params("partno"),
11135 }
11136 } else {
11137
11138 for _, prod := range allproducts {
11139 if strings.EqualFold(prod.Category, c.Params("cat")) && (c.Params("subcat") == "" || strings.EqualFold(escapesubcat(prod.Subcategory), c.Params("subcat"))) {
11140 categoryproducts = append(categoryproducts, prod)
11141 }
11142 }
11143 tmplData = map[string]interface{}{
11144 "Products": categoryproducts,
11145 "Page": h1,
11146 "Category": c.Params("cat"),
11147 "Subcategory": c.Params("subcat"),
11148 "Prods": allproducts,
11149 }
11150 }
11151 err = tmpl.Execute(&result, tmplData)
11152 if err != nil {
11153 msg := fmt.Sprintf("Error execute html template: %v", err)
11154 log.Println(msg)
11155 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11156 }
11157 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11158 return err
11159}
11160
11161func getcats() (cats []string) {
11162 var catsMap = make(map[string]int)
11163 for _, prod := range allproducts {
11164 catsMap[prod.Category]++
11165 }
11166 for cat := range catsMap {
11167 cats = append(cats, cat)
11168 }
11169 return cats
11170}
11171func contains(slice []string, str string) bool {
11172 for _, s := range slice {
11173 if s == str {
11174 return true
11175 }
11176 }
11177 return false
11178}
11179func getcategories(allproducts p.Products) (map[string]int, []string, map[string]map[string]int, map[string][]string) {
11180 categoryCounts := make(map[string]int)
11181 subcategoryCounts := make(map[string]map[string]int)
11182 subcategoriesByCategory := make(map[string][]string)
11183
11184 for _, prod := range allproducts {
11185 if prod.Category != "" {
11186 categoryCounts[prod.Category]++
11187 if prod.Subcategory != "" {
11188 if subcategoryCounts[prod.Category] == nil {
11189 subcategoryCounts[prod.Category] = make(map[string]int)
11190 }
11191 subcategoryCounts[prod.Category][prod.Subcategory]++
11192 if !contains(subcategoriesByCategory[prod.Category], prod.Subcategory) {
11193 subcategoriesByCategory[prod.Category] = append(subcategoriesByCategory[prod.Category], prod.Subcategory)
11194 }
11195 }
11196 }
11197 }
11198
11199 var sortableCategories []struct {
11200 Name string
11201 Count int
11202 }
11203 for cat, count := range categoryCounts {
11204 sortableCategories = append(sortableCategories, struct {
11205 Name string
11206 Count int
11207 }{Name: cat, Count: count})
11208 }
11209 sort.Slice(sortableCategories, func(i, j int) bool {
11210 return sortableCategories[i].Count > sortableCategories[j].Count
11211 })
11212 var sortedCategories []string
11213 for _, cat := range sortableCategories {
11214 sortedCategories = append(sortedCategories, cat.Name)
11215 var sortableSubcategories []struct {
11216 Name string
11217 Count int
11218 }
11219 for subcat, count := range subcategoryCounts[cat.Name] {
11220 sortableSubcategories = append(sortableSubcategories, struct {
11221 Name string
11222 Count int
11223 }{Name: subcat, Count: count})
11224 }
11225 sort.Slice(sortableSubcategories, func(i, j int) bool {
11226 return sortableSubcategories[i].Count > sortableSubcategories[j].Count
11227 })
11228 var sortedSubcategories []string
11229 for _, subcat := range sortableSubcategories {
11230 sortedSubcategories = append(sortedSubcategories, subcat.Name)
11231 }
11232 subcategoriesByCategory[cat.Name] = sortedSubcategories
11233 }
11234 return categoryCounts, sortedCategories, subcategoryCounts, subcategoriesByCategory
11235}
11236
11237func getsubcats(cat string) (subcats []string) {
11238 var subcatsMap = make(map[string]int)
11239 for _, prod := range allproducts {
11240 if cat == "" || strings.EqualFold(cat, prod.Category) {
11241 if prod.Subcategory != "" {
11242 subcatsMap[escapesubcat(prod.Subcategory)]++
11243 }
11244 }
11245 }
11246 for subcat := range subcatsMap {
11247 subcats = append(subcats, subcat)
11248 }
11249 return subcats
11250}
11251
11252// escapesubcat spells a subcategory the way it appears in a URL.
11253//
11254// The rules live in pkg/storepage now, because both views need them and
11255// only this one had them: the terminal could recognize no /cat/x/y path
11256// whose subcategory had been escaped, so it handed those links to the
11257// browser rather than opening the listing it has.
11258func escapesubcat(sc string) string { return storepage.EscapeSubcat(sc) }
11259
11260func handlecat(c fiber.Ctx) error {
11261 if c.Params("cat") == "" && c.Params("subcat") == "" {
11262 return cathtmlfunc(c)
11263 }
11264 var catexists bool
11265 var subcatexists bool
11266 catexists = false
11267 for _, cat := range getcats() {
11268 if strings.EqualFold(cat, c.Params("cat")) {
11269 catexists = true
11270 break
11271 }
11272 }
11273 subcatexists = false
11274 if c.Params("subcat") != "" {
11275 for _, subcat := range getsubcats("") {
11276 if strings.EqualFold(escapesubcat(subcat), c.Params("subcat")) {
11277 subcatexists = true
11278 break
11279 }
11280 }
11281 }
11282 if c.Params("subcat") != "" && !subcatexists {
11283 log.Printf("subcategory %s does not match any existing subcategory\n", c.Params("subcat"))
11284 return c.Redirect().To("/cat/" + c.Params("cat"))
11285 }
11286 if !catexists {
11287 log.Printf("category %s does not match any existing category\n", c.Params("cat"))
11288 return c.Redirect().To("/cat")
11289 }
11290 if catexists || (catexists && subcatexists) {
11291 return cathtmlfunc(c)
11292 }
11293 return c.SendStatus(fiber.StatusNotFound)
11294}
11295
11296func homepage(c fiber.Ctx) error {
11297 tmpl, err := mainTmpl()
11298 if err != nil {
11299 msg := fmt.Sprintf("Could not parsing html template: %v", err)
11300 log.Println(msg)
11301 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11302 }
11303 tmpl0, err := tmpl.Clone()
11304 if err != nil {
11305 msg := fmt.Sprintf("Error cloning template: %v", err)
11306 log.Println(msg)
11307 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11308 }
11309 _, err = tmpl0.New("main").Parse(h.FrontPage())
11310 if err != nil {
11311 msg := fmt.Sprintf("Error parsing Front Page template: %v", err)
11312 log.Println(msg)
11313 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11314 }
11315 _, err = tmpl0.New("about").Parse(h.AboutPage())
11316 if err != nil {
11317 msg := fmt.Sprintf("Error parsing About Page template: %v", err)
11318 log.Println(msg)
11319 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11320 }
11321 _, err = tmpl0.New("policy").Parse(h.PolicyPage())
11322 if err != nil {
11323 msg := fmt.Sprintf("Error parsing Policy Page template: %v", err)
11324 log.Println(msg)
11325 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11326 }
11327 _, err = tmpl0.New("links").Parse(h.LinksPage())
11328 if err != nil {
11329 msg := fmt.Sprintf("Error parsing Links Page template: %v", err)
11330 log.Println(msg)
11331 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11332 }
11333 tmpl = tmpl0
11334 log.Println(c.Get("User-Agent"))
11335 c.Set("Content-Type", "text/html;charset=utf-8")
11336 h1 := pageMeta(c, htmlPageTemplateData)
11337 // The front page has the canvas stl2 animates into, and deco, which draws
11338 // the borders round the product tables, the ASCII art behind the
11339 // photographs and the headings as FIGlet banners. Both are decorations: the
11340 // HTML is the same either way, and a browser that runs neither gets the
11341 // page it always got.
11342 // The small build of each: these ride on every page view, and which
11343 // build newest-wins picks is decided by whichever toolchain finished
11344 // last β a page was serving the 23MB stl2 rather than the 9MB one
11345 // because the Go build had just been rebuilt. Size on a page a
11346 // customer loads is not a thing to leave to build order.
11347 h1.WasmBinary = wasmSmallFor("cart", "stl2", "deco")
11348 tmplData := map[string]interface{}{
11349 "Page": h1,
11350 "Prods": allproducts,
11351 }
11352 var result bytes.Buffer
11353 err = tmpl.Execute(&result, tmplData)
11354 if err != nil {
11355 msg := fmt.Sprintf("Error executing template: %v", err)
11356 log.Println(msg)
11357 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11358 }
11359 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11360 return err
11361}
11362
11363func productpage(c fiber.Ctx) error {
11364 tmpl, err := mainTmpl()
11365 if err != nil {
11366 msg := fmt.Sprintf("Error parsing html template: %v", err)
11367 log.Println(msg)
11368 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11369 }
11370 tmpl0, err := tmpl.Clone()
11371 if err != nil {
11372 msg := fmt.Sprintf("Error cloning template: %v", err)
11373 log.Println(msg)
11374 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11375 }
11376 _, err = tmpl0.New("main").Parse(h.ProductPage())
11377 if err != nil {
11378 msg := fmt.Sprintf("Error parsing product page template: %v", err)
11379 log.Println(msg)
11380 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11381 }
11382 tmpl = tmpl0
11383 c.Set("Content-Type", "text/html;charset=utf-8")
11384 for _, prod := range allproducts {
11385 if strings.EqualFold(prod.Partno, c.Params("partno")) {
11386 var result bytes.Buffer
11387 h1 := pageMeta(c, htmlPageTemplateData)
11388 h1.Page = "product"
11389 // A product page has the canvas and the package-type element the
11390 // STL viewer reads to pick its model β and an Add to cart,
11391 // which needs the cart that defines addToCart.
11392 h1.WasmBinary = wasmSmallFor("cart", "stl2")
11393 h1.Title = fmt.Sprintf("%s | %s", prod.Name, h1.Title)
11394 tmplData := map[string]interface{}{
11395 "Prod": prod,
11396 "Page": h1,
11397 "Prods": allproducts,
11398 }
11399 err := tmpl.Execute(&result, tmplData)
11400 if err != nil {
11401 log.Println("error: ", err)
11402 _, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
11403 return err
11404 }
11405 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11406 return err
11407 }
11408 }
11409 log.Printf("product %s does not match any existing product\n", c.Params("partno"))
11410 return c.Status(fiber.StatusNotFound).Redirect().To("/cat")
11411}
11412
11413// homeReadyJS restores the terminal's home directory before the wasm starts,
11414// as a desktop finds ~ where it left it: bottle's jsfs (the filesystem Go's os
11415// package uses in the page) is persisted to IndexedDB for /home only. The
11416// rest of the tree β /tmp, /dev, the templates a page seeds β is made fresh
11417// each load. The store keeps its cart there (pkg/tui/cartsave.go). Where jsfs
11418// is absent (?bare) or IndexedDB is refused, the wasm starts anyway, with a
11419// cart that lasts the page.
11420const homeReadyJS = `const homeReady = (globalThis.jsfs && jsfs.persist)
11421 ? jsfs.persist.enable("m2-home", { exclude: (p) => !(p === "/home" || p.startsWith("/home/")) }).catch(() => {})
11422 : Promise.resolve();`
11423
11424
11425// ===== pkg/web/shipping.go =====
11426package web
11427
11428// The shipping form, as the page draws it.
11429//
11430// These are methods on the page data rather than FuncMap entries for the
11431// reason wasmpick.go's WasmExecs records: templates here are parsed in
11432// several places and only some of them call .Funcs(funcs), so a template
11433// function is a parse error waiting for whichever page was built bare.
11434// A method travels with the data and every parse site can call it.
11435
11436import (
11437 "github.com/0magnet/m2/pkg/storepage"
11438)
11439
11440// ShippingFields is the form, in order β the same list pkg/tui builds its
11441// overlay from and the same field names wasm/cart reads back.
11442func (h htmlTemplateData) ShippingFields() []storepage.Field {
11443 return storepage.ShippingFields
11444}
11445
11446// ShippingStates is the State select's options.
11447func (h htmlTemplateData) ShippingStates() []storepage.Jurisdiction {
11448 return storepage.States
11449}
11450
11451// ShippingCountries is the Country select's options.
11452func (h htmlTemplateData) ShippingCountries() []string { return storepage.Countries }
11453
11454// StatePrompt is the unselected entry at the top of the State select.
11455func (h htmlTemplateData) StatePrompt() string { return storepage.StatePrompt }
11456
11457// MinShipping is the least the store ships for, as the number a min
11458// attribute wants ("7").
11459func (h htmlTemplateData) MinShipping() string {
11460 return itoa(storepage.MinShippingCents / 100)
11461}
11462
11463// MinShippingValue is that same figure as a price ("7.00"), for the
11464// amount field's default.
11465func (h htmlTemplateData) MinShippingValue() string {
11466 c := storepage.MinShippingCents
11467 return itoa(c/100) + "." + pad2(c%100)
11468}
11469
11470func itoa(n int) string {
11471 if n == 0 {
11472 return "0"
11473 }
11474 var b []byte
11475 for n > 0 {
11476 b = append([]byte{byte('0' + n%10)}, b...)
11477 n /= 10
11478 }
11479 return string(b)
11480}
11481
11482func pad2(n int) string {
11483 s := itoa(n)
11484 if len(s) < 2 {
11485 return "0" + s
11486 }
11487 return s
11488}
11489
11490// CoinCheckout reports whether the deployment takes crypto, so the cart
11491// offers the button only where it will work. A method rather than a
11492// FuncMap entry, for the reason at the top of this file.
11493func (h htmlTemplateData) CoinCheckout() bool { return cryptoPay != nil }
11494
11495// CoinName is the coin the store takes, for the button's label.
11496func (h htmlTemplateData) CoinName() string {
11497 if cryptoPay == nil {
11498 return ""
11499 }
11500 return string(cryptoPay.backend.Coin())
11501}
11502
11503
11504// ===== pkg/web/shipping_test.go =====
11505package web
11506
11507import (
11508 "bytes"
11509 htmpl "html/template"
11510 "os"
11511 "strings"
11512 "testing"
11513
11514 "github.com/0magnet/m2/pkg/storepage"
11515)
11516
11517// renderFooter runs the footer partial the way a page does. It is parsed
11518// BARE β no .Funcs β on purpose: that is the state a template set built
11519// somewhere that forgot the FuncMap is in, and the shipping form must
11520// survive it. See pkg/web/shipping.go.
11521func renderFooter(t *testing.T) string {
11522 t.Helper()
11523 src, err := os.ReadFile("../../htmpl/footer.html")
11524 if err != nil {
11525 t.Fatal(err)
11526 }
11527 tmpl, err := htmpl.New("footer").Parse(string(src))
11528 if err != nil {
11529 t.Fatalf("footer does not parse without a FuncMap: %v", err)
11530 }
11531 var b bytes.Buffer
11532 if err := tmpl.Execute(&b, struct{ Page htmlTemplateData }{}); err != nil {
11533 t.Fatal(err)
11534 }
11535 return b.String()
11536}
11537
11538// The cart wasm reads each control by [name='...']; a renamed or dropped
11539// control silently produces an empty field in the shipping line, so the
11540// generated form must still carry every one of them.
11541func TestShippingFormKeepsItsControlNames(t *testing.T) {
11542 got := renderFooter(t)
11543 for _, f := range storepage.ShippingFields {
11544 if !strings.Contains(got, "name='"+f.Name+"'") {
11545 t.Errorf("no control named %q in the rendered form", f.Name)
11546 }
11547 if !strings.Contains(got, "id='"+f.Name+"'") {
11548 t.Errorf("no id %q in the rendered form", f.Name)
11549 }
11550 }
11551}
11552
11553// Every jurisdiction reaches the page, by code and by name.
11554func TestShippingFormListsEveryState(t *testing.T) {
11555 got := renderFooter(t)
11556 if n := strings.Count(got, "<option value='"); n != len(storepage.States)+len(storepage.Countries)+1 {
11557 t.Errorf("option count = %d, want %d states + %d countries + 1 prompt",
11558 n, len(storepage.States), len(storepage.Countries))
11559 }
11560 for _, s := range storepage.States {
11561 if !strings.Contains(got, "<option value='"+s.Code+"'>"+s.Name+"</option>") {
11562 t.Errorf("missing state option %s / %s", s.Code, s.Name)
11563 }
11564 }
11565}
11566
11567// The minimum is one number now; the form must show it rather than its own.
11568func TestShippingFormUsesTheSharedMinimum(t *testing.T) {
11569 got := renderFooter(t)
11570 if !strings.Contains(got, "min='7'") || !strings.Contains(got, "value='7.00'") {
11571 t.Errorf("amount field does not carry the shared minimum:\n%s", got)
11572 }
11573}
11574
11575// A pipe in any field shifts every field after it in the cart line.
11576func TestShippingRejectsAndScrubsTheSeparator(t *testing.T) {
11577 s := storepage.Shipping{
11578 Cents: 700, Name: "A|B", City: "Dallas", State: "TX", Country: "United States",
11579 }
11580 if msg := s.Validate(); msg == "" {
11581 t.Error("a pipe in Name was accepted")
11582 }
11583 if strings.Count(s.ID(), "|") != 7 {
11584 t.Errorf("ID() did not scrub the separator: %q", s.ID())
11585 }
11586 back, ok := storepage.ParseShipping(s.ID())
11587 if !ok || back.City != "Dallas" || back.State != "TX" {
11588 t.Errorf("round trip lost fields: %+v ok=%v", back, ok)
11589 }
11590}
11591
11592// Below the minimum is refused rather than silently charged.
11593func TestShippingEnforcesTheMinimum(t *testing.T) {
11594 if msg := (storepage.Shipping{Cents: 0}).Validate(); msg == "" {
11595 t.Error("zero shipping was accepted")
11596 }
11597 if msg := (storepage.Shipping{Cents: storepage.MinShippingCents}).Validate(); msg != "" {
11598 t.Errorf("the minimum itself was refused: %s", msg)
11599 }
11600}
11601
11602
11603// ===== pkg/web/sourcecode.go =====
11604// Package web pkg/web/sourcecode.go β the store serves its own source.
11605package web
11606
11607import (
11608 "bytes"
11609 "embed"
11610 "fmt"
11611 "io/fs"
11612 "os"
11613 "strings"
11614
11615 "github.com/alecthomas/chroma/v2"
11616 "github.com/alecthomas/chroma/v2/formatters/html"
11617 "github.com/alecthomas/chroma/v2/lexers"
11618 "github.com/alecthomas/chroma/v2/styles"
11619 "github.com/gofiber/fiber/v3"
11620
11621 "github.com/0magnet/m2/pkg/config"
11622 "github.com/0magnet/m2/pkg/product"
11623 "github.com/0magnet/m2/pkg/tui"
11624)
11625
11626//go:embed *.go
11627var srcWeb embed.FS
11628
11629type srcEntry struct {
11630 Prefix string
11631 FS fs.FS
11632}
11633
11634// goSources holds each package's embedded Go files for /sourcecode/go. A Go
11635// package cannot embed files outside its own directory, so since the code
11636// moved out of the repo root every package embeds its own *.go and is listed
11637// here. The web package's own embed picks up the other.go drop-in when a
11638// deployment builds with one.
11639var goSources = []srcEntry{
11640 {"pkg/config", config.Source},
11641 {"pkg/product", product.Source},
11642 {"pkg/tui", tui.Source},
11643 {"pkg/web", srcWeb},
11644}
11645
11646// RegisterSource adds a package's embedded sources to /sourcecode/go β used
11647// by the commands package, which web cannot import without a cycle.
11648func RegisterSource(prefix string, fsys fs.FS) {
11649 goSources = append(goSources, srcEntry{prefix, fsys})
11650}
11651
11652var sourcesWasm []fs.FS
11653var sourceCore = os.DirFS("ui")
11654var sourceHtml = os.DirFS("htmpl")
11655var sourceContent = os.DirFS("content")
11656
11657func serveSourceCode(r *fiber.App) {
11658 for _, wasmSRC := range f.WasmSRC {
11659 sourcesWasm = append(sourcesWasm, os.DirFS(wasmSRC))
11660 }
11661 r.Get("/sourcecode", func(c fiber.Ctx) error {
11662 ret := `<!doctype html>
11663<html lang='en'>
11664<head>
11665<link rel="stylesheet" href="/style.css" type="text/css">
11666</head>
11667<body class='grid-container' style='background-color:black;color:white;'>
11668<a href='/sourcecode/go'>GO</a><br><br>
11669
11670<a href='/sourcecode/html'>HTML</a><br><br>
11671
11672<a href='/sourcecode/content'>Content</a><br><br>
11673
11674<a href='/sourcecode/core'>C.O.R.E.</a><br><br>
11675
11676<a href='/sourcecode/wasm'>WASM</a><br><br>
11677
11678</body>
11679</html>
11680`
11681 c.Set("Content-Type", "text/html;charset=utf-8")
11682 _, err := c.Status(fiber.StatusOK).Write([]byte(ret))
11683 return err
11684 })
11685
11686 r.Get("/sourcecode/html", sourcecodehtml)
11687 r.Get("/sourcecode/content", sourcecodecontent)
11688 r.Get("/sourcecode/go", sourcecodego)
11689 r.Get("/sourcecode/core", sourcecodecore)
11690 r.Get("/sourcecode/wasm", func(c fiber.Ctx) error {
11691 ret := `<!doctype html>
11692<html lang='en'>
11693<head>
11694<link rel="stylesheet" href="/style.css" type="text/css">
11695</head>
11696<body class='grid-container' style='background-color:black;color:white;'>
11697`
11698 for _, wasmSRC := range f.WasmSRC {
11699 pathNameSlc := strings.Split(wasmSRC, "/")
11700 pathName := pathNameSlc[len(pathNameSlc)-1]
11701 ret += `<a href='/sourcecode/wasm/` + pathName + `'>` + pathName + `</a><br>
11702 `
11703 }
11704 ret += `</body></html>
11705 `
11706 c.Set("Content-Type", "text/html;charset=utf-8")
11707 _, err := c.Status(fiber.StatusOK).Write([]byte(ret))
11708 return err
11709 })
11710
11711 for i, wasmSRC := range f.WasmSRC {
11712 pathNameSlc := strings.Split(wasmSRC, "/")
11713 pathName := pathNameSlc[len(pathNameSlc)-1]
11714 r.Get("/sourcecode/wasm/"+pathName, func(c fiber.Ctx) error {
11715 return sourcecode(c, sourcesWasm[i], "dracula", "go")
11716 })
11717 }
11718}
11719
11720func sourcecodehtml(c fiber.Ctx) error {
11721 return sourcecode(c, sourceHtml, "monokai", "html")
11722}
11723func sourcecodecontent(c fiber.Ctx) error {
11724 return sourcecode(c, sourceContent, "monokai", "html")
11725}
11726
11727func sourcecodego(c fiber.Ctx) error {
11728 var builder strings.Builder
11729 for _, src := range goSources {
11730 if err := collectSource(&builder, src.FS, src.Prefix, "go"); err != nil {
11731 return err
11732 }
11733 }
11734 return renderSource(c, builder.String(), "monokai", "go")
11735}
11736
11737func sourcecodecore(c fiber.Ctx) error {
11738 return sourcecode(c, sourceCore, "solarized-dark256", "go")
11739}
11740
11741func sourcecode(c fiber.Ctx, fsys fs.FS, styleName string, lang string) error {
11742 var builder strings.Builder
11743 if err := collectSource(&builder, fsys, "", lang); err != nil {
11744 return err
11745 }
11746 return renderSource(c, builder.String(), styleName, lang)
11747}
11748
11749// collectSource concatenates every .<lang> file in fsys into builder, each
11750// under a banner naming it (with prefix, so files from different packages
11751// keep their repo paths).
11752func collectSource(builder *strings.Builder, fsys fs.FS, prefix string, lang string) error {
11753 return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
11754 if err != nil {
11755 return err
11756 }
11757 if !d.IsDir() && strings.HasSuffix(path, "."+lang) {
11758 content, err := fs.ReadFile(fsys, path)
11759 if err != nil {
11760 return err
11761 }
11762 name := path
11763 if prefix != "" {
11764 name = prefix + "/" + path
11765 }
11766 builder.WriteString(fmt.Sprintf("// ===== %s =====\n", name))
11767 builder.Write(content)
11768 builder.WriteString("\n\n")
11769 }
11770 return nil
11771 })
11772}
11773
11774func renderSource(c fiber.Ctx, source string, styleName string, lang string) error {
11775 c.Set("Content-Type", "text/html;charset=utf-8")
11776 var buf bytes.Buffer
11777
11778 // Pick lexer & style
11779 lexer := lexers.Get(lang)
11780 if lexer == nil {
11781 lexer = lexers.Fallback
11782 }
11783 lexer = chroma.Coalesce(lexer)
11784
11785 style := styles.Get(styleName)
11786 if style == nil {
11787 style = styles.Fallback
11788 }
11789
11790 // Formatter with line numbers & CSS classes
11791 formatter := html.New(
11792 html.WithLineNumbers(true),
11793 html.WithClasses(true),
11794 )
11795
11796 iterator, err := lexer.Tokenise(nil, source)
11797 if err != nil {
11798 return err
11799 }
11800
11801 // Optional: include CSS in output
11802 var css bytes.Buffer
11803 _ = formatter.WriteCSS(&css, style) //nolint:errcheck // the stylesheet is decoration; the highlighted source is still readable without it
11804 buf.WriteString("<style>")
11805 buf.Write(css.Bytes())
11806 buf.WriteString("</style>")
11807
11808 if err := formatter.Format(&buf, style, iterator); err != nil {
11809 return err
11810 }
11811
11812 _, err = c.Status(fiber.StatusOK).Write(buf.Bytes())
11813 return err
11814}
11815
11816
11817// ===== pkg/web/tmpl.go =====
11818// Package web pkg/web/tmpl.go β template plumbing.
11819package web
11820
11821import (
11822 "bytes"
11823 "fmt"
11824 htmpl "html/template"
11825 "log"
11826 "os"
11827 "path/filepath"
11828 "sort"
11829 "strconv"
11830 "strings"
11831 ttmpl "text/template"
11832 "time"
11833
11834 "github.com/gofiber/fiber/v3"
11835
11836 p "github.com/0magnet/m2/pkg/product"
11837)
11838
11839/*
11840//go:embed htmpl/*
11841var templatesFS embed.FS
11842
11843//go:embed content/*
11844var contentFS embed.FS
11845*/
11846/*
11847var (
11848 templatesFS = os.DirFS("htmpl")
11849 contentFS = os.DirFS("content")
11850)
11851*/
11852/*
11853func mustReadEmbeddedFileToString(path string, fs embed.FS) string {
11854 return string(mustReadEmbeddedFileToBytes(path, fs))
11855}
11856
11857func mustReadEmbeddedFileToBytes(path string, fs embed.FS) []byte {
11858 data, err := fs.ReadFile(path)
11859 if err != nil {
11860 panic(err)
11861 }
11862 return data
11863}
11864*/
11865
11866func mustReadFileToString(path string) string {
11867 return string(mustReadFileToBytes(path))
11868}
11869
11870// optionalReadFileToString returns "" when the file does not exist.
11871// Used for deployment-local assets (e.g. content/font.css) that are
11872// not part of the source repo.
11873func optionalReadFileToString(path string) string {
11874 data, err := os.ReadFile(path) //nolint
11875 if err != nil {
11876 return ""
11877 }
11878 return string(data)
11879}
11880
11881// contentFile returns the deployment-local file when present, falling
11882// back to the committed <path>.example. Lets site operators override
11883// stock pages (about, policy, links) without touching tracked files.
11884func contentFile(path string) string {
11885 if s := optionalReadFileToString(path); s != "" {
11886 return s
11887 }
11888 return mustReadFileToString(path + ".example")
11889}
11890
11891func mustReadFileToBytes(path string) []byte {
11892 data, err := os.ReadFile(path) //nolint
11893 if err != nil {
11894 panic(err)
11895 }
11896 return data
11897}
11898
11899type htmlTemplate struct {
11900 Empty func() string
11901 Head func() string
11902 Logo func() string
11903 Header func() string
11904 Categories func() string
11905 CatSubcats func() string
11906 Footer func() string
11907 MainPage func() string
11908 AuxPage func() string
11909 FrontPage func() string
11910 CategoryPage func() string
11911 CategoryPageMD func() string
11912 ProductPage func() string
11913 ProductPageMD func() string
11914 Schema func() string
11915 Cart func() string
11916 XMLSitemap func() string
11917 Wasm func() string
11918 Keys func() string
11919 AboutPage func() string
11920 PolicyPage func() string
11921 LinksPage func() string
11922 CheckoutPage func() string
11923 CompletePage func() string
11924 CheckoutCSS func() string
11925 StyleCSS func() string
11926 FontCSS func() string
11927}
11928
11929var h = htmlTemplate{
11930 Empty: func() string { return mustReadFileToString("htmpl/empty.html") },
11931 Head: func() string { return mustReadFileToString("htmpl/head.html") },
11932 Logo: func() string { return mustReadFileToString("htmpl/logo.html") },
11933 Header: func() string { return mustReadFileToString("htmpl/header.html") },
11934 Categories: func() string { return mustReadFileToString("htmpl/categories.html") },
11935 CatSubcats: func() string { return mustReadFileToString("htmpl/catsubcats.html") },
11936 Footer: func() string { return mustReadFileToString("htmpl/footer.html") },
11937 MainPage: func() string { return mustReadFileToString("htmpl/main.html") },
11938 AuxPage: func() string { return mustReadFileToString("htmpl/auxpage.html") },
11939 FrontPage: func() string { return mustReadFileToString("htmpl/front.html") },
11940 CategoryPage: func() string { return mustReadFileToString("htmpl/category.html") },
11941 CategoryPageMD: func() string { return mustReadFileToString("htmpl/category.md") },
11942 ProductPage: func() string { return mustReadFileToString("htmpl/product.html") },
11943 ProductPageMD: func() string { return mustReadFileToString("htmpl/product.md") },
11944 Schema: func() string { return mustReadFileToString("htmpl/schema.html") },
11945 Cart: func() string { return mustReadFileToString("htmpl/cart.html") },
11946 XMLSitemap: func() string { return mustReadFileToString("htmpl/sitemap.xml") },
11947 Wasm: func() string { return mustReadFileToString("htmpl/wasm.html") },
11948 Keys: func() string { return mustReadFileToString("htmpl/keys.html") },
11949 CompletePage: func() string { return mustReadFileToString("htmpl/complete.html") },
11950 AboutPage: func() string { return contentFile("content/about.html") },
11951 PolicyPage: func() string { return contentFile("content/policy.html") },
11952 LinksPage: func() string { return contentFile("content/links.html") },
11953 CheckoutPage: func() string { return mustReadFileToString("content/checkout.html") },
11954 CheckoutCSS: func() string { return mustReadFileToString("content/checkout.css") },
11955 StyleCSS: func() string {
11956 return fontCSS() + mustReadFileToString("content/style.css")
11957 },
11958 // The terminal page wants the face on its own, without the stylesheet.
11959 FontCSS: func() string { return fontCSS() },
11960}
11961
11962var htmlPageTemplateData htmlTemplateData
11963
11964var funcs = htmpl.FuncMap{
11965 "replace": replace, "mul": mul, "div": div, "safeHTML": safeHTML,
11966 "safeJS": safeJS, "stripProtocol": stripProtocol, "add": add, "sub": sub,
11967 "toFloat": toFloat, "equalsIgnoreCase": equalsIgnoreCase,
11968 "getsubcats": getsubcats, "escapesubcat": escapesubcat,
11969 "sortsubcats": sortsubcats, "repeat": repeat, "subcatlink": subcatlink,
11970 "navmenu": navmenu,
11971}
11972
11973func mainTmpl() (tmpl *htmpl.Template, err error) {
11974 tmpl = htmpl.New("index").Funcs(funcs)
11975 if _, err := tmpl.Parse(h.MainPage()); err != nil {
11976 log.Println("Error parsing index template:", err)
11977 return tmpl, err
11978 }
11979
11980 for _, p := range mainPartials() {
11981 if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
11982 log.Printf("Error parsing %s template: %v", p.Name, err)
11983 return tmpl, err
11984 }
11985 }
11986 return tmpl, err
11987}
11988
11989// mainPartialSrc is every template main.html may invoke, besides the
11990// "main" its caller supplies.
11991//
11992// One list of name and loader rather than a list of names and a map to
11993// look them up in: the names have to be readable without loading the
11994// bodies, because a test has no working directory to read them from, and
11995// two collections would let a name arrive with no body behind it.
11996//
11997// The test matters because a missing partial is not a parse error.
11998// html/template notices only at execute time, and then on every page of
11999// the site at once, which is a poor place to find out.
12000var mainPartialSrc = []struct {
12001 Name string
12002 Body func() string
12003}{
12004 {"head", h.Head},
12005 {"schema", h.Schema},
12006 {"header", h.Header},
12007 {"catsubcats", h.CatSubcats},
12008 {"categories", h.Categories},
12009 {"footer", h.Footer},
12010 {"cart", h.Cart},
12011 {"wasm", h.Wasm},
12012 {"keys", h.Keys},
12013}
12014
12015// mainPartials loads them.
12016func mainPartials() []struct{ Name, Content string } {
12017 out := make([]struct{ Name, Content string }, 0, len(mainPartialSrc))
12018 for _, p := range mainPartialSrc {
12019 out = append(out, struct{ Name, Content string }{p.Name, p.Body()})
12020 }
12021 return out
12022}
12023
12024func auxTmpl() (tmpl *htmpl.Template, err error) {
12025 tmpl = htmpl.New("index").Funcs(funcs)
12026 if _, err := tmpl.Parse(h.AuxPage()); err != nil {
12027 log.Println("Error parsing index template:", err)
12028 return tmpl, err
12029 }
12030
12031 partials := []struct {
12032 Name string
12033 Content string
12034 }{
12035 {"head", h.Head()},
12036 {"schema", h.Empty()},
12037 {"wasm", h.Empty()},
12038 }
12039
12040 for _, p := range partials {
12041 if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
12042 log.Printf("Error parsing %s template: %v", p.Name, err)
12043 return tmpl, err
12044 }
12045 }
12046 return tmpl, err
12047}
12048
12049func pageMeta(c fiber.Ctx, base htmlTemplateData) htmlTemplateData {
12050 h := base
12051 host := string(c.Request().Host())
12052 /*
12053 proto := "http"
12054 if c.Secure() {
12055 proto += "s"
12056 }
12057 */
12058 proto := "https"
12059 h.Canonical = proto + "://" + host + c.OriginalURL()
12060 h.BaseURL = proto + "://" + host
12061 h.RequestHost = host
12062 h.Protocol = proto
12063 h.CatsCounts, h.Cats, h.SubCatsCounts, h.SubCatsByCat = getcategories(allproducts)
12064 h.LenAllProducts = len(allproducts)
12065 h.Time = time.Now().Format(time.RFC3339Nano)
12066 h.Year = fmt.Sprintf("%v", time.Now().Year())
12067 h.MetaDesc = f.Sitemeta
12068 h.ImgBase = h.ImgSRC
12069 if strings.HasPrefix(h.ImgBase, "/") {
12070 h.ImgBase = h.BaseURL + h.ImgBase
12071 }
12072 h.KeyWords = strings.Replace(f.Sitelongname, " ", ", ", -1)
12073 return h
12074}
12075
12076func initTMPL() {
12077 htmlPageTemplateData = htmlTemplateData{
12078 TestMode: f.Teststripekey,
12079 Title: f.Sitelongname,
12080 StripePK: f.StripePK,
12081 SiteName: f.Sitedomain,
12082 SiteTagLine: f.Sitetagline,
12083 SiteName1: htmpl.HTML(checkerBoard(f.Sitedomain)), //nolint
12084 SiteLongName: f.Sitelongname,
12085 SiteASCIILogo: htmpl.HTML(f.SiteASCIILogo), //nolint
12086 SitePrettyName: f.Siteprettyname,
12087 SitePrettyNameCap: f.Siteprettynamecap,
12088 SitePrettyNameCaps: f.Siteprettynamecaps,
12089 TelegramContact: f.Tgcontact,
12090 TelegramChannel: f.Tgchannel,
12091 WasmExecPath: f.WasmExecPath,
12092 WasmExecRel: f.WasmExecPath,
12093 Cats: getcats(),
12094 LenAllProducts: len(allproducts),
12095 ImgSRC: func() (ret string) {
12096 ret = f.Siteimagesrc
12097 if ret == "" {
12098 ret = "/i"
12099 }
12100 return ret
12101 }(),
12102 Page: "front",
12103 Time: time.Now().Format(time.RFC3339Nano),
12104 Year: fmt.Sprintf("%v", time.Now().Year()),
12105 NavLinks: extraNavLinks,
12106 }
12107 htmlPageTemplateData.CatsCounts, htmlPageTemplateData.Cats, htmlPageTemplateData.SubCatsCounts, htmlPageTemplateData.SubCatsByCat = getcategories(allproducts)
12108 // No wasm by default; a page opts in with wasmFor. Defaulting to all of
12109 // them is what had every page in the site fetching stl2 (7.9MB) for a
12110 // canvas it does not have, and asking for the tui and desk binaries,
12111 // which have no TinyGo build and answer 500. Those two pages build their
12112 // own HTML and loader anyway, so they never read this list.
12113 htmlPageTemplateData.WasmBinary = nil
12114
12115}
12116
12117// wasmFor returns only the configured binaries named, so a page carries the
12118// wasm it can actually use and nothing else.
12119//
12120// Every page used to carry all of them, and each binary opens by looking for
12121// the element it drives and returning when that element is not there β so an
12122// ordinary category page fetched several megabytes, instantiated it, and got
12123// a handful of early returns. stl2 is the expensive one at 7.9MB, and only
12124// the front page and a product page have the canvas it draws into.
12125//
12126// It also stops the binaries that have no TinyGo build yet from being asked
12127// for on every page in the site: those fetches answer 500, which is two
12128// failed requests per page view for something the page could not use anyway.
12129//
12130// Names match on the part before the suffix, so a caller says "stl2" and gets
12131// whichever of stl2.wasm or stl2-tiny.wasm this build is configured for.
12132func wasmFor(names ...string) []wasmPick { return wasmPicks(pickWasm, names) }
12133
12134// wasmSmallFor is wasmFor for a binary a page carries on every view
12135// rather than when the reader asks for it: it takes the tinygo build
12136// where there is one. See pickWasmSmall.
12137func wasmSmallFor(names ...string) []wasmPick { return wasmPicks(pickWasmSmall, names) }
12138
12139func wasmPicks(pick func(string) (wasmPick, bool), names []string) []wasmPick {
12140 // WasmSRC lists the sources THIS process builds, and a page must not
12141 // name a drop-in the deployment never compiles. The tab compiles
12142 // nothing and its list is empty, so the same gate there would drop
12143 // every binary before it was looked for; the origin, which pick asks
12144 // directly, is the authority instead.
12145 inTab := servingInTab()
12146 configured := map[string]bool{}
12147 if !inTab {
12148 for _, src := range f.WasmSRC {
12149 configured[strings.TrimSuffix(filepath.Base(src), filepath.Ext(src))] = true
12150 }
12151 }
12152 ret := make([]wasmPick, 0, len(names))
12153 for _, want := range names {
12154 if !inTab && !configured[want] {
12155 continue
12156 }
12157 if p, ok := pick(want); ok {
12158 ret = append(ret, p)
12159 }
12160 }
12161 return ret
12162}
12163
12164type xmlTemplateData struct {
12165 BaseURL string
12166 Extra []string
12167 Cats []string
12168 SubCatsByCat map[string][]string
12169 Products p.Products
12170 Update string
12171}
12172
12173func generateSitemapXML() string {
12174 xmlSitemapTemplateData := xmlTemplateData{
12175 BaseURL: "https://" + f.Sitedomain,
12176 Products: allproducts,
12177 Extra: extraSitemapURLs,
12178 Update: time.Now().Format("2006-01-02"),
12179 }
12180 _, xmlSitemapTemplateData.Cats, _, xmlSitemapTemplateData.SubCatsByCat = getcategories(allproducts)
12181 var err1 error
12182 xtmpl, err1 := ttmpl.New("index").Funcs(ttmpl.FuncMap{"getsubcats": getsubcats}).Parse(h.XMLSitemap())
12183 if err1 != nil {
12184 log.Println("Error parsing index template:", err1)
12185 }
12186 var result bytes.Buffer
12187 err1 = xtmpl.Execute(&result, xmlSitemapTemplateData)
12188 if err1 != nil {
12189 log.Println("error: ", err1)
12190 }
12191 return result.String()
12192}
12193
12194func toFloat(s string) float64 {
12195 if s == "" {
12196 return 0.0
12197 }
12198 f, err := strconv.ParseFloat(s, 64)
12199 if err != nil {
12200 return 0.0
12201 }
12202 return f
12203}
12204
12205func checkerBoard(input string) string {
12206 var result strings.Builder
12207 for i, char := range input {
12208 // Wrap every other letter with the specified HTML
12209 if i%2 == 0 {
12210 result.WriteString(fmt.Sprintf("<span class='nv'>%c</span>", char))
12211 } else {
12212 result.WriteRune(char)
12213 }
12214 }
12215 return result.String()
12216}
12217
12218type htmlTemplateData struct {
12219 Title string
12220 MetaDesc string
12221 Canonical string
12222 BaseURL string
12223 ImgSRC string // url where images are hosted
12224 // ImgBase is ImgSRC made absolute. og:image and schema.org image must be
12225 // absolute URLs: a link unfurler has no page to resolve a relative one
12226 // against and most simply drop it, which is why every share of this site
12227 // showed no picture. ImgSRC may already name another host, so this cannot
12228 // be done by pasting BaseURL in front of it in the template.
12229 ImgBase string
12230 OrdersURL string // url where checkout is served from
12231 SiteName string
12232 SiteTagLine string
12233 SiteName1 htmpl.HTML //checkerboard - alternate swap text & bg color
12234 SiteLongName string
12235 SitePrettyName string //ππππππ₯π π€π‘πππ£π.πππ₯
12236 SitePrettyNameCap string //ππππππ₯π π€π‘πππ£π.πππ₯
12237 SitePrettyNameCaps string //ππΈπΎβπΌπππββπΌβπΌ.βπΌπ
12238 SiteASCIILogo htmpl.HTML
12239 TelegramContact string
12240 TelegramChannel string
12241 Protocol string
12242 RequestHost string
12243 KeyWords string
12244 Style htmpl.HTML
12245 Heading htmpl.HTML
12246 StripePK string
12247 Cats []string
12248 CatsCounts map[string]int
12249 SubCatsCounts map[string]map[string]int
12250 SubCatsByCat map[string][]string
12251 LenAllProducts int
12252 Mobile bool
12253 Gocanvas htmpl.HTML
12254 WasmBinary []wasmPick
12255 WasmExecPath string
12256 WasmExecRel string
12257 StyleFontFace htmpl.CSS
12258 Message htmpl.HTML
12259 Page string
12260 Year string
12261 Time string
12262 AboutHTML htmpl.HTML
12263 LinksHTML htmpl.HTML
12264 PolicyHTML htmpl.HTML
12265 TestMode bool
12266
12267 // NavLinks are the entries drop-in routes added to the header; see
12268 // extraNavLinks.
12269 NavLinks []NavLink
12270}
12271
12272func equalsIgnoreCase(a, b string) bool {
12273 return strings.EqualFold(strings.Join(strings.Fields(a), ""), strings.Join(strings.Fields(b), ""))
12274}
12275
12276func replace(s, o, n string) string {
12277 return strings.ReplaceAll(s, o, n)
12278}
12279func mul(a, b float64) float64 {
12280 return a * b
12281}
12282func div(a, b float64) float64 {
12283 return a / b
12284}
12285func add(a, b int) int {
12286 return a + b
12287}
12288func sub(a, b int) int {
12289 return a - b
12290}
12291func safeHTML(s string) htmpl.HTML {
12292 return htmpl.HTML(s) //nolint
12293}
12294func safeJS(s string) htmpl.JS {
12295 return htmpl.JS(s) //nolint
12296}
12297func stripProtocol(s string) string {
12298 return strings.Replace(strings.Replace(s, "https://", "", -1), "http://", "", -1)
12299}
12300func repeat(s string, count int) string {
12301 var result string
12302 for i := 0; i < count; i++ {
12303 result += s
12304 }
12305 return result
12306}
12307func sortsubcats(subcats []string, counts map[string]map[string]int) []string {
12308 sort.Slice(subcats, func(i, j int) bool {
12309 catI, catJ := subcats[i], subcats[j]
12310 countI, countJ := counts[catI]["count"], counts[catJ]["count"]
12311 return countI > countJ
12312 })
12313 return subcats
12314}
12315
12316func subcatlink(subcategory string) string {
12317 s := subcategory
12318 s = strings.ReplaceAll(s, "ΒΌ", "quarter-")
12319 s = strings.ReplaceAll(s, "Β½", "half-")
12320 s = strings.ReplaceAll(s, "1/16", "sixteenth-")
12321 s = strings.ReplaceAll(s, "%", "-pct")
12322 s = strings.ReplaceAll(s, " ", " ")
12323 s = strings.ReplaceAll(s, "watt1", "watt-1")
12324 s = strings.ReplaceAll(s, "watt5", "watt-5")
12325 s = strings.ReplaceAll(s, " ", "-")
12326 s = strings.ReplaceAll(s, "--", "-")
12327 return s
12328}
12329
12330// fontCSS is the deployment's @font-face block, read from disk. It is a
12331// plain function rather than a method on h because h's own initializer
12332// needs it, and a var cannot refer to itself.
12333func fontCSS() string { return optionalReadFileToString("content/font.css") }
12334
12335
12336// ===== pkg/web/tmpl_test.go =====
12337package web
12338
12339import (
12340 "os"
12341 "regexp"
12342 "testing"
12343)
12344
12345var tmplInvoke = regexp.MustCompile(`\{\{\s*template\s+"([^"]+)"`)
12346
12347// Every partial main.html asks for has to be registered with it.
12348//
12349// html/template does not notice a missing one when the page is parsed β
12350// only when it is executed, and then on every page of the site at once.
12351// That is a poor place to find out, and a new partial is exactly the
12352// change that forgets the registration.
12353func TestMainTemplateInvokesOnlyRegisteredPartials(t *testing.T) {
12354 src, err := os.ReadFile("../../htmpl/main.html")
12355 if err != nil {
12356 t.Fatalf("read main.html: %v", err)
12357 }
12358 known := map[string]bool{
12359 // Supplied by the handler for the page being rendered.
12360 "main": true,
12361 }
12362 for _, p := range mainPartialSrc {
12363 known[p.Name] = true
12364 }
12365 found := tmplInvoke.FindAllSubmatch(src, -1)
12366 if len(found) == 0 {
12367 t.Fatal("main.html invokes no templates at all, which cannot be right")
12368 }
12369 for _, m := range found {
12370 if name := string(m[1]); !known[name] {
12371 t.Errorf("main.html invokes %q, which mainTmpl does not register", name)
12372 }
12373 }
12374}
12375
12376// Every registration names a distinct template and carries a loader for
12377// it. The loaders read from the working directory, so this checks the
12378// shape rather than calling them.
12379func TestMainPartialsAreWellFormed(t *testing.T) {
12380 seen := map[string]bool{}
12381 for _, p := range mainPartialSrc {
12382 if p.Name == "" || p.Body == nil {
12383 t.Errorf("partial %+v has no name or no body", p.Name)
12384 }
12385 if seen[p.Name] {
12386 t.Errorf("%q is registered twice", p.Name)
12387 }
12388 seen[p.Name] = true
12389 }
12390}
12391
12392
12393// ===== pkg/web/toolchain_js.go =====
12394//go:build js && wasm
12395
12396package web
12397
12398// haveToolchain reports whether this build can shell out to a Go
12399// compiler. In a page it cannot: there is no Go, no tinygo, and nothing
12400// to exec them with.
12401//
12402// This exists because the whole m2 command tree now runs in the browser
12403// shell, so `m2 wasm` and `m2 run` are reachable there β and every
12404// failure path in wasmbuild.go is a log.Fatal, which is os.Exit, which
12405// in a page takes down the wasm instance and every terminal in it. A
12406// command that cannot do its job should say so and return non-zero, not
12407// kill the tab.
12408const haveToolchain = false
12409
12410
12411// ===== pkg/web/toolchain_native.go =====
12412//go:build !js || !wasm
12413
12414package web
12415
12416// haveToolchain reports whether this build can shell out to a Go
12417// compiler. Natively it can.
12418const haveToolchain = true
12419
12420
12421// ===== pkg/web/wasmbuild.go =====
12422// Package web pkg/web/wasmbuild.go β compiling the drop-in wasm apps.
12423package web
12424
12425import (
12426 "encoding/json"
12427 "fmt"
12428 "io"
12429 "io/fs"
12430 "log"
12431 "net/http"
12432 "os"
12433 "os/exec"
12434 "path/filepath"
12435 "regexp"
12436 "strconv"
12437
12438 "github.com/gofiber/fiber/v3"
12439 "strings"
12440 "time"
12441
12442 "github.com/bitfield/script"
12443 "github.com/briandowns/spinner"
12444
12445 "github.com/0magnet/m2/pkg/config"
12446)
12447
12448func CompileWASM() {
12449 if !haveToolchain {
12450 // Nothing here to build with; see toolchain_js.go.
12451 log.Println("wasm: no Go toolchain in this environment; nothing compiled")
12452 return
12453 }
12454 s := spinner.New(spinner.CharSets[14], 25*time.Millisecond)
12455 s.Suffix = " Compiling wasm..."
12456 // Each build runs on the system's default Go first; only if that fails does
12457 // it retry on a pinned Go toolchain. The pinned version is normally resolved
12458 // dynamically β tinygo trails the latest Go release, so on failure we read
12459 // the Go range tinygo supports from its own error and pin the newest matching
12460 // Go patch (see resolveCompatibleToolchain). That keeps the site on the newest
12461 // Go whenever tinygo supports it and self-heals across Go bumps with no
12462 // hardcoded version. GOTOOLCHAINFALLBACK in the MENV config is an OPTIONAL
12463 // offline backstop, used only if the dynamic lookup can't reach the release
12464 // list; leave it empty for fully dynamic behavior.
12465 offlineFallback := config.ScriptExecString("${GOTOOLCHAINFALLBACK}")
12466
12467 // Stage the drop-ins BEFORE building anything, from whatever the last run
12468 // left on disk, because a page binary embeds them (dropinfs.go) and
12469 // go:embed is read when it compiles.
12470 //
12471 // Staging after the builds instead would make the embed current within a
12472 // single run, and it is tempting for that reason. It also puts the tinygo
12473 // builds β stl2's is about twenty minutes β in front of every term.wasm
12474 // rebuild, so a one-line server edit would not reach the site for half an
12475 // hour. Staging first costs the opposite and much less: when a drop-in's
12476 // SOURCE changes, the copy inside the page binary is one restart behind.
12477 //
12478 // That is affordable because the embedded copy is a fallback and nothing
12479 // else. The host serves the drop-ins off disk, where they are current the
12480 // moment the build finishes, and a tab whose carried copy is stale is
12481 // still a tab serving a working cart. Nothing reads the embed unless the
12482 // disk has nothing.
12483 stageDropins(dropInSources())
12484
12485 for _, wasmSRC := range f.WasmSRC {
12486 ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
12487 outputFile := baseName(wasmSRC) + ".wasm"
12488 mk := func(tc string) string {
12489 return fmt.Sprintf("cd %s || exit 1 ; time GOOS=js GOARCH=wasm %s%s -o %s %s -ldflags=\"-s -w\" %s && cd %s && du %s", wasmSRC, tc, f.Gobuild, ascend+outputFile, ldflags(wasmSRC), ".", ascend, outputFile)
12490 }
12491 buildWasmWithFallback(mk, offlineFallback, s, true)
12492 }
12493 if !f.UseTinygo {
12494 return
12495 }
12496 for _, wasmSRC := range f.WasmSRC {
12497 ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
12498 outputFile := baseName(wasmSRC) + "-tiny.wasm"
12499 mk := func(tc string) string {
12500 return fmt.Sprintf("cd %s || exit 1 ; time GOOS=js GOARCH=wasm %s%s -o %s %s %s && cd %s && du %s", wasmSRC, tc, f.Tinygobuild, ascend+outputFile, ldflags(wasmSRC), ".", ascend, outputFile)
12501 }
12502 // The Go build above succeeded and serves; a drop-in that tinygo
12503 // cannot compile (e.g. one using net/http, which tinygo's shim
12504 // does not build) must not take the whole store down.
12505 buildWasmWithFallback(mk, offlineFallback, s, false)
12506 }
12507}
12508
12509// dropInSources is WasmSRC minus the page binaries β the sources whose builds
12510// a page loads alongside itself, and so the ones worth carrying.
12511func dropInSources() []string {
12512 var out []string
12513 for _, src := range f.WasmSRC {
12514 if !pageWasm[baseName(src)] {
12515 out = append(out, src)
12516 }
12517 }
12518 return out
12519}
12520
12521// pageWasm names the binaries that ARE a page rather than something a page
12522// loads alongside itself. They are not staged into the embed: the tab does not
12523// serve /tui or /desk, and term.wasm is the binary doing the embedding β a
12524// file cannot contain itself.
12525var pageWasm = map[string]bool{"term": true, "tui": true, "desk": true}
12526
12527func baseName(wasmSRC string) string {
12528 return strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
12529}
12530
12531// stageDropins copies the smallest build of each drop-in into the directory
12532// dropinfs.go embeds, so the page binaries compiled next carry them.
12533//
12534// Best effort throughout. A tree without the staging directory β a binary
12535// running somewhere other than its own source β simply stages nothing and the
12536// build carries nothing, which is the same as a fresh checkout.
12537func stageDropins(srcs []string) {
12538 dir := filepath.Join("pkg", "web", "dropins")
12539 // os.Root confines every name below to this directory. The names are
12540 // derived from WasmSRC, which is configuration rather than a request, but
12541 // "configuration cannot traverse" is a claim about the config file and not
12542 // about this code; the root makes it a property of this code.
12543 root, err := os.OpenRoot(dir)
12544 if err != nil {
12545 log.Printf("wasm: no %s to stage into; page binaries will carry no drop-ins", dir)
12546 return
12547 }
12548 defer root.Close() //nolint:errcheck // read-mostly; the writes below are closed individually
12549
12550 // Clear first, so a drop-in dropped from WasmSRC stops being carried.
12551 if ents, rerr := fs.ReadDir(root.FS(), "."); rerr == nil {
12552 for _, e := range ents {
12553 if !e.IsDir() && strings.HasSuffix(e.Name(), ".wasm") {
12554 _ = root.Remove(e.Name()) //nolint:errcheck // stale copy; the writes below are what matter
12555 }
12556 }
12557 }
12558
12559 for _, src := range srcs {
12560 name := baseName(src)
12561 // Smallest first, matching what a page is given to load.
12562 for _, cand := range []string{name + "-tiny.wasm", name + ".wasm"} {
12563 b, rerr := os.ReadFile(cand) //nolint:gosec // a path this process just built
12564 if rerr != nil {
12565 continue
12566 }
12567 if werr := writeInRoot(root, cand, b); werr != nil {
12568 log.Printf("wasm: staging %s: %v", cand, werr)
12569 continue
12570 }
12571 log.Printf("wasm: staged %s (%d bytes) for the in-tab server", cand, len(b))
12572 break
12573 }
12574 }
12575}
12576
12577// writeInRoot creates one file under root and writes it whole.
12578func writeInRoot(root *os.Root, name string, b []byte) error {
12579 fh, err := root.Create(name)
12580 if err != nil {
12581 return err
12582 }
12583 if _, err = fh.Write(b); err != nil {
12584 _ = fh.Close() //nolint:errcheck // the write error is the one to report
12585 return err
12586 }
12587 return fh.Close()
12588}
12589
12590// runBash runs an inner bash script and returns its combined stdout+stderr, so
12591// the caller can both log it and parse compiler errors out of it.
12592func runBash(inner string) (string, error) {
12593 // The command is built by this package, not taken from a request.
12594 out, err := exec.Command("bash", "-c", inner).CombinedOutput() //nolint:gosec
12595 return string(out), err
12596}
12597
12598// buildWasmWithFallback builds wasm on the system Go first (mkCmd("")). On
12599// failure it resolves a Go toolchain tinygo can use β dynamically from tinygo's
12600// own error output, or the offlineFallback if the release list is unreachable β
12601// and retries with GOTOOLCHAIN pinned to it. When required, a build that still
12602// fails is fatal; otherwise it is logged and skipped (the pages fall back to
12603// whichever binary exists). mkCmd receives the string to place before the
12604// build command: "" for the default, or "GOTOOLCHAIN=β¦ ".
12605func buildWasmWithFallback(mkCmd func(toolchainPrefix string) string, offlineFallback string, s *spinner.Spinner, required bool) {
12606 log.Println("Compiling wasm with:")
12607 log.Println(mkCmd(""))
12608 s.Start()
12609 out, err := runBash(mkCmd(""))
12610 s.Stop()
12611 log.Print(out)
12612 if err == nil {
12613 log.Println("Compiled wasm!")
12614 return
12615 }
12616 tc := resolveCompatibleToolchain(out)
12617 if tc == "" {
12618 tc = offlineFallback
12619 }
12620 if tc == "" {
12621 if required {
12622 log.Fatalf("wasm build failed and no compatible Go toolchain could be resolved: %v", err)
12623 }
12624 log.Printf("optional wasm build failed (no compatible toolchain): %v", err)
12625 return
12626 }
12627 log.Printf("wasm build failed on the default Go; retrying with GOTOOLCHAIN=%s", tc)
12628 s.Start()
12629 out, err = runBash(mkCmd("GOTOOLCHAIN=" + tc + " "))
12630 s.Stop()
12631 log.Print(out)
12632 if err != nil {
12633 if required {
12634 log.Fatal(err)
12635 }
12636 log.Printf("optional wasm build failed: %v", err)
12637 return
12638 }
12639 log.Printf("Compiled wasm (Go toolchain %s)!", tc)
12640}
12641
12642// resolveCompatibleToolchain reads tinygo's supported Go range from a failed
12643// build's output (e.g. "requires go version 1.19 through 1.26, got go1.27") and
12644// returns the newest released Go patch of that ceiling minor (e.g. "go1.26.7"),
12645// or "" if the range can't be parsed or the release list can't be fetched.
12646func resolveCompatibleToolchain(buildOutput string) string {
12647 m := regexp.MustCompile(`through (\d+)\.(\d+)`).FindStringSubmatch(buildOutput)
12648 if m == nil {
12649 return ""
12650 }
12651 minor := m[1] + "." + m[2]
12652
12653 client := &http.Client{Timeout: 20 * time.Second}
12654 resp, err := client.Get("https://go.dev/dl/?mode=json&include=all")
12655 if err != nil {
12656 return ""
12657 }
12658 defer func() { _ = resp.Body.Close() }() //nolint:errcheck,gosec
12659 body, err := io.ReadAll(resp.Body)
12660 if err != nil {
12661 return ""
12662 }
12663 var rels []struct {
12664 Version string `json:"version"`
12665 }
12666 if err := json.Unmarshal(body, &rels); err != nil {
12667 return ""
12668 }
12669
12670 // Newest patch of go<minor> (require a patch number: GOTOOLCHAIN rejects a
12671 // bare "go1.26" β it must be a full toolchain version like go1.26.7).
12672 re := regexp.MustCompile(`^go` + regexp.QuoteMeta(minor) + `\.(\d+)$`)
12673 best, bestPatch := "", -1
12674 for _, r := range rels {
12675 mm := re.FindStringSubmatch(r.Version)
12676 if mm == nil {
12677 continue
12678 }
12679 // A version that does not parse yields 0, which loses the comparison
12680 // below, so an unparsable tag is skipped rather than erroring.
12681 if p, _ := strconv.Atoi(mm[1]); p > bestPatch { //nolint:errcheck
12682 bestPatch, best = p, r.Version
12683 }
12684 }
12685 return best
12686}
12687
12688func ldflags(s string) (ss string) {
12689 checkFiles, err := script.FindFiles(s).Slice()
12690 if err != nil {
12691 log.Fatal(err)
12692 }
12693 if f.LDFlagsX != "" {
12694 for _, s := range checkFiles {
12695 res, err := script.File(s).Match(strings.Split(f.LDFlagsX, "=")[0]).String()
12696 if err != nil {
12697 log.Fatal(err)
12698 }
12699 if res != "" {
12700 ss += fmt.Sprintf(` -X 'main.%s' `, f.LDFlagsX)
12701 break
12702 }
12703 }
12704 }
12705 for _, s := range checkFiles {
12706 res, err := script.File(s).Match("wasmName").String()
12707 if err != nil {
12708 log.Fatal(err)
12709 }
12710 if res != "" {
12711 ss += fmt.Sprintf(` -X 'main.wasmName=%s' `, strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))+".wasm")
12712 break
12713 }
12714 }
12715 if ss != "" {
12716 ss = `-ldflags="` + ss + `"`
12717 }
12718 return ss
12719}
12720
12721// serveCarriedWASM registers a route for every drop-in the build staged into
12722// the binary, and for the runtime shims a page needs to start one.
12723//
12724// It is the tab's copy of serveWASM. serveWASM reads the working directory and
12725// is registered only in the host role, which is right there and wrong here: a
12726// tab has no working directory carrying binaries and never will. Everything
12727// unmatched falls through to the read-through proxy, so a drop-in the build did
12728// not stage still reaches the origin; this only means the common ones do not
12729// have to.
12730//
12731// Registered BEFORE the proxy catch-all, and only for files actually carried,
12732// so a route never shadows a path the origin could answer better.
12733func serveCarriedWASM(r *fiber.App) {
12734 for _, file := range embeddedDropinNames() {
12735 body, ok := embeddedDropin(file)
12736 if !ok {
12737 continue
12738 }
12739 r.Get("/"+file, func(c fiber.Ctx) error {
12740 c.Set(fiber.HeaderContentType, "application/wasm")
12741 // Immutable: this one is compiled into the binary serving it, so
12742 // it cannot change without the page that loaded it changing too.
12743 c.Set(fiber.HeaderCacheControl, "public, max-age=31536000, immutable")
12744 return c.Send(body)
12745 })
12746 }
12747}
12748
12749
12750// ===== pkg/web/wasmpick.go =====
12751package web
12752
12753// Which build of each wasm drop-in a page loads, and which runtime shim starts
12754// it.
12755//
12756// USETINYGO does not mean "tinygo only". wasmbuild.go builds the Go variant of
12757// every source FIRST and unconditionally (that build is `required`), and only
12758// then, if the flag is set, additionally builds the tinygo variants as
12759// best-effort. So the usual state on the server is that BOTH stl2.wasm and
12760// stl2-tiny.wasm exist.
12761//
12762// The serving side did not know that. The old wasmBinary() named "<x>-tiny.wasm" for
12763// every source whenever the flag was on, without looking at the disk, which had
12764// two consequences:
12765//
12766// - A fresh Go build was ignored in favor of a stale tinygo one. The Go build
12767// of stl2 takes about 4 seconds and the tinygo build about 22 minutes, so
12768// during that window the page served a binary from before the edit β a
12769// 22-minute feedback loop for a 4-second change.
12770// - A tinygo build that FAILED still had its name emitted. wasmbuild.go says
12771// a drop-in tinygo cannot compile "must not take the whole store down" and
12772// that "the pages fall back to whichever binary exists", but only tuipage
12773// and deskpage ever did that stat; template pages asked for a file that was
12774// not there.
12775//
12776// So the choice is made per binary, from the disk, newest wins.
12777
12778import (
12779 "net/http"
12780 "os"
12781 "strings"
12782 "sync"
12783)
12784
12785// wasmPick is one binary a page will load together with the shim that can start
12786// it. The two travel together because they must match: tinygo's wasm_exec.js
12787// cannot start a stdlib Go binary (it throws a LinkError on the gojs imports)
12788// and the stdlib shim cannot start a tinygo one.
12789type wasmPick struct {
12790 File string // "stl2.wasm" or "stl2-tiny.wasm"
12791 ExecPath string // the wasm_exec.js matching its toolchain
12792 GoGlobal string // the JS global holding that shim's Go constructor
12793}
12794
12795// The two shims both define globalThis.Go, so a page needing both must load
12796// them one after another and stash each constructor before the next overwrites
12797// it. These are the names it stashes them under.
12798const (
12799 goGlobalStd = "__GoStd"
12800 goGlobalTiny = "__GoTiny"
12801)
12802
12803// newerFile reports which of two paths has the later modification time.
12804// Missing files lose; if neither exists, ok is false.
12805func newerFile(a, b string) (path string, ok bool) {
12806 sa, ea := os.Stat(a)
12807 sb, eb := os.Stat(b)
12808 switch {
12809 case ea == nil && eb == nil:
12810 if sb.ModTime().After(sa.ModTime()) {
12811 return b, true
12812 }
12813 return a, true
12814 case ea == nil:
12815 return a, true
12816 case eb == nil:
12817 return b, true
12818 }
12819 return "", false
12820}
12821
12822// pickWasm chooses the build of one drop-in to serve.
12823//
12824// When the tinygo build is not wanted at all the Go build is the only candidate,
12825// so a site that never sets USETINYGO behaves exactly as before.
12826func pickWasm(name string) (wasmPick, bool) {
12827 if servingInTab() {
12828 if p, ok := pickCarried(name); ok {
12829 return p, true
12830 }
12831 return pickFromOrigin(name)
12832 }
12833 std := name + ".wasm"
12834 if !f.UseTinygo {
12835 if _, err := os.Stat(std); err != nil {
12836 return wasmPick{}, false
12837 }
12838 return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
12839 }
12840 which, ok := newerFile(std, name+"-tiny.wasm")
12841 if !ok {
12842 return wasmPick{}, false
12843 }
12844 if strings.HasSuffix(which, "-tiny.wasm") {
12845 return wasmPick{File: which, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
12846 }
12847 return wasmPick{File: which, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
12848}
12849
12850// pickWasmSmall chooses the SMALLER build of a drop-in: the tinygo one
12851// when it exists, whatever the modification times say.
12852//
12853// pickWasm's newest-wins rule is right for a binary a page loads because
12854// that binary is the point of the page β during the twenty-odd minutes a
12855// tinygo build takes, serving the four-second Go build is what keeps the
12856// edit visible. It is the wrong rule for one every page carries whether
12857// the reader uses it or not. The cart is that: 4.5MB as a Go build
12858// against 1MB as a tinygo one, fetched on every catalog page view to
12859// keep a list of part numbers in localStorage.
12860//
12861// Falls back to pickWasm, so a site with no tinygo β or one whose tinygo
12862// build of this source failed β gets the Go build rather than nothing.
12863func pickWasmSmall(name string) (wasmPick, bool) {
12864 if servingInTab() {
12865 if p, ok := pickCarried(name); ok {
12866 return p, true
12867 }
12868 return pickFromOrigin(name)
12869 }
12870 if f.UseTinygo {
12871 if tiny := name + "-tiny.wasm"; fileExists(tiny) {
12872 return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
12873 }
12874 }
12875 return pickWasm(name)
12876}
12877
12878func fileExists(path string) bool {
12879 _, err := os.Stat(path)
12880 return err == nil
12881}
12882
12883// pageBinary picks the binary a full-page app loads, taking the first of
12884// names the deployment has actually built, and the tinygo build of it
12885// where there is one.
12886//
12887// The names are given joint-first. /tui and /desk were two binaries of
12888// about fifty megabytes each that were all but the same fifty β the desk
12889// links pkg/storepane to put a store in a window, and the storefront page
12890// is pkg/storepane and nothing else β so wasm/term is the two together,
12891// eight kilobytes larger than the desk alone and serving both. A
12892// deployment that builds it gets it; one that still lists the separate
12893// drop-ins gets those, and nothing has to change at once.
12894//
12895// The shim travels with the pick because the two are not interchangeable:
12896// tinygo's wasm_exec.js throws a LinkError on a stdlib Go binary's gojs
12897// imports, and the stdlib one cannot start a tinygo binary.
12898func pageBinary(names ...string) (file, execPath string) {
12899 for _, n := range names {
12900 if f.UseTinygo && fileExists(n+"-tiny.wasm") {
12901 return n + "-tiny.wasm", f.WasmExecPathTinyGo
12902 }
12903 if fileExists(n + ".wasm") {
12904 return n + ".wasm", f.WasmExecPathGo
12905 }
12906 }
12907 // Nothing built yet. Name the app's own Go build, so a page served
12908 // during the first compile asks for the file that is about to exist
12909 // rather than for nothing at all.
12910 return names[len(names)-1] + ".wasm", f.WasmExecPathGo
12911}
12912
12913// wasmExec is one runtime shim a page must load.
12914type wasmExec struct {
12915 Path string
12916 GoGlobal string
12917}
12918
12919// wasmExecsFor returns the shims a set of picks needs, deduplicated and in a
12920// stable order. A page with a tinygo binary and a Go binary on it gets both.
12921func wasmExecsFor(picks []wasmPick) []wasmExec {
12922 var out []wasmExec
12923 seen := map[string]bool{}
12924 for _, p := range picks {
12925 if p.ExecPath == "" || seen[p.GoGlobal] {
12926 continue
12927 }
12928 seen[p.GoGlobal] = true
12929 out = append(out, wasmExec{Path: p.ExecPath, GoGlobal: p.GoGlobal})
12930 }
12931 return out
12932}
12933
12934// WasmExecs is the shim list for this page's binaries, as a METHOD rather than
12935// a template function.
12936//
12937// It was a FuncMap entry, and that broke the whole site: templates are parsed
12938// in several places here and only some of them call .Funcs(funcs), so a page
12939// whose set was built bare β order.go's, for one β died at parse time with
12940// `function "wasmexecs" not defined` and served a 500. A method travels with
12941// the data instead of with the template set, so every set can call it and no
12942// new parse site has to remember anything.
12943func (h htmlTemplateData) WasmExecs() []wasmExec { return wasmExecsFor(h.WasmBinary) }
12944
12945// The in-tab server's filesystem is not the authority on which drop-ins a
12946// page may load.
12947//
12948// When the store runs inside the page (pkg/storepane's `serve`, behind the
12949// desk's netscrape window) its jsfs carries the templates and content the
12950// render code reads and nothing else β images, fonts and models already
12951// read through to the origin on a miss, and so do the wasm binaries. But
12952// the picking above asks the local disk, which in the tab holds none of
12953// them, so every page rendered there came out with no drop-in referenced
12954// at all: no cart, no decoration, and no attractor over the logo. The
12955// binaries were reachable the whole time; nothing ever named them.
12956//
12957// So in the tab the origin is asked instead, once per name.
12958
12959// originSrc is the origin an in-tab server's misses read through to, and
12960// what it has learned about which binaries that origin serves. base is
12961// empty in the host process, where the disk is the authority and none of
12962// this runs.
12963var originSrc struct {
12964 mu sync.Mutex
12965 base string
12966 seen map[string]bool
12967}
12968
12969// serveFromOrigin marks this process the in-tab server. NewApp calls it
12970// with the same origin it gives the read-through proxy.
12971func serveFromOrigin(base string) {
12972 originSrc.mu.Lock()
12973 defer originSrc.mu.Unlock()
12974 originSrc.base, originSrc.seen = base, map[string]bool{}
12975}
12976
12977// servingInTab reports whether this is the in-page server.
12978func servingInTab() bool {
12979 originSrc.mu.Lock()
12980 defer originSrc.mu.Unlock()
12981 return originSrc.base != ""
12982}
12983
12984// originHas reports whether the origin serves a file, asking it at most
12985// once per name and remembering the answer. A binary the origin does not
12986// have goes unreferenced, which is the answer a missing file gives on a
12987// host.
12988func originHas(file string) bool {
12989 originSrc.mu.Lock()
12990 base, seen := originSrc.base, originSrc.seen
12991 if have, ok := seen[file]; ok {
12992 originSrc.mu.Unlock()
12993 return have
12994 }
12995 originSrc.mu.Unlock()
12996
12997 have := false
12998 if req, err := http.NewRequest(http.MethodHead, base+"/"+file, nil); err == nil {
12999 if resp, derr := http.DefaultClient.Do(req); derr == nil {
13000 _ = resp.Body.Close() //nolint:errcheck // nothing was read from it
13001 have = resp.StatusCode == http.StatusOK
13002 }
13003 }
13004
13005 originSrc.mu.Lock()
13006 seen[file] = have
13007 originSrc.mu.Unlock()
13008 return have
13009}
13010
13011// pickFromOrigin chooses a drop-in the way the tab must: smallest build
13012// the origin actually serves.
13013//
13014// It does not consult UseTinygo. That flag says whether THIS process
13015// should run tinygo over the sources, and the tab has no toolchain and
13016// built nothing; what matters is which builds exist at the other end. The
13017// tab also pays for every byte twice β once over the network into the
13018// page, once through the proxy into the frame β so the small build is
13019// wanted wherever there is one.
13020// pickCarried chooses a drop-in the binary itself carries. It runs before the
13021// origin is asked, so a tab with a staged build answers out of itself.
13022func pickCarried(name string) (wasmPick, bool) {
13023 if tiny := name + "-tiny.wasm"; carriesDropin(tiny) {
13024 return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13025 }
13026 if std := name + ".wasm"; carriesDropin(std) {
13027 return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13028 }
13029 return wasmPick{}, false
13030}
13031
13032// carriesDropin reports whether the build staged this file into the binary.
13033func carriesDropin(file string) bool {
13034 _, ok := lookupDropin(file)
13035 return ok
13036}
13037
13038func pickFromOrigin(name string) (wasmPick, bool) {
13039 if tiny := name + "-tiny.wasm"; originHas(tiny) {
13040 return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13041 }
13042 if std := name + ".wasm"; originHas(std) {
13043 return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13044 }
13045 return wasmPick{}, false
13046}
13047
13048
13049// ===== pkg/web/wasmpick_test.go =====
13050package web
13051
13052import (
13053 "bytes"
13054 htmpl "html/template"
13055 "net/http"
13056 "net/http/httptest"
13057 "os"
13058 "path/filepath"
13059 "strings"
13060 "testing"
13061 "time"
13062)
13063
13064// touch writes a file with an explicit mtime, so "newest wins" can be tested
13065// without sleeping between writes.
13066func touch(t *testing.T, path string, age time.Duration) {
13067 t.Helper()
13068 if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
13069 t.Fatal(err)
13070 }
13071 when := time.Now().Add(-age)
13072 if err := os.Chtimes(path, when, when); err != nil {
13073 t.Fatal(err)
13074 }
13075}
13076
13077func TestNewerFile(t *testing.T) {
13078 dir := t.TempDir()
13079 a := filepath.Join(dir, "a.wasm")
13080 b := filepath.Join(dir, "b.wasm")
13081
13082 if _, ok := newerFile(a, b); ok {
13083 t.Error("neither file exists, but newerFile reported a winner")
13084 }
13085
13086 touch(t, a, time.Hour)
13087 if got, ok := newerFile(a, b); !ok || got != a {
13088 t.Errorf("only a exists: got %q ok=%v, want %q true", got, ok, a)
13089 }
13090 if got, ok := newerFile(b, a); !ok || got != a {
13091 t.Errorf("only a exists (as second arg): got %q ok=%v, want %q true", got, ok, a)
13092 }
13093
13094 // b newer than a: b must win from either argument position, or the
13095 // selection would depend on the order the caller happened to pass them.
13096 touch(t, b, time.Minute)
13097 if got, _ := newerFile(a, b); got != b {
13098 t.Errorf("b is newer: got %q, want %q", got, b)
13099 }
13100 if got, _ := newerFile(b, a); got != b {
13101 t.Errorf("b is newer (arg order swapped): got %q, want %q", got, b)
13102 }
13103}
13104
13105// A tie must not flap between the two on successive page loads.
13106func TestNewerFileTieIsStable(t *testing.T) {
13107 dir := t.TempDir()
13108 a := filepath.Join(dir, "a.wasm")
13109 b := filepath.Join(dir, "b.wasm")
13110 touch(t, a, time.Hour)
13111 touch(t, b, time.Hour)
13112 first, _ := newerFile(a, b)
13113 for i := 0; i < 5; i++ {
13114 if got, _ := newerFile(a, b); got != first {
13115 t.Fatalf("tie is not stable: %q then %q", first, got)
13116 }
13117 }
13118}
13119
13120// The shim must match the build, and a page carrying both toolchains must get
13121// both shims under distinct globals β the whole point of the aliasing.
13122func TestWasmExecsForDedupesAndKeepsBoth(t *testing.T) {
13123 picks := []wasmPick{
13124 {File: "stl2-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13125 {File: "deco-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13126 {File: "cart.wasm", ExecPath: "/go/wasm_exec.js", GoGlobal: goGlobalStd},
13127 }
13128 got := wasmExecsFor(picks)
13129 if len(got) != 2 {
13130 t.Fatalf("got %d shims, want 2 (one per toolchain): %+v", len(got), got)
13131 }
13132 if got[0].GoGlobal != goGlobalTiny || got[1].GoGlobal != goGlobalStd {
13133 t.Errorf("shims out of order or wrong globals: %+v", got)
13134 }
13135 if goGlobalTiny == goGlobalStd {
13136 t.Error("the two globals must differ, or the second shim overwrites the first")
13137 }
13138}
13139
13140func TestWasmExecsForSkipsEmptyPath(t *testing.T) {
13141 if got := wasmExecsFor([]wasmPick{{File: "x.wasm"}}); len(got) != 0 {
13142 t.Errorf("a pick with no shim path yielded %+v, want none", got)
13143 }
13144}
13145
13146// The wasm partial must render from a template set built WITHOUT .Funcs().
13147//
13148// This is the bug that took the site down: the shim list was a FuncMap entry,
13149// but templates are parsed in several places here and only some call
13150// .Funcs(funcs). A page whose set was built bare died at parse time with
13151// `function "wasmexecs" not defined` and served a 500 for the whole front page.
13152// A method on the data has no such dependency, and this test is the proof.
13153func TestWasmPartialParsesWithoutFuncMap(t *testing.T) {
13154 src, err := os.ReadFile("../../htmpl/wasm.html")
13155 if err != nil {
13156 t.Skip("template not present:", err)
13157 }
13158 tmpl := htmpl.New("index")
13159 if _, err := tmpl.New("wasm").Parse(string(src)); err != nil {
13160 t.Fatalf("wasm.html needs a FuncMap it may not get: %v", err)
13161 }
13162 data := struct{ Page htmlTemplateData }{Page: htmlTemplateData{
13163 WasmBinary: []wasmPick{
13164 {File: "stl2-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13165 {File: "cart.wasm", ExecPath: "/go/wasm_exec.js", GoGlobal: goGlobalStd},
13166 },
13167 }}
13168 var buf bytes.Buffer
13169 if err := tmpl.ExecuteTemplate(&buf, "wasm", data); err != nil {
13170 t.Fatalf("executing the wasm partial: %v", err)
13171 }
13172 out := buf.String()
13173 // Both shims, both stashed under distinct globals, and each binary using
13174 // the one that matches it.
13175 for _, want := range []string{
13176 "/tiny/wasm_exec.js", "/go/wasm_exec.js",
13177 `window["` + goGlobalTiny + `"] = Go`, `window["` + goGlobalStd + `"] = Go`,
13178 "stl2-tiny.wasm", "cart.wasm",
13179 } {
13180 if !strings.Contains(out, want) {
13181 t.Errorf("rendered wasm partial is missing %q", want)
13182 }
13183 }
13184}
13185
13186// A page with no wasm must render nothing at all β not an empty <script> that
13187// would still try to construct a Go runtime.
13188func TestWasmPartialEmptyWhenNoBinaries(t *testing.T) {
13189 src, err := os.ReadFile("../../htmpl/wasm.html")
13190 if err != nil {
13191 t.Skip("template not present:", err)
13192 }
13193 tmpl := htmpl.New("index")
13194 if _, err := tmpl.New("wasm").Parse(string(src)); err != nil {
13195 t.Fatal(err)
13196 }
13197 var buf bytes.Buffer
13198 data := struct{ Page htmlTemplateData }{}
13199 if err := tmpl.ExecuteTemplate(&buf, "wasm", data); err != nil {
13200 t.Fatal(err)
13201 }
13202 if strings.Contains(buf.String(), "<script") {
13203 t.Errorf("a page with no wasm still emitted a script tag:\n%s", buf.String())
13204 }
13205}
13206
13207// The cart rides on every catalog page, so it takes the tinygo build
13208// where there is one β 1MB against 4.5MB, fetched whether or not the
13209// reader ever opens the cart. pickWasm's newest-wins rule is right for a
13210// binary that IS the page and wrong for one that merely travels with it.
13211func TestPickWasmSmallPrefersTheTinyBuild(t *testing.T) {
13212 t.Chdir(t.TempDir())
13213 saved := *f
13214 t.Cleanup(func() { *f = saved })
13215 f.UseTinygo = true
13216 f.WasmExecPathGo = "/go.js"
13217 f.WasmExecPathTinyGo = "/tiny.js"
13218
13219 write := func(name string) {
13220 if err := os.WriteFile(name, []byte("x"), 0o600); err != nil {
13221 t.Fatal(err)
13222 }
13223 }
13224 // The Go build written LAST, so newest-wins would take it.
13225 write("cart-tiny.wasm")
13226 time.Sleep(10 * time.Millisecond)
13227 write("cart.wasm")
13228
13229 if got, ok := pickWasm("cart"); !ok || got.File != "cart.wasm" {
13230 t.Fatalf("pickWasm took %+v, want the newer cart.wasm β the premise of this test", got)
13231 }
13232 got, ok := pickWasmSmall("cart")
13233 if !ok {
13234 t.Fatal("pickWasmSmall found nothing")
13235 }
13236 if got.File != "cart-tiny.wasm" {
13237 t.Errorf("pickWasmSmall took %q, want cart-tiny.wasm", got.File)
13238 }
13239 if got.ExecPath != f.WasmExecPathTinyGo || got.GoGlobal != goGlobalTiny {
13240 t.Errorf("tiny build paired with %+v, want tinygo's shim", got)
13241 }
13242}
13243
13244// With no tinygo build β or no tinygo at all β the Go build is served
13245// rather than nothing.
13246func TestPickWasmSmallFallsBackToTheGoBuild(t *testing.T) {
13247 t.Chdir(t.TempDir())
13248 saved := *f
13249 t.Cleanup(func() { *f = saved })
13250 f.UseTinygo = true
13251 f.WasmExecPathGo = "/go.js"
13252 if err := os.WriteFile("cart.wasm", []byte("x"), 0o600); err != nil {
13253 t.Fatal(err)
13254 }
13255 got, ok := pickWasmSmall("cart")
13256 if !ok || got.File != "cart.wasm" || got.GoGlobal != goGlobalStd {
13257 t.Errorf("with no tinygo build got %+v, want the Go one", got)
13258 }
13259 f.UseTinygo = false
13260 if got, ok := pickWasmSmall("cart"); !ok || got.File != "cart.wasm" {
13261 t.Errorf("with tinygo off got %+v, want the Go one", got)
13262 }
13263}
13264
13265// The joint build wins when the deployment has one, and the page's own
13266// is the fallback β so a site that has not rebuilt yet keeps working.
13267func TestPageBinaryPrefersTheJointBuild(t *testing.T) {
13268 t.Chdir(t.TempDir())
13269 saved := *f
13270 t.Cleanup(func() { *f = saved })
13271 f.UseTinygo = false
13272 f.WasmExecPathGo = "/go.js"
13273 f.WasmExecPathTinyGo = "/tiny.js"
13274 write := func(name string) {
13275 if err := os.WriteFile(name, []byte("x"), 0o600); err != nil {
13276 t.Fatal(err)
13277 }
13278 }
13279
13280 // Only the separate builds so far: each page gets its own.
13281 write("tui.wasm")
13282 write("desk.wasm")
13283 if got, _ := pageBinary("term", "tui"); got != "tui.wasm" {
13284 t.Errorf("with no joint build /tui got %q, want tui.wasm", got)
13285 }
13286 if got, _ := pageBinary("term", "desk"); got != "desk.wasm" {
13287 t.Errorf("with no joint build /desk got %q, want desk.wasm", got)
13288 }
13289
13290 // The joint build arrives and both pages move to it.
13291 write("term.wasm")
13292 for _, page := range []string{"tui", "desk"} {
13293 got, exec := pageBinary("term", page)
13294 if got != "term.wasm" {
13295 t.Errorf("/%s got %q, want term.wasm", page, got)
13296 }
13297 if exec != f.WasmExecPathGo {
13298 t.Errorf("/%s paired term.wasm with %q, want the Go shim", page, exec)
13299 }
13300 }
13301}
13302
13303// The tinygo build of a name beats the Go build of that same name, and a
13304// name with neither is skipped rather than named.
13305func TestPageBinaryTinygoAndSkipping(t *testing.T) {
13306 t.Chdir(t.TempDir())
13307 saved := *f
13308 t.Cleanup(func() { *f = saved })
13309 f.UseTinygo = true
13310 f.WasmExecPathGo = "/go.js"
13311 f.WasmExecPathTinyGo = "/tiny.js"
13312
13313 if err := os.WriteFile("term-tiny.wasm", []byte("x"), 0o600); err != nil {
13314 t.Fatal(err)
13315 }
13316 got, exec := pageBinary("term", "tui")
13317 if got != "term-tiny.wasm" || exec != f.WasmExecPathTinyGo {
13318 t.Errorf("got %q with %q, want the tinygo joint build and its shim", got, exec)
13319 }
13320
13321 // Nothing built at all: name the page's own Go build rather than "",
13322 // so a page served during the first compile asks for the file that is
13323 // about to exist.
13324 t.Chdir(t.TempDir())
13325 if got, _ := pageBinary("term", "desk"); got != "desk.wasm" {
13326 t.Errorf("with nothing built got %q, want desk.wasm", got)
13327 }
13328}
13329
13330// The in-tab server has none of the binaries on its own filesystem. It must
13331// still name them: they read through to the origin like every other file it
13332// does not carry. Before this, every page it rendered referenced no drop-in
13333// at all β which is what left the attractor missing from the store inside
13334// the desk's browser window while the same page had it on the origin.
13335func TestPickFromOriginNamesWhatTheOriginServes(t *testing.T) {
13336 noDropins(t) // this case is about the origin, not what the build carried
13337 t.Chdir(t.TempDir()) // deliberately empty: the tab's disk has no wasm
13338 saved := *f
13339 t.Cleanup(func() { *f = saved })
13340 f.UseTinygo = false // the tab has no toolchain and never built anything
13341 f.WasmExecPathGo = "/go.js"
13342 f.WasmExecPathTinyGo = "/tiny.js"
13343
13344 var asked []string
13345 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13346 asked = append(asked, r.Method+" "+r.URL.Path)
13347 switch r.URL.Path {
13348 case "/stl2-tiny.wasm", "/cart.wasm":
13349 w.WriteHeader(http.StatusOK)
13350 default:
13351 w.WriteHeader(http.StatusNotFound)
13352 }
13353 }))
13354 t.Cleanup(srv.Close)
13355
13356 if servingInTab() {
13357 t.Fatal("a fresh process must not think it is in a tab")
13358 }
13359 serveFromOrigin(srv.URL)
13360 t.Cleanup(func() { serveFromOrigin("") })
13361 if !servingInTab() {
13362 t.Fatal("serveFromOrigin did not mark the in-tab role")
13363 }
13364
13365 // A small build at the origin wins even though UseTinygo is off here:
13366 // the flag describes this process's toolchain, not the other end.
13367 got, ok := pickWasmSmall("stl2")
13368 if !ok || got.File != "stl2-tiny.wasm" || got.ExecPath != "/tiny.js" || got.GoGlobal != goGlobalTiny {
13369 t.Errorf("stl2 = %+v, %v; want stl2-tiny.wasm on the tinygo shim", got, ok)
13370 }
13371 // Only a Go build at the origin: take it, with the matching shim.
13372 got, ok = pickWasmSmall("cart")
13373 if !ok || got.File != "cart.wasm" || got.ExecPath != "/go.js" || got.GoGlobal != goGlobalStd {
13374 t.Errorf("cart = %+v, %v; want cart.wasm on the Go shim", got, ok)
13375 }
13376 // Nothing at the origin: name nothing, as a missing file does on a host.
13377 if got, ok = pickWasm("deco"); ok {
13378 t.Errorf("deco = %+v, %v; want no pick", got, ok)
13379 }
13380
13381 // Each name is asked about once and the answer kept.
13382 before := len(asked)
13383 for range 3 {
13384 pickWasmSmall("stl2")
13385 pickWasm("deco")
13386 }
13387 if len(asked) != before {
13388 t.Errorf("origin re-probed: %v", asked[before:])
13389 }
13390 for _, a := range asked {
13391 if !strings.HasPrefix(a, http.MethodHead+" ") {
13392 t.Errorf("probe %q should be a HEAD: a drop-in is up to 22MB", a)
13393 }
13394 }
13395}
13396
13397// wasmPicks gates on WasmSRC, which names the sources this process builds.
13398// In the tab that list is empty β it builds nothing β and the gate dropped
13399// every binary before the pick was reached, which is why naming them from
13400// the origin alone was not enough.
13401func TestWasmPicksIgnoresWasmSRCInTheTab(t *testing.T) {
13402 noDropins(t)
13403 t.Chdir(t.TempDir())
13404 saved := *f
13405 t.Cleanup(func() { *f = saved })
13406 f.WasmSRC = nil // as in the tab: nothing configured to build
13407 f.WasmExecPathTinyGo = "/tiny.js"
13408
13409 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13410 if r.URL.Path == "/cart-tiny.wasm" {
13411 w.WriteHeader(http.StatusOK)
13412 return
13413 }
13414 w.WriteHeader(http.StatusNotFound)
13415 }))
13416 t.Cleanup(srv.Close)
13417
13418 // On a host with nothing configured, nothing is named.
13419 if got := wasmSmallFor("cart"); len(got) != 0 {
13420 t.Errorf("host with empty WasmSRC named %+v, want nothing", got)
13421 }
13422
13423 serveFromOrigin(srv.URL)
13424 t.Cleanup(func() { serveFromOrigin("") })
13425 got := wasmSmallFor("cart", "stl2")
13426 if len(got) != 1 || got[0].File != "cart-tiny.wasm" {
13427 t.Errorf("in-tab picks = %+v, want just cart-tiny.wasm", got)
13428 }
13429}
13430
13431// noDropins makes the test behave as a binary built from a tree that staged
13432// nothing, whatever this one was built from.
13433func noDropins(t *testing.T) {
13434 t.Helper()
13435 saved := lookupDropin
13436 lookupDropin = func(string) ([]byte, bool) { return nil, false }
13437 t.Cleanup(func() { lookupDropin = saved })
13438}
13439
13440// A carried drop-in is answered from the binary; the origin is only asked for
13441// what the build did not stage. The tab pays a round trip per name otherwise,
13442// and pays it on the first render of every page.
13443func TestCarriedDropinBeatsTheOrigin(t *testing.T) {
13444 t.Chdir(t.TempDir())
13445 saved := *f
13446 t.Cleanup(func() { *f = saved })
13447 f.WasmSRC, f.UseTinygo = nil, false
13448 f.WasmExecPathGo, f.WasmExecPathTinyGo = "/go.js", "/tiny.js"
13449
13450 var asked []string
13451 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13452 asked = append(asked, r.URL.Path)
13453 w.WriteHeader(http.StatusOK) // the origin has everything
13454 }))
13455 t.Cleanup(srv.Close)
13456
13457 carried := map[string][]byte{"stl2-tiny.wasm": []byte("staged")}
13458 savedLookup := lookupDropin
13459 lookupDropin = func(file string) ([]byte, bool) { b, ok := carried[file]; return b, ok }
13460 t.Cleanup(func() { lookupDropin = savedLookup })
13461
13462 serveFromOrigin(srv.URL)
13463 t.Cleanup(func() { serveFromOrigin("") })
13464
13465 if got, ok := pickWasmSmall("stl2"); !ok || got.File != "stl2-tiny.wasm" {
13466 t.Errorf("stl2 = %+v %v, want the carried stl2-tiny.wasm", got, ok)
13467 }
13468 if len(asked) != 0 {
13469 t.Errorf("origin asked about a carried drop-in: %v", asked)
13470 }
13471 // Not carried: the origin answers, as before.
13472 if got, ok := pickWasmSmall("cart"); !ok || got.File != "cart-tiny.wasm" {
13473 t.Errorf("cart = %+v %v, want cart-tiny.wasm from the origin", got, ok)
13474 }
13475 if len(asked) == 0 {
13476 t.Error("origin should have been asked about the uncarried drop-in")
13477 }
13478}
13479
13480
13481// ===== cmd/m2/commands/gen.go =====
13482// Package commands cmd/m2/commands/gen.go β the `m2 gen` config template.
13483package commands
13484
13485import (
13486 "fmt"
13487
13488 "github.com/spf13/cobra"
13489)
13490
13491var genCmd = &cobra.Command{
13492 Use: "gen",
13493 Short: "generate conf template",
13494 Long: "generate conf template",
13495 // To the command's own writer, not os.Stdout. They are the same
13496 // thing at a shell prompt and not in a page, where the command runs
13497 // inside a wasm instance whose stdout goes to the JS console β so
13498 // `m2 gen > site.conf` wrote an empty file and `m2 gen | head`
13499 // printed nothing at all.
13500 RunE: func(cmd *cobra.Command, _ []string) error {
13501 _, err := fmt.Fprint(cmd.OutOrStdout(), envfiletemplate)
13502 return err
13503 },
13504}
13505
13506const envfiletemplate = `#########################################################################
13507# M2 CONFIG
13508#
13509# Copy to <yoursite>.conf and run with: MENV=<yoursite>.conf m2 run
13510# This file is sourced by bash; use shell syntax.
13511# Comment a value with # to use the built-in default.
13512#########################################################################
13513
13514### Stripe Configuration ################################################
13515
13516#-- Live and test API keys - REQUIRED for checkout
13517# https://dashboard.stripe.com/apikeys
13518STRIPELIVEPK='pk_live_...'
13519STRIPELIVESK='sk_live_...'
13520STRIPETESTPK='pk_test_...'
13521STRIPETESTSK='sk_test_...'
13522
13523#-- Use the test keys instead of the live keys
13524TESTSTRIPEKEY=true
13525
13526### Product Data ########################################################
13527
13528#-- Products CSV path (see products.example.csv for the schema)
13529PRODUCTSCSV='products.csv'
13530
13531### Site Identity #######################################################
13532
13533#-- Image subdomain, no trailing slash (ex. 'https://img.example.com')
13534# empty = serve images from ./img
13535SITEIMAGESRC=''
13536
13537#-- Orders subdomain, no trailing slash (ex. 'https://pay.example.com')
13538SITEORDERSURL=''
13539
13540#-- Website (Host) Name - domain minus extension (ex. 'example')
13541SITENAME='example'
13542
13543#-- Website Domain Extension (ex. '.com' '.net')
13544SITEEXT='.com'
13545
13546#-- Site Long Name (ex. 'example electronic surplus')
13547SITELONGNAME='example web store'
13548
13549#-- Site Tag Line
13550SITETAGLINE='an example web store'
13551
13552#-- Site Meta Description (SEO)
13553SITEMETA='an example web store selling example things'
13554
13555#-- Telegram contact + channel; username only, no 'https://t.me/'
13556TGCONTACT=''
13557TGCHANNEL=''
13558
13559### Web Server ##########################################################
13560
13561#-- Port to serve http on
13562WEBPORT='9883'
13563
13564
13565### WebAssembly #########################################################
13566
13567#-- Compile wasm with tinygo (smaller output) in addition to go
13568USETINYGO=true
13569
13570#-- wasm source directories, relative paths
13571# 'wasm/cart' powers checkout β keep it. 'wasm/term' is the terminal
13572# storefront at /tui and the desktop at /desk in one binary: the desk
13573# links the storefront anyway, so the two separately were fifty
13574# megabytes of very nearly the same fifty. 'wasm/tui' and 'wasm/desk'
13575# still build one page each if you would rather have them apart.
13576# Additional entries are drop-in wasm apps: any dir under wasm/ with a
13577# main package (a self-contained module with its own vendor/ also
13578# works). Empty () disables all wasm.
13579WASMSRC=('wasm/cart' 'wasm/term')
13580
13581### Receipt Printing (CUPS) #############################################
13582
13583#-- CUPS printer name (default: system default)
13584PRINTERNAME=''
13585
13586#-- CUPS options, comma separated (ex. 'media=Custom.80x200mm,fit-to-page')
13587CUPSOPTIONS=''
13588
13589#-- timeout for lp command
13590LPTIMEOUT='10s'
13591
13592### Terminal UI ##########################################################
13593
13594#-- m2 tui client mode: browse this store over http instead of the
13595# local products csv / img / content files (ex. 'https://example.com')
13596STOREURL=''
13597`
13598
13599
13600// ===== cmd/m2/commands/root.go =====
13601// Package commands cmd/m2/commands/root.go β the m2 CLI.
13602package commands
13603
13604import (
13605 "embed"
13606 "log"
13607
13608 "github.com/spf13/cobra"
13609 "github.com/stripe/stripe-go/v81"
13610
13611 "github.com/0magnet/calvin/clihelp"
13612
13613 "github.com/0magnet/m2/pkg/config"
13614 "github.com/0magnet/m2/pkg/web"
13615)
13616
13617//go:embed *.go
13618var source embed.FS
13619
13620// f aliases the shared configuration, as in pkg/web.
13621var f = &config.F
13622
13623func init() {
13624 stripe.EnableTelemetry = false
13625 RootCmd.CompletionOptions.DisableDefaultCmd = true
13626 RootCmd.AddCommand(
13627 runCmd,
13628 genCmd,
13629 wasmCmd,
13630 tuiCmd,
13631 )
13632 RootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
13633 // The banner keeps the domain, which is what this serves and what the
13634 // binary is short for.
13635 clihelp.Init(RootCmd, "magnetosphere.net", true)
13636
13637 web.RegisterSource("cmd/m2/commands", source)
13638}
13639
13640func init() {
13641 runCmd.Flags().SortFlags = false
13642 config.AddStringFlag([]*cobra.Command{runCmd}, &f.ProductsCSV, "products csv file")
13643 config.AddBoolFlag([]*cobra.Command{runCmd, wasmCmd}, &f.Teststripekey, "use stripe test api keys instead of live key")
13644 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripeliveSK, "stripe live api sk")
13645 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripelivePK, "stripe live api pk")
13646 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestSK, "stripe test api sk")
13647 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestPK, "stripe test api pk")
13648 config.AddIntFlag([]*cobra.Command{runCmd}, &f.WebPort, "port to serve on")
13649 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteimagesrc, "domain for images - leave blank to serve images")
13650 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteordersurl, "domain for orders - leave blank for same domain")
13651 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitename, "site name")
13652 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteext, "site domain extension")
13653 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitelongname, "site long name")
13654 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitetagline, "site tag line")
13655 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitemeta, "site meta")
13656 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgcontact, "telegram contact")
13657 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgchannel, "telegram channel")
13658 config.AddBoolFlag([]*cobra.Command{runCmd}, &f.UseTinygo, "use tinygo instead of go to compile wasm")
13659 config.AddStringSliceFlag([]*cobra.Command{runCmd, wasmCmd}, &f.WasmSRC, "wasm source code files RELATIVE PATHS without '..'")
13660 config.AddStringFlag([]*cobra.Command{runCmd}, &f.PrinterName, "CUPS printer name (default: system default)")
13661 config.AddStringFlag([]*cobra.Command{runCmd}, &f.CupsOptions, "e.g. 'media=Custom.80x200mm,fit-to-page'")
13662 config.AddStringFlag([]*cobra.Command{runCmd}, &f.BrowseSuffix, "browse-origin domain for the desk browser - a DIFFERENT registrable domain from this site")
13663 config.AddStringFlag([]*cobra.Command{runCmd}, &f.BrowseAddr, "listen address for the browse-origin bootstrap")
13664 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyXpub, "account-level skycoin xpub for crypto checkout - NOT the reward account")
13665 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyNodeURL, "skycoin node API root for settlement")
13666 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyRatePair, "exchange pair to price skycoin from, e.g. skycoin_usdt")
13667 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyRateFix, "a fixed USD rate per SKY, instead of an exchange")
13668 config.AddStringFlag([]*cobra.Command{runCmd}, &f.RateMin, "refuse a crypto quote below this USD rate")
13669 config.AddStringFlag([]*cobra.Command{runCmd}, &f.RateMax, "refuse a crypto quote above this USD rate")
13670 config.AddIntFlag([]*cobra.Command{runCmd}, &f.InvoiceMins, "minutes a crypto quote is held before expiring")
13671 config.AddDurationFlag([]*cobra.Command{runCmd}, &f.LpTimeout, "timeout for lp command")
13672 config.AddStringFlag([]*cobra.Command{tuiCmd}, &f.Storeurl, "browse this store over http (ex. 'https://magnetosphere.net') instead of local files")
13673}
13674
13675// RootCmd is the top-level m2 command.
13676var RootCmd = &cobra.Command{
13677 Use: "m2",
13678 Short: "web store server",
13679 Long: "web store server",
13680}
13681
13682// Execute executes the root cli command
13683func Execute() {
13684 if err := RootCmd.Execute(); err != nil {
13685 log.Fatal("Failed to execute command: ", err)
13686 }
13687}
13688
13689
13690// ===== cmd/m2/commands/run.go =====
13691// Package commands cmd/m2/commands/run.go β the `m2 run` web server command.
13692package commands
13693
13694import (
13695 "fmt"
13696 "log"
13697 "os"
13698 "strings"
13699
13700 "github.com/spf13/cobra"
13701 "golang.org/x/text/cases"
13702 "golang.org/x/text/language"
13703
13704 "github.com/0magnet/calvin"
13705 "github.com/0magnet/calvin/clihelp"
13706
13707 "github.com/0magnet/m2/pkg/config"
13708 "github.com/0magnet/m2/pkg/web"
13709)
13710
13711var runCmd = &cobra.Command{
13712 Use: "run",
13713 Short: "run the web application",
13714 // The subcommand carries the same banner as the root, so `m2 run --help`
13715 // is not a plainer screen than `m2 --help`.
13716 Long: clihelp.Banner("magnetosphere.net") + "\n\n" + func() string {
13717 helptext := `Run the web application
13718Generate a config file first
13719
13720Config defaults file may also be specified with:
13721MENV=m2.conf m2 run
13722OR
13723MENV=/path/to/m2.conf m2 run
13724print the MENV file template with:
13725m2 gen`
13726 if config.MENV == "" {
13727 return helptext
13728 }
13729 if _, err := os.Stat(config.MENV); err == nil {
13730 return `Run the web application
13731
13732menv file detected: ` + config.MENV
13733 }
13734 return helptext
13735 }(),
13736 RunE: func(cmd *cobra.Command, _ []string) error {
13737 f.Sitedomain = f.Sitename + f.Siteext
13738 log.Println(" Initializing " + f.Sitedomain)
13739 out := cmd.OutOrStdout()
13740 // A banner that will not print is not a reason to refuse to serve.
13741 fmt.Fprintln(out, calvin.BlackboardBold(f.Sitedomain)) //nolint:errcheck
13742 fmt.Fprintln(out, calvin.AsciiFont(f.Sitedomain)) //nolint:errcheck
13743 config.InitStripe()
13744 f.Siteprettyname = calvin.BlackboardBold(f.Sitedomain) //"ππππππ₯π π€π‘πππ£π.πππ₯"
13745 c := cases.Title(language.English)
13746 f.Siteprettynamecap = calvin.BlackboardBold(c.String(f.Sitedomain)) //"ππππππ₯π π€π‘πππ£π.πππ₯"
13747 f.Siteprettynamecaps = calvin.BlackboardBold(strings.ToUpper(f.Sitedomain)) //"ππΈπΎβπΌπππββπΌβπΌ.βπΌπ"
13748 f.SiteASCIILogo = strings.Replace(strings.Replace(calvin.AsciiFont(f.Sitedomain), " ", " ", -1), "\n", "<br>\n", -1)
13749
13750 if f.UseTinygo {
13751 f.WasmExecPath = f.WasmExecPathTinyGo
13752 f.Buildwasmwith = f.Tinygobuild
13753 }
13754 if len(f.WasmSRC) == 0 {
13755 f.WasmExecPath = ""
13756 f.Buildwasmwith = ""
13757 }
13758 log.Println("Checking for products CSV")
13759 log.Println("Reading products CSV")
13760 if err := web.LoadCatalog(); err != nil {
13761 return fmt.Errorf("loading the catalog: %w", err)
13762 }
13763 go web.WatchCatalog()
13764 web.Serve()
13765 return nil
13766 },
13767}
13768
13769
13770// ===== cmd/m2/commands/tui.go =====
13771// Package commands cmd/m2/commands/tui.go β the `m2 tui` terminal storefront.
13772package commands
13773
13774import (
13775 "errors"
13776 "fmt"
13777
13778 "github.com/spf13/cobra"
13779
13780 "github.com/0magnet/m2/pkg/product"
13781 "github.com/0magnet/m2/pkg/tui"
13782)
13783
13784var tuiCmd = &cobra.Command{
13785 Use: "tui",
13786 Short: "browse the store in the terminal",
13787 Long: `Browse the store in a terminal UI.
13788
13789Reads the same config as 'run':
13790MENV=m2.conf m2 tui
13791
13792As a client of a running store (catalog, images, and checkout over
13793http; no local files needed):
13794m2 tui --storeurl https://magnetosphere.net`,
13795 // RunE with returned errors rather than log.Fatal: this command also
13796 // runs inside a page, and log.Fatal is os.Exit β which there takes down
13797 // the whole wasm instance, the shell it was typed into and every other
13798 // window on the desk with it.
13799 RunE: func(cmd *cobra.Command, _ []string) error {
13800 var prods product.Products
13801 if f.Storeurl != "" {
13802 var err error
13803 prods, err = tui.FetchCatalog(f.Storeurl)
13804 if err != nil {
13805 return fmt.Errorf("could not fetch the catalog from %s: %w", f.Storeurl, err)
13806 }
13807 } else {
13808 if f.ProductsCSV == "" {
13809 f.ProductsCSV = "products.csv"
13810 }
13811 prods = product.ReadCSV(f.ProductsCSV)
13812 }
13813 if len(prods) == 0 {
13814 return errors.New("no products in the catalog")
13815 }
13816 // The context carries how to get a screen where tcell.NewScreen is
13817 // the wrong answer β a browser, where it has to be bound to the
13818 // terminal this was typed into. See tui.WithScreen.
13819 return tui.Run(cmd.Context(), prods)
13820 },
13821}
13822
13823
13824// ===== cmd/m2/commands/wasm.go =====
13825// Package commands cmd/m2/commands/wasm.go β the `m2 wasm` compile command.
13826package commands
13827
13828import (
13829 "errors"
13830
13831 "github.com/spf13/cobra"
13832
13833 "github.com/0magnet/m2/pkg/config"
13834 "github.com/0magnet/m2/pkg/web"
13835)
13836
13837var wasmCmd = &cobra.Command{
13838 Use: "wasm",
13839 Short: "compile wasm",
13840 Long: "compile wasm",
13841 RunE: func(_ *cobra.Command, _ []string) error {
13842 if len(f.WasmSRC) == 0 {
13843 return errors.New("no wasm source code specified")
13844 }
13845 config.InitStripe()
13846 web.CompileWASM()
13847 return nil
13848 },
13849}
13850
13851