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 // PprofAddr serves Go's profiles on a loopback listener of their own;
79 // empty serves none.
80 PprofAddr string
81
82 // Crypto checkout. Empty SkyXpub leaves the whole thing off and the
83 // store card-only, which is what a deployment that has not configured
84 // a wallet should get rather than a button that errors.
85 SkyXpub string // BIP44 ACCOUNT-level xpub, watch-only; must not be the reward account
86 SkyNodeURL string // skycoin node API root; empty means the local node
87 SkyRatePair string // exchange pair for pricing, e.g. "skycoin_usdt"; never a bare ticker
88 SkyRateFix string // a rate the operator sets instead of asking an exchange
89 RateMin string // refuse a quote below this, as a decimal string
90 RateMax string // refuse a quote above this
91 InvoiceMins int // how long a quote is held before it expires
92
93 // Order confirmation email (pkg/web/ordermail.go). Empty MailFrom or
94 // Sendmail sends none.
95 MailFrom string // the From address, e.g. orders@magnetosphere.net
96 MailBcc string // the store's copy of every confirmation
97 Sendmail string // the command each message is piped to, recipient appended, e.g. "/usr/local/bin/mox sendmail"
98}
99
100// F is the live configuration, shared by every package.
101var F = Values{
102 // WasmSRC: []string{"wasm/stl2.go","wasm/checkout_wasm.go"},
103 WasmExecPath: runtime.GOROOT() + "/lib/wasm/wasm_exec.js", //nolint
104 WasmExecPathGo: runtime.GOROOT() + "/lib/wasm/wasm_exec.js", //nolint
105 WasmExecPathTinyGo: strings.TrimSuffix(runtime.GOROOT(), "go") + "tinygo" + "/targets/wasm_exec.js", //nolint
106 Gobuild: "go build",
107 Tinygobuild: "tinygo build -target=wasm --no-debug",
108 Buildwasmwith: "go build",
109 LDFlagsX: "stripePK",
110}
111
112// InitStripe selects the live or test key pair, hands the secret key to the
113// stripe library, and bakes the publishable key into the wasm ldflags.
114func InitStripe() {
115 F.StripeSK = F.StripeliveSK
116 F.StripePK = F.StripelivePK
117 if F.Teststripekey {
118 F.StripeSK = F.StripetestSK
119 F.StripePK = F.StripetestPK
120 }
121 stripe.Key = F.StripeSK
122 // awkward way to do this
123 F.LDFlagsX += "=" + F.StripePK
124}
125
126var (
127 // Hardcoded array of valid shorthand characters, excluding "h"
128 shorthandChars = []rune("abcdefgijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
129 nextShortIndex = 0 // Index for the next shorthand flag
130)
131
132func getNextShortFlag() string {
133 if nextShortIndex >= len(shorthandChars) {
134 return ""
135 }
136 short := shorthandChars[nextShortIndex]
137 nextShortIndex++
138 return string(short)
139}
140
141var a = true
142var b = false
143
144// AddStringFlag registers a string flag bound to a field of F on each command,
145// defaulting to the MENV file's value for the field's uppercase name.
146func AddStringFlag(cmds []*cobra.Command, fieldPtr *string, description string) {
147 for i := range cmds {
148 cmds[i].Flags().StringVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecString(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(s string) string {
149 if s != "" {
150 s = "-" + s
151 }
152 return s
153 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
154 }
155}
156
157// AddStringSliceFlag is AddStringFlag for []string fields (bash arrays).
158func AddStringSliceFlag(cmds []*cobra.Command, fieldPtr *[]string, description string) {
159 for i := range cmds {
160 cmds[i].Flags().StringSliceVarP(
161 fieldPtr,
162 ccc(fieldPtr, &F, b),
163 getNextShortFlag(),
164 ScriptExecStringSlice(fmt.Sprintf("${%s[@]}", ccc(fieldPtr, &F, a))),
165 fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
166 )
167 }
168}
169
170// AddBoolFlag is AddStringFlag for bool fields.
171func AddBoolFlag(cmds []*cobra.Command, fieldPtr *bool, description string) {
172 for i := range cmds {
173 cmds[i].Flags().BoolVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecBool(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(b bool) string {
174 return "-" + strconv.FormatBool(b)
175 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
176 }
177}
178
179// AddIntFlag is AddStringFlag for int fields.
180func AddIntFlag(cmds []*cobra.Command, fieldPtr *int, description string) {
181 for i := range cmds {
182 cmds[i].Flags().IntVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecInt(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(i int) string {
183 return fmt.Sprintf("-%d", i)
184 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
185 }
186}
187
188// AddDurationFlag is AddStringFlag for time.Duration fields.
189func AddDurationFlag(cmds []*cobra.Command, fieldPtr *time.Duration, description string) {
190 for i := range cmds {
191 // Keep parity with the pattern of embedding a "-" when a non-zero default is present.
192 def := ScriptExecDuration(fmt.Sprintf("${%s%s}",
193 ccc(fieldPtr, &F, a),
194 func(d time.Duration) string {
195 if d != 0 {
196 return "-" + d.String() // e.g. "-5s"
197 }
198 return ""
199 }(*fieldPtr),
200 ))
201 cmds[i].Flags().DurationVarP(
202 fieldPtr,
203 ccc(fieldPtr, &F, b),
204 getNextShortFlag(),
205 def,
206 fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
207 )
208 }
209}
210
211// ccc finds the name of the struct field val points into, upper or lower case.
212func ccc(val interface{}, strct interface{}, upper bool) string {
213 v := reflect.ValueOf(strct)
214 if v.Kind() == reflect.Ptr {
215 v = v.Elem()
216 }
217 if v.Kind() != reflect.Struct {
218 panic("uc: second argument must be a pointer to a struct")
219 }
220 for i := 0; i < v.NumField(); i++ {
221 field := v.Field(i)
222 if field.CanAddr() && field.Addr().Interface() == val {
223 if upper {
224 return strings.ToUpper(v.Type().Field(i).Name)
225 }
226 return strings.ToLower(v.Type().Field(i).Name)
227 }
228 }
229 return ""
230}
231
232// ScriptExecString evaluates a bash expression with the MENV file sourced.
233func ScriptExecString(s string) string {
234 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
235 if err == nil {
236 return strings.TrimSpace(z)
237 }
238 return ""
239}
240
241// ScriptExecStringSlice evaluates a bash array expression with the MENV file
242// sourced. Empty entries are dropped: an empty or unset array still prints
243// one empty line, which would otherwise come back as [""] and defeat every
244// `len == 0` check downstream (e.g. WASMSRC=() disabling wasm).
245func ScriptExecStringSlice(s string) []string {
246 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()
247 if err != nil {
248 return nil
249 }
250 out := z[:0]
251 for _, e := range z {
252 if strings.TrimSpace(e) != "" {
253 out = append(out, e)
254 }
255 }
256 return out
257}
258
259// ScriptExecBool evaluates a bash expression as a bool with the MENV file sourced.
260func ScriptExecBool(s string) bool {
261 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
262 if err == nil {
263 b, err := strconv.ParseBool(z)
264 if err == nil {
265 return b
266 }
267 }
268 return false
269}
270
271// ScriptExecInt evaluates a bash expression as an int with the MENV file sourced.
272func ScriptExecInt(s string) int {
273 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
274 if err == nil {
275 if z == "" {
276 return 0
277 }
278 i, err := strconv.Atoi(z)
279 if err == nil {
280 return i
281 }
282 }
283 return 0
284}
285
286// ScriptExecDuration evaluates a bash expression as a duration with the MENV
287// file sourced. Accepts Go duration strings ("750ms", "2s", "5m", "1h") and
288// bare integers (treated as seconds).
289func ScriptExecDuration(s string) time.Duration {
290 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
291 if err != nil {
292 return 0
293 }
294 z = strings.TrimSpace(z)
295 if z == "" {
296 return 0
297 }
298 z = strings.TrimPrefix(z, "-") // keep parity with how defaults are built
299
300 // Try full Go duration syntax first.
301 if d, err := time.ParseDuration(z); err == nil {
302 return d
303 }
304 // Fallback: plain integer means seconds.
305 if n, err := strconv.ParseInt(z, 10, 64); err == nil {
306 return time.Duration(n) * time.Second
307 }
308 return 0
309}
310
311
312// ===== pkg/product/csv.go =====
313// Package product pkg/product/csv.go β catalog CSV loading.
314package product
315
316import (
317 "bufio"
318 "bytes"
319 "embed"
320 "fmt"
321 "log"
322 "strings"
323
324 "github.com/bitfield/script"
325)
326
327//go:embed *.go
328var Source embed.FS
329
330func readproductscsv(csvFile string) (data []byte) {
331 data, err := script.File(csvFile).Bytes() //nolint
332 if err != nil {
333 log.Printf(`Error reading %s file %v`, csvFile, err)
334 }
335 return data
336}
337
338const csvMinFields = 51 // f[0] through f[50]
339
340// ReadCSV reads the catalog from a file. The parsing is in ParseCSV so that
341// it can be tested without one.
342func ReadCSV(csvFile string) Products {
343 return ParseCSV(readproductscsv(csvFile))
344}
345
346// ParseCSV turns the catalog bytes into products, skipping rows that are not
347// enabled and rows too short to fill one.
348func ParseCSV(data []byte) (prods Products) {
349 scanner := bufio.NewScanner(bytes.NewReader(data))
350 lineNum := 0
351 for scanner.Scan() {
352 lineNum++
353 line := scanner.Text()
354 f := strings.Split(line, ",")
355 if len(f) < 4 {
356 continue
357 }
358 if f[3] != "TRUE" {
359 continue
360 }
361 if len(f) < csvMinFields {
362 log.Printf("csv line %d: expected %d fields, got %d β skipping", lineNum, csvMinFields, len(f))
363 continue
364 }
365 q := Product{
366 Image1: f[0],
367 Partno: f[1],
368 Name: f[2],
369 Enable: f[3],
370 Price: f[4],
371 Quantity: f[5],
372 Shippable: f[6],
373 Minorder: f[7],
374 Maxorder: f[8],
375 Defaultquantity: f[9],
376 Stepquantity: f[10],
377 Mfgpartno: f[11],
378 Mfgname: f[12],
379 Category: f[13],
380 Subcategory: f[14],
381 Location: f[15],
382 Msrp: f[16],
383 Cost: f[17],
384 Typ: f[18],
385 Packagetype: f[19],
386 Technology: f[20],
387 Materials: f[21],
388 Value: f[22],
389 ValUnit: f[23],
390 Resistance: f[24],
391 ResUnit: f[25],
392 Tolerance: f[26],
393 VoltsRating: f[27],
394 AmpsRating: f[28],
395 WattsRating: f[29],
396 TempRating: f[30],
397 TempUnit: f[31],
398 Description1: f[32],
399 Description2: f[33],
400 Color1: f[34],
401 Color2: f[35],
402 Sourceinfo: f[36],
403 Datasheet: f[37],
404 Docs: f[38],
405 Reference: f[39],
406 Attributes: f[40],
407 Year: f[41],
408 Condition: f[42],
409 Note: f[43],
410 Warning: f[44],
411 CableLengthInches: f[45],
412 LengthInches: f[46],
413 WidthInches: f[47],
414 HeightInches: f[48],
415 WeightLb: f[49],
416 WeightOz: f[50],
417 }
418 prods = append(prods, q)
419 }
420 return prods
421}
422
423// ValidateCSV scans products for patterns that could cause issues in HTML/JS rendering.
424// Returns a list of warnings. Call after ReadCSV to check data integrity.
425func ValidateCSV(prods Products) []string {
426 var warnings []string
427 for i, pr := range prods {
428 check := func(field, value string) {
429 if strings.ContainsAny(value, "<>\"'&") {
430 warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains HTML-unsafe characters: %q", i, pr.Partno, field, value))
431 }
432 if strings.Contains(value, "|") {
433 warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains pipe character: %q", i, pr.Partno, field, value))
434 }
435 }
436 check("Partno", pr.Partno)
437 check("Name", pr.Name)
438 check("Description1", pr.Description1)
439 check("Description2", pr.Description2)
440 check("Note", pr.Note)
441 check("Warning", pr.Warning)
442 check("Category", pr.Category)
443 check("Subcategory", pr.Subcategory)
444 check("Mfgname", pr.Mfgname)
445 check("Mfgpartno", pr.Mfgpartno)
446 }
447 return warnings
448}
449
450
451// ===== pkg/product/csv_test.go =====
452package product
453
454import (
455 "strings"
456 "testing"
457)
458
459// row builds a catalog line with the fields the parser needs, so a test can
460// set the few it cares about without writing fifty-one commas.
461func row(set map[int]string) string {
462 f := make([]string, csvMinFields)
463 f[0] = "img.jpg"
464 f[1] = "PN-1"
465 f[2] = "A Part"
466 f[3] = "TRUE"
467 f[4] = "1.50"
468 f[5] = "10"
469 for i, v := range set {
470 f[i] = v
471 }
472 return strings.Join(f, ",")
473}
474
475func TestParseCSVReadsAProduct(t *testing.T) {
476 prods := ParseCSV([]byte(row(nil)))
477 if len(prods) != 1 {
478 t.Fatalf("got %d products, want 1", len(prods))
479 }
480 got := prods[0]
481 if got.Partno != "PN-1" || got.Name != "A Part" || got.Price != "1.50" || got.Quantity != "10" {
482 t.Errorf("fields did not land where they should: %+v", got)
483 }
484}
485
486// The last field is the fiftieth, and off-by-one at the end of a fifty-one
487// column row is invisible in a spreadsheet.
488func TestParseCSVReadsTheLastField(t *testing.T) {
489 prods := ParseCSV([]byte(row(map[int]string{50: "3.25", 49: "1"})))
490 if len(prods) != 1 {
491 t.Fatalf("got %d products, want 1", len(prods))
492 }
493 if prods[0].WeightOz != "3.25" {
494 t.Errorf("WeightOz = %q, want the last column", prods[0].WeightOz)
495 }
496 if prods[0].WeightLb != "1" {
497 t.Errorf("WeightLb = %q, want the second to last column", prods[0].WeightLb)
498 }
499}
500
501// Only enabled rows are sold. A row that says anything but TRUE is a product
502// deliberately withdrawn, and showing it anyway sells something not in stock.
503func TestParseCSVSkipsRowsThatAreNotEnabled(t *testing.T) {
504 for _, enable := range []string{"FALSE", "false", "true", "", "1", "yes"} {
505 prods := ParseCSV([]byte(row(map[int]string{3: enable})))
506 if len(prods) != 0 {
507 t.Errorf("Enable=%q produced a product; only TRUE should", enable)
508 }
509 }
510}
511
512// A short row cannot fill a product, and reading one anyway would panic on a
513// live catalog rather than skip a line.
514func TestParseCSVSkipsShortRows(t *testing.T) {
515 short := strings.Join([]string{"img.jpg", "PN-1", "A Part", "TRUE", "1.50"}, ",")
516 if prods := ParseCSV([]byte(short)); len(prods) != 0 {
517 t.Errorf("a row with 5 fields produced %d products", len(prods))
518 }
519 // Exactly one short of the minimum is the boundary worth pinning.
520 f := make([]string, csvMinFields-1)
521 for i := range f {
522 f[i] = "x"
523 }
524 f[3] = "TRUE"
525 if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 0 {
526 t.Errorf("a row one field short produced %d products", len(prods))
527 }
528}
529
530func TestParseCSVSkipsVeryShortAndBlankRows(t *testing.T) {
531 for _, line := range []string{"", ",", "a,b,c", "\n\n"} {
532 if prods := ParseCSV([]byte(line)); len(prods) != 0 {
533 t.Errorf("%q produced %d products", line, len(prods))
534 }
535 }
536}
537
538func TestParseCSVReadsSeveralRows(t *testing.T) {
539 data := strings.Join([]string{
540 row(map[int]string{1: "AAA"}),
541 row(map[int]string{3: "FALSE", 1: "SKIPPED"}),
542 row(map[int]string{1: "BBB"}),
543 }, "\n")
544 prods := ParseCSV([]byte(data))
545 if len(prods) != 2 {
546 t.Fatalf("got %d products, want 2", len(prods))
547 }
548 if prods[0].Partno != "AAA" || prods[1].Partno != "BBB" {
549 t.Errorf("got %q and %q, want AAA and BBB", prods[0].Partno, prods[1].Partno)
550 }
551}
552
553// The catalog is split on commas rather than parsed as CSV, so a quoted
554// field containing one shifts every column after it β including Enable, which
555// is column 3. The row then does not say TRUE where the parser looks, and the
556// product is dropped from the store without a word.
557//
558// A spreadsheet writes that quoting itself the moment a name contains a comma,
559// so this is reachable from ordinary editing rather than from bad data.
560//
561// This test records what the parser does today. If it is ever changed to use
562// encoding/csv, this test is the one that should fail.
563func TestParseCSVSilentlyDropsRowsWithAQuotedComma(t *testing.T) {
564 f := make([]string, csvMinFields)
565 for i := range f {
566 f[i] = "x"
567 }
568 f[1] = "PN-1"
569 f[2] = `"Resistor, 10k"` // one field in a spreadsheet, two after a split
570 f[3] = "TRUE"
571 f[4] = "1.50"
572 line := strings.Join(f, ",")
573
574 // The row is long enough β it is the shift that loses it, not the length.
575 if n := len(strings.Split(line, ",")); n <= csvMinFields {
576 t.Fatalf("the fixture has %d fields; it needs more than %d to isolate the shift", n, csvMinFields)
577 }
578
579 prods := ParseCSV([]byte(line))
580 if len(prods) != 0 {
581 t.Fatalf("the parser now keeps rows with a quoted comma (%d products); "+
582 "replace this test with one asserting the fields are correct", len(prods))
583 }
584
585 // Without the comma the same row is read, which is what shows the comma is
586 // the cause rather than anything else in the fixture.
587 f[2] = "Resistor 10k"
588 if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 1 {
589 t.Errorf("the same row without the comma gave %d products, want 1", len(prods))
590 }
591}
592
593func TestValidateCSVAcceptsCleanData(t *testing.T) {
594 prods := ParseCSV([]byte(row(nil)))
595 if w := ValidateCSV(prods); len(w) != 0 {
596 t.Errorf("clean data produced warnings: %v", w)
597 }
598}
599
600// These characters are what turn a product name into markup on the page.
601func TestValidateCSVFlagsCharactersThatBreakThePage(t *testing.T) {
602 for _, bad := range []string{`<script>`, `a"b`, "a'b", "a&b", "a>b"} {
603 prods := ParseCSV([]byte(row(map[int]string{2: bad})))
604 if len(prods) != 1 {
605 t.Fatalf("%q: got %d products", bad, len(prods))
606 }
607 w := ValidateCSV(prods)
608 if len(w) == 0 {
609 t.Errorf("%q in a product name was not flagged", bad)
610 continue
611 }
612 if !strings.Contains(w[0], "Name") {
613 t.Errorf("%q: the warning does not name the field: %s", bad, w[0])
614 }
615 }
616}
617
618// The pipe is the field separator in the cart's stored format, so a pipe in a
619// product name corrupts the cart rather than the page.
620func TestValidateCSVFlagsPipes(t *testing.T) {
621 prods := ParseCSV([]byte(row(map[int]string{2: "A|B"})))
622 w := ValidateCSV(prods)
623 if len(w) == 0 {
624 t.Fatal("a pipe in a product name was not flagged")
625 }
626 if !strings.Contains(w[0], "pipe") {
627 t.Errorf("the warning does not mention the pipe: %s", w[0])
628 }
629}
630
631// Every field that reaches the page is checked, not just the name.
632func TestValidateCSVChecksEveryRenderedField(t *testing.T) {
633 fields := map[int]string{
634 1: "PN<", // Partno
635 2: "N<", // Name
636 12: "MFG<", // Mfgname
637 11: "MP<", // Mfgpartno
638 13: "CAT<", // Category
639 14: "SUB<", // Subcategory
640 32: "D1<", // Description1
641 33: "D2<", // Description2
642 43: "NT<", // Note
643 44: "WN<", // Warning
644 }
645 for col, val := range fields {
646 prods := ParseCSV([]byte(row(map[int]string{3: "TRUE", col: val})))
647 if len(prods) != 1 {
648 t.Fatalf("column %d: got %d products", col, len(prods))
649 }
650 if w := ValidateCSV(prods); len(w) == 0 {
651 t.Errorf("an unsafe character in column %d was not flagged", col)
652 }
653 }
654}
655
656func TestValidateCSVOnNoProducts(t *testing.T) {
657 if w := ValidateCSV(nil); len(w) != 0 {
658 t.Errorf("an empty catalog produced warnings: %v", w)
659 }
660}
661
662// The warning names the row so it can be found in a spreadsheet of thousands.
663func TestValidateCSVNamesTheProduct(t *testing.T) {
664 prods := ParseCSV([]byte(row(map[int]string{1: "PN-SPECIAL", 2: "bad<"})))
665 w := ValidateCSV(prods)
666 if len(w) == 0 {
667 t.Fatal("no warning")
668 }
669 if !strings.Contains(w[0], "PN-SPECIAL") {
670 t.Errorf("the warning does not name the part: %s", w[0])
671 }
672}
673
674
675// ===== pkg/product/product.go =====
676// Package product pkg/product/product.go
677package product
678
679type Product struct {
680 Enable string
681 Partno string
682 Name string
683 Image1 string
684 Price string
685 Quantity string
686 Shippable string
687 Minorder string
688 Maxorder string
689 Defaultquantity string
690 Stepquantity string
691 Mfgpartno string
692 Mfgname string
693 Category string
694 Subcategory string
695 Location string
696 Msrp string
697 Cost string
698 Typ string
699 Packagetype string
700 Technology string
701 Materials string
702 Value string
703 ValUnit string
704 Resistance string
705 ResUnit string
706 Tolerance string
707 VoltsRating string
708 AmpsRating string
709 WattsRating string
710 TempRating string
711 TempUnit string
712 Description1 string
713 Description2 string
714 Color1 string
715 Color2 string
716 Sourceinfo string
717 Datasheet string
718 Docs string
719 Reference string
720 Attributes string
721 Year string
722 Condition string
723 Note string
724 Warning string
725 CableLengthInches string
726 LengthInches string
727 WidthInches string
728 HeightInches string
729 WeightLb string
730 WeightOz string
731}
732
733// Products is an array of Product
734type Products []Product
735
736
737// ===== pkg/tui/address.go =====
738package tui
739
740import (
741 "strings"
742
743 "github.com/0magnet/websh/progressive"
744)
745
746// The page's address (websh's PROTOCOL.md, The page): where the host lets
747// it, the store tells it which page is showing, as the site's own path β
748// /p/<part number>, /cat/<category> β so the place can be linked to, and a
749// link opens the store there again. Elsewhere nothing is sent.
750
751// StartPath is the site path to open at, instead of the home page: the
752// path a link handed back (progressive.Caps.LinkPath).
753var StartPath string
754
755// announcePage tells the host the page now showing, when it changed: its
756// path for the page's address, and its mirror (mirror.go).
757func (u *ui) announcePage() {
758 c := progressive.Current()
759 if !c.Has("page") && !c.Has("mirror") {
760 return
761 }
762 p := u.pagePath()
763 if p == u.announced {
764 return
765 }
766 u.announced = p
767 if c.Has("mirror") {
768 for _, seq := range progressive.Mirror([]byte(u.mirrorHTML())) {
769 u.writeTerm(seq)
770 }
771 }
772 if !c.Has("page") {
773 return
774 }
775 title := u.sitedomain
776 if part, ok := strings.CutPrefix(p, "/p/"); ok {
777 if prod := u.cat.find(part); prod != nil && prod.Name != "" {
778 title = prod.Name + " β " + u.sitedomain
779 }
780 }
781 u.writeTerm(progressive.Page(p, title))
782}
783
784// pageFromPath is pagePath backwards: the page a site path names, or "" for
785// none this store has.
786func (u *ui) pageFromPath(p string) string {
787 switch {
788 case p == "":
789 return ""
790 case p == "/":
791 return "home"
792 case strings.HasPrefix(p, "/#"):
793 return "content:" + strings.TrimPrefix(p, "/#")
794 case strings.HasPrefix(p, "/p/"):
795 part := strings.TrimPrefix(p, "/p/")
796 if u.cat.find(part) == nil {
797 return ""
798 }
799 return "prod:" + part
800 case p == "/cat" || strings.HasPrefix(p, "/cat/"):
801 // As the site reads it: case aside, and a subcategory by either
802 // spelling.
803 if t, ok := u.cat.resolve(strings.TrimPrefix(p, "/cat")); ok {
804 return listPageID(t)
805 }
806 }
807 return ""
808}
809
810
811// ===== pkg/tui/address_test.go =====
812package tui
813
814import (
815 "strings"
816 "testing"
817
818 "github.com/0magnet/m2/pkg/product"
819)
820
821// TestAddressAndMirror: a site path names the page the store would show at
822// it, as the site reads it, and the mirror of each page is its structure,
823// escaped, with links to the site.
824func TestAddressAndMirror(t *testing.T) {
825 old := f.Storeurl
826 f.Storeurl = "https://shop.example"
827 defer func() { f.Storeurl = old }()
828 u := &ui{cat: newCatalogInfo(product.Products{
829 {Partno: "qjack", Name: "mono <jack>", Price: "1.25", Quantity: "50", Category: "audio", Subcategory: "jack"},
830 {Partno: "r10k", Name: "10k resistor", Price: "0.05", Category: "resistor"},
831 }), sitedomain: "shop.example"}
832
833 for path, want := range map[string]string{
834 "/": "home",
835 "/p/qjack": "prod:qjack",
836 "/p/nosuch": "",
837 "/cat": "list:",
838 "/cat/Audio": "list:audio",
839 "/cat/audio/jack": "list:audio|jack",
840 "/cat/nosuch": "",
841 "/elsewhere": "",
842 } {
843 if got := u.pageFromPath(path); got != want {
844 t.Errorf("%s: %q, want %q", path, got, want)
845 }
846 }
847
848 u.current = "prod:qjack"
849 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"`) {
850 t.Errorf("product mirror: %s", m)
851 }
852 u.current = "list:audio"
853 if m := u.mirrorHTML(); !strings.Contains(m, "<h1>audio</h1><p>1 products.</p>") || !strings.Contains(m, "mono <jack></a> β $1.25") {
854 t.Errorf("list mirror: %s", m)
855 }
856 u.current = "home"
857 if m := u.mirrorHTML(); !strings.Contains(m, `<a href="https://shop.example/cat/resistor">resistor</a> (1)`) {
858 t.Errorf("home mirror: %s", m)
859 }
860}
861
862
863// ===== pkg/tui/border.go =====
864// Package tui pkg/tui/border.go β the pisano frame around the product
865// table.
866//
867// The same drawing the website puts around its category tables, from the
868// same library: an integer sequence reduced modulo m, walked as a turtle
869// and rendered in box-drawing characters. On the page that is the wasm/deco
870// drop-in decorating the DOM after the fact; here there is no DOM to
871// decorate, so the frame is composed into the cells the table is drawn in.
872//
873// The figures are wasm/deco's, deliberately β one frame seen in two places
874// is the point, and a terminal that invented its own would just be a second
875// drawing to keep in step. What differs is the fit. The page knows the table
876// and grows a frame around it (FitAround); a terminal knows the area and has
877// to put both inside it (FitBox), because there is nowhere for a border to
878// overflow to.
879package tui
880
881import (
882 "strconv"
883 "sync"
884
885 "github.com/gdamore/tcell/v3"
886
887 "github.com/0magnet/pisano/pkg/border"
888 "github.com/0magnet/pisano/pkg/pisano"
889)
890
891// borderArt is a composed frame: the glyphs, the color of each, and the
892// clear middle the table goes in.
893type borderArt struct {
894 g border.Grid
895 tint border.Tint
896 in border.Rect
897}
898
899// recipe is one frame's three figures: the run along the top and bottom,
900// the run down the sides, and the closed figure at the corners.
901type recipe struct{ across, down, corner border.Figure }
902
903var (
904 recipesOnce sync.Once
905 borderSet []recipe
906)
907
908// recipes are wasm/deco's, and the reasoning is its: modulus 13 is both
909// runs β it travels +0,-4, which makes a side, and a quarter turn makes a
910// top square to it by construction rather than by search. Modulus 31 at
911// four passes is the corner, closed and with the full symmetry of the
912// square; an open figure would have loose ends of its own just where a run
913// is trying to stop. Modulus 8 is the second rung, for an area that cannot
914// spare the first's reach β a smaller figure beats no figure.
915//
916// The order is the preference, fullest corner first, and fitBorder keeps
917// it as long as the content can afford it.
918func recipes() []recipe {
919 recipesOnce.Do(func() {
920 side, ok := border.OfPasses(13, 1)
921 if !ok {
922 return
923 }
924 top := side.RotateCW()
925 for _, c := range [][2]int{{31, 4}, {8, 2}} {
926 corner, ok := border.OfPasses(c[0], c[1])
927 if !ok || !corner.Closed {
928 continue
929 }
930 borderSet = append(borderSet, recipe{across: top, down: side, corner: corner})
931 }
932 })
933 return borderSet
934}
935
936// borderPalette is pisano's six pass colors, which is what its own turtle
937// tints with β so the frame here and the frame on the page are the same
938// drawing in the same colors.
939var borderPalette = func() []tcell.Style {
940 hex := pisano.PassHex()
941 out := make([]tcell.Style, len(hex))
942 for i, h := range hex {
943 out[i] = styleText.Foreground(tcell.GetColor(h))
944 }
945 return out
946}()
947
948// frameCache keeps one frame per area size.
949//
950// Composing one is not free β it lays every copy of both runs and all four
951// corners onto a grid, cuts the runs where their own wave crosses, then
952// walks the result to check it is a single closed line. A draw happens on
953// every event the UI handles, pointer motion included, so composing per
954// draw would be the most expensive thing the program does. A size that
955// fits nothing is cached as a miss, so it is not retried either.
956var (
957 frameMu sync.Mutex
958 frameCache = map[string]*borderArt{}
959)
960
961// minFramed is the smallest clear middle worth taking an area down to. A
962// frame that fits but leaves room for a header and one row has spent the
963// table to draw a picture of one.
964const minFramed = 6
965
966// roomyRows and roomyCols are what a middle has to keep for the fuller
967// corner to be worth its longer reach: a table still worth reading β its
968// header and a dozen-odd products, at a width that does not start cutting
969// names β rather than merely a table.
970//
971// Below them the corner is competing with the content for the same cells
972// and the content wins, which is the only reason to draw a smaller figure
973// at all.
974const (
975 roomyRows = 16
976 roomyCols = 40
977)
978
979// fitBorder composes the largest frame that fits in a cols by rows area,
980// or reports none. The area is what the caller has; the frame is drawn
981// inside it and the table goes in what is left.
982func fitBorder(cols, rows int) *borderArt {
983 if cols < 8 || rows < 8 {
984 return nil
985 }
986 key := strconv.Itoa(cols) + "x" + strconv.Itoa(rows)
987 frameMu.Lock()
988 defer frameMu.Unlock()
989 if hit, seen := frameCache[key]; seen {
990 return hit
991 }
992 // The fullest corner that still leaves a table worth reading, and the
993 // roomiest otherwise.
994 //
995 // wasm/deco takes the first that fits, largest corner first, and can:
996 // it grows a frame AROUND its table, so a bigger corner only makes a
997 // bigger frame and costs the table nothing. Here the frame is cut OUT
998 // of a fixed area and every cell of corner is a row of products not
999 // shown, so the order alone is not an answer β but neither is simply
1000 // taking the roomiest, which spends the good corner to buy four rows
1001 // on a screen that had forty to spare.
1002 //
1003 // So: the same order deco uses, and the same preference, held only as
1004 // long as the content is not the thing running out. Under that, the
1005 // figure gives way rather than the table.
1006 var got, roomiest *borderArt
1007 var bestRoom int
1008 for _, r := range recipes() {
1009 l, g, ok := border.FitBox(r.across, r.down, r.corner, cols, rows)
1010 if !ok {
1011 continue
1012 }
1013 in := g.Inside()
1014 w, h := in.X1-in.X0, in.Y1-in.Y0
1015 if w < minFramed || h < minFramed {
1016 continue
1017 }
1018 art := &borderArt{g: g, tint: l.TintByCopy(g, len(borderPalette)), in: in}
1019 if h >= roomyRows && w >= roomyCols {
1020 // Room to spare, and this is the fullest corner left to try.
1021 // Nothing after it is better, and composing it would be work
1022 // for a frame this one has already beaten.
1023 got = art
1024 break
1025 }
1026 // Rows before columns: a terminal runs out of height first, and a
1027 // row lost is a product off the screen where a column lost is a
1028 // name a little shorter.
1029 if score := h<<16 | w; score > bestRoom {
1030 bestRoom, roomiest = score, art
1031 }
1032 }
1033 if got == nil {
1034 got = roomiest
1035 }
1036 frameCache[key] = got
1037 return got
1038}
1039
1040// place centers the frame in the area it was fitted to and reports the
1041// rectangle its clear middle occupies on screen β where the table goes.
1042//
1043// Centered because a frame is quantised by its figures and is never
1044// exactly the size asked for; hanging it from the top left would leave the
1045// slack in one corner.
1046func (b *borderArt) place(area rect) (origin, inner rect) {
1047 ox := area.x + (area.w-b.g.W())/2
1048 oy := area.y + (area.h-b.g.H())/2
1049 origin = rect{ox, oy, b.g.W(), b.g.H()}
1050 inner = rect{ox + b.in.X0, oy + b.in.Y0, b.in.X1 - b.in.X0, b.in.Y1 - b.in.Y0}
1051 return origin, inner
1052}
1053
1054// draw paints the frame. Blanks are skipped rather than drawn as spaces,
1055// so the frame never erases anything it overlaps β it is a decoration, and
1056// the page's is drawn under its table for the same reason.
1057func (b *borderArt) draw(sc tcell.Screen, origin rect) {
1058 for y, row := range b.g {
1059 for x, ch := range row {
1060 if ch == border.Blank {
1061 continue
1062 }
1063 st := styleText
1064 if y < len(b.tint) && x < len(b.tint[y]) {
1065 if i := b.tint[y][x]; i >= 0 {
1066 st = borderPalette[i%len(borderPalette)]
1067 }
1068 }
1069 sc.SetContent(origin.x+x, origin.y+y, ch, nil, st)
1070 }
1071 }
1072}
1073
1074// framed splits an area into a frame and the cells inside it. An area that
1075// fits no frame is handed back whole, which is what the page does too: a
1076// box with no border is the answer, not a box with a broken one.
1077func framed(area rect) (inner rect, b *borderArt, origin rect) {
1078 b = fitBorder(area.w, area.h)
1079 if b == nil {
1080 return area, nil, rect{}
1081 }
1082 origin, inner = b.place(area)
1083 return inner, b, origin
1084}
1085
1086
1087// ===== pkg/tui/border_test.go =====
1088package tui
1089
1090import (
1091 "testing"
1092
1093 "github.com/0magnet/pisano/pkg/border"
1094)
1095
1096// A frame has to fit in the area it was asked for. It is cut out of the
1097// table's own space, so one cell over means a border drawn across the
1098// heading or the footer.
1099func TestBorderFitsTheAreaItIsGiven(t *testing.T) {
1100 for _, sz := range [][2]int{{160, 60}, {120, 40}, {80, 30}, {60, 24}} {
1101 b := fitBorder(sz[0], sz[1])
1102 if b == nil {
1103 continue // nothing fits here, which is an answer
1104 }
1105 if b.g.W() > sz[0] || b.g.H() > sz[1] {
1106 t.Errorf("%dx%d: frame is %dx%d, larger than its area", sz[0], sz[1], b.g.W(), b.g.H())
1107 }
1108 w, h := b.in.X1-b.in.X0, b.in.Y1-b.in.Y0
1109 if w < minFramed || h < minFramed {
1110 t.Errorf("%dx%d: middle is %dx%d, under the minimum", sz[0], sz[1], w, h)
1111 }
1112 }
1113}
1114
1115// The clear middle is where the table is drawn, so nothing may be inked
1116// inside it β a glyph there would be a box character among the products.
1117func TestBorderMiddleIsClear(t *testing.T) {
1118 b := fitBorder(160, 60)
1119 if b == nil {
1120 t.Fatal("no frame at 160x60, which is the size this page runs at")
1121 }
1122 for y := b.in.Y0; y < b.in.Y1; y++ {
1123 for x := b.in.X0; x < b.in.X1; x++ {
1124 if ch := b.g[y][x]; ch != border.Blank {
1125 t.Fatalf("%q at %d,%d is inside the clear middle", ch, x, y)
1126 }
1127 }
1128 }
1129}
1130
1131// place puts the middle on screen where the table will be drawn, and the
1132// frame around it β both inside the area.
1133func TestBorderPlacesItsMiddleInsideTheArea(t *testing.T) {
1134 area := rect{x: 3, y: 9, w: 160, h: 60}
1135 b := fitBorder(area.w, area.h)
1136 if b == nil {
1137 t.Fatal("no frame")
1138 }
1139 origin, inner := b.place(area)
1140 if origin.x < area.x || origin.y < area.y ||
1141 origin.x+origin.w > area.x+area.w || origin.y+origin.h > area.y+area.h {
1142 t.Errorf("frame at %+v escapes the area %+v", origin, area)
1143 }
1144 if inner.x < origin.x || inner.y < origin.y ||
1145 inner.x+inner.w > origin.x+origin.w || inner.y+inner.h > origin.y+origin.h {
1146 t.Errorf("middle at %+v escapes the frame %+v", inner, origin)
1147 }
1148 if inner.w >= area.w || inner.h >= area.h {
1149 t.Errorf("middle %dx%d is not smaller than the area %dx%d", inner.w, inner.h, area.w, area.h)
1150 }
1151}
1152
1153// cornerReach is how far a frame's corner comes in from its own edge,
1154// which is what tells the two figures apart: the fuller corner reaches
1155// further, and that reach is its whole cost.
1156func cornerReach(b *borderArt) int { return b.in.Y0 }
1157
1158// With room to spare the frame uses the fuller corner, as the page does.
1159func TestBorderKeepsTheFullCornerWhenThereIsRoom(t *testing.T) {
1160 roomy := fitBorder(160, 60)
1161 tight := fitBorder(160, 30)
1162 if roomy == nil || tight == nil {
1163 t.Fatal("expected a frame at both sizes")
1164 }
1165 if cornerReach(roomy) <= cornerReach(tight) {
1166 t.Errorf("the roomy area's corner reaches %d, no further than the tight one's %d",
1167 cornerReach(roomy), cornerReach(tight))
1168 }
1169 if h := roomy.in.Y1 - roomy.in.Y0; h < roomyRows {
1170 t.Errorf("the full corner was kept on a middle of only %d rows", h)
1171 }
1172}
1173
1174// When it is the content running out, the figure gives way rather than
1175// the table: the frame chosen leaves more rows than the fuller corner
1176// would have.
1177func TestBorderGivesUpTheCornerRatherThanTheTable(t *testing.T) {
1178 const cols, rows = 160, 30
1179 got := fitBorder(cols, rows)
1180 if got == nil {
1181 t.Fatal("no frame at 160x30")
1182 }
1183 full := recipes()[0]
1184 _, g, ok := border.FitBox(full.across, full.down, full.corner, cols, rows)
1185 if !ok {
1186 t.Skip("the fuller corner does not fit here at all, which is a different path")
1187 }
1188 in := g.Inside()
1189 if want := in.Y1 - in.Y0; got.in.Y1-got.in.Y0 <= want {
1190 t.Errorf("gave up the corner for %d rows, no better than the %d it would have left",
1191 got.in.Y1-got.in.Y0, want)
1192 }
1193}
1194
1195// An area too small for any figure gets no frame, rather than a broken
1196// one or a table squeezed to nothing.
1197func TestBorderDeclinesASmallArea(t *testing.T) {
1198 for _, sz := range [][2]int{{4, 4}, {20, 6}, {7, 30}} {
1199 if b := fitBorder(sz[0], sz[1]); b != nil {
1200 t.Errorf("%dx%d got a frame with a %dx%d middle, want none",
1201 sz[0], sz[1], b.in.X1-b.in.X0, b.in.Y1-b.in.Y0)
1202 }
1203 }
1204}
1205
1206// Composing is the expensive thing here and a draw happens on every
1207// event, so the same size must come back from the cache rather than be
1208// composed again.
1209func TestBorderCachesBySize(t *testing.T) {
1210 a := fitBorder(132, 44)
1211 b := fitBorder(132, 44)
1212 if a != b {
1213 t.Error("the same size composed twice instead of coming from the cache")
1214 }
1215}
1216
1217
1218// ===== pkg/tui/cartsave.go =====
1219package tui
1220
1221import (
1222 "encoding/json"
1223 "os"
1224 "path/filepath"
1225 "runtime"
1226 "strings"
1227
1228 "github.com/0magnet/m2/pkg/storepage"
1229)
1230
1231// The cart is a file, as a desktop program keeps its state:
1232// ~/.config/m2/cart-<store>.json, read at start and written on every change.
1233// The same code runs in the browser, where the filesystem is bottle's jsfs β
1234// the page persists its /home to IndexedDB β so a reload finds the cart where
1235// a restart does on a desktop. One file per store, so carts at two stores do
1236// not mix.
1237//
1238// It holds what the buyer chose and nothing the store decides: part numbers
1239// and quantities, and where to ship. Prices come from the catalog when the
1240// cart is shown, and the server checks them again at checkout.
1241
1242// savedCart is the file.
1243type savedCart struct {
1244 Items []savedItem `json:"items"`
1245 Shipping *storepage.Shipping `json:"shipping,omitempty"`
1246}
1247
1248type savedItem struct {
1249 Partno string `json:"partno"`
1250 Qty int `json:"qty"`
1251}
1252
1253// configDir is where a user's settings live: XDG's, or the shell's home in
1254// a page, whose process has no HOME of its own.
1255func configDir() string {
1256 if d, err := os.UserConfigDir(); err == nil {
1257 return d
1258 }
1259 if runtime.GOOS == "js" {
1260 return "/home/user/.config"
1261 }
1262 return ""
1263}
1264
1265// cartPath is this store's cart file, or "" where there is nowhere to keep one.
1266func (u *ui) cartPath() string {
1267 dir := configDir()
1268 if dir == "" {
1269 return ""
1270 }
1271 name := strings.Map(func(r rune) rune {
1272 switch {
1273 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-':
1274 return r
1275 }
1276 return '_'
1277 }, u.sitedomain)
1278 return filepath.Join(dir, "m2", "cart-"+name+".json")
1279}
1280
1281// loadCart reads the cart file, if there is one.
1282func (u *ui) loadCart() {
1283 p := u.cartPath()
1284 if p == "" {
1285 return
1286 }
1287 b, err := os.ReadFile(p) //nolint:gosec // the user's own config file
1288 if err != nil {
1289 return
1290 }
1291 var c savedCart
1292 if json.Unmarshal(b, &c) != nil {
1293 return
1294 }
1295 u.cartQty = map[string]int{}
1296 u.cartOrder = nil
1297 for _, it := range c.Items {
1298 if it.Partno == "" || it.Qty <= 0 {
1299 continue
1300 }
1301 if _, ok := u.cartQty[it.Partno]; !ok {
1302 u.cartOrder = append(u.cartOrder, it.Partno)
1303 }
1304 u.cartQty[it.Partno] += it.Qty
1305 }
1306 u.shipping = c.Shipping
1307}
1308
1309// cartChanged writes the cart after any change to it: to a file beside it
1310// first, then over it, so a cart is never half written.
1311func (u *ui) cartChanged() {
1312 p := u.cartPath()
1313 if p == "" {
1314 return
1315 }
1316 c := savedCart{Items: []savedItem{}, Shipping: u.shipping}
1317 for _, partno := range u.cartOrder {
1318 if q := u.cartQty[partno]; q > 0 {
1319 c.Items = append(c.Items, savedItem{partno, q})
1320 }
1321 }
1322 b, err := json.MarshalIndent(c, "", " ")
1323 if err != nil {
1324 return
1325 }
1326 if os.MkdirAll(filepath.Dir(p), 0o700) != nil {
1327 return
1328 }
1329 tmp := p + ".tmp"
1330 if os.WriteFile(tmp, append(b, '\n'), 0o600) != nil {
1331 return
1332 }
1333 _ = os.Rename(tmp, p) //nolint:errcheck // the cart stays as it was; the next change tries again
1334}
1335
1336// cartLine is one line of the cart as the website's cart wasm writes it and
1337// /submit-order reads it: a product's id is its part number and its amount the line's cents
1338// (price Γ quantity); the shipping line's id is storepage's shipping-to line.
1339type cartLine struct {
1340 ID string `json:"id"`
1341 Amount int `json:"amount"`
1342 Qty int `json:"quantity"`
1343}
1344
1345// cartLines is the cart in that form, for the order sent at checkout.
1346func (u *ui) cartLines() []cartLine {
1347 var ls []cartLine
1348 for _, partno := range u.cartOrder {
1349 qty := u.cartQty[partno]
1350 if qty <= 0 {
1351 continue
1352 }
1353 cents := 0
1354 if p := u.cat.find(partno); p != nil {
1355 cents = priceCents(p.Price)
1356 }
1357 ls = append(ls, cartLine{ID: partno, Amount: cents * qty, Qty: qty})
1358 }
1359 if u.shipping != nil {
1360 ls = append(ls, cartLine{ID: u.shipping.ID(), Amount: u.shipping.Cents, Qty: 1})
1361 }
1362 return ls
1363}
1364
1365// setQty sets one product's quantity in the cart; zero takes it out.
1366func (u *ui) setQty(partno string, qty int) {
1367 if qty <= 0 {
1368 delete(u.cartQty, partno)
1369 for i, p := range u.cartOrder {
1370 if p == partno {
1371 u.cartOrder = append(u.cartOrder[:i], u.cartOrder[i+1:]...)
1372 break
1373 }
1374 }
1375 } else {
1376 if _, ok := u.cartQty[partno]; !ok {
1377 u.cartOrder = append(u.cartOrder, partno)
1378 }
1379 u.cartQty[partno] = qty
1380 }
1381 u.cartChanged()
1382}
1383
1384// emptyCart takes everything out, the shipping line too, as the page's
1385// Empty Cart does.
1386func (u *ui) emptyCart() {
1387 u.cartQty = map[string]int{}
1388 u.cartOrder = nil
1389 u.shipping = nil
1390 u.cartChanged()
1391}
1392
1393
1394// ===== pkg/tui/cartsave_test.go =====
1395package tui
1396
1397import (
1398 "os"
1399 "path/filepath"
1400 "reflect"
1401 "strings"
1402 "testing"
1403
1404 "github.com/gdamore/tcell/v3"
1405
1406 "github.com/0magnet/m2/pkg/product"
1407 "github.com/0magnet/m2/pkg/storepage"
1408)
1409
1410// TestTheCartIsAFile: the cart is written to the store's file in the user's
1411// config directory on every change and read back from it at start β what
1412// the buyer chose, without prices.
1413func TestTheCartIsAFile(t *testing.T) {
1414 dir := t.TempDir()
1415 t.Setenv("XDG_CONFIG_HOME", dir)
1416 cat := newCatalogInfo(product.Products{
1417 {Partno: "A1", Price: "1.50", Enable: "TRUE", Category: "x"},
1418 {Partno: "B2", Price: "10", Enable: "TRUE", Category: "x"},
1419 })
1420 u := &ui{cartQty: map[string]int{}, cat: cat, sitedomain: "shop.example"}
1421 u.setQty("A1", 3)
1422 u.setQty("B2", 1)
1423 u.shipping = &storepage.Shipping{Cents: 795, Name: "N", Address: "1 St", City: "C", State: "TX", Zip: "77001", Country: "USA", Phone: "5"}
1424 u.cartChanged()
1425
1426 b, err := os.ReadFile(filepath.Join(dir, "m2", "cart-shop.example.json")) //nolint:gosec // the test's own temp dir
1427 if err != nil {
1428 t.Fatal(err)
1429 }
1430 if strings.Contains(string(b), "1.50") || !strings.Contains(string(b), `"partno": "A1"`) {
1431 t.Errorf("the file:\n%s", b)
1432 }
1433
1434 v := &ui{cat: cat, sitedomain: "shop.example"}
1435 v.loadCart()
1436 if !reflect.DeepEqual(v.cartLines(), u.cartLines()) {
1437 t.Fatalf("read back as\n%+v\nwant\n%+v", v.cartLines(), u.cartLines())
1438 }
1439 if got := v.cartLines()[0]; got != (cartLine{ID: "A1", Amount: 450, Qty: 3}) {
1440 t.Errorf("first line %+v", got)
1441 }
1442
1443 v.setQty("A1", 0)
1444 if _, ok := v.cartQty["A1"]; ok || len(v.cartOrder) != 1 {
1445 t.Fatalf("A1 not removed: %v %v", v.cartQty, v.cartOrder)
1446 }
1447 w := &ui{cat: cat, sitedomain: "other.example"}
1448 w.loadCart()
1449 if len(w.cartOrder) != 0 {
1450 t.Error("another store's cart was read")
1451 }
1452}
1453
1454// TestPlacementOnItsOverlay: what an overlay places lies on that overlay,
1455// and only what is drawn after it takes it away.
1456func TestPlacementOnItsOverlay(t *testing.T) {
1457 u := &ui{screen: &rawScreen{}}
1458 dialog := rect{10, 5, 40, 20}
1459 u.overlaysDrawn = 1
1460 u.want("card", placed{r: rect{11, 6, 38, 17}, widget: "stripe"})
1461 u.overlaysDrawn = 0
1462 u.want("photo", placed{r: rect{0, 0, 30, 10}, url: "https://x/y.jpg"})
1463 u.placeSync([]rect{dialog})
1464 if _, ok := u.placeSent["card"]; !ok {
1465 t.Error("the card form was taken off its own dialog")
1466 }
1467 if _, ok := u.placeSent["photo"]; ok {
1468 t.Error("the photo under the dialog was left over it")
1469 }
1470
1471 u.overlaysDrawn = 1
1472 u.want("card", placed{r: rect{11, 6, 38, 17}, widget: "stripe"})
1473 u.placeSync([]rect{dialog, {20, 10, 10, 5}}) // a notice over the form
1474 if _, ok := u.placeSent["card"]; ok {
1475 t.Error("the card form was left over the notice drawn after it")
1476 }
1477}
1478
1479// rawScreen takes what is written to the terminal itself, as the page's
1480// terminal does; nothing else of it is used.
1481type rawScreen struct {
1482 tcell.Screen
1483 out []byte
1484}
1485
1486func (r *rawScreen) WriteRaw(b []byte) { r.out = append(r.out, b...) }
1487
1488
1489// ===== pkg/tui/catalog.go =====
1490// Package tui pkg/tui/catalog.go β the site's structures, derived the same
1491// way the templates derive them: category ordering from getcategories, the
1492// navigation tree from htmpl/catsubcats.html, the product page from
1493// htmpl/product.html. Where the website renders HTML, this renders the same
1494// data to terminal text.
1495package tui
1496
1497import (
1498 "fmt"
1499 "sort"
1500 "strconv"
1501 "strings"
1502
1503 "github.com/0magnet/tallytree"
1504
1505 "github.com/0magnet/m2/pkg/product"
1506 "github.com/0magnet/m2/pkg/storepage"
1507)
1508
1509// catalogInfo mirrors what pageMeta hands the templates: categories sorted
1510// by product count, subcategories likewise, with counts.
1511type catalogInfo struct {
1512 prods product.Products
1513 cats []string
1514 catCounts map[string]int
1515 subcatCounts map[string]map[string]int
1516 subcatsByCat map[string][]string
1517}
1518
1519func newCatalogInfo(prods product.Products) *catalogInfo {
1520 c := &catalogInfo{
1521 prods: prods,
1522 catCounts: make(map[string]int),
1523 subcatCounts: make(map[string]map[string]int),
1524 subcatsByCat: make(map[string][]string),
1525 }
1526 for _, prod := range prods {
1527 if prod.Category == "" {
1528 continue
1529 }
1530 c.catCounts[prod.Category]++
1531 if prod.Subcategory != "" {
1532 if c.subcatCounts[prod.Category] == nil {
1533 c.subcatCounts[prod.Category] = make(map[string]int)
1534 }
1535 c.subcatCounts[prod.Category][prod.Subcategory]++
1536 }
1537 }
1538 for cat := range c.catCounts {
1539 c.cats = append(c.cats, cat)
1540 }
1541 sort.Slice(c.cats, func(i, j int) bool {
1542 if c.catCounts[c.cats[i]] != c.catCounts[c.cats[j]] {
1543 return c.catCounts[c.cats[i]] > c.catCounts[c.cats[j]]
1544 }
1545 return c.cats[i] < c.cats[j]
1546 })
1547 for cat, subs := range c.subcatCounts {
1548 var names []string
1549 for sub := range subs {
1550 names = append(names, sub)
1551 }
1552 sort.Slice(names, func(i, j int) bool {
1553 if subs[names[i]] != subs[names[j]] {
1554 return subs[names[i]] > subs[names[j]]
1555 }
1556 return names[i] < names[j]
1557 })
1558 c.subcatsByCat[cat] = names
1559 }
1560 return c
1561}
1562
1563// products returns the rows the matching category/subcategory table
1564// lists, as /cat/<cat>[/<subcat>] does: every product in the category,
1565// subcategorized or not, and a subcategory page narrowing that to one.
1566//
1567// It used to hold back the products that have a subcategory, which is
1568// front.html's rule for its "Other Products in X" table and not this
1569// page's. Where every product in a category is subcategorized β which
1570// is most of them β that left the category page with nothing on it. The
1571// site had the same bug in its homepage sections and fixed it there
1572// (catTargetCSS in pkg/web); this is that fix, for that reason.
1573func (c *catalogInfo) products(cat, subcat string) product.Products {
1574 var out product.Products
1575 for _, p := range c.prods {
1576 switch {
1577 case cat == "":
1578 out = append(out, p)
1579 case subcat == "":
1580 if p.Category == cat {
1581 out = append(out, p)
1582 }
1583 default:
1584 if p.Category == cat && p.Subcategory == subcat {
1585 out = append(out, p)
1586 }
1587 }
1588 }
1589 return out
1590}
1591
1592// resolve reads a "<cat>" or "<cat>/<subcat>" path as a place in this
1593// catalog, matching the way a URL does: case-insensitively, and only
1594// where the catalog holds that name. It reports whether it found one.
1595func (c *catalogInfo) resolve(path string) (navTarget, bool) {
1596 parts := strings.SplitN(strings.Trim(path, "/"), "/", 2)
1597 if parts[0] == "" {
1598 return navTarget{}, true // /cat/ is all products
1599 }
1600 cat := ""
1601 for name := range c.catCounts {
1602 if strings.EqualFold(name, parts[0]) {
1603 cat = name
1604 break
1605 }
1606 }
1607 if cat == "" {
1608 return navTarget{}, false
1609 }
1610 if len(parts) == 1 {
1611 return navTarget{cat: cat}, true
1612 }
1613 // Either spelling: the name as the catalog holds it, or the name as
1614 // a URL spells it. The site writes "ΒΌ watt 5%" into a path as
1615 // quarter-watt-5-pct, and while the escaping lived only in pkg/web
1616 // this could not match those at all β every subcategory link with a
1617 // fraction or a percent in it left the store rather than opening the
1618 // listing right here.
1619 for _, sub := range c.subcatsByCat[cat] {
1620 if strings.EqualFold(sub, parts[1]) || strings.EqualFold(storepage.EscapeSubcat(sub), parts[1]) {
1621 return navTarget{cat: cat, subcat: sub}, true
1622 }
1623 }
1624 return navTarget{}, false
1625}
1626
1627func (c *catalogInfo) find(partno string) *product.Product {
1628 for i := range c.prods {
1629 if strings.EqualFold(c.prods[i].Partno, partno) {
1630 return &c.prods[i]
1631 }
1632 }
1633 return nil
1634}
1635
1636// navTarget is a place the categories tree can go: all products, a
1637// category, or a subcategory.
1638type navTarget struct{ cat, subcat string }
1639
1640type navItem struct {
1641 label string
1642 target navTarget
1643 hasSubs bool
1644}
1645
1646// buildTree lays the category menu out: counts right-aligned by β padding,
1647// β/β branches, β¬ where a category opens into subcategories.
1648//
1649// The drawing is tallytree's, not this file's. It used to be written out here
1650// and again in htmpl/catsubcats.html, two implementations of one picture kept
1651// in agreement by hand; both now call the library, so the alignment arithmetic
1652// exists once and has tests.
1653//
1654// Depth is pinned to two because the menu always COULD have subcategories, so
1655// it reserves room for them even in a catalog where nothing does β otherwise
1656// a flat catalog would draw a narrower tree than the same shop with one
1657// subcategory added.
1658func (c *catalogInfo) buildTree(expanded map[string]bool) []navItem {
1659 forest := []tallytree.Node{{Label: "All Products", Count: len(c.prods)}}
1660 for _, cat := range c.cats {
1661 n := tallytree.Node{Label: cat, Count: c.catCounts[cat]}
1662 for _, sub := range c.subcatsByCat[cat] {
1663 n.Children = append(n.Children, tallytree.Node{Label: sub, Count: c.subcatCounts[cat][sub]})
1664 }
1665 forest = append(forest, n)
1666 }
1667 rows := tallytree.Rows(forest, tallytree.Options{
1668 Depth: 2,
1669 Open: func(n tallytree.Node, d int) bool { return d != 0 || expanded == nil || expanded[n.Label] },
1670 })
1671 items := make([]navItem, 0, len(rows))
1672 cat := ""
1673 for _, r := range rows {
1674 t := navTarget{}
1675 switch {
1676 case r.Depth == 0 && r.Label != "All Products":
1677 cat = r.Label
1678 t = navTarget{cat: r.Label}
1679 case r.Depth > 0:
1680 t = navTarget{cat: cat, subcat: r.Label}
1681 }
1682 items = append(items, navItem{r.Line(tallytree.Options{}), t, r.HasChildren})
1683 }
1684 return items
1685}
1686
1687// checkerboard styles alternate letters inverse-video, as the site's
1688// checkerBoard wraps every other letter of the domain in an .nv span.
1689func checkerboard(s string) string {
1690 var out strings.Builder
1691 for i, ch := range s {
1692 if i%2 == 0 {
1693 fmt.Fprintf(&out, "[black:white]%c[white:black]", ch)
1694 } else {
1695 out.WriteRune(ch)
1696 }
1697 }
1698 out.WriteString("[-:-]")
1699 return out.String()
1700}
1701
1702// set reports a field the way product.html's conditions do: present unless
1703// empty or a bare zero.
1704func set(v string) bool {
1705 return v != "" && v != "0" && v != "0.0"
1706}
1707
1708// productLines renders htmpl/product.html: the same fields, labels, and
1709// order, as terminal text.
1710//
1711// anchor wraps the template's clickable things β its Add to cart button
1712// and its category, subcategory and datasheet links β in whatever makes
1713// them clickable on the page doing the drawing. A nil anchor draws them
1714// as plain text, which is what a page with nowhere to send a click gets.
1715func productLines(u *ui, p *product.Product, anchor func(text string, act func()) string) string {
1716 if anchor == nil {
1717 anchor = func(text string, _ func()) string { return text }
1718 }
1719 var b strings.Builder
1720 line := func(format string, args ...interface{}) {
1721 fmt.Fprintf(&b, format+"\n", args...)
1722 }
1723 line("[::b]%s[-:-:-]", esc(p.Name))
1724 line("Price: [white]$%s[-]", esc(p.Price))
1725 line("In stock: %s", esc(p.Quantity))
1726 if p.Quantity != "0" {
1727 // The template's <button onclick='addToCart(...)'>, in the place
1728 // it sits on the page: under the stock line, above the part no.
1729 line("%s", anchor("[black:#005f87] "+storepage.AddToCart+" [-:-]", func() { u.addToCart(p) }))
1730 line("partno: %s", esc(p.Partno))
1731 }
1732 if !strings.EqualFold(strings.Join(strings.Fields(p.Description1), ""), strings.Join(strings.Fields(p.Name), "")) && p.Description1 != "" {
1733 line("\n%s\n", esc(p.Description1))
1734 }
1735 if p.Mfgname != "" {
1736 line("Brand: %s", esc(p.Mfgname))
1737 }
1738 if p.Mfgpartno != "" {
1739 line("MPN: %s", esc(p.Mfgpartno))
1740 }
1741 cat, sub := p.Category, p.Subcategory
1742 line("Category: %s", anchor("[aqua]"+esc(cat)+"[-]",
1743 func() { u.show(listPageID(navTarget{cat: cat})) }))
1744 if sub != "" {
1745 line("Subcategory: %s", anchor("[aqua]"+esc(sub)+"[-]",
1746 func() { u.show(listPageID(navTarget{cat: cat, subcat: sub})) }))
1747 }
1748 if set(p.VoltsRating) {
1749 line("Voltage: %s", esc(p.VoltsRating))
1750 }
1751 if set(p.Value) {
1752 line("Value: %s%s", esc(p.Value), esc(p.ValUnit))
1753 }
1754 if set(p.AmpsRating) {
1755 line("Amperage: %s", esc(p.AmpsRating))
1756 }
1757 if p.Tolerance != "0" && p.Tolerance != "" {
1758 if f, err := strconv.ParseFloat(p.Tolerance, 64); err == nil {
1759 line("Tolerance: %.2f%%", 100*f)
1760 }
1761 }
1762 if p.Typ != "" {
1763 line("Typ: %s", esc(p.Typ))
1764 }
1765 if p.Packagetype != "" {
1766 line("Package Type: %s", esc(p.Packagetype))
1767 }
1768 if p.Technology != "" {
1769 line("Technology: %s", esc(p.Technology))
1770 }
1771 if p.Materials != "" {
1772 line("Materials: %s", esc(p.Materials))
1773 }
1774 if set(p.WattsRating) {
1775 line("Watts Rating: %s", esc(p.WattsRating))
1776 }
1777 if p.Year != "0" && p.Year != "" {
1778 line("Year: %s", esc(p.Year))
1779 }
1780 if set(p.CableLengthInches) {
1781 line("Cable Length: %s inches", esc(p.CableLengthInches))
1782 }
1783 if p.WeightOz != "0" && p.WeightOz != "0.0" && p.WeightOz != "" {
1784 line("Weight: %s oz", esc(p.WeightOz))
1785 }
1786 if p.TempRating != "0" && p.TempRating != "0.0" && p.TempRating != "" {
1787 line("Temp rating: %s%s", esc(p.TempRating), esc(p.TempUnit))
1788 }
1789 if p.Condition != "" {
1790 line("Condition: %s", esc(p.Condition))
1791 }
1792 if p.Datasheet != "" {
1793 // The template links this at <imgsrc>/pdf/<name>; the terminal
1794 // cannot show a PDF, so following it hands over the URL.
1795 ds := p.Datasheet
1796 line("Datasheet: %s", anchor("[aqua]"+esc(ds)+"[-]",
1797 func() { u.openURL(datasheetURL(ds)) }))
1798 }
1799 if p.Docs != "" {
1800 line("Documentation: %s", esc(p.Docs))
1801 }
1802 if p.Note != "" {
1803 line("Note: [yellow]%s[-]", esc(p.Note))
1804 }
1805 if p.Warning != "" {
1806 line("Warning: [red]%s[-]", esc(p.Warning))
1807 }
1808 if p.Description2 != "" {
1809 line("Additional Description: %s", esc(p.Description2))
1810 }
1811 return b.String()
1812}
1813
1814
1815// ===== pkg/tui/catalog_test.go =====
1816package tui
1817
1818import (
1819 "strings"
1820 "testing"
1821
1822 "github.com/0magnet/m2/pkg/product"
1823)
1824
1825// sampleCatalog is a catalog small enough to write the expected drawing out
1826// by hand, and shaped to exercise the alignment: counts of one, two, three and
1827// four digits, a category with subcategories and one without, and a last
1828// sibling on both levels.
1829func sampleCatalog() *catalogInfo {
1830 return &catalogInfo{
1831 prods: make(product.Products, 1247),
1832 cats: []string{"resistor", "inductor"},
1833 catCounts: map[string]int{"resistor": 111, "inductor": 34},
1834 subcatsByCat: map[string][]string{"resistor": {"quarter watt 5%", "ceramic"}},
1835 subcatCounts: map[string]map[string]int{"resistor": {"quarter watt 5%": 67, "ceramic": 1}},
1836 }
1837}
1838
1839// The menu's counts are right-aligned by padding the branch rule itself, so
1840// every count ends in the same column and the tree still reads as one line.
1841// These are the exact rows the site drew before the layout moved to tallytree.
1842func TestBuildTreeDrawing(t *testing.T) {
1843 want := []string{
1844 "βββ1247 All Products",
1845 "βββ¬β111 resistor",
1846 "β βββ67 quarter watt 5%",
1847 "β ββββ1 ceramic",
1848 "βββββ34 inductor",
1849 }
1850 got := sampleCatalog().buildTree(map[string]bool{"resistor": true})
1851 if len(got) != len(want) {
1852 t.Fatalf("%d rows, want %d", len(got), len(want))
1853 }
1854 for i := range want {
1855 if got[i].label != want[i] {
1856 t.Errorf("row %d:\n got %q\nwant %q", i, got[i].label, want[i])
1857 }
1858 }
1859}
1860
1861// A collapsed category keeps the β¬ that says it has subcategories, and the
1862// columns do not move when it closes.
1863func TestBuildTreeCollapsed(t *testing.T) {
1864 got := sampleCatalog().buildTree(map[string]bool{})
1865 if len(got) != 3 {
1866 t.Fatalf("%d rows with everything collapsed, want 3", len(got))
1867 }
1868 if !strings.Contains(got[1].label, "β¬") {
1869 t.Errorf("collapsed category lost its β¬: %q", got[1].label)
1870 }
1871 open := sampleCatalog().buildTree(map[string]bool{"resistor": true})
1872 if a, b := strings.Index(got[1].label, " "), strings.Index(open[1].label, " "); a != b {
1873 t.Errorf("collapsing moved the count column: %d vs %d", a, b)
1874 }
1875}
1876
1877// A category page lists everything in the category, as /cat/<cat> does
1878// β not only the products with no subcategory, which is front.html's
1879// rule for its "Other Products in X" table. Getting that wrong left
1880// every category page in this catalog empty.
1881func TestCategoryPageListsTheWholeCategory(t *testing.T) {
1882 c := newCatalogInfo(product.Products{
1883 {Partno: "a", Category: "diode", Subcategory: "1N47XX"},
1884 {Partno: "b", Category: "diode", Subcategory: "1N47XX"},
1885 {Partno: "c", Category: "diode", Subcategory: "bridge"},
1886 {Partno: "d", Category: "diode"},
1887 {Partno: "e", Category: "resistor"},
1888 })
1889 names := func(ps product.Products) string {
1890 var out []string
1891 for _, p := range ps {
1892 out = append(out, p.Partno)
1893 }
1894 return strings.Join(out, ",")
1895 }
1896 if got := names(c.products("diode", "")); got != "a,b,c,d" {
1897 t.Errorf("/cat/diode lists %q, want %q", got, "a,b,c,d")
1898 }
1899 if got := names(c.products("diode", "1N47XX")); got != "a,b" {
1900 t.Errorf("/cat/diode/1N47XX lists %q, want %q", got, "a,b")
1901 }
1902 if got := names(c.products("", "")); got != "a,b,c,d,e" {
1903 t.Errorf("all products lists %q, want %q", got, "a,b,c,d,e")
1904 }
1905}
1906
1907// Targets have to survive the layout: the rows carry where each entry goes.
1908func TestBuildTreeTargets(t *testing.T) {
1909 got := sampleCatalog().buildTree(map[string]bool{"resistor": true})
1910 want := []navTarget{{}, {cat: "resistor"}, {cat: "resistor", subcat: "quarter watt 5%"},
1911 {cat: "resistor", subcat: "ceramic"}, {cat: "inductor"}}
1912 for i := range want {
1913 if got[i].target != want[i] {
1914 t.Errorf("row %d target %+v, want %+v", i, got[i].target, want[i])
1915 }
1916 }
1917 if !got[1].hasSubs || got[4].hasSubs {
1918 t.Error("hasSubs is wrong: resistor has subcategories, inductor does not")
1919 }
1920}
1921
1922
1923// ===== pkg/tui/checkout.go =====
1924// Package tui pkg/tui/checkout.go β the cart, the shipping form, and
1925// checkout, mirroring the website's footer commerce (footer.html + the
1926// cart wasm). On the website the cart and the shipping form are
1927// <details> dropdowns rising from the fixed footer and checkout is a
1928// <dialog>; here they are overlays above the footer bar, outside the
1929// navigation history β v toggles the cart, Esc closes.
1930//
1931// The TUI is a client of the same store server the browser cart talks
1932// to: it builds the identical items payload ("partno X qty" lines plus
1933// a "shipping-to|..." line) and POSTs the same /create-payment-intent.
1934// The card-entry step lives behind payWithElements: in the browser
1935// (pay_js.go) it lays the Stripe Payment Element over the checkout
1936// dialog, as the cart wasm opens it in the page's; a native terminal
1937// (pay_native.go) cannot take card details, and says so.
1938package tui
1939
1940import (
1941 "bytes"
1942 "encoding/json"
1943 "fmt"
1944 "io"
1945 "net/http"
1946 "strings"
1947 "time"
1948
1949 "github.com/gdamore/tcell/v3"
1950
1951 "github.com/0magnet/m2/pkg/storepage"
1952)
1953
1954// The shipping line, the form that fills it and the state list all live in
1955// pkg/storepage now, so the page, this and the cart wasm cannot drift apart.
1956// See pkg/storepage/shipping.go.
1957
1958// commerceRect anchors an overlay just above the footer summary it
1959// expands from β the cart (and checkout) above View Cart, the form
1960// above Add Shipping Info.
1961func (u *ui) commerceRect() rect {
1962 w, h := u.commerce.size()
1963 sw, sh := u.screen.Size()
1964 if w > sw-2 {
1965 w = sw - 2
1966 }
1967 x := 1
1968 region := 0
1969 if _, ok := u.commerce.(*shippingOverlay); ok {
1970 region = 1
1971 }
1972 if region < len(u.footRegions) {
1973 x = u.footRegions[region].x1 - 1
1974 }
1975 if x+w > sw-1 {
1976 x = sw - 1 - w
1977 }
1978 if x < 0 {
1979 x = 0
1980 }
1981 y := sh - 2 - h
1982 if y < 1 {
1983 y = 1
1984 h = sh - 3
1985 }
1986 return rect{x, y, w, h}
1987}
1988
1989func (u *ui) closeCommerce() {
1990 u.commerce = nil
1991}
1992
1993func (u *ui) toggleCart() {
1994 if u.commerce != nil {
1995 u.closeCommerce()
1996 return
1997 }
1998 u.openCart()
1999}
2000
2001// ---- the cart overlay ----
2002
2003// The page's cart is a table β item, price, a quantity box and Remove on
2004// each line β so this one has a line the arrows are on, whose quantity +
2005// and - change and r takes out.
2006type cartOverlay struct {
2007 ta *textArea
2008 lines int
2009 sel int // the line the arrows are on
2010 items []string // the part numbers on its lines, in order
2011}
2012
2013func (u *ui) openCart() {
2014 sel := 0
2015 if c, ok := u.commerce.(*cartOverlay); ok {
2016 sel = c.sel
2017 }
2018 var items []string
2019 for _, partno := range u.cartOrder {
2020 if u.cartQty[partno] > 0 && u.cat.find(partno) != nil {
2021 items = append(items, partno)
2022 }
2023 }
2024 if sel >= len(items) {
2025 sel = len(items) - 1
2026 }
2027 if sel < 0 {
2028 sel = 0
2029 }
2030 var b strings.Builder
2031 total := 0
2032 for i, partno := range items {
2033 qty := u.cartQty[partno]
2034 p := u.cat.find(partno)
2035 cents := priceCents(p.Price) * qty
2036 total += cents
2037 mark, name := " ", "[aqua]"+esc(p.Name)+"[-]"
2038 if i == sel {
2039 mark, name = "[#5fd7ff]βΈ[-] ", "[black:aqua]"+esc(p.Name)+"[-:-]"
2040 }
2041 fmt.Fprintf(&b, "%s%3d Γ %s $%d.%02d\n", mark, qty, name, cents/100, cents%100)
2042 }
2043 if u.shipping != nil {
2044 total += u.shipping.Cents
2045 to := strings.TrimSpace(u.shipping.Name + " " + u.shipping.City + " " + u.shipping.State)
2046 fmt.Fprintf(&b, " shipping[gray] to %s[-] $%d.%02d\n",
2047 esc(to), u.shipping.Cents/100, u.shipping.Cents%100)
2048 }
2049 if total == 0 {
2050 b.WriteString("[gray]your cart is empty[-]\n")
2051 }
2052 fmt.Fprintf(&b, "\nTotal: $%d.%02d\n", total/100, total%100)
2053 if len(items) > 0 {
2054 b.WriteString("\n[#5fd7ff]ββ[-] line Β· [#5fd7ff]+ -[-] quantity Β· [#5fd7ff]r[-] remove")
2055 }
2056 b.WriteString("\n[#5fd7ff]s[-] shipping Β· [#5fd7ff]p[-] card")
2057 if coinOffered {
2058 b.WriteString(" Β· [#5fd7ff]k[-] coin")
2059 }
2060 b.WriteString(" Β· [#5fd7ff]x[-] empty Β· [#5fd7ff]v[-] close\n")
2061 fmt.Fprintf(&b, "[gray]checkout talks to the store server at %s[-]", esc(ordersURL()))
2062 content := b.String()
2063 u.commerce = &cartOverlay{ta: newTextArea(content, false), lines: strings.Count(content, "\n") + 1, sel: sel, items: items}
2064}
2065
2066func (c *cartOverlay) title() string { return "View Cart" }
2067func (c *cartOverlay) size() (int, int) {
2068 return 70, c.lines + 2
2069}
2070
2071func (c *cartOverlay) draw(u *ui, sc tcell.Screen, r rect) {
2072 c.ta.draw(sc, r)
2073}
2074
2075func (c *cartOverlay) key(u *ui, ev *tcell.EventKey) bool {
2076 line := ""
2077 if c.sel < len(c.items) {
2078 line = c.items[c.sel]
2079 }
2080 switch ev.Key() {
2081 case tcell.KeyUp:
2082 if c.sel > 0 {
2083 c.sel--
2084 u.openCart()
2085 }
2086 return true
2087 case tcell.KeyDown:
2088 if c.sel < len(c.items)-1 {
2089 c.sel++
2090 u.openCart()
2091 }
2092 return true
2093 case tcell.KeyDelete:
2094 if line != "" {
2095 u.setQty(line, 0)
2096 u.openCart()
2097 }
2098 return true
2099 }
2100 switch keyRune(ev) {
2101 case '+', '=':
2102 if line != "" {
2103 u.setQty(line, u.cartQty[line]+1)
2104 u.openCart()
2105 }
2106 return true
2107 case '-':
2108 if line != "" {
2109 u.setQty(line, u.cartQty[line]-1)
2110 u.openCart()
2111 }
2112 return true
2113 case 'r':
2114 if line != "" {
2115 u.setQty(line, 0)
2116 u.openCart()
2117 }
2118 return true
2119 case 'x':
2120 u.emptyCart()
2121 u.openCart()
2122 return true
2123 case 's':
2124 u.openShipping()
2125 return true
2126 case 'p':
2127 u.startCheckout()
2128 return true
2129 case 'k':
2130 if !coinOffered {
2131 return false
2132 }
2133 // The checkout a terminal can finish by itself; see crypto.go.
2134 u.startCoinCheckout()
2135 return true
2136 }
2137 return false
2138}
2139
2140// ---- the shipping form, from footer.html's Add Shipping Info ----
2141
2142type shippingOverlay struct {
2143 frm *form
2144 h int
2145}
2146
2147func (s *shippingOverlay) title() string { return "Add Shipping Info" }
2148func (s *shippingOverlay) size() (int, int) { return 62, s.h }
2149func (s *shippingOverlay) draw(u *ui, sc tcell.Screen, r rect) { s.frm.draw(sc, r) }
2150func (s *shippingOverlay) key(u *ui, ev *tcell.EventKey) bool { return s.frm.key(ev) }
2151
2152func (u *ui) openShipping() {
2153 prev := u.shipping
2154 if prev == nil {
2155 prev = &storepage.Shipping{Cents: storepage.MinShippingCents, Country: storepage.DefaultCountry}
2156 }
2157 // Built by walking the shared field list rather than naming each input,
2158 // so a field added to the form appears here and on the page together.
2159 // read[name] returns what that control holds, in the spelling the cart
2160 // line wants β a state picker shows "Texas" and yields "TX".
2161 read := map[string]func() string{}
2162 items := make([]formItem, 0, len(storepage.ShippingFields))
2163 for _, f := range storepage.ShippingFields {
2164 switch f.Kind {
2165 case storepage.FieldState:
2166 opts := storepage.StateNames()
2167 dd := &fdropdown{lbl: f.TUILabel, opts: opts}
2168 for i, n := range opts {
2169 if n == storepage.StateName(prev.State) {
2170 dd.sel = i
2171 }
2172 }
2173 items = append(items, dd)
2174 read[f.Name] = func() string { return storepage.StateCode(dd.value()) }
2175 case storepage.FieldCountry:
2176 dd := &fdropdown{lbl: f.TUILabel, opts: storepage.Countries}
2177 items = append(items, dd)
2178 read[f.Name] = dd.value
2179 default:
2180 val := prev.Get(f.Name)
2181 if f.Kind == storepage.FieldMoney {
2182 val = fmt.Sprintf("%d.%02d", prev.Cents/100, prev.Cents%100)
2183 }
2184 in := &finput{lbl: f.TUILabel, text: []rune(val), width: f.Width}
2185 in.cur = len(in.text)
2186 items = append(items, in)
2187 read[f.Name] = in.value
2188 }
2189 }
2190
2191 // Declared separately on purpose: the buttons below close over frm, so it
2192 // has to exist before newForm is called.
2193 var frm *form //nolint:staticcheck
2194 frm = newForm(items, []fbutton{
2195 {"Add Shipping to Cart", func() {
2196 sh := storepage.Shipping{Cents: priceCents(read["shipping-price"]())}
2197 for _, f := range storepage.ShippingFields {
2198 if f.Kind != storepage.FieldMoney {
2199 sh.Set(f.Name, read[f.Name]())
2200 }
2201 }
2202 if msg := sh.Validate(); msg != "" {
2203 u.notice = msg
2204 return
2205 }
2206 u.shipping = &sh
2207 u.cartChanged()
2208 u.openCart()
2209 }},
2210 {"Cancel", func() { u.openCart() }},
2211 }, func() { u.openCart() })
2212 u.commerce = &shippingOverlay{frm: frm, h: len(items)*2 + 3}
2213}
2214
2215// ---- checkout: the cart wasm's flow, against the same server ----
2216
2217type checkoutOverlay struct {
2218 ta *textArea
2219 // form is set while the card form is laid over the dialog (pay_js.go):
2220 // the dialog grows to hold it, keeps a line above it for what is being
2221 // paid and one below for the keys, and Enter pays.
2222 form *cardForm
2223}
2224
2225// cardForm is the card form's part of the dialog.
2226type cardForm struct {
2227 head string // what is being paid, above the form
2228 pay func() // confirms the payment, as the form's own Pay button does
2229}
2230
2231func (c *checkoutOverlay) title() string { return "Checkout" }
2232func (c *checkoutOverlay) size() (int, int) {
2233 if c.form != nil {
2234 return 72, 32
2235 }
2236 return 78, 14
2237}
2238
2239func (c *checkoutOverlay) draw(u *ui, sc tcell.Screen, r rect) {
2240 if c.form == nil || r.h < 6 {
2241 c.ta.draw(sc, r)
2242 return
2243 }
2244 printMarkup(sc, r.x+1, r.y, r.w-2, c.form.head, styleText)
2245 formR := rect{r.x, r.y + 1, r.w, r.h - 3}
2246 // The cells under the form, for a terminal that cannot place it: the
2247 // dialog's own message says what is missing there.
2248 c.ta.draw(sc, formR)
2249 u.want("card", placed{r: formR, widget: "stripe", input: true})
2250 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)
2251}
2252
2253func (c *checkoutOverlay) key(u *ui, ev *tcell.EventKey) bool {
2254 if c.form != nil && ev.Key() == tcell.KeyEnter {
2255 c.form.pay()
2256 return true
2257 }
2258 return c.ta.key(ev)
2259}
2260
2261// ordersURL is where checkout is served from: the store being browsed
2262// in client mode, else SITEORDERSURL when the config names one (as the
2263// website's cart uses it), else the local store server.
2264func ordersURL() string {
2265 if s := storeURL(); s != "" {
2266 return s
2267 }
2268 if f.Siteordersurl != "" {
2269 return f.Siteordersurl
2270 }
2271 port := f.WebPort
2272 if port == 0 {
2273 port = 9883
2274 }
2275 return fmt.Sprintf("http://127.0.0.1:%d", port)
2276}
2277
2278// cartItem matches the JSON the cart wasm POSTs to /create-payment-intent.
2279type cartItem struct {
2280 ID string `json:"ID"`
2281 Amount int64 `json:"Amount"`
2282}
2283
2284func (u *ui) startCheckout() {
2285 if len(u.cartOrder) == 0 {
2286 u.notice = "the cart is empty"
2287 return
2288 }
2289 if u.shipping == nil {
2290 u.notice = "add shipping info first (s in the cart)"
2291 return
2292 }
2293 if !storepage.ValidEmail(u.shipping.Email) {
2294 u.notice = "add an email to the shipping info, for the order confirmation (s in the cart)"
2295 return
2296 }
2297
2298 // The same payload the cart wasm sends, and the same one the coin
2299 // checkout sends: see cartItems in crypto.go.
2300 items := u.cartItems()
2301
2302 co := &checkoutOverlay{ta: newTextArea("\n contacting "+esc(ordersURL())+" β¦", true)}
2303 u.commerce = co
2304
2305 go func() {
2306 clientSecret, err := createPaymentIntent(items)
2307 u.post(func() {
2308 if u.commerce != co {
2309 return // the overlay was closed meanwhile
2310 }
2311 if err != nil {
2312 co.ta.setContent(fmt.Sprintf(
2313 "\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",
2314 esc(err.Error()), esc(ordersURL())))
2315 return
2316 }
2317 u.payWithElements(co, clientSecret)
2318 })
2319 }()
2320}
2321
2322// createPaymentIntent POSTs the cart to the store server, which
2323// validates every line against the catalog and answers with the payment
2324// intent's client secret β identical to the cart wasm's fetch.
2325func createPaymentIntent(items []cartItem) (string, error) {
2326 body, err := json.Marshal(map[string]interface{}{"items": items})
2327 if err != nil {
2328 return "", err
2329 }
2330 client := &http.Client{Timeout: 15 * time.Second}
2331 resp, err := client.Post(ordersURL()+"/create-payment-intent", "application/json", bytes.NewReader(body))
2332 if err != nil {
2333 return "", err
2334 }
2335 defer resp.Body.Close() //nolint:errcheck // read-side close
2336 data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
2337 if err != nil {
2338 return "", err
2339 }
2340 var out struct {
2341 ClientSecret string `json:"clientSecret"`
2342 Error string `json:"error"`
2343 }
2344 if err := json.Unmarshal(data, &out); err != nil {
2345 return "", fmt.Errorf("bad response (%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
2346 }
2347 if out.Error != "" {
2348 return "", fmt.Errorf("%s", out.Error)
2349 }
2350 if out.ClientSecret == "" {
2351 return "", fmt.Errorf("no client secret in response (%d)", resp.StatusCode)
2352 }
2353 return out.ClientSecret, nil
2354}
2355
2356
2357// ===== pkg/tui/crypto.go =====
2358// Package tui pkg/tui/crypto.go β paying in coin from the terminal.
2359//
2360// This is the checkout a terminal can actually complete. Stripe's card
2361// entry is a browser widget, which is why payWithElements tells a native
2362// terminal it cannot take card details; a payment address is text, and text
2363// is what a terminal has. The same /create-invoice endpoint the page uses
2364// answers here, so the terminal is not a second implementation of anything
2365// β it renders what the server already decided.
2366package tui
2367
2368import (
2369 "bytes"
2370 "encoding/json"
2371 "fmt"
2372 "io"
2373 "net/http"
2374 "net/url"
2375 "strings"
2376 "time"
2377
2378 "github.com/gdamore/tcell/v3"
2379
2380 "github.com/0magnet/m2/pkg/storepage"
2381)
2382
2383// invoiceView is the subset of an invoice the server hands a client.
2384type invoiceView struct {
2385 ID string `json:"id"`
2386 Coin string `json:"coin"`
2387 Address string `json:"address"`
2388 Amount string `json:"amount"`
2389 URI string `json:"uri"`
2390 FiatCents int64 `json:"fiat_cents"`
2391 Status string `json:"status"`
2392 Paid string `json:"paid"`
2393 Outstanding string `json:"outstanding"`
2394 Expires string `json:"expires"`
2395 TxID string `json:"txid"`
2396 Error string `json:"error"`
2397}
2398
2399type invoiceOverlay struct {
2400 ta *textArea
2401 inv *invoiceView
2402}
2403
2404func (o *invoiceOverlay) title() string { return "Pay in coin" }
2405func (o *invoiceOverlay) size() (int, int) { return 74, 16 }
2406func (o *invoiceOverlay) draw(u *ui, sc tcell.Screen, r rect) { o.ta.draw(sc, r) }
2407
2408func (o *invoiceOverlay) key(u *ui, ev *tcell.EventKey) bool {
2409 if keyRune(ev) == 'r' && o.inv != nil {
2410 u.refreshInvoice(o)
2411 return true
2412 }
2413 return o.ta.key(ev)
2414}
2415
2416// startCoinCheckout asks the store for an invoice and shows it.
2417func (u *ui) startCoinCheckout() {
2418 if len(u.cartOrder) == 0 {
2419 u.notice = "the cart is empty"
2420 return
2421 }
2422 if u.shipping == nil {
2423 u.notice = "add shipping info first (s in the cart)"
2424 return
2425 }
2426 if !storepage.ValidEmail(u.shipping.Email) {
2427 u.notice = "add an email to the shipping info, for the order confirmation (s in the cart)"
2428 return
2429 }
2430 items := u.cartItems()
2431
2432 ov := &invoiceOverlay{ta: newTextArea("\n asking "+esc(ordersURL())+" for an address β¦", true)}
2433 u.commerce = ov
2434
2435 go func() {
2436 inv, err := createInvoice(items)
2437 u.post(func() {
2438 if u.commerce != ov {
2439 return // closed meanwhile
2440 }
2441 if err != nil {
2442 ov.ta.setContent(fmt.Sprintf(
2443 "\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",
2444 esc(err.Error())))
2445 return
2446 }
2447 ov.inv = inv
2448 ov.ta.setContent(renderInvoice(inv))
2449 })
2450 }()
2451}
2452
2453// refreshInvoice re-reads one invoice; the server polls the chain when
2454// asked, so this is what turns "pending" into "paid" on screen.
2455func (u *ui) refreshInvoice(ov *invoiceOverlay) {
2456 id := ov.inv.ID
2457 go func() {
2458 inv, err := getInvoice(id)
2459 u.post(func() {
2460 if u.commerce != ov {
2461 return
2462 }
2463 if err != nil {
2464 u.notice = "could not refresh: " + esc(err.Error())
2465 return
2466 }
2467 ov.inv = inv
2468 ov.ta.setContent(renderInvoice(inv))
2469 })
2470 }()
2471}
2472
2473// cartItems builds the payload both checkouts send.
2474func (u *ui) cartItems() []cartItem {
2475 var items []cartItem
2476 for _, partno := range u.cartOrder {
2477 qty := u.cartQty[partno]
2478 p := u.cat.find(partno)
2479 if qty == 0 || p == nil {
2480 continue
2481 }
2482 items = append(items, cartItem{
2483 ID: fmt.Sprintf("%s X %d", partno, qty),
2484 Amount: int64(priceCents(p.Price) * qty),
2485 })
2486 }
2487 if u.shipping != nil {
2488 items = append(items, cartItem{ID: u.shipping.ID(), Amount: int64(u.shipping.Cents)})
2489 }
2490 return items
2491}
2492
2493func renderInvoice(inv *invoiceView) string {
2494 var b strings.Builder
2495 fmt.Fprintf(&b, "\n send [::b]%s %s[-:-:-]\n", esc(inv.Amount), esc(inv.Coin))
2496 fmt.Fprintf(&b, " to [aqua]%s[-]\n\n", esc(inv.Address))
2497 fmt.Fprintf(&b, " [gray]uri[-] %s\n", esc(inv.URI))
2498 fmt.Fprintf(&b, " [gray]order[-] $%d.%02d\n", inv.FiatCents/100, inv.FiatCents%100)
2499 if t, err := time.Parse(time.RFC3339, inv.Expires); err == nil {
2500 fmt.Fprintf(&b, " [gray]quote holds until[-] %s\n", esc(t.Local().Format("15:04:05")))
2501 }
2502 b.WriteString("\n")
2503 switch inv.Status {
2504 case "paid":
2505 b.WriteString(" [green]paid β thank you. the order is printing.[-]\n")
2506 if inv.TxID != "" {
2507 fmt.Fprintf(&b, " [gray]txid %s[-]\n", esc(inv.TxID))
2508 }
2509 case "underpaid":
2510 fmt.Fprintf(&b, " [yellow]part paid:[-] %s received, [::b]%s[-:-:-] still owed\n",
2511 esc(inv.Paid), esc(inv.Outstanding))
2512 case "expired":
2513 b.WriteString(" [red]the quote expired[-] β close and start again for a fresh price\n")
2514 default:
2515 b.WriteString(" [gray]waiting for the payment to confirmβ¦[-]\n")
2516 }
2517 b.WriteString("\n [#5fd7ff]r[-] check again Β· [#5fd7ff]esc[-] close\n")
2518 return b.String()
2519}
2520
2521func createInvoice(items []cartItem) (*invoiceView, error) {
2522 body, err := json.Marshal(map[string]any{"coin": "SKY", "items": items})
2523 if err != nil {
2524 return nil, err
2525 }
2526 client := &http.Client{Timeout: 30 * time.Second}
2527 resp, err := client.Post(ordersURL()+"/create-invoice", "application/json", bytes.NewReader(body))
2528 if err != nil {
2529 return nil, err
2530 }
2531 return readInvoice(resp)
2532}
2533
2534func getInvoice(id string) (*invoiceView, error) {
2535 client := &http.Client{Timeout: 30 * time.Second}
2536 resp, err := client.Get(ordersURL() + "/invoice/" + url.PathEscape(id))
2537 if err != nil {
2538 return nil, err
2539 }
2540 return readInvoice(resp)
2541}
2542
2543func readInvoice(resp *http.Response) (*invoiceView, error) {
2544 defer resp.Body.Close() //nolint:errcheck // read-side close
2545 data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
2546 if err != nil {
2547 return nil, err
2548 }
2549 var inv invoiceView
2550 if err := json.Unmarshal(data, &inv); err != nil {
2551 return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
2552 }
2553 if inv.Error != "" {
2554 return nil, fmt.Errorf("%s", inv.Error)
2555 }
2556 if resp.StatusCode != http.StatusOK {
2557 return nil, fmt.Errorf("%s", resp.Status)
2558 }
2559 return &inv, nil
2560}
2561
2562
2563// ===== pkg/tui/frame_dump_test.go =====
2564package tui
2565
2566import (
2567 "image/png"
2568 "os"
2569 "testing"
2570)
2571
2572func TestDumpFrame(t *testing.T) {
2573 out := os.Getenv("FRAMEOUT")
2574 if out == "" {
2575 t.Skip("set FRAMEOUT to dump a frame")
2576 }
2577 logo, err := loadImage(os.Getenv("FRAMELOGO"))
2578 if err != nil {
2579 t.Fatal(err)
2580 }
2581 bd := makeBackdrop(logo, 200, 100)
2582 fr := globeFrame(bd, 0.9, 2.3, -0.6)
2583 f, err := os.Create(out) //nolint:gosec
2584 if err != nil {
2585 t.Fatalf("create %s: %v", out, err)
2586 }
2587 defer f.Close() //nolint:errcheck,gosec
2588 if err := png.Encode(f, fr); err != nil {
2589 t.Fatal(err)
2590 }
2591}
2592
2593
2594// ===== pkg/tui/htmltable.go =====
2595// Package tui pkg/tui/htmltable.go β tables in a content page.
2596//
2597// htmlToText used to flatten a table: </td> became two spaces and </tr> a
2598// newline, so the Links page β which is a three-column table of supplier,
2599// location and notes β arrived as ragged prose with the columns running into
2600// each other. The site draws it as a table and so should this.
2601//
2602// The work is laid out here rather than in render.go because it needs its own
2603// wrapper: a cell's text carries the [aqua] and [link=N] tags the anchor pass
2604// already wrote into it, and those are zero width. Wrapping on len() would
2605// count them and wrapping without them would drop a link's styling β and its
2606// clickable region β at the break.
2607package tui
2608
2609import (
2610 "regexp"
2611 "strings"
2612
2613 xhtml "html"
2614)
2615
2616var (
2617 tagTable = regexp.MustCompile(`(?is)<table[^>]*>(.*?)</table>`)
2618 tagRow = regexp.MustCompile(`(?is)<tr[^>]*>(.*?)</tr>`)
2619 tagCellFull = regexp.MustCompile(`(?is)<(t[dh])[^>]*>(.*?)</t[dh]>`)
2620 runOfSpaces = regexp.MustCompile(`[ \t\n]+`)
2621)
2622
2623// colGap is the space between columns, in cells.
2624const colGap = 2
2625
2626// minCol is how narrow shrinking may make a column before it gives up and
2627// lets the table overflow instead. A column squeezed below this is unreadable,
2628// and an honestly too-wide table is easier to deal with than a shredded one.
2629const minCol = 6
2630
2631// cellText reduces one cell's HTML to its printed text, keeping the markup
2632// tags the anchor pass wrote and dropping everything else.
2633func cellText(s string) string {
2634 s = tagBreak.ReplaceAllString(s, " ")
2635 s = tagAny.ReplaceAllString(s, "")
2636 s = xhtml.UnescapeString(s)
2637 return strings.TrimSpace(runOfSpaces.ReplaceAllString(s, " "))
2638}
2639
2640// isResetTag reports whether a color tag turns styling off rather than on:
2641// "[-]", "[-:-:-]" and the like are built only from dashes and colons.
2642func isResetTag(body string) bool {
2643 if body == "" {
2644 return false
2645 }
2646 return strings.Trim(body, "-:") == ""
2647}
2648
2649// mtok is one piece of a tagged string: a zero-width tag, or one visible rune.
2650type mtok struct {
2651 tag string
2652 txt string
2653}
2654
2655// markupTokens splits a tagged string the way parseMarkup reads it, so the
2656// two agree on what is a tag and what is text. "[[" is a literal bracket.
2657func markupTokens(s string) []mtok {
2658 var out []mtok
2659 r := []rune(s)
2660 for i := 0; i < len(r); i++ {
2661 if r[i] != '[' {
2662 out = append(out, mtok{txt: string(r[i])})
2663 continue
2664 }
2665 if i+1 < len(r) && r[i+1] == '[' {
2666 out = append(out, mtok{txt: "["})
2667 i++
2668 continue
2669 }
2670 end := -1
2671 for j := i + 1; j < len(r) && j < i+24; j++ {
2672 if r[j] == ']' {
2673 end = j
2674 break
2675 }
2676 }
2677 if end < 0 {
2678 out = append(out, mtok{txt: "["})
2679 continue
2680 }
2681 body := string(r[i+1 : end])
2682 if _, ok := linkBody(body); ok || tagBody(body) {
2683 out = append(out, mtok{tag: "[" + body + "]"})
2684 i = end
2685 continue
2686 }
2687 out = append(out, mtok{txt: "["})
2688 }
2689 return out
2690}
2691
2692// wrapMarkup breaks a tagged string into lines of at most w printed columns.
2693//
2694// The color and link tags still open at a break are repeated at the start of
2695// the next line: each line is drawn on its own, so a wrapped link that did not
2696// carry its [link=N] across would stop being clickable half way through.
2697func wrapMarkup(s string, w int) []string {
2698 if w < 1 {
2699 w = 1
2700 }
2701 var (
2702 lines []string
2703 line strings.Builder
2704 lineW int
2705 color, link string
2706 word []mtok
2707 wordW int
2708 )
2709 startLine := func() {
2710 line.Reset()
2711 lineW = 0
2712 line.WriteString(color)
2713 line.WriteString(link)
2714 }
2715 pushLine := func() {
2716 lines = append(lines, line.String())
2717 startLine()
2718 }
2719 emitWord := func() {
2720 if len(word) == 0 {
2721 return
2722 }
2723 switch {
2724 case lineW > 0 && lineW+1+wordW > w:
2725 pushLine()
2726 case lineW > 0:
2727 line.WriteString(" ")
2728 lineW++
2729 }
2730 for _, t := range word {
2731 if t.tag != "" {
2732 body := t.tag[1 : len(t.tag)-1]
2733 if _, ok := linkBody(body); ok {
2734 if body == "/link" {
2735 link = ""
2736 } else {
2737 link = t.tag
2738 }
2739 } else if isResetTag(body) {
2740 color = ""
2741 } else {
2742 color = t.tag
2743 }
2744 line.WriteString(t.tag)
2745 continue
2746 }
2747 if lineW >= w { // a single word longer than the column
2748 pushLine()
2749 }
2750 if t.txt == "[" {
2751 line.WriteString("[[")
2752 } else {
2753 line.WriteString(t.txt)
2754 }
2755 lineW++
2756 }
2757 word = word[:0]
2758 wordW = 0
2759 }
2760
2761 for _, t := range markupTokens(s) {
2762 if t.tag == "" && t.txt == " " {
2763 emitWord()
2764 continue
2765 }
2766 word = append(word, t)
2767 if t.tag == "" {
2768 wordW++
2769 }
2770 }
2771 emitWord()
2772 if lineW > 0 || len(lines) == 0 {
2773 lines = append(lines, line.String())
2774 }
2775 return lines
2776}
2777
2778// fitCols shrinks the natural column widths until the row fits, always taking
2779// from the widest column so the notes column gives way before the two narrow
2780// ones do.
2781func fitCols(nat []int, width int) []int {
2782 cols := append([]int(nil), nat...)
2783 n := len(cols)
2784 if n == 0 {
2785 return cols
2786 }
2787 avail := width - colGap*(n-1)
2788 if avail < n {
2789 avail = n
2790 }
2791 total := 0
2792 for _, c := range cols {
2793 total += c
2794 }
2795 for total > avail {
2796 wi, wv := 0, cols[0]
2797 for i, c := range cols {
2798 if c > wv {
2799 wi, wv = i, c
2800 }
2801 }
2802 if wv <= minCol {
2803 break
2804 }
2805 cols[wi]--
2806 total--
2807 }
2808 return cols
2809}
2810
2811// renderTable draws one table's inner HTML as aligned columns.
2812func renderTable(inner string, width int) string {
2813 var (
2814 rows [][]string
2815 heads []bool
2816 )
2817 for _, r := range tagRow.FindAllStringSubmatch(inner, -1) {
2818 cells := tagCellFull.FindAllStringSubmatch(r[1], -1)
2819 if len(cells) == 0 {
2820 continue
2821 }
2822 row := make([]string, 0, len(cells))
2823 head := true
2824 for _, c := range cells {
2825 if !strings.EqualFold(c[1], "th") {
2826 head = false
2827 }
2828 row = append(row, cellText(c[2]))
2829 }
2830 rows = append(rows, row)
2831 heads = append(heads, head)
2832 }
2833 if len(rows) == 0 {
2834 return ""
2835 }
2836 n := 0
2837 for _, r := range rows {
2838 if len(r) > n {
2839 n = len(r)
2840 }
2841 }
2842 nat := make([]int, n)
2843 for _, r := range rows {
2844 for i, c := range r {
2845 if w := markupWidth(c); w > nat[i] {
2846 nat[i] = w
2847 }
2848 }
2849 }
2850 cols := fitCols(nat, width)
2851
2852 var b strings.Builder
2853 for ri, row := range rows {
2854 cellLines := make([][]string, n)
2855 h := 1
2856 for i := 0; i < n; i++ {
2857 txt := ""
2858 if i < len(row) {
2859 txt = row[i]
2860 }
2861 if heads[ri] && txt != "" {
2862 txt = "[::b]" + txt + "[-:-:-]"
2863 }
2864 cellLines[i] = wrapMarkup(txt, cols[i])
2865 if len(cellLines[i]) > h {
2866 h = len(cellLines[i])
2867 }
2868 }
2869 for li := 0; li < h; li++ {
2870 var line strings.Builder
2871 for i := 0; i < n; i++ {
2872 cell := ""
2873 if li < len(cellLines[i]) {
2874 cell = cellLines[i][li]
2875 }
2876 line.WriteString(cell)
2877 if i < n-1 {
2878 pad := cols[i] - markupWidth(cell) + colGap
2879 if pad < 1 {
2880 pad = 1
2881 }
2882 line.WriteString(strings.Repeat(" ", pad))
2883 }
2884 }
2885 b.WriteString(strings.TrimRight(line.String(), " "))
2886 b.WriteByte('\n')
2887 }
2888 if heads[ri] {
2889 var rule strings.Builder
2890 for i := 0; i < n; i++ {
2891 rule.WriteString(strings.Repeat("β", cols[i]))
2892 if i < n-1 {
2893 rule.WriteString(strings.Repeat(" ", colGap))
2894 }
2895 }
2896 b.WriteString("[gray]" + rule.String() + "[-]\n")
2897 }
2898 }
2899 return b.String()
2900}
2901
2902
2903// ===== pkg/tui/htmltable_test.go =====
2904package tui
2905
2906import (
2907 "strings"
2908 "testing"
2909)
2910
2911// A table is the Links page's whole content. Flattened to runs of spaces it
2912// stopped being a table at all, so what matters is that the columns line up
2913// and that a cell too wide for its column wraps inside it rather than
2914// pushing everything after it out of true.
2915func TestTableAlignsColumnsAndWraps(t *testing.T) {
2916 const in = `<table>
2917<thead><tr><th>Supplier</th><th>Where</th><th>Notes</th></tr></thead>
2918<tbody>
2919<tr><td>Alpha</td><td>Dallas, TX</td><td>one two three four five six seven</td></tr>
2920<tr><td>Beta</td><td>Lima, OH</td><td>short</td></tr>
2921</tbody></table>`
2922 text, _ := htmlToText(in, "2026", 46)
2923 lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
2924 for _, l := range lines {
2925 if markupWidth(l) > 46 {
2926 t.Errorf("line wider than the pane (%d): %q", markupWidth(l), l)
2927 }
2928 }
2929 // Every row starts its second column at the same place.
2930 if a, b := columnOf(lines, "Dallas, TX"), columnOf(lines, "Lima, OH"); a != b || a <= 0 {
2931 t.Errorf("second column not aligned: %d vs %d\n%s", a, b, text)
2932 }
2933 // The long notes cell wrapped instead of running off.
2934 if !strings.Contains(text, "seven") {
2935 t.Errorf("wrapped text lost:\n%s", text)
2936 }
2937 if columnOf(lines, "one") != columnOf(lines, "six") {
2938 t.Errorf("continuation not in the notes column:\n%s", text)
2939 }
2940}
2941
2942// A link that wraps has to stay a link on the second line: each line is
2943// drawn on its own, so the [link=N] tag must be repeated at the break.
2944func TestWrappedLinkKeepsItsTag(t *testing.T) {
2945 const in = `<table><tr><td><a href='https://example.com/'>a very long supplier name here</a></td></tr></table>`
2946 text, hrefs := htmlToText(in, "2026", 12)
2947 if len(hrefs) != 1 || hrefs[0] != "https://example.com/" {
2948 t.Fatalf("hrefs = %v", hrefs)
2949 }
2950 for i, l := range strings.Split(strings.TrimSpace(text), "\n") {
2951 if strings.TrimSpace(stripTagsLine(l)) == "" {
2952 continue
2953 }
2954 if !strings.Contains(l, "[link=1]") {
2955 t.Errorf("line %d dropped the link tag: %q", i, l)
2956 }
2957 }
2958}
2959
2960// Anchors inside a table are numbered with the rest of the page, in
2961// document order: the table is set aside and drawn last, which must not
2962// renumber anything.
2963func TestTableAnchorsKeepDocumentOrder(t *testing.T) {
2964 const in = `<p><a href='/one'>one</a></p>
2965<table><tr><td><a href='/two'>two</a></td><td><a href='/three'>three</a></td></tr></table>
2966<p><a href='/four'>four</a></p>`
2967 _, hrefs := htmlToText(in, "2026", 60)
2968 want := []string{"/one", "/two", "/three", "/four"}
2969 if len(hrefs) != len(want) {
2970 t.Fatalf("hrefs = %v", hrefs)
2971 }
2972 for i := range want {
2973 if hrefs[i] != want[i] {
2974 t.Errorf("hrefs[%d] = %q, want %q", i, hrefs[i], want[i])
2975 }
2976 }
2977}
2978
2979func stripTagsLine(s string) string {
2980 out := ""
2981 for _, sg := range parseMarkup(s, styleText) {
2982 out += string(sg.txt)
2983 }
2984 return out
2985}
2986
2987// columnOf reports the printed column a word starts at, on the first line
2988// that holds it.
2989func columnOf(lines []string, word string) int {
2990 for _, l := range lines {
2991 plain := stripTagsLine(l)
2992 if i := strings.Index(plain, word); i >= 0 {
2993 return i
2994 }
2995 }
2996 return -1
2997}
2998
2999
3000// ===== pkg/tui/kit.go =====
3001// Package tui pkg/tui/kit.go β drawing primitives on tcell: a small
3002// color-tag markup ([fg:bg:flags], as tview popularized), line printing
3003// with alignment and wrapping, and bordered boxes. The TUI is written
3004// straight on tcell v3 so it runs wherever the 0magnet ecosystem's
3005// terminal stack does β natively today, in the browser via
3006// tuiwasm/xtcell tomorrow.
3007package tui
3008
3009import (
3010 "fmt"
3011 "strconv"
3012 "strings"
3013
3014 "github.com/gdamore/tcell/v3"
3015)
3016
3017type rect struct{ x, y, w, h int }
3018
3019// esc quotes user data so it never parses as a color tag: "[" doubles,
3020// and the parser reads "[[" back as one literal bracket.
3021func esc(s string) string {
3022 return strings.ReplaceAll(s, "[", "[[")
3023}
3024
3025// seg is a run of text in one style, optionally inside a link.
3026type seg struct {
3027 txt []rune
3028 st tcell.Style
3029 // link is the 1-based index of the anchor this run belongs to, or 0
3030 // for ordinary text. What it indexes is the drawing code's business:
3031 // each page keeps its own table of what its links do. See linkTag.
3032 link int
3033}
3034
3035// namedColors are the palette names the markup uses; anything else goes
3036// to tcell.GetColor (which handles #rrggbb and the W3C names).
3037var namedColors = map[string]tcell.Color{
3038 "aqua": tcell.ColorAqua.TrueColor(),
3039 "white": tcell.ColorWhite.TrueColor(),
3040 "black": tcell.ColorBlack.TrueColor(),
3041 "gray": tcell.GetColor("#808080"),
3042 "grey": tcell.GetColor("#808080"),
3043 "yellow": tcell.ColorYellow.TrueColor(),
3044 "red": tcell.ColorRed.TrueColor(),
3045 "green": tcell.ColorGreen.TrueColor(),
3046 "orange": tcell.GetColor("#ffa500"),
3047}
3048
3049func markupColor(name string) (tcell.Color, bool) {
3050 if c, ok := namedColors[name]; ok {
3051 return c, true
3052 }
3053 c := tcell.GetColor(name)
3054 if c == tcell.ColorDefault && name != "default" {
3055 return c, false
3056 }
3057 return c, true
3058}
3059
3060// linkTag wraps text in an anchor: everything between [link=N] and
3061// [/link] carries link index N, which the drawing code turns into a
3062// clickable region. N is 1-based so that a zero index means "no link".
3063//
3064// The page's own markup is the only thing that may produce these β esc
3065// doubles the bracket in anything that came from the catalog or from a
3066// content file, so user data cannot open an anchor.
3067func linkTag(n int, s string) string {
3068 return fmt.Sprintf("[link=%d]%s[/link]", n, s)
3069}
3070
3071// linkBody reads a [link=N] / [/link] tag, returning the index it opens
3072// (0 for the closing tag) and whether the body was one of them at all.
3073func linkBody(s string) (int, bool) {
3074 if s == "/link" {
3075 return 0, true
3076 }
3077 rest, ok := strings.CutPrefix(s, "link=")
3078 if !ok {
3079 return 0, false
3080 }
3081 n, err := strconv.Atoi(rest)
3082 if err != nil || n < 1 {
3083 return 0, false
3084 }
3085 return n, true
3086}
3087
3088// tagBody reports whether the text between brackets looks like a color
3089// tag: colors and flags only, at most two colons.
3090func tagBody(s string) bool {
3091 if s == "" {
3092 return false
3093 }
3094 colons := 0
3095 for _, r := range s {
3096 switch {
3097 case r == ':':
3098 colons++
3099 case r == '#' || r == '-':
3100 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
3101 default:
3102 return false
3103 }
3104 }
3105 return colons <= 2
3106}
3107
3108// applyTag folds one [fg:bg:flags] spec into a style. Empty parts keep,
3109// "-" resets to the base.
3110func applyTag(cur, base tcell.Style, body string) tcell.Style {
3111 parts := strings.SplitN(body, ":", 3)
3112 if len(parts) > 0 && parts[0] != "" {
3113 if parts[0] == "-" {
3114 cur = cur.Foreground(base.GetForeground())
3115 } else if c, ok := markupColor(parts[0]); ok {
3116 cur = cur.Foreground(c)
3117 }
3118 }
3119 if len(parts) > 1 && parts[1] != "" {
3120 if parts[1] == "-" {
3121 cur = cur.Background(base.GetBackground())
3122 } else if c, ok := markupColor(parts[1]); ok {
3123 cur = cur.Background(c)
3124 }
3125 }
3126 if len(parts) > 2 {
3127 if parts[2] == "-" || parts[2] == "" {
3128 cur = cur.Attributes(base.GetAttributes())
3129 } else {
3130 for _, f := range parts[2] {
3131 switch f {
3132 case 'b':
3133 cur = cur.Bold(true)
3134 case 'u':
3135 cur = cur.Underline(true)
3136 }
3137 }
3138 }
3139 }
3140 return cur
3141}
3142
3143// parseMarkup splits a tagged string into styled segments.
3144func parseMarkup(s string, base tcell.Style) []seg {
3145 var out []seg
3146 cur := base
3147 link := 0
3148 var run []rune
3149 flush := func() {
3150 if len(run) > 0 {
3151 out = append(out, seg{run, cur, link})
3152 run = nil
3153 }
3154 }
3155 r := []rune(s)
3156 for i := 0; i < len(r); i++ {
3157 if r[i] != '[' {
3158 run = append(run, r[i])
3159 continue
3160 }
3161 if i+1 < len(r) && r[i+1] == '[' { // escaped literal bracket
3162 run = append(run, '[')
3163 i++
3164 continue
3165 }
3166 end := -1
3167 for j := i + 1; j < len(r) && j < i+24; j++ {
3168 if r[j] == ']' {
3169 end = j
3170 break
3171 }
3172 }
3173 if end < 0 {
3174 run = append(run, r[i])
3175 continue
3176 }
3177 body := string(r[i+1 : end])
3178 if n, ok := linkBody(body); ok {
3179 flush()
3180 link = n
3181 i = end
3182 continue
3183 }
3184 if !tagBody(body) {
3185 run = append(run, r[i])
3186 continue
3187 }
3188 flush()
3189 cur = applyTag(cur, base, body)
3190 i = end
3191 }
3192 flush()
3193 return out
3194}
3195
3196// markupWidth is the printed width of a tagged string.
3197func markupWidth(s string) int {
3198 n := 0
3199 for _, sg := range parseMarkup(s, tcell.StyleDefault) {
3200 n += len(sg.txt)
3201 }
3202 return n
3203}
3204
3205// printSegs draws segments on one row, clipped to maxW; returns cells drawn.
3206func printSegs(sc tcell.Screen, x, y, maxW int, segs []seg) int {
3207 return printSegsLinks(sc, x, y, maxW, segs, nil)
3208}
3209
3210// printSegsLinks is printSegs, reporting each anchor it drew: onLink is
3211// called once per run of link cells with the run's position, width and
3212// link index, which is what turns markup into something clickable.
3213//
3214// Per run of cells and not per segment: an anchor split by a color
3215// change inside it is still one link, and a link clipped away at maxW
3216// must not leave a region covering cells that were never drawn.
3217func printSegsLinks(sc tcell.Screen, x, y, maxW int, segs []seg, onLink func(x, y, w, link int)) int {
3218 n := 0
3219 run, runStart := 0, 0
3220 flush := func() {
3221 if run != 0 && onLink != nil {
3222 onLink(x+runStart, y, n-runStart, run)
3223 }
3224 run = 0
3225 }
3226 for _, sg := range segs {
3227 if sg.link != run {
3228 flush()
3229 run, runStart = sg.link, n
3230 }
3231 for _, r := range sg.txt {
3232 if n >= maxW {
3233 flush()
3234 return n
3235 }
3236 sc.SetContent(x+n, y, r, nil, sg.st)
3237 n++
3238 }
3239 }
3240 flush()
3241 return n
3242}
3243
3244// printMarkup draws one tagged line, clipped to maxW.
3245// The width it returns mirrors printSegs, which callers of that do use.
3246func printMarkup(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style) int { //nolint:unparam
3247 return printSegs(sc, x, y, maxW, parseMarkup(s, base))
3248}
3249
3250// printMarkupLinks draws one tagged line and reports its anchors.
3251func printMarkupLinks(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style, onLink func(x, y, w, link int)) {
3252 printSegsLinks(sc, x, y, maxW, parseMarkup(s, base), onLink)
3253}
3254
3255// printMarkupCenter draws one tagged line centered in w columns.
3256func printMarkupCenter(sc tcell.Screen, x, y, w int, s string, base tcell.Style) {
3257 pad := (w - markupWidth(s)) / 2
3258 if pad < 0 {
3259 pad = 0
3260 }
3261 printMarkup(sc, x+pad, y, w-pad, s, base)
3262}
3263
3264// wrapSegs word-wraps parsed lines to width w.
3265func wrapSegs(s string, w int, base tcell.Style, wrap bool) [][]seg {
3266 if w < 1 {
3267 w = 1
3268 }
3269 var out [][]seg
3270 for _, line := range strings.Split(s, "\n") {
3271 segs := parseMarkup(line, base)
3272 if !wrap {
3273 out = append(out, segs)
3274 continue
3275 }
3276 // Flatten to styled runes, then break into rows at spaces.
3277 var flat []styledRune
3278 for _, sg := range segs {
3279 for _, r := range sg.txt {
3280 flat = append(flat, styledRune{r, sg.st, sg.link})
3281 }
3282 }
3283 for len(flat) > w {
3284 cut := -1
3285 for i := w; i > 0; i-- {
3286 if flat[i].r == ' ' {
3287 cut = i
3288 break
3289 }
3290 }
3291 if cut <= 0 {
3292 cut = w
3293 }
3294 out = append(out, packSegs(flat[:cut]))
3295 for cut < len(flat) && flat[cut].r == ' ' {
3296 cut++
3297 }
3298 flat = flat[cut:]
3299 }
3300 out = append(out, packSegs(flat))
3301 }
3302 return out
3303}
3304
3305type styledRune struct {
3306 r rune
3307 st tcell.Style
3308 link int
3309}
3310
3311// keyRune reads the rune of a KeyRune event (v3 carries it as Str).
3312func keyRune(ev *tcell.EventKey) rune {
3313 if ev.Key() != tcell.KeyRune {
3314 return 0
3315 }
3316 rs := []rune(ev.Str())
3317 if len(rs) == 0 {
3318 return 0
3319 }
3320 return rs[0]
3321}
3322
3323func packSegs(flat []styledRune) []seg {
3324 var segs []seg
3325 for _, c := range flat {
3326 if n := len(segs); n > 0 && segs[n-1].st == c.st && segs[n-1].link == c.link {
3327 segs[n-1].txt = append(segs[n-1].txt, c.r)
3328 } else {
3329 segs = append(segs, seg{[]rune{c.r}, c.st, c.link})
3330 }
3331 }
3332 return segs
3333}
3334
3335// fillRect paints a rectangle with spaces in the given style.
3336func fillRect(sc tcell.Screen, r rect, st tcell.Style) {
3337 for y := r.y; y < r.y+r.h; y++ {
3338 for x := r.x; x < r.x+r.w; x++ {
3339 sc.SetContent(x, y, ' ', nil, st)
3340 }
3341 }
3342}
3343
3344// drawRule draws a horizontal rule, the terminal stand-in for the 1px
3345// table borders that close the page's header and footer.
3346func drawRule(sc tcell.Screen, x, y, w int, st tcell.Style) {
3347 for i := 0; i < w; i++ {
3348 sc.SetContent(x+i, y, 'β', nil, st)
3349 }
3350}
3351
3352// drawBox fills and frames a rectangle, with an optional title in the
3353// top border.
3354func drawBox(sc tcell.Screen, r rect, title string, border, fill tcell.Style) {
3355 if r.w < 2 || r.h < 2 {
3356 return
3357 }
3358 fillRect(sc, r, fill)
3359 for x := r.x + 1; x < r.x+r.w-1; x++ {
3360 sc.SetContent(x, r.y, 'β', nil, border)
3361 sc.SetContent(x, r.y+r.h-1, 'β', nil, border)
3362 }
3363 for y := r.y + 1; y < r.y+r.h-1; y++ {
3364 sc.SetContent(r.x, y, 'β', nil, border)
3365 sc.SetContent(r.x+r.w-1, y, 'β', nil, border)
3366 }
3367 sc.SetContent(r.x, r.y, 'β', nil, border)
3368 sc.SetContent(r.x+r.w-1, r.y, 'β', nil, border)
3369 sc.SetContent(r.x, r.y+r.h-1, 'β', nil, border)
3370 sc.SetContent(r.x+r.w-1, r.y+r.h-1, 'β', nil, border)
3371 if title != "" {
3372 printMarkup(sc, r.x+2, r.y, r.w-4, title, fill)
3373 }
3374}
3375
3376// inner is the area inside a box's border.
3377func (r rect) inner() rect {
3378 return rect{r.x + 1, r.y + 1, r.w - 2, r.h - 2}
3379}
3380
3381func (r rect) contains(x, y int) bool {
3382 return x >= r.x && y >= r.y && x < r.x+r.w && y < r.y+r.h
3383}
3384
3385
3386// ===== pkg/tui/kittyimg.go =====
3387package tui
3388
3389import (
3390 "bytes"
3391 "encoding/base64"
3392 "fmt"
3393 "image"
3394 "image/draw"
3395 "image/png"
3396 "os"
3397 "strconv"
3398
3399 "github.com/0magnet/websh/progressive"
3400)
3401
3402// Pictures in a terminal that is not websh: where the terminal speaks
3403// kitty's graphics protocol (kitty, WezTerm, Ghostty, Konsole β the probe
3404// asks), a product's photo is the photo itself, laid over the cells that
3405// draw it in half blocks, as websh lays it with a placement. Widgets need a
3406// page; in a terminal they stay cells.
3407//
3408// M2_IMAGES chooses: "place" (websh's placements), "kitty", or "cells".
3409
3410// imageMode is how pictures go beyond cells here: "place", "kitty" or "".
3411func imageMode() string {
3412 switch os.Getenv("M2_IMAGES") {
3413 case "cells":
3414 return ""
3415 case "kitty":
3416 return "kitty"
3417 case "place":
3418 return "place"
3419 }
3420 c := progressive.Current()
3421 if !c.Has("place") && c.Has("kitty-graphics") {
3422 return "kitty"
3423 }
3424 // A terminal that does not know OSC 7337 ignores it, so placements are
3425 // the default even where nothing answered.
3426 return "place"
3427}
3428
3429// kittyID is the image id a placement's pictures go by.
3430func (u *ui) kittyID(id string) uint32 {
3431 if u.kittyIDs == nil {
3432 u.kittyIDs = map[string]uint32{}
3433 }
3434 n, ok := u.kittyIDs[id]
3435 if !ok {
3436 n = uint32(len(u.kittyIDs) + 7337) //nolint:gosec // a few placements at most
3437 u.kittyIDs[id] = n
3438 }
3439 return n
3440}
3441
3442// kittyPlace lays p's photo over its cells, or does nothing for what is not
3443// a photo.
3444func (u *ui) kittyPlace(id string, p placed) {
3445 if p.url == "" {
3446 return
3447 }
3448 data, err := u.kittyPNG(p)
3449 if err != nil {
3450 return
3451 }
3452 enc := base64.StdEncoding.EncodeToString(data)
3453 var b bytes.Buffer
3454 fmt.Fprintf(&b, "\x1b7\x1b[%d;%dH", p.r.y+1, p.r.x+1) // save the cursor, go to the cells
3455 first := true
3456 for len(enc) > 0 || first {
3457 n := min(len(enc), 4096)
3458 more := "0"
3459 if n < len(enc) {
3460 more = "1"
3461 }
3462 if first {
3463 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])
3464 first = false
3465 } else {
3466 fmt.Fprintf(&b, "\x1b_Gm=%s;%s\x1b\\", more, enc[:n])
3467 }
3468 enc = enc[n:]
3469 }
3470 b.WriteString("\x1b8") // and back
3471 u.writeTerm(b.String())
3472}
3473
3474// kittyRemove takes p's picture away.
3475func (u *ui) kittyRemove(id string) {
3476 u.writeTerm("\x1b_Ga=d,d=I,i=" + strconv.FormatUint(uint64(u.kittyID(id)), 10) + ",q=2\x1b\\")
3477}
3478
3479// kittyPNG is p's photo as a PNG the shape of its cells β kitty stretches a
3480// picture to the cells it is given β contained or, for "cover", cropped.
3481func (u *ui) kittyPNG(p placed) ([]byte, error) {
3482 key := fmt.Sprintf("%s|%dx%d|%s", p.url, p.r.w, p.r.h, p.fit)
3483 if b, ok := u.kittyCache[key]; ok {
3484 return b, nil
3485 }
3486 raw, err := getImageData(p.url)
3487 if err != nil {
3488 return nil, err
3489 }
3490 src, _, err := image.Decode(bytes.NewReader(raw))
3491 if err != nil {
3492 return nil, err
3493 }
3494 cw, ch := 1.0, 2.0 // a cell's shape where the terminal does not say
3495 if c := progressive.Current(); c != nil && c.Cell.W > 0 && c.Cell.H > 0 {
3496 cw, ch = c.Cell.W, c.Cell.H
3497 }
3498 aspect := float64(p.r.w) * cw / (float64(p.r.h) * ch)
3499 sb := src.Bounds()
3500 sw, sh := float64(sb.Dx()), float64(sb.Dy())
3501 var dst *image.RGBA
3502 if p.fit == "cover" { // crop the middle to the cells' shape
3503 w, h := sw, sh
3504 if sw/sh > aspect {
3505 w = sh * aspect
3506 } else {
3507 h = sw / aspect
3508 }
3509 r := image.Rect(0, 0, int(w), int(h))
3510 dst = image.NewRGBA(r)
3511 off := image.Pt(sb.Min.X+int((sw-w)/2), sb.Min.Y+int((sh-h)/2))
3512 draw.Draw(dst, r, src, off, draw.Src)
3513 } else { // the whole of it, transparent around it
3514 w, h := sw, sh
3515 if sw/sh > aspect {
3516 h = sw / aspect
3517 } else {
3518 w = sh * aspect
3519 }
3520 dst = image.NewRGBA(image.Rect(0, 0, int(w), int(h)))
3521 at := image.Pt(int((w-sw)/2), int((h-sh)/2))
3522 draw.Draw(dst, sb.Sub(sb.Min).Add(at), src, sb.Min, draw.Src)
3523 }
3524 var out bytes.Buffer
3525 if err := png.Encode(&out, dst); err != nil {
3526 return nil, err
3527 }
3528 if u.kittyCache == nil {
3529 u.kittyCache = map[string][]byte{}
3530 }
3531 u.kittyCache[key] = out.Bytes()
3532 return out.Bytes(), nil
3533}
3534
3535
3536// ===== pkg/tui/links_test.go =====
3537package tui
3538
3539import (
3540 "strings"
3541 "testing"
3542
3543 "github.com/gdamore/tcell/v3"
3544
3545 "github.com/0magnet/m2/pkg/product"
3546 "github.com/0magnet/m2/pkg/storepage"
3547)
3548
3549// nullScreen is somewhere for the printers to put cells. Only SetContent
3550// is implemented: tcell v3 ships no simulation screen, and the anchor
3551// bookkeeping under test needs nothing else. The embedded nil interface
3552// supplies the rest of the method set, so a test that reached for one
3553// would panic rather than quietly pass.
3554type nullScreen struct{ tcell.Screen }
3555
3556func (nullScreen) SetContent(int, int, rune, []rune, tcell.Style) {}
3557
3558// span is a drawn anchor, as printSegsLinks reports one.
3559type span struct{ x, y, w, link int }
3560
3561// recorder collects the anchors a print reports, which is the whole of
3562// what makes a link clickable.
3563type recorder struct{ got []span }
3564
3565func (r *recorder) sink(x, y, w, link int) {
3566 r.got = append(r.got, span{x, y, w, link})
3567}
3568
3569// A link tag marks its text and nothing else, and the tag itself takes
3570// up no room on the line.
3571func TestLinkTagMarksOnlyItsText(t *testing.T) {
3572 s := "Category: " + linkTag(1, "diode") + " end"
3573 if w := markupWidth(s); w != len("Category: diode end") {
3574 t.Errorf("width %d, want %d", w, len("Category: diode end"))
3575 }
3576 var linked string
3577 for _, sg := range parseMarkup(s, tcell.StyleDefault) {
3578 if sg.link == 1 {
3579 linked += string(sg.txt)
3580 }
3581 }
3582 if linked != "diode" {
3583 t.Errorf("link 1 covers %q, want %q", linked, "diode")
3584 }
3585}
3586
3587// Color changes inside an anchor do not split it: the region has to
3588// cover the whole of the link or half of it stops being clickable.
3589func TestLinkSurvivesAColorChangeInside(t *testing.T) {
3590 var r recorder
3591 printMarkupLinks(nullScreen{}, 3, 7, 80, linkTag(1, "[aqua]dio[white]de[-]"), styleText, r.sink)
3592 if len(r.got) != 1 {
3593 t.Fatalf("%d regions, want 1: %+v", len(r.got), r.got)
3594 }
3595 if want := (span{x: 3, y: 7, w: 5, link: 1}); r.got[0] != want {
3596 t.Errorf("region %+v, want %+v", r.got[0], want)
3597 }
3598}
3599
3600// A link clipped off the end of the line must not leave a region over
3601// cells that were never drawn.
3602func TestLinkRegionStopsAtTheClip(t *testing.T) {
3603 var r recorder
3604 printMarkupLinks(nullScreen{}, 0, 0, 6, "abc"+linkTag(1, "defghij"), styleText, r.sink)
3605 if len(r.got) != 1 {
3606 t.Fatalf("%d regions, want 1: %+v", len(r.got), r.got)
3607 }
3608 if got := r.got[0].x + r.got[0].w; got != 6 {
3609 t.Errorf("region ends at %d, want 6 (the clip)", got)
3610 }
3611}
3612
3613// Two anchors on one line are two regions, in the order they were
3614// numbered β which is what binds each to the right action.
3615func TestTwoLinksOnOneLine(t *testing.T) {
3616 var idx []int
3617 printMarkupLinks(nullScreen{}, 0, 0, 80, linkTag(1, "one")+", "+linkTag(2, "two"), styleText,
3618 func(_, _, _, link int) { idx = append(idx, link) })
3619 if len(idx) != 2 || idx[0] != 1 || idx[1] != 2 {
3620 t.Errorf("link indices %v, want [1 2]", idx)
3621 }
3622}
3623
3624// Catalog text is escaped before it reaches the parser, so a product
3625// named with a bracket cannot open an anchor of its own.
3626func TestEscapedTextCannotForgeALink(t *testing.T) {
3627 for _, sg := range parseMarkup(esc("[link=1]nope[/link]"), tcell.StyleDefault) {
3628 if sg.link != 0 {
3629 t.Fatalf("escaped text produced link %d", sg.link)
3630 }
3631 }
3632}
3633
3634// The stock pages are mostly anchors; htmlToText has to hand back where
3635// each one goes, in the order its tags are numbered.
3636func TestHTMLToTextReturnsHrefs(t *testing.T) {
3637 in := `<p>see <a title='x' href="https://example.com/a">A</a> and ` +
3638 `<a href='/cat/diode'>diodes</a> and <a>nowhere</a></p>`
3639 text, hrefs := htmlToText(in, "2026", 80)
3640 want := []string{"https://example.com/a", "/cat/diode", ""}
3641 if len(hrefs) != len(want) {
3642 t.Fatalf("%d hrefs, want %d: %q", len(hrefs), len(want), hrefs)
3643 }
3644 for i := range want {
3645 if hrefs[i] != want[i] {
3646 t.Errorf("href %d = %q, want %q", i, hrefs[i], want[i])
3647 }
3648 }
3649 if !strings.Contains(text, "[link=2]") {
3650 t.Errorf("text carries no anchor tags: %q", text)
3651 }
3652 if markupWidth(text) != len("see A and diodes and nowhere\n") {
3653 t.Errorf("anchor tags left width %d in %q", markupWidth(text), text)
3654 }
3655}
3656
3657// An href is routed the way the site routes it: a path this store serves
3658// is a page the TUI has, and anything else is a link out.
3659func TestFollowRoutesSitePaths(t *testing.T) {
3660 u := &ui{panels: map[string]panel{}, cat: newCatalogInfo(product.Products{
3661 {Partno: "1N4001", Category: "diode", Subcategory: "1N40XX"},
3662 {Partno: "1N4729", Category: "diode", Subcategory: "1N47XX"},
3663 })}
3664 if u.follow("") != nil {
3665 t.Error("an empty href is not a link")
3666 }
3667 u.current = "content:about"
3668 u.follow("/")()
3669 if u.current != "home" {
3670 t.Errorf("/ went to %q, want home", u.current)
3671 }
3672 u.follow("/cat/diode/1N47XX")()
3673 if want := "list:diode|1N47XX"; u.current != want {
3674 t.Errorf("subcategory path went to %q, want %q", u.current, want)
3675 }
3676 u.follow("/p/1N4001")()
3677 if want := "prod:1N4001"; u.current != want {
3678 t.Errorf("product path went to %q, want %q", u.current, want)
3679 }
3680 // A link out does not navigate; it hands over the address.
3681 before := u.current
3682 u.follow("https://example.com")()
3683 if u.current != before {
3684 t.Errorf("an external link navigated to %q", u.current)
3685 }
3686 if !strings.Contains(u.notice, "example.com") {
3687 t.Errorf("external link gave no address: %q", u.notice)
3688 }
3689}
3690
3691// The pointer lights up whichever of the page's links it is over: the
3692// header nav on the top row, the footer's cells on the bottom one, and
3693// the panel's own anchors in between.
3694func TestHoveredFindsEachKindOfLink(t *testing.T) {
3695 const h = 20
3696 u := &ui{hoverX: -1, hoverY: -1}
3697 u.navRegions = append(u.navRegions, struct {
3698 x1, x2 int
3699 act string
3700 }{4, 14, "cats"})
3701 u.footRegions = append(u.footRegions, struct {
3702 x1, x2 int
3703 act string
3704 }{2, 9, "cart"})
3705 u.links = []linkRegion{{x: 6, y: 7, w: 5, act: func() {}}}
3706
3707 for _, c := range []struct {
3708 name string
3709 x, y int
3710 wantX1, wantX2 int
3711 want bool
3712 }{
3713 {"nothing seen yet", -1, -1, 0, 0, false},
3714 {"header entry", 5, 0, 4, 14, true},
3715 {"header, past the entry", 30, 0, 0, 0, false},
3716 {"footer cell", 3, h - 1, 2, 9, true},
3717 {"panel anchor", 8, 7, 6, 11, true},
3718 {"one cell past the anchor", 11, 7, 0, 0, false},
3719 {"the row below it", 8, 8, 0, 0, false},
3720 } {
3721 u.hoverX, u.hoverY = c.x, c.y
3722 x1, x2, _, ok := u.hovered(h)
3723 if ok != c.want {
3724 t.Errorf("%s: hovered=%v, want %v", c.name, ok, c.want)
3725 continue
3726 }
3727 if ok && (x1 != c.wantX1 || x2 != c.wantX2) {
3728 t.Errorf("%s: span %d..%d, want %d..%d", c.name, x1, x2, c.wantX1, c.wantX2)
3729 }
3730 }
3731}
3732
3733// sampleTable is a two-column table whose first column is the row's link.
3734func sampleTable() *table {
3735 tbl := &table{cols: []tcol{
3736 {title: "Name", link: true},
3737 {title: "Price", width: 9, alignRight: true},
3738 }}
3739 tbl.setRows([]trow{
3740 {cells: []tcell_{{text: "one"}, {text: "$1"}}},
3741 {cells: []tcell_{{text: "two"}, {text: "$2"}}},
3742 })
3743 return tbl
3744}
3745
3746// Every drawn row offers its name as an anchor, covering the name and
3747// not the blank column beside it β that span is both what one click
3748// follows and what the pointer lights up.
3749func TestTableReportsEachRowsLinkCell(t *testing.T) {
3750 tbl := sampleTable()
3751 var got []span
3752 tbl.onLink = func(x, y, w, row, _ int) { got = append(got, span{x, y, w, row}) }
3753 tbl.draw(nullScreen{}, rect{x: 2, y: 4, w: 40, h: 10})
3754
3755 want := []span{{x: 2, y: 5, w: 3, link: 0}, {x: 2, y: 6, w: 3, link: 1}}
3756 if len(got) != len(want) {
3757 t.Fatalf("%d anchors, want %d: %+v", len(got), len(want), got)
3758 }
3759 for i := range want {
3760 if got[i] != want[i] {
3761 t.Errorf("anchor %d = %+v, want %+v", i, got[i], want[i])
3762 }
3763 }
3764}
3765
3766// A column that is not the link column offers nothing, and neither does
3767// the header row.
3768func TestTableReportsOnlyTheLinkColumn(t *testing.T) {
3769 tbl := sampleTable()
3770 tbl.cols[0].link = false
3771 n := 0
3772 tbl.onLink = func(int, int, int, int, int) { n++ }
3773 tbl.draw(nullScreen{}, rect{x: 0, y: 0, w: 40, h: 10})
3774 if n != 0 {
3775 t.Errorf("%d anchors from a table with no link column, want 0", n)
3776 }
3777}
3778
3779// A row has an anchor per link cell, and an empty one offers none β
3780// which is how a product with no stock has nothing to add to a cart,
3781// the page omitting its button for the same reason.
3782func TestTableSkipsAnEmptyLinkCell(t *testing.T) {
3783 tbl := &table{cols: []tcol{
3784 {title: "Name", link: true},
3785 {title: "Buy", width: 11, link: true},
3786 }}
3787 tbl.setRows([]trow{
3788 {cells: []tcell_{{text: "in stock"}, {text: storepage.AddToCart}}},
3789 {cells: []tcell_{{text: "sold out"}, {text: ""}}},
3790 })
3791 byCol := map[int]int{}
3792 tbl.onLink = func(_, _, _, _, col int) { byCol[col]++ }
3793 tbl.draw(nullScreen{}, rect{x: 0, y: 0, w: 40, h: 10})
3794
3795 if byCol[0] != 2 {
3796 t.Errorf("%d name anchors, want one per row", byCol[0])
3797 }
3798 if byCol[1] != 1 {
3799 t.Errorf("%d buy anchors, want one β the sold-out row has no button", byCol[1])
3800 }
3801}
3802
3803// The link cell never reaches the table's own mouse handling β the page
3804// follows the anchor first. What is left is the rest of the row: it
3805// selects, and a second click on the selected row opens it.
3806func TestTableClickSelectsThenOpens(t *testing.T) {
3807 tbl := sampleTable()
3808 opened := -1
3809 tbl.onActivate = func(i int) { opened = i }
3810 click := tcell.NewEventMouse(0, 0, tcell.Button1, 0)
3811
3812 tbl.mouse(2, click) // row 1, which is not selected
3813 if opened != -1 {
3814 t.Errorf("the first click opened %d, want nothing", opened)
3815 }
3816 if tbl.sel != 1 {
3817 t.Errorf("the first click selected row %d, want 1", tbl.sel)
3818 }
3819 tbl.mouse(2, click)
3820 if opened != 1 {
3821 t.Errorf("the second click opened %d, want 1", opened)
3822 }
3823}
3824
3825
3826// ===== pkg/tui/mirror.go =====
3827package tui
3828
3829import (
3830 "fmt"
3831 "html"
3832 "net/url"
3833 "sort"
3834 "strings"
3835
3836 "github.com/0magnet/m2/pkg/storepage"
3837)
3838
3839// The mirror (websh's PROTOCOL.md, Accessibility): where the host takes it,
3840// the store publishes the page it shows as structure β the product, the list,
3841// the categories β for a screen reader, which reads the cells as a grid of
3842// characters. Its links go to the site's own pages.
3843
3844// mirrorLimit is how many products a list's mirror names.
3845const mirrorLimit = 500
3846
3847// mirrorHTML is the page now showing, as an HTML fragment.
3848func (u *ui) mirrorHTML() string {
3849 esc := html.EscapeString
3850 site := storeURL()
3851 link := func(path, text string) string {
3852 return `<a href="` + esc(site+path) + `">` + esc(text) + `</a>`
3853 }
3854 catPath := func(cat, sub string) string {
3855 p := "/cat/" + url.PathEscape(cat)
3856 if sub != "" {
3857 p += "/" + url.PathEscape(storepage.EscapeSubcat(sub))
3858 }
3859 return p
3860 }
3861 var b strings.Builder
3862 u.mu.Lock()
3863 cur := u.current
3864 u.mu.Unlock()
3865 switch {
3866 case strings.HasPrefix(cur, "prod:"):
3867 p := u.cat.find(strings.TrimPrefix(cur, "prod:"))
3868 if p == nil {
3869 return ""
3870 }
3871 fmt.Fprintf(&b, "<h1>%s</h1><ul>", esc(p.Name))
3872 for _, kv := range [][2]string{{"Price", "$" + strings.TrimPrefix(p.Price, "$")}, {"In stock", p.Quantity}, {"Part number", p.Partno}, {"Package", p.Packagetype}} {
3873 if kv[1] != "" && kv[1] != "$" {
3874 fmt.Fprintf(&b, "<li>%s: %s</li>", kv[0], esc(kv[1]))
3875 }
3876 }
3877 if p.Category != "" {
3878 b.WriteString("<li>Category: " + link(catPath(p.Category, ""), p.Category))
3879 if p.Subcategory != "" {
3880 b.WriteString(", " + link(catPath(p.Category, p.Subcategory), p.Subcategory))
3881 }
3882 b.WriteString("</li>")
3883 }
3884 b.WriteString("</ul>")
3885 for _, d := range []string{p.Description1, p.Description2} {
3886 if d != "" {
3887 fmt.Fprintf(&b, "<p>%s</p>", esc(d))
3888 }
3889 }
3890 b.WriteString("<p>" + link("/p/"+url.PathEscape(p.Partno), "This product on the website") + "</p>")
3891 case strings.HasPrefix(cur, "list:"):
3892 cat, sub, _ := strings.Cut(strings.TrimPrefix(cur, "list:"), "|")
3893 title := "All products"
3894 if cat != "" {
3895 title = cat
3896 if sub != "" {
3897 title += ": " + sub
3898 }
3899 }
3900 prods := u.cat.products(cat, sub)
3901 fmt.Fprintf(&b, "<h1>%s</h1><p>%d products.</p><ul>", esc(title), len(prods))
3902 for i, p := range prods {
3903 if i == mirrorLimit {
3904 fmt.Fprintf(&b, "<li>and %d more</li>", len(prods)-i)
3905 break
3906 }
3907 fmt.Fprintf(&b, "<li>%s β $%s</li>", link("/p/"+url.PathEscape(p.Partno), p.Name), esc(strings.TrimPrefix(p.Price, "$")))
3908 }
3909 b.WriteString("</ul>")
3910 case strings.HasPrefix(cur, "content:"):
3911 name := strings.TrimPrefix(cur, "content:")
3912 fmt.Fprintf(&b, "<h1>%s</h1>%s", esc(name), fetchContent(name)) // the site's own page; the host keeps only structure
3913 default: // home
3914 long := f.Sitelongname
3915 if long == "" {
3916 long = u.sitedomain
3917 }
3918 fmt.Fprintf(&b, "<h1>%s</h1>", esc(long))
3919 if f.Sitetagline != "" {
3920 fmt.Fprintf(&b, "<p>%s</p>", esc(f.Sitetagline))
3921 }
3922 cats := make([]string, 0, len(u.cat.catCounts))
3923 for c := range u.cat.catCounts {
3924 cats = append(cats, c)
3925 }
3926 sort.Strings(cats)
3927 b.WriteString("<h2>Categories</h2><ul>")
3928 for _, c := range cats {
3929 fmt.Fprintf(&b, "<li>%s (%d)</li>", link(catPath(c, ""), c), u.cat.catCounts[c])
3930 }
3931 b.WriteString("</ul>")
3932 }
3933 return b.String()
3934}
3935
3936
3937// ===== pkg/tui/open_js.go =====
3938//go:build js && wasm
3939
3940// Package tui pkg/tui/open_js.go β following a link in the browser
3941// build. The TUI is a page, so a link that leaves the store can be
3942// opened the way the page's own would be: in another tab.
3943package tui
3944
3945import "syscall/js"
3946
3947// openURL opens a link the store cannot render itself β an external
3948// site, or the PDF behind a datasheet.
3949func (u *ui) openURL(href string) {
3950 if href == "" {
3951 return
3952 }
3953 w := js.Global().Get("window")
3954 if !w.Truthy() {
3955 u.notice = "[aqua]" + esc(href) + "[-]"
3956 return
3957 }
3958 // noopener, or the opened page gets a handle on this one.
3959 if opened := w.Call("open", href, "_blank", "noopener"); !opened.Truthy() {
3960 // A blocked popup is silent otherwise; show the address instead
3961 // so the link is still followable by hand.
3962 u.notice = "[aqua]" + esc(href) + "[-]"
3963 }
3964}
3965
3966
3967// ===== pkg/tui/open_native.go =====
3968//go:build !js || !wasm
3969
3970// Package tui pkg/tui/open_native.go β following a link in a native
3971// terminal, where there is no browser to hand it to.
3972package tui
3973
3974// openURL shows a link the store cannot render itself β an external
3975// site, or the PDF behind a datasheet. A terminal has nowhere to open
3976// one, so it displays the address, which is what a terminal can do with
3977// a link that a person can then act on.
3978func (u *ui) openURL(href string) {
3979 if href == "" {
3980 return
3981 }
3982 u.notice = "[aqua]" + esc(href) + "[-]"
3983}
3984
3985
3986// ===== pkg/tui/pay_js.go =====
3987//go:build js && wasm
3988
3989// Package tui pkg/tui/pay_js.go β the browser half of the payment seam.
3990// The card form is Stripe's Payment Element: Stripe's fields in Stripe's
3991// frame, so the card never passes through this program, which is why a
3992// terminal cannot have one. Here the terminal is in a page, so the form is
3993// laid over the checkout dialog's cells as a websh placement β a widget this
3994// package offers as "stripe" β as the website's cart wasm opens the same
3995// form in the page's dialog.
3996//
3997// A card is paid without leaving the terminal (redirect "if_required"):
3998// the order goes to /submit-order, as /complete would send it, and the cart
3999// is emptied. A payment method that must leave the page goes to /complete
4000// as the website's does, and /complete submits the order it finds in
4001// localStorage: so for as long as a payment is being confirmed, and only
4002// then, the order is put there too (handOff). The cart itself is a file
4003// (cartsave.go).
4004package tui
4005
4006import (
4007 "bytes"
4008 "encoding/json"
4009 "fmt"
4010 "io"
4011 "net/http"
4012 "strings"
4013 "sync"
4014 "syscall/js"
4015 "time"
4016
4017 "github.com/gdamore/tcell/v3"
4018
4019 "github.com/0magnet/websh/widget"
4020)
4021
4022func init() {
4023 widget.Register("stripe", mountCardForm)
4024}
4025
4026// cardPay is the checkout the card form is for.
4027type cardPay struct {
4028 u *ui
4029 co *checkoutOverlay
4030 secret string
4031 lines []cartLine // the cart the payment intent was made for
4032
4033 mu sync.Mutex
4034 elements js.Value // the mounted form's Elements, undefined while none is
4035 stripe js.Value // Stripe.js, for that key
4036 say func(s string, bad bool) // the message under the form
4037 busy bool
4038}
4039
4040var (
4041 payMu sync.Mutex
4042 paying *cardPay
4043)
4044
4045func (u *ui) payWithElements(co *checkoutOverlay, clientSecret string) {
4046 if f.StripePK == "" {
4047 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")
4048 return
4049 }
4050 if _, inPage := u.screen.(interface{ WriteRaw([]byte) }); !inPage {
4051 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")
4052 return
4053 }
4054 st := &cardPay{u: u, co: co, secret: clientSecret, lines: u.cartLines()}
4055 payMu.Lock()
4056 paying = st
4057 payMu.Unlock()
4058 total, _ := u.cartTotal()
4059 head := fmt.Sprintf("Pay [::b]$%d.%02d[-:-:-] by card", total/100, total%100)
4060 if f.Teststripekey {
4061 head += " [yellow]test mode: 4242 4242 4242 4242, any future date[-]"
4062 }
4063 co.form = &cardForm{head: head, pay: func() { go st.confirm() }}
4064 // Under the form, seen only where it is not laid over these cells.
4065 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")
4066}
4067
4068// mountCardForm fills a placement with the card form for the checkout in
4069// progress.
4070func mountCardForm(el js.Value) func() {
4071 payMu.Lock()
4072 st := paying
4073 payMu.Unlock()
4074 if st == nil {
4075 return nil
4076 }
4077 doc := js.Global().Get("document")
4078 mk := func(tag, css string) js.Value {
4079 e := doc.Call("createElement", tag)
4080 e.Get("style").Set("cssText", css)
4081 return e
4082 }
4083 box := mk("div", "position:absolute;inset:0;overflow:auto;background:#000;padding:10px 14px;box-sizing:border-box")
4084 form := mk("div", "min-height:120px")
4085 btn := mk("button", "margin-top:12px;padding:6px 18px;font:inherit;color:#000;background:#00ffff;border:0;cursor:pointer")
4086 btn.Set("textContent", "Pay now")
4087 msg := mk("div", "margin-top:10px;color:#bbb;white-space:pre-wrap")
4088 box.Call("append", form, btn, msg)
4089 el.Call("append", box)
4090
4091 click := js.FuncOf(func(_ js.Value, args []js.Value) any {
4092 args[0].Call("preventDefault")
4093 go st.confirm()
4094 return nil
4095 })
4096 btn.Call("addEventListener", "click", click)
4097
4098 var (
4099 gone bool
4100 pe js.Value
4101 )
4102 say := func(s string, bad bool) {
4103 color := "#bbb"
4104 if bad {
4105 color = "#ff6b6b"
4106 }
4107 msg.Get("style").Set("color", color)
4108 msg.Set("textContent", s)
4109 }
4110 say("loading the card form β¦", false)
4111 withStripe(f.StripePK, func(stripe js.Value, err string) {
4112 if gone {
4113 return
4114 }
4115 if err != "" {
4116 say(err, true)
4117 return
4118 }
4119 elements := stripe.Call("elements", map[string]any{
4120 "clientSecret": st.secret,
4121 "appearance": map[string]any{
4122 "theme": "night",
4123 "variables": map[string]any{
4124 "colorPrimary": "#00ffff",
4125 "colorBackground": "#000000",
4126 "fontFamily": "ui-monospace, Menlo, Consolas, monospace",
4127 },
4128 },
4129 })
4130 pe = elements.Call("create", "payment", map[string]any{"layout": "tabs"})
4131 pe.Call("mount", form)
4132 st.mu.Lock()
4133 st.stripe, st.elements, st.say = stripe, elements, say
4134 st.mu.Unlock()
4135 say("", false)
4136 })
4137 return func() {
4138 gone = true
4139 st.mu.Lock()
4140 st.elements, st.say = js.Undefined(), nil
4141 st.mu.Unlock()
4142 if pe.Truthy() {
4143 pe.Call("destroy")
4144 }
4145 btn.Call("removeEventListener", "click", click)
4146 click.Release()
4147 el.Set("textContent", "")
4148 }
4149}
4150
4151// confirm pays with what is in the form. It waits on Stripe, so it runs on
4152// its own goroutine, never the event loop's.
4153func (st *cardPay) confirm() {
4154 st.mu.Lock()
4155 if st.busy || !st.elements.Truthy() {
4156 st.mu.Unlock()
4157 return
4158 }
4159 st.busy = true
4160 stripe, elements := st.stripe, st.elements
4161 st.mu.Unlock()
4162 defer func() {
4163 st.mu.Lock()
4164 st.busy = false
4165 st.mu.Unlock()
4166 }()
4167 // What is said goes under whichever form is mounted when it is said.
4168 tell := func(s string, bad bool) {
4169 st.mu.Lock()
4170 say := st.say
4171 st.mu.Unlock()
4172 if say != nil {
4173 say(s, bad)
4174 }
4175 }
4176 tell("paying β¦", false)
4177 // If Stripe takes the page to /complete, that page submits this.
4178 restore := handOff(st.lines)
4179 origin := js.Global().Get("location").Get("origin").String()
4180 res, err := await(stripe.Call("confirmPayment", map[string]any{
4181 "elements": elements,
4182 "confirmParams": map[string]any{"return_url": origin + "/complete"},
4183 "redirect": "if_required",
4184 }))
4185 // Still here: the page did not leave, so /complete will not need it.
4186 restore()
4187 if err != "" {
4188 tell("could not reach Stripe: "+err, true)
4189 return
4190 }
4191 if e := res.Get("error"); e.Truthy() {
4192 tell(e.Get("message").String(), true)
4193 return
4194 }
4195 pi := res.Get("paymentIntent")
4196 id, status := pi.Get("id").String(), pi.Get("status").String()
4197 if status != "succeeded" {
4198 tell("the payment is "+status+" β it has not gone through yet ("+id+")", true)
4199 return
4200 }
4201 tell("paid β sending the order β¦", false)
4202 serr := submitOrder(st.lines, id)
4203 st.u.postWait(func() {
4204 payMu.Lock()
4205 if paying == st {
4206 paying = nil
4207 }
4208 payMu.Unlock()
4209 st.co.form = nil
4210 if serr != nil {
4211 // Paid, and the order not recorded: the cart stays, as the
4212 // record of what was bought, and the payment's id is the way
4213 // to find it.
4214 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())))
4215 return
4216 }
4217 st.u.emptyCart()
4218 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)))
4219 })
4220}
4221
4222// submitOrder sends the order as the website's /complete does: the cart as
4223// it was saved, beside the payment's id, which the server checks with
4224// Stripe before it records anything.
4225func submitOrder(lines []cartLine, paymentIntentID string) error {
4226 body, err := json.Marshal(map[string]any{
4227 "localStorageData": map[string]any{completeKey: lines},
4228 "paymentIntentId": paymentIntentID,
4229 })
4230 if err != nil {
4231 return err
4232 }
4233 client := &http.Client{Timeout: 30 * time.Second}
4234 resp, err := client.Post(ordersURL()+"/submit-order", "application/json", bytes.NewReader(body))
4235 if err != nil {
4236 return err
4237 }
4238 defer resp.Body.Close() //nolint:errcheck // read-side close
4239 if resp.StatusCode != http.StatusOK {
4240 data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) //nolint:errcheck // the status says enough without it
4241 return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
4242 }
4243 return nil
4244}
4245
4246// await waits for a promise, from a goroutine that is not the event loop's.
4247func await(p js.Value) (js.Value, string) {
4248 type result struct {
4249 v js.Value
4250 err string
4251 }
4252 ch := make(chan result, 1)
4253 var then, catch js.Func
4254 then = js.FuncOf(func(_ js.Value, args []js.Value) any {
4255 ch <- result{v: args[0]}
4256 return nil
4257 })
4258 catch = js.FuncOf(func(_ js.Value, args []js.Value) any {
4259 ch <- result{err: js.Global().Get("String").Invoke(args[0]).String()}
4260 return nil
4261 })
4262 p.Call("then", then, catch)
4263 r := <-ch
4264 then.Release()
4265 catch.Release()
4266 return r.v, r.err
4267}
4268
4269// Stripe.js is loaded once, from Stripe (it may not be bundled or served
4270// from elsewhere), the first time a card form is shown.
4271var (
4272 stripeMu sync.Mutex
4273 stripeByKey = map[string]js.Value{}
4274 stripeLoading bool
4275 stripeWaiting []func(string)
4276)
4277
4278// withStripe calls fn with Stripe.js for key, once it has loaded, on the
4279// event loop.
4280func withStripe(key string, fn func(stripe js.Value, err string)) {
4281 ready := func(err string) {
4282 if err != "" {
4283 fn(js.Value{}, err)
4284 return
4285 }
4286 stripeMu.Lock()
4287 s, ok := stripeByKey[key]
4288 if !ok {
4289 s = js.Global().Get("Stripe").Invoke(key)
4290 stripeByKey[key] = s
4291 }
4292 stripeMu.Unlock()
4293 fn(s, "")
4294 }
4295 if js.Global().Get("Stripe").Type() == js.TypeFunction {
4296 ready("")
4297 return
4298 }
4299 stripeMu.Lock()
4300 stripeWaiting = append(stripeWaiting, ready)
4301 if stripeLoading {
4302 stripeMu.Unlock()
4303 return
4304 }
4305 stripeLoading = true
4306 stripeMu.Unlock()
4307
4308 doc := js.Global().Get("document")
4309 script := doc.Call("createElement", "script")
4310 script.Set("src", "https://js.stripe.com/v3/")
4311 var onload, onerror js.Func
4312 done := func(err string) {
4313 onload.Release()
4314 onerror.Release()
4315 stripeMu.Lock()
4316 waiting := stripeWaiting
4317 stripeWaiting, stripeLoading = nil, false
4318 stripeMu.Unlock()
4319 for _, w := range waiting {
4320 w(err)
4321 }
4322 }
4323 onload = js.FuncOf(func(js.Value, []js.Value) any { done(""); return nil })
4324 onerror = js.FuncOf(func(js.Value, []js.Value) any {
4325 done("could not load Stripe.js β is js.stripe.com blocked?")
4326 return nil
4327 })
4328 script.Call("addEventListener", "load", onload)
4329 script.Call("addEventListener", "error", onerror)
4330 doc.Get("head").Call("append", script)
4331}
4332
4333// postWait is post for what must not be dropped β a payment's outcome:
4334// it waits for room in the queue rather than giving up, until shutdown.
4335func (u *ui) postWait(fn func()) {
4336 for {
4337 u.postMu.Lock()
4338 if u.finished {
4339 u.postMu.Unlock()
4340 return
4341 }
4342 select {
4343 case u.screen.EventQ() <- tcell.NewEventInterrupt(fn):
4344 u.postMu.Unlock()
4345 return
4346 default:
4347 }
4348 u.postMu.Unlock()
4349 time.Sleep(20 * time.Millisecond)
4350 }
4351}
4352
4353// completeKey is where the website's /complete page finds the order it
4354// submits after a payment that left the page.
4355const completeKey = "cartItems"
4356
4357// handOff puts the order where /complete looks for it, and returns what puts
4358// back what was there β the website's own cart uses the same key.
4359func handOff(lines []cartLine) (restore func()) {
4360 restore = func() {}
4361 defer func() { _ = recover() }() //nolint:errcheck // storage the browser refuses: /complete then finds nothing, as before
4362 st := js.Global().Get("localStorage")
4363 b, err := json.Marshal(lines)
4364 if !st.Truthy() || err != nil {
4365 return restore
4366 }
4367 was := st.Call("getItem", completeKey)
4368 st.Call("setItem", completeKey, string(b))
4369 return func() {
4370 defer func() { _ = recover() }() //nolint:errcheck // as above
4371 if was.Type() == js.TypeString {
4372 st.Call("setItem", completeKey, was)
4373 } else {
4374 st.Call("removeItem", completeKey)
4375 }
4376 }
4377}
4378
4379
4380// ===== pkg/tui/pay_native.go =====
4381//go:build !js || !wasm
4382
4383// Package tui pkg/tui/pay_native.go β the native half of the payment
4384// seam. Card details can never be typed into a terminal (they would
4385// pass through this program, which PCI forbids); the browser build of
4386// this TUI lays the Stripe Payment Element over the checkout dialog
4387// (pay_js.go). Here the native flow proves the whole pipeline β cart,
4388// shipping, server validation, payment intent β and says where the last
4389// step lives.
4390package tui
4391
4392// payWithElements takes over the checkout dialog once the store server
4393// has created the payment intent for this cart.
4394func (u *ui) payWithElements(co *checkoutOverlay, clientSecret string) {
4395 preview := clientSecret
4396 if len(preview) > 12 {
4397 preview = preview[:12] + "β¦"
4398 }
4399 co.ta.setContent("\n [green]the store server accepted the cart[-] and created the payment\n intent (" +
4400 esc(preview) + ").\n\n" +
4401 " [yellow]card entry needs the Stripe Payment Element[-], which a terminal\n" +
4402 " cannot host. In the browser build of this TUI it opens right here,\n" +
4403 " over the terminal β in a native terminal, complete this order at\n" +
4404 " [aqua]https://" + esc(u.sitedomain) + "[-]\n\n" +
4405 " [gray]Esc closes β the cart is kept[-]\n")
4406}
4407
4408
4409// ===== pkg/tui/place.go =====
4410package tui
4411
4412import (
4413 "encoding/base64"
4414 "encoding/json"
4415 "strings"
4416)
4417
4418// The real thing over the cells, where the terminal can show it: websh's
4419// placements (OSC 7337). The home panel's logo and turning globe have the
4420// website's own β its logo.svg and its globe, a widget this program offers
4421// as "globe" (widgets_js.go) β laid over the cells that draw them, and a product's photo has
4422// the photo itself. A terminal without placements ignores the sequences, so
4423// they are written wherever this runs, and the cells are drawn as ever; an
4424// overlay (a menu, the cart, a notice) keeps the cells it crosses.
4425
4426// placementsShown is set where websh told this program, run as a process of
4427// its own, that its terminal shows placements (widgets_js.go).
4428var placementsShown = func() bool { return false }
4429
4430// A placed is what one placement shows over r.
4431type placed struct {
4432 r rect
4433 url string // an image, or
4434 widget string // a widget the page registered
4435 fit string
4436 input bool // the mouse over it is its own: the globe is turned by hand
4437 // above is how many of the frame's overlays were drawn when it was
4438 // asked for: one an overlay asks for lies on that overlay, and only
4439 // what is drawn after it covers it.
4440 above int
4441}
4442
4443// want asks for p over its cells as id this frame.
4444func (u *ui) want(id string, p placed) {
4445 if p.r.w <= 0 || p.r.h <= 0 {
4446 return
4447 }
4448 if p.url != "" && !strings.HasPrefix(p.url, "https://") && !strings.HasPrefix(p.url, "http://") {
4449 return // a file here, not an image the terminal can fetch
4450 }
4451 p.above = u.overlaysDrawn
4452 if u.placeWant == nil {
4453 u.placeWant = map[string]placed{}
4454 }
4455 u.placeWant[id] = p
4456}
4457
4458// placeSync, after a frame, tells the terminal what changed: what was wanted
4459// and is not covered by an overlay is laid (or moved), and the rest taken
4460// away β all of it when the person keeps to the cells (w).
4461func (u *ui) placeSync(overlays []rect) {
4462 if u.cellsOnly {
4463 clear(u.placeWant)
4464 }
4465 for id, p := range u.placeWant {
4466 for _, o := range overlays[min(p.above, len(overlays)):] {
4467 if p.r.overlaps(o) {
4468 delete(u.placeWant, id)
4469 break
4470 }
4471 }
4472 }
4473 mode := imageMode()
4474 for id := range u.placeSent {
4475 if _, ok := u.placeWant[id]; !ok {
4476 if mode == "kitty" {
4477 u.kittyRemove(id)
4478 } else {
4479 u.writeTerm("\x1b]7337;remove;" + id + "\x1b\\")
4480 }
4481 delete(u.placeSent, id)
4482 }
4483 }
4484 for id, p := range u.placeWant {
4485 if s, ok := u.placeSent[id]; ok && s == p {
4486 continue
4487 }
4488 if mode == "" {
4489 continue
4490 }
4491 if mode == "kitty" {
4492 u.kittyPlace(id, p)
4493 if u.placeSent == nil {
4494 u.placeSent = map[string]placed{}
4495 }
4496 u.placeSent[id] = p
4497 continue
4498 }
4499 b, err := json.Marshal(struct {
4500 Row int `json:"row"`
4501 Col int `json:"col"`
4502 W int `json:"w"`
4503 H int `json:"h"`
4504 URL string `json:"url,omitempty"`
4505 Widget string `json:"widget,omitempty"`
4506 Fit string `json:"fit,omitempty"`
4507 Input bool `json:"input,omitempty"`
4508 }{p.r.y, p.r.x, p.r.w, p.r.h, p.url, p.widget, p.fit, p.input})
4509 if err != nil {
4510 continue
4511 }
4512 u.writeTerm("\x1b]7337;place;" + id + ";" + base64.StdEncoding.EncodeToString(b) + "\x1b\\")
4513 if u.placeSent == nil {
4514 u.placeSent = map[string]placed{}
4515 }
4516 u.placeSent[id] = p
4517 }
4518 // The page's own terminal (a screen written straight into its emulator)
4519 // surely shows placements, and so does a websh that says so to a program
4520 // it runs; elsewhere the cells are the picture.
4521 _, inPage := u.screen.(interface{ WriteRaw([]byte) })
4522 u.homeCovered.Store((inPage || placementsShown()) && u.placeSent["home"].widget != "")
4523 clear(u.placeWant)
4524}
4525
4526// placeClear takes every placement away, as the program ends.
4527func (u *ui) placeClear() {
4528 if imageMode() == "kitty" {
4529 for id := range u.placeSent {
4530 u.kittyRemove(id)
4531 }
4532 return
4533 }
4534 if len(u.placeSent) > 0 {
4535 u.writeTerm("\x1b]7337;clear\x1b\\")
4536 }
4537}
4538
4539// writeTerm writes s to the terminal itself, outside the screen's frames:
4540// to the tty on a real terminal, or straight to the emulator in a page.
4541func (u *ui) writeTerm(s string) {
4542 if w, ok := u.screen.(interface{ WriteRaw([]byte) }); ok {
4543 w.WriteRaw([]byte(s))
4544 return
4545 }
4546 if tty, ok := u.screen.Tty(); ok {
4547 _, _ = tty.Write([]byte(s)) //nolint:errcheck // a terminal that is gone shows nothing either
4548 }
4549}
4550
4551// overlaps reports whether r and o share a cell.
4552func (r rect) overlaps(o rect) bool {
4553 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
4554}
4555
4556
4557// ===== pkg/tui/probe_js.go =====
4558//go:build js
4559
4560package tui
4561
4562// probeTerminal: in a browser the host is asked otherwise β the page hands a
4563// program in it the answer (storepane), and a program run from websh's
4564// filesystem asks through its terminal (websh's childtty).
4565func probeTerminal() {}
4566
4567
4568// ===== pkg/tui/probe_other.go =====
4569//go:build !js
4570
4571package tui
4572
4573import (
4574 "os"
4575 "time"
4576
4577 "golang.org/x/term"
4578
4579 "github.com/0magnet/websh/progressive"
4580)
4581
4582// probeTerminal asks the terminal what it can do beyond cells β kitty
4583// graphics, say β before tcell takes it, so the store can show real
4584// pictures where the terminal can. It asks on /dev/tty, in raw mode, and
4585// closes it after, which ends the read the probe may still have waiting:
4586// no key is taken from tcell.
4587func probeTerminal() {
4588 tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
4589 if err != nil {
4590 return
4591 }
4592 defer tty.Close() //nolint:errcheck // done with it either way
4593 fd := int(tty.Fd()) //nolint:gosec // a file descriptor fits an int
4594 old, err := term.MakeRaw(fd)
4595 if err != nil {
4596 return
4597 }
4598 defer term.Restore(fd, old) //nolint:errcheck // back as it was, as far as it goes
4599 progressive.Probe(tty, tty, 1500*time.Millisecond) //nolint:errcheck,gosec // no answer is cells
4600}
4601
4602
4603// ===== pkg/tui/remote.go =====
4604// Package tui pkg/tui/remote.go β the TUI as a store client. With
4605// STOREURL set (m2 tui --storeurl https://magnetosphere.net) nothing is
4606// read from disk: the catalog comes from /api/products, images from /i,
4607// the logo from /logo.jpg, and checkout from the same origin β exactly
4608// what the browser build of this TUI will do from the page the store
4609// serves it on. Without it, the TUI reads the deployment's own files as
4610// before.
4611package tui
4612
4613import (
4614 "encoding/json"
4615 "fmt"
4616 "io"
4617 "net/http"
4618 "net/url"
4619 "os"
4620 "strings"
4621 "sync"
4622 "time"
4623
4624 "github.com/0magnet/m2/pkg/product"
4625)
4626
4627// storeURL is the remote store, normalized; empty means local files.
4628func storeURL() string {
4629 return strings.TrimRight(f.Storeurl, "/")
4630}
4631
4632// FetchCatalog loads the product catalog from a store's /api/products.
4633func FetchCatalog(store string) (product.Products, error) {
4634 client := &http.Client{Timeout: 30 * time.Second}
4635 resp, err := client.Get(strings.TrimRight(store, "/") + "/api/products")
4636 if err != nil {
4637 return nil, err
4638 }
4639 defer resp.Body.Close() //nolint:errcheck // read-side close
4640 if resp.StatusCode != http.StatusOK {
4641 return nil, fmt.Errorf("%s from %s/api/products", resp.Status, store)
4642 }
4643 var prods product.Products
4644 if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&prods); err != nil {
4645 return nil, err
4646 }
4647 return prods, nil
4648}
4649
4650// FetchSite fills the site identity (name, tagline, telegram links) from
4651// a store's /api/site β the browser build has no MENV file to source.
4652func FetchSite(store string) error {
4653 client := &http.Client{Timeout: 15 * time.Second}
4654 resp, err := client.Get(strings.TrimRight(store, "/") + "/api/site")
4655 if err != nil {
4656 return err
4657 }
4658 defer resp.Body.Close() //nolint:errcheck // read-side close
4659 if resp.StatusCode != http.StatusOK {
4660 return fmt.Errorf("%s from %s/api/site", resp.Status, store)
4661 }
4662 var site struct {
4663 Sitename, Siteext, Sitelongname, Sitetagline string
4664 Tgcontact, Tgchannel string
4665 Teststripekey bool
4666 Sitemeta, Sitedomain string
4667 Siteprettyname, Siteprettynamecap string
4668 Siteprettynamecaps, SiteASCIILogo string
4669 Stripepk string
4670 NavLinks []navLink
4671 Coin string
4672 }
4673 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&site); err != nil {
4674 return err
4675 }
4676 f.Sitename, f.Siteext = site.Sitename, site.Siteext
4677 f.Sitelongname, f.Sitetagline = site.Sitelongname, site.Sitetagline
4678 f.Tgcontact, f.Tgchannel = site.Tgcontact, site.Tgchannel
4679 f.Teststripekey = site.Teststripekey
4680 f.Sitemeta, f.Sitedomain = site.Sitemeta, site.Sitedomain
4681 f.Siteprettyname, f.Siteprettynamecap = site.Siteprettyname, site.Siteprettynamecap
4682 f.Siteprettynamecaps, f.SiteASCIILogo = site.Siteprettynamecaps, site.SiteASCIILogo
4683 if site.Stripepk != "" {
4684 f.StripePK = site.Stripepk
4685 }
4686 navLinks = site.NavLinks
4687 coinOffered = site.Coin != ""
4688 return nil
4689}
4690
4691// navLink is an entry a server-side drop-in added to the site navigation β
4692// pkg/web's NavLink, as /api/site serves it.
4693//
4694// The Etc menu was written out twice, once in htmpl/header.html and once in
4695// openEtc, and only the template knew about these: the store's Software page
4696// was in the page's menu and missing from the terminal's. A store client
4697// cannot see pkg/web's registry, so the server hands it over instead.
4698type navLink struct {
4699 Title, Href, Desc string
4700}
4701
4702// coinOffered is whether the store takes coin, which the cart offers as k.
4703// A store reports it in /api/site; the local TUI, beside its own server,
4704// offers it and lets the server answer.
4705var coinOffered = true
4706
4707// navLinks is what /api/site last reported. Empty in a deployment with no
4708// drop-ins, and empty in a local TUI with no store to ask.
4709var navLinks []navLink
4710
4711// fetchContent reads a stock page (about/policy/links): from the store's
4712// /api/content in client mode, from the deployment's content/ otherwise.
4713func fetchContent(name string) string {
4714 s := storeURL()
4715 if s == "" {
4716 return contentFile("content/" + name + ".html")
4717 }
4718 client := &http.Client{Timeout: 15 * time.Second}
4719 resp, err := client.Get(s + "/api/content/" + url.PathEscape(name))
4720 if err != nil {
4721 return ""
4722 }
4723 defer resp.Body.Close() //nolint:errcheck // read-side close
4724 if resp.StatusCode != http.StatusOK {
4725 return ""
4726 }
4727 data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
4728 if err != nil {
4729 return ""
4730 }
4731 return string(data)
4732}
4733
4734// imageCache holds fetched remote images; the render worker re-reads an
4735// image at every pane size, and the network should pay only once.
4736var (
4737 imageCacheMu sync.Mutex
4738 imageCache = map[string][]byte{}
4739)
4740
4741// getImageData reads an image by path or URL.
4742func getImageData(path string) ([]byte, error) {
4743 if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
4744 return os.ReadFile(path) //nolint:gosec // paths come from the catalog, not the user
4745 }
4746 imageCacheMu.Lock()
4747 data, ok := imageCache[path]
4748 imageCacheMu.Unlock()
4749 if ok {
4750 return data, nil
4751 }
4752 client := &http.Client{Timeout: 20 * time.Second}
4753 // Fetching the URL it was asked for is the point of this function.
4754 resp, err := client.Get(path) //nolint:gosec
4755 if err != nil {
4756 return nil, err
4757 }
4758 defer resp.Body.Close() //nolint:errcheck // read-side close
4759 if resp.StatusCode != http.StatusOK {
4760 return nil, fmt.Errorf("%s", resp.Status)
4761 }
4762 data, err = io.ReadAll(io.LimitReader(resp.Body, 16<<20))
4763 if err != nil {
4764 return nil, err
4765 }
4766 imageCacheMu.Lock()
4767 if len(imageCache) > 64 {
4768 imageCache = map[string][]byte{}
4769 }
4770 imageCache[path] = data
4771 imageCacheMu.Unlock()
4772 return data, nil
4773}
4774
4775
4776// ===== pkg/tui/render.go =====
4777// Package tui pkg/tui/render.go β images, the globe, and text conversion.
4778// Everything renders to cells: product photos become pixel buffers
4779// painted as β half-blocks (or libcaca canvases blitted glyph by glyph),
4780// and tcell diffs frames so only changed cells reach the terminal.
4781package tui
4782
4783import (
4784 "bytes"
4785 "fmt"
4786 "image"
4787 "image/color"
4788 _ "image/gif"
4789 _ "image/jpeg"
4790 _ "image/png"
4791 "net/url"
4792 "os"
4793 "path/filepath"
4794 "regexp"
4795 "strconv"
4796 "strings"
4797
4798 xhtml "html"
4799
4800 "github.com/gdamore/tcell/v3"
4801 "golang.org/x/image/draw"
4802
4803 "github.com/0magnet/chaosrack/pkg/geom"
4804 "github.com/0magnet/chaosrack/pkg/rasterview"
4805 "github.com/0magnet/img2txt-go/caca"
4806
4807 "github.com/0magnet/m2/pkg/product"
4808)
4809
4810// imgMode selects how product photos are painted.
4811type imgMode int
4812
4813const (
4814 modeHalfBlock imgMode = iota // truecolor, two pixels per cell via β
4815 modeCaca // libcaca ANSI art, as /logo renders it
4816)
4817
4818// artwork is a rendered product photo: a pixel buffer (half-block mode),
4819// a cell canvas (caca mode), or a message when there is nothing to show.
4820type artwork struct {
4821 rgba *image.RGBA
4822 cells *cellArt
4823 msg string
4824}
4825
4826// cellArt is a libcaca canvas as terminal cells.
4827type cellArt struct {
4828 w, h int
4829 ch []rune
4830 st []tcell.Style
4831}
4832
4833// imagePath resolves a product photo the way the templates do:
4834// img/<category>/<image1> (from the store's /i in client mode), falling
4835// back locally to img/<image1>. A nil product (nothing selected yet β
4836// an empty table's preview) has no image.
4837func imagePath(p *product.Product) string {
4838 if p == nil || p.Image1 == "" {
4839 return ""
4840 }
4841 if s := storeURL(); s != "" {
4842 return s + "/i/" + url.PathEscape(p.Category) + "/" + url.PathEscape(p.Image1)
4843 }
4844 for _, path := range []string{
4845 filepath.Join("img", p.Category, p.Image1),
4846 filepath.Join("img", p.Image1),
4847 } {
4848 if _, err := os.Stat(path); err == nil {
4849 return path
4850 }
4851 }
4852 return ""
4853}
4854
4855// datasheetURL resolves a product datasheet the way product.html does:
4856// <ImgSRC>/pdf/<name>, which is /i/pdf/<name> on the store's own origin.
4857// Without a store there is no origin to build on, so the local path the
4858// file would be served from is what a click has to offer instead.
4859func datasheetURL(name string) string {
4860 if name == "" {
4861 return ""
4862 }
4863 if s := storeURL(); s != "" {
4864 return s + "/i/pdf/" + url.PathEscape(name)
4865 }
4866 return filepath.Join("img", "pdf", name)
4867}
4868
4869func loadImage(path string) (image.Image, error) {
4870 data, err := getImageData(path)
4871 if err != nil {
4872 return nil, err
4873 }
4874 img, _, err := image.Decode(bytes.NewReader(data))
4875 return img, err
4876}
4877
4878// scaleToFit scales img into a pixel buffer no larger than maxW x maxH,
4879// preserving aspect. Half-block pixels are roughly square.
4880func scaleToFit(img image.Image, maxW, maxH int) *image.RGBA {
4881 b := img.Bounds()
4882 pw, ph := b.Dx(), b.Dy()
4883 if pw*maxH > ph*maxW {
4884 ph = ph * maxW / pw
4885 pw = maxW
4886 } else {
4887 pw = pw * maxH / ph
4888 ph = maxH
4889 }
4890 if pw < 1 {
4891 pw = 1
4892 }
4893 if ph < 1 {
4894 ph = 1
4895 }
4896 dst := image.NewRGBA(image.Rect(0, 0, pw, ph))
4897 draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
4898 return dst
4899}
4900
4901// renderArt produces a product photo sized to fit cols x rows cells.
4902func renderArt(path string, cols, rows int, mode imgMode) artwork {
4903 if path == "" {
4904 return artwork{msg: "no image"}
4905 }
4906 if cols < 2 || rows < 2 {
4907 return artwork{msg: "pane too small"}
4908 }
4909 if mode == modeCaca {
4910 cells, err := renderCaca(path, cols, rows)
4911 if err != nil {
4912 return artwork{msg: err.Error()}
4913 }
4914 return artwork{cells: cells}
4915 }
4916 img, err := loadImage(path)
4917 if err != nil {
4918 return artwork{msg: err.Error()}
4919 }
4920 if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
4921 return artwork{msg: "empty image"}
4922 }
4923 return artwork{rgba: scaleToFit(img, cols, rows*2)}
4924}
4925
4926// renderCaca draws an image as libcaca ANSI art β the same renderer
4927// behind the /logo endpoint, via the pure-Go img2txt port β and reads
4928// the canvas cells straight into terminal cells.
4929func renderCaca(path string, cols, rows int) (*cellArt, error) {
4930 data, err := getImageData(path)
4931 if err != nil {
4932 return nil, err
4933 }
4934 im, err := caca.DecodeImage(bytes.NewReader(data))
4935 if err != nil {
4936 return nil, err
4937 }
4938 if im.W == 0 || im.H == 0 {
4939 return nil, fmt.Errorf("bad dimensions")
4940 }
4941 // img2txt's 6x10 font-cell aspect, constrained by both axes.
4942 lines := cols * im.H * 6 / im.W / 10
4943 if lines > rows {
4944 lines = rows
4945 cols = lines * im.W * 10 / im.H / 6
4946 }
4947 if lines < 1 || cols < 1 {
4948 return nil, fmt.Errorf("pane too small")
4949 }
4950 cv := caca.NewCanvas(cols, lines)
4951 cv.SetColorANSI(caca.Default, caca.Transparent)
4952 cv.Clear()
4953 im.Dither.SetAlgorithm("fstein")
4954 cv.DitherBitmap(0, 0, cols, lines, im.Dither, im.Pixels)
4955
4956 art := &cellArt{w: cv.Width, h: cv.Height,
4957 ch: make([]rune, cv.Width*cv.Height), st: make([]tcell.Style, cv.Width*cv.Height)}
4958 for i, ch := range cv.Chars {
4959 art.ch[i] = ch
4960 art.st[i] = tcell.StyleDefault.
4961 Foreground(rgb12(caca.AttrToRGB12Fg(cv.Attrs[i]))).
4962 Background(rgb12(caca.AttrToRGB12Bg(cv.Attrs[i])))
4963 }
4964 return art, nil
4965}
4966
4967// rgb12 expands libcaca's 12-bit 0xRGB into a tcell color.
4968func rgb12(v uint16) tcell.Color {
4969 return tcell.NewRGBColor(
4970 int32((v>>8&0xF)*17), int32((v>>4&0xF)*17), int32((v&0xF)*17))
4971}
4972
4973// drawArtwork paints a rendered photo centered in r, and says which cells
4974// the photo took (false when there was no photo to draw).
4975func drawArtwork(sc tcell.Screen, r rect, art *artwork) (rect, bool) {
4976 switch {
4977 case art == nil:
4978 case art.rgba != nil:
4979 rows := (art.rgba.Bounds().Dy() + 1) / 2
4980 yoff := (r.h - rows) / 2
4981 if yoff < 0 {
4982 yoff = 0
4983 }
4984 drawRGBA(sc, r.x, r.y+yoff, r.w, r.h-yoff, art.rgba)
4985 pw := art.rgba.Bounds().Dx()
4986 xoff := 0
4987 if r.w > pw {
4988 xoff = (r.w - pw) / 2
4989 }
4990 return rect{r.x + xoff, r.y + yoff, min(pw, r.w), min(rows, r.h-yoff)}, true
4991 case art.cells != nil:
4992 xoff := (r.w - art.cells.w) / 2
4993 yoff := (r.h - art.cells.h) / 2
4994 if xoff < 0 {
4995 xoff = 0
4996 }
4997 if yoff < 0 {
4998 yoff = 0
4999 }
5000 for y := 0; y < art.cells.h && y < r.h; y++ {
5001 for x := 0; x < art.cells.w && x < r.w; x++ {
5002 i := y*art.cells.w + x
5003 sc.SetContent(r.x+xoff+x, r.y+yoff+y, art.cells.ch[i], nil, art.cells.st[i])
5004 }
5005 }
5006 return rect{r.x + xoff, r.y + yoff, min(art.cells.w, r.w), min(art.cells.h, r.h)}, true
5007 case art.msg != "":
5008 printMarkupCenter(sc, r.x, r.y+r.h/2, r.w, "[gray]"+esc(art.msg)+"[-]", styleText)
5009 }
5010 return rect{}, false
5011}
5012
5013// The home panel: a wireframe globe turning over the site logo, as the
5014// website's landing view renders its wasm globe over the svg logo.
5015
5016// makeBackdrop scales the logo to one panel size.
5017func makeBackdrop(logo image.Image, cols, rows int) *image.RGBA {
5018 if logo == nil || cols <= 0 || rows <= 0 {
5019 if cols < 1 {
5020 cols = 1
5021 }
5022 if rows < 1 {
5023 rows = 1
5024 }
5025 return image.NewRGBA(image.Rect(0, 0, cols, rows*2))
5026 }
5027 // Full brightness, as the website shows it: the svg behind the canvas
5028 // is not dimmed there, so its white stays white here too. The globe
5029 // reads on top by its own saturation, the way it does on the page.
5030 return scaleToFit(logo, cols, rows*2)
5031}
5032
5033// globeLines is the globe the website's attractor engine uploads to
5034// WebGL β same geometry, from the same package.
5035var globeLines = geom.Globe(18, 36, 60)
5036
5037// globeFrame composites one animation frame: the dimmed logo with the
5038// chaosrack globe drawn over it by chaosrack's own software renderer, in
5039// its default look β random pose, redβblue gradient along model z (see
5040// rasterview.DefaultGradient). BackDim is the one departure from the
5041// WebGL look: an unantialiased raster needs the depth cue.
5042func globeFrame(backdrop *image.RGBA, ax, ay, az float64) *image.RGBA {
5043 frame := image.NewRGBA(backdrop.Bounds())
5044 copy(frame.Pix, backdrop.Pix)
5045 view := rasterview.View{AngleX: ax, AngleY: ay, AngleZ: az, Dist: 3.2, BackDim: 0.5}
5046 view.Render(frame, globeLines.Vertices, globeLines.Indices, rasterview.DefaultGradient())
5047 return frame
5048}
5049
5050// drawRGBA paints a pixel buffer into terminal cells at x0,y0, two pixels
5051// per cell via β, centered in cols columns. Cells unchanged since the
5052// last frame cost nothing: tcell diffs them away.
5053func drawRGBA(sc tcell.Screen, x0, y0, cols, maxRows int, img *image.RGBA) {
5054 pw, ph := img.Bounds().Dx(), img.Bounds().Dy()
5055 xoff := 0
5056 if cols > pw {
5057 xoff = (cols - pw) / 2
5058 }
5059 for y := 0; y < ph; y += 2 {
5060 if y/2 >= maxRows {
5061 break
5062 }
5063 for x := 0; x < pw; x++ {
5064 if xoff+x >= cols {
5065 break
5066 }
5067 top := img.RGBAAt(x, y)
5068 var bot color.RGBA
5069 if y+1 < ph {
5070 bot = img.RGBAAt(x, y+1)
5071 }
5072 st := tcell.StyleDefault.
5073 Foreground(tcell.NewRGBColor(step16(top.R), step16(top.G), step16(top.B))).
5074 Background(tcell.NewRGBColor(step16(bot.R), step16(bot.G), step16(bot.B)))
5075 sc.SetContent(x0+xoff+x, y0+y/2, 'β', nil, st)
5076 }
5077 }
5078}
5079
5080var (
5081 tmplAction = regexp.MustCompile(`\{\{[^}]*\}\}`)
5082 tagAnchor = regexp.MustCompile(`(?is)<a\b([^>]*)>(.*?)</a>`)
5083 attrHref = regexp.MustCompile(`(?is)\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
5084 tagBreak = regexp.MustCompile(`(?i)<br\s*/?>`)
5085 tagBlock = regexp.MustCompile(`(?i)</(p|h1|h2|h3|div|tr|li|ul|table|pre)>`)
5086 tagHeading = regexp.MustCompile(`(?is)<h[1-3][^>]*>(.*?)</h[1-3]>`)
5087 tagLi = regexp.MustCompile(`(?i)<li[^>]*>`)
5088 tagCell = regexp.MustCompile(`(?i)</t[dh]>`)
5089 tagAny = regexp.MustCompile(`(?s)<[^>]*>`)
5090 manyBlank = regexp.MustCompile(`\n{3,}`)
5091)
5092
5093// htmlToText renders a stock-page fragment (about/policy/links) as
5094// terminal text: links cyan like the site, headings bold, tags dropped.
5095//
5096// It also returns each anchor's href, in the order the [link=N] tags it
5097// wrote number them, so the page can make them followable. The Links
5098// page is a page of nothing but links; cyan text that does nothing is
5099// not a rendering of it.
5100//
5101// width is the column count the caller will draw into; tables are laid out
5102// to fit it. See htmltable.go.
5103func htmlToText(s, year string, width int) (string, []string) {
5104 s = strings.ReplaceAll(s, "{{.Page.Year}}", year)
5105 s = tmplAction.ReplaceAllString(s, "")
5106 s = strings.ReplaceAll(s, "[", "[[") // user HTML must not become tags
5107 s = tagHeading.ReplaceAllString(s, "\n[::b]$1[-:-:-]\n")
5108 var hrefs []string
5109 s = tagAnchor.ReplaceAllStringFunc(s, func(m string) string {
5110 g := tagAnchor.FindStringSubmatch(m)
5111 href := ""
5112 if a := attrHref.FindStringSubmatch(g[1]); a != nil {
5113 href = xhtml.UnescapeString(a[1] + a[2] + a[3]) // only one group matches
5114 }
5115 hrefs = append(hrefs, href)
5116 return linkTag(len(hrefs), "[aqua]"+g[2]+"[-]")
5117 })
5118 // Tables come out whole and are drawn last. The flattening below would
5119 // otherwise reduce their cells to runs of spaces, and drawing them first
5120 // would feed the finished columns straight back into it.
5121 var tables []string
5122 s = tagTable.ReplaceAllStringFunc(s, func(m string) string {
5123 tables = append(tables, tagTable.FindStringSubmatch(m)[1])
5124 return "\x00T" + strconv.Itoa(len(tables)-1) + "\x00"
5125 })
5126 s = tagBreak.ReplaceAllString(s, "\n")
5127 s = tagLi.ReplaceAllString(s, " * ")
5128 s = tagCell.ReplaceAllString(s, " ")
5129 s = tagBlock.ReplaceAllString(s, "\n")
5130 s = tagAny.ReplaceAllString(s, "")
5131 s = xhtml.UnescapeString(s)
5132 s = manyBlank.ReplaceAllString(s, "\n\n")
5133 for i, inner := range tables {
5134 s = strings.Replace(s, "\x00T"+strconv.Itoa(i)+"\x00", "\n"+renderTable(inner, width), 1)
5135 }
5136 return strings.TrimSpace(s) + "\n", hrefs
5137}
5138
5139// step16 snaps a channel to 16 levels, spanning the full range: 0x00 stays
5140// 0x00 and 0xFF stays 0xFF, so the logo's black and white are untouched and
5141// only the interpolated greys move, by at most 1/32 of the range.
5142//
5143// This is a rendering cost, not an aesthetic choice. The WebGL renderer
5144// caches rasterised glyphs under a key that includes both colors, so a
5145// truecolor half-block is a distinct glyph per color pair. A full-screen
5146// gradient at ~11k cells hands it ~11k keys a frame against an atlas of
5147// ~18k slots; it fills, clears, and re-rasterises everything, every frame.
5148// Quantising collapses the key space enough for the cache to hold, which
5149// measured out at roughly half the CPU and less than half the worst-case
5150// frame time.
5151func step16(v uint8) int32 { return int32(v&0xF0 | v>>4) }
5152
5153
5154// ===== pkg/tui/tui.go =====
5155// Package tui pkg/tui/tui.go β a terminal view of the web store, derived
5156// from the website itself: the same masthead, the catsubcats navigation
5157// tree, one panel at a time like the front page's :target panels, the
5158// category tables, product pages in template order, the stock content
5159// pages, and the footer bar. The landing view is the site's landing
5160// view: a wireframe globe turning over the logo.
5161//
5162// Written directly on tcell v3 with the small widget kit in widgets.go β
5163// the same terminal stack as the rest of the 0magnet ecosystem, so the
5164// browser build can ride tuiwasm/xtcell.
5165package tui
5166
5167import (
5168 "context"
5169 "embed"
5170 "fmt"
5171 "image"
5172 "math"
5173 "math/rand"
5174 "os"
5175 "strconv"
5176 "strings"
5177 "sync"
5178 "sync/atomic"
5179 "time"
5180
5181 "github.com/gdamore/tcell/v3"
5182
5183 "github.com/0magnet/calvin"
5184
5185 "github.com/0magnet/m2/pkg/config"
5186 "github.com/0magnet/m2/pkg/product"
5187 "github.com/0magnet/m2/pkg/storepage"
5188)
5189
5190//go:embed *.go
5191var Source embed.FS
5192
5193// f aliases the shared configuration, as in pkg/web.
5194var f = &config.F
5195
5196// site styles, from content/style.css
5197var (
5198 styleText = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.ColorBlack.TrueColor())
5199 styleLink = styleText.Foreground(tcell.ColorAqua.TrueColor()) // a{color:cyan}
5200 colorHover = tcell.ColorGreen.TrueColor() // a:hover{color:green}
5201 styleSel = tcell.StyleDefault.Foreground(tcell.GetColor("#ffa500")).Background(tcell.ColorWhite.TrueColor()) // a.cur
5202 // .β thead{background-color:#6a1b9a;color:white} β a filled band, not
5203 // underlined text. Underlined purple read as a link, which those
5204 // column titles are not.
5205 styleThead = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#6a1b9a")).Bold(true)
5206 styleBorder = styleText.Foreground(tcell.ColorBlue.TrueColor())
5207 styleField = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#000087"))
5208 styleButton = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#005f87"))
5209
5210 // The header and footer tables are closed by a 1px blue border on the
5211 // page; their cell edges are white. Terminal rules stand in for both.
5212 ruleBlue = styleText.Foreground(tcell.GetColor("#0000ff"))
5213 ruleWhite = styleText.Foreground(tcell.ColorWhite.TrueColor())
5214)
5215
5216// panel is one of the site's :target panels.
5217type panel interface {
5218 draw(u *ui, sc tcell.Screen, r rect)
5219 key(u *ui, ev *tcell.EventKey) bool
5220 mouse(u *ui, r rect, ev *tcell.EventMouse) bool
5221}
5222
5223// overlayW is a commerce overlay (cart / shipping / checkout).
5224type overlayW interface {
5225 draw(u *ui, sc tcell.Screen, r rect)
5226 key(u *ui, ev *tcell.EventKey) bool
5227 size() (w, h int)
5228 title() string
5229}
5230
5231// linkRegion is one anchor on the drawn page: the cells it occupies and
5232// what following it does.
5233type linkRegion struct {
5234 x, y, w int
5235 act func()
5236}
5237
5238// dropState is an open header dropdown.
5239type dropState struct {
5240 kind string // "cats" | "etc"
5241 lst *list
5242 targets []navTarget // cats only
5243 width int
5244}
5245
5246// artHolder carries one asynchronously rendered product photo.
5247type artHolder struct {
5248 key string // what art holds
5249 want string // what was last requested
5250 art *artwork
5251}
5252
5253type artReq struct {
5254 key string
5255 path string
5256 w, h int
5257 mode imgMode
5258 holder *artHolder
5259}
5260
5261type ui struct {
5262 screen tcell.Screen
5263 cat *catalogInfo
5264 quit bool
5265
5266 panels map[string]panel
5267 history []string
5268 fwd []string
5269
5270 drop *dropState
5271 commerce overlayW
5272 notice string
5273
5274 // postMu gates post against RunOn's shutdown closing the queue.
5275 postMu sync.Mutex
5276 finished bool
5277
5278 // header nav and footer summary click regions, rebuilt each draw
5279 navRegions []struct {
5280 x1, x2 int
5281 act string
5282 }
5283 footRegions []struct {
5284 x1, x2 int
5285 act string
5286 }
5287
5288 // links are the anchors the visible page drew, rebuilt every draw and
5289 // consulted before the panel sees a click. On the website every one of
5290 // these is an <a>; here it is a run of cells with something to do.
5291 links []linkRegion
5292
5293 // hoverX, hoverY is the cell the pointer was last seen over, which is
5294 // what a:hover needs. -1 means the pointer has not been anywhere yet.
5295 hoverX, hoverY int
5296
5297 // bare drops the pisano frame around the product table, giving its
5298 // rows back to the table. Toggled with d.
5299 bare bool
5300
5301 // image rendering
5302 mode imgMode
5303 reqCh chan artReq
5304
5305 // expanded holds which categories' branches are open in the
5306 // Categories dropdown, like the template's <details open>.
5307 expanded map[string]bool
5308
5309 // home animation. The ticker goroutine computes frame (a pixel
5310 // buffer) and the home panel paints it; both sides go through mu.
5311 // current names the visible panel; the goroutine reads it too.
5312 mu sync.Mutex
5313 current string
5314 logo image.Image
5315 homeW int
5316 homeH int
5317 frame *image.RGBA
5318 backdrop *image.RGBA
5319 bdW, bdH int
5320 // pose randomized at startup like chaosrack's randomizeOrientation,
5321 // then tumbled by a random rate on every axis.
5322 poseX, poseY, poseZ float64
5323 rateX, rateY, rateZ float64
5324 done chan struct{}
5325 // Placements (place.go): what this frame wants over its cells, and
5326 // what the terminal has.
5327 placeWant, placeSent map[string]placed
5328 // cellsOnly keeps to the cells: no placements, as in a terminal without
5329 // them. Toggled with w.
5330 cellsOnly bool
5331 // overlaysDrawn counts the overlays drawn so far this frame; see placed.above.
5332 overlaysDrawn int
5333 // homeCovered: the page's globe is over the home panel's cells (set by
5334 // placeSync, read by animateHome), so they need not turn.
5335 homeCovered atomic.Bool
5336
5337 asciiLines []string
5338 caps string
5339
5340 // cart, as the footer's View Cart
5341 cartOrder []string
5342 cartQty map[string]int
5343 shipping *storepage.Shipping
5344
5345 sitedomain string
5346 // announced is the page path last told to the host (address.go).
5347 announced string
5348 // kittyIDs and kittyCache: pictures for kitty's graphics protocol
5349 // (kittyimg.go).
5350 kittyIDs map[string]uint32
5351 kittyCache map[string][]byte
5352 masthead [3]string
5353}
5354
5355// screenKey carries an alternative way of getting a screen.
5356type screenKey struct{}
5357
5358// WithScreen tells Run how to obtain its screen, for a host where
5359// tcell's own answer is the wrong one.
5360//
5361// This is what lets `m2 tui` be the same command in both places. In a
5362// terminal it opens a tcell screen on the tty and there is nothing to
5363// say; in a browser the screen has to be an xtcell one bound to the
5364// particular terminal and element the command was typed into, which
5365// only the page knows. Rather than a second command for the browser β
5366// which is what there used to be, and which promptly drifted β the page
5367// puts the answer here and runs the real one.
5368//
5369// A function and not a screen: most commands never need one, and
5370// building it takes the terminal over.
5371func WithScreen(ctx context.Context, newScreen func() (tcell.Screen, error)) context.Context {
5372 return context.WithValue(ctx, screenKey{}, newScreen)
5373}
5374
5375// screenFrom returns the screen ctx asks for, or nil for tcell's own.
5376func screenFrom(ctx context.Context) func() (tcell.Screen, error) {
5377 if ctx == nil {
5378 return nil
5379 }
5380 fn, _ := ctx.Value(screenKey{}).(func() (tcell.Screen, error))
5381 return fn
5382}
5383
5384// Run browses the store in a full-screen terminal UI until the user
5385// quits, on the screen ctx names or on a new tcell one.
5386func Run(ctx context.Context, prods product.Products) error {
5387 if newScreen := screenFrom(ctx); newScreen != nil {
5388 sc, err := newScreen()
5389 if err != nil {
5390 return err
5391 }
5392 return RunOn(sc, prods)
5393 }
5394 probeTerminal() // what this terminal can do beyond cells (probe_other.go)
5395 sc, err := tcell.NewScreen()
5396 if err != nil {
5397 return err
5398 }
5399 if err := sc.Init(); err != nil {
5400 return err
5401 }
5402 return RunOn(sc, prods)
5403}
5404
5405// RunOn drives the store UI on an initialized screen until the user
5406// quits, then finalizes it.
5407func RunOn(sc tcell.Screen, prods product.Products) error {
5408 u := &ui{
5409 cat: newCatalogInfo(prods),
5410 panels: map[string]panel{},
5411 mode: modeHalfBlock,
5412 reqCh: make(chan artReq, 1),
5413 expanded: map[string]bool{},
5414 done: make(chan struct{}),
5415 cartQty: map[string]int{},
5416 hoverX: -1,
5417 hoverY: -1,
5418 }
5419 u.sitedomain = f.Sitename + f.Siteext
5420 if u.sitedomain == "" {
5421 u.sitedomain = "m2"
5422 }
5423 u.loadCart()
5424 u.offerWidgets()
5425 u.asciiLines = strings.Split(strings.TrimRight(calvin.AsciiFont(u.sitedomain), "\n"), "\n")
5426 // The page sets this line in blackboard bold, and the terminal cannot:
5427 // the double-struck capitals live outside the BMP and no monospace font
5428 // here carries them, so every one of them falls back to a face whose
5429 // glyph is nearly twice the cell it is given and collides with its
5430 // neighbor. Plain capitals are the same words, drawn on the grid.
5431 u.caps = strings.ToUpper(u.sitedomain)
5432 long := f.Sitelongname
5433 if long == "" {
5434 long = u.sitedomain
5435 }
5436 tag := ""
5437 if f.Sitetagline != "" {
5438 tag = "- " + esc(f.Sitetagline) + " -"
5439 }
5440 u.masthead = [3]string{"[::b]" + esc(long) + "[-:-:-]", "[gray]" + tag + "[-]", checkerboard(u.sitedomain)}
5441
5442 // A fresh random starting pose each run, as the website gives each
5443 // page load β and a random spin rate per axis, each with a floor so
5444 // all three visibly contribute, the vector normalized so the overall
5445 // speed matches auto-rotate's.
5446 u.poseX = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
5447 u.poseY = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
5448 u.poseZ = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
5449 randRate := func() float64 {
5450 r := 0.35 + 0.65*rand.Float64() //nolint:gosec
5451 if rand.Intn(2) == 0 { //nolint:gosec
5452 return -r
5453 }
5454 return r
5455 }
5456 u.rateX, u.rateY, u.rateZ = randRate(), randRate(), randRate()
5457 norm := math.Sqrt(u.rateX*u.rateX + u.rateY*u.rateY + u.rateZ*u.rateZ)
5458 const pace = 0.042 // auto-rotate's 0.3 rad/s at the 140ms ticker
5459 u.rateX, u.rateY, u.rateZ = u.rateX/norm*pace, u.rateY/norm*pace, u.rateZ/norm*pace
5460
5461 logos := []string{"logo.jpg", "logo.png"}
5462 if s := storeURL(); s != "" {
5463 logos = []string{s + "/logo.jpg", s + "/logo.png"}
5464 }
5465 for _, p := range logos {
5466 if img, err := loadImage(p); err == nil {
5467 u.logo = img
5468 break
5469 }
5470 }
5471
5472 u.screen = sc
5473 sc.SetStyle(styleText)
5474 sc.EnableMouse()
5475
5476 u.panels["home"] = &homePanel{}
5477 u.current = "home"
5478
5479 go u.renderWorker()
5480 go u.animateHome()
5481 if p := u.pageFromPath(StartPath); p != "" && p != "home" {
5482 u.show(p) // a link opened the store at this page
5483 }
5484 StartPath = "" // once: the store run again starts at home
5485
5486 defer func() {
5487 // Stop accepting posts before the screen closes the event queue
5488 // under them β a ticker mid-post must not race the close.
5489 u.postMu.Lock()
5490 u.finished = true
5491 u.postMu.Unlock()
5492 u.placeClear() // the placements go with the program
5493 sc.Fini()
5494 close(u.done)
5495 }()
5496 events := sc.EventQ()
5497 u.draw()
5498 for !u.quit {
5499 ev, ok := <-events
5500 if !ok {
5501 break
5502 }
5503 switch e := ev.(type) {
5504 case *tcell.EventInterrupt:
5505 if fn, ok := e.Data().(func()); ok && fn != nil {
5506 fn()
5507 }
5508 case *tcell.EventResize:
5509 sc.Sync()
5510 clear(u.placeSent) // the full redraw erased what was laid over the cells; lay it again
5511 case *tcell.EventKey:
5512 // v3 reports key releases too where the terminal can; a
5513 // shortcut must fire once, on the press.
5514 if e.Pressed() {
5515 u.handleKey(e)
5516 }
5517 case *tcell.EventMouse:
5518 u.handleMouse(e)
5519 }
5520 u.draw()
5521 }
5522 return nil
5523}
5524
5525// animation ticker use it. Non-blocking and refused after shutdown, so
5526// a producer can never race the queue's close nor wedge against a loop
5527// that has already exited.
5528func (u *ui) post(fn func()) {
5529 u.postMu.Lock()
5530 defer u.postMu.Unlock()
5531 if u.finished {
5532 return
5533 }
5534 select {
5535 case u.screen.EventQ() <- tcell.NewEventInterrupt(fn):
5536 default:
5537 }
5538}
5539
5540// ---- drawing ----
5541
5542// linkSink returns the callback the markup printers report anchors to,
5543// binding each link index to the page's own table of what its links do.
5544// A page builds that table as it builds its markup; see linkTag.
5545func (u *ui) linkSink(acts []func()) func(x, y, w, link int) {
5546 return func(x, y, w, link int) {
5547 if w <= 0 || link < 1 || link > len(acts) || acts[link-1] == nil {
5548 return
5549 }
5550 u.links = append(u.links, linkRegion{x, y, w, acts[link-1]})
5551 }
5552}
5553
5554// hovered is the run of cells the pointer is over, if it is over one of
5555// the page's links: the header nav, the footer's two cells, or an anchor
5556// the visible panel drew.
5557func (u *ui) hovered(h int) (x1, x2, y int, ok bool) {
5558 switch {
5559 case u.hoverY < 0:
5560 case u.hoverY == 0:
5561 for _, reg := range u.navRegions {
5562 if u.hoverX >= reg.x1 && u.hoverX < reg.x2 {
5563 return reg.x1, reg.x2, 0, true
5564 }
5565 }
5566 case u.hoverY == h-1:
5567 for _, reg := range u.footRegions {
5568 if u.hoverX >= reg.x1 && u.hoverX < reg.x2 {
5569 return reg.x1, reg.x2, h - 1, true
5570 }
5571 }
5572 default:
5573 for _, l := range u.links {
5574 if u.hoverY == l.y && u.hoverX >= l.x && u.hoverX < l.x+l.w {
5575 return l.x, l.x + l.w, l.y, true
5576 }
5577 }
5578 }
5579 return 0, 0, 0, false
5580}
5581
5582// paintHover recolors the link under the pointer, as a:hover{color:green}
5583// does on the page.
5584//
5585// It repaints cells the page has already drawn rather than styling them
5586// as they go, because which cells belong to a link is only known once it
5587// is drawn β and this way no drawing code has to know where the pointer
5588// is. It runs before the overlays, so an open dropdown covers it rather
5589// than being recolored by it.
5590func (u *ui) paintHover(sc tcell.Screen, h int) {
5591 x1, x2, y, ok := u.hovered(h)
5592 if !ok {
5593 return
5594 }
5595 for x := x1; x < x2; x++ {
5596 str, st, _ := sc.Get(x, y)
5597 sc.Put(x, y, str, st.Foreground(colorHover))
5598 }
5599}
5600
5601func (u *ui) draw() {
5602 sc := u.screen
5603 w, h := sc.Size()
5604 if w < 4 || h < 8 {
5605 sc.Show()
5606 return
5607 }
5608 sc.HideCursor()
5609 u.links = u.links[:0]
5610 u.overlaysDrawn = 0
5611 fillRect(sc, rect{0, 0, w, h}, styleText)
5612 u.drawHeader(sc, w)
5613 // Row 1 is the header table's bottom border, so the masthead starts
5614 // below it, as the page's title block sits under the nav rule.
5615 for i, line := range u.masthead {
5616 printMarkupCenter(sc, 0, 2+i, w, line, styleText)
5617 }
5618 content := rect{0, 5, w, h - 8}
5619 u.mu.Lock()
5620 cur := u.current
5621 u.mu.Unlock()
5622 if p := u.panels[cur]; p != nil {
5623 p.draw(u, sc, content)
5624 }
5625 u.drawFooter(sc, w, h)
5626 u.paintHover(sc, h)
5627 var overlays []rect // what is drawn over the panel, which no placement may cover
5628 if u.commerce != nil {
5629 r := u.commerceRect()
5630 overlays = append(overlays, r)
5631 drawBox(sc, r, "[white] "+u.commerce.title()+" [-]", styleText.Foreground(tcell.ColorWhite.TrueColor()), styleText)
5632 u.overlaysDrawn = len(overlays)
5633 u.commerce.draw(u, sc, r.inner())
5634 }
5635 if u.drop != nil {
5636 r := u.dropRect()
5637 overlays = append(overlays, r)
5638 drawBox(sc, r, "[white] "+map[string]string{"cats": "Categories", "etc": "Etc..."}[u.drop.kind]+" [-]", styleBorder, styleText)
5639 u.drop.lst.draw(sc, r.inner())
5640 }
5641 if u.notice != "" {
5642 nw := markupWidth(u.notice) + 6
5643 if nw > w-4 {
5644 nw = w - 4
5645 }
5646 r := rect{(w - nw) / 2, h/2 - 2, nw, 5}
5647 overlays = append(overlays, r)
5648 drawBox(sc, r, "", styleText.Foreground(tcell.ColorWhite.TrueColor()), styleText)
5649 printMarkupCenter(sc, r.x+1, r.y+2, r.w-2, u.notice, styleText)
5650 }
5651 sc.Show()
5652 u.placeSync(overlays)
5653 u.announcePage()
5654}
5655
5656// drawHeader lays the nav out as the website's header table does: three
5657// equal columns β Categories, Home, Etc... β each label centered in its
5658// third, with its dropdown opening beneath it. The current section is
5659// cyan and underlined and the rest are white, as the page renders its
5660// links, and the row is closed by the header table's blue bottom border.
5661func (u *ui) drawHeader(sc tcell.Screen, w int) {
5662 entries := []struct{ label, act string }{
5663 {"Categories", "cats"}, {"Home", "home"}, {"Etc...", "etc"},
5664 }
5665 active := u.navActive()
5666 u.navRegions = u.navRegions[:0]
5667 third := w / 3
5668 for i, e := range entries {
5669 x := i*third + (third-len(e.label))/2
5670 if x < 0 {
5671 x = 0
5672 }
5673 tag := "[white]"
5674 if e.act == active {
5675 tag = "[aqua::u]"
5676 }
5677 printMarkup(sc, x, 0, w-x, tag+e.label+"[-:-:-]", styleText)
5678 u.navRegions = append(u.navRegions, struct {
5679 x1, x2 int
5680 act string
5681 }{x, x + len(e.label), e.act})
5682 }
5683 drawRule(sc, 0, 1, w, ruleBlue)
5684}
5685
5686// navActive names the header entry for the page on screen, the way the
5687// site marks the section you are in: the catalog pages belong to
5688// Categories, the stock pages to Etc..., everything else to Home.
5689func (u *ui) navActive() string {
5690 u.mu.Lock()
5691 cur := u.current
5692 u.mu.Unlock()
5693 switch {
5694 case strings.HasPrefix(cur, "list:"), strings.HasPrefix(cur, "prod:"):
5695 return "cats"
5696 case strings.HasPrefix(cur, "content:"):
5697 return "etc"
5698 }
5699 return "home"
5700}
5701
5702// drawFooter is the website's fixed footer: the status flash, address
5703// and key hints on one row, then the footer table itself β View Cart on
5704// the left half and Add Shipping Info on the right, each centered in its
5705// cell, ruled above and divided down the middle as the page's is.
5706func (u *ui) drawFooter(sc tcell.Screen, w, h int) {
5707 status := "[#FF10F0:#009EFF] Open For Business [-:-]"
5708 if f.Teststripekey {
5709 status = "[yellow:red] Test Mode Active - No Orders Processed [-:-]"
5710 }
5711 arrows := ""
5712 if len(u.history) > 0 {
5713 arrows += "β"
5714 }
5715 if len(u.fwd) > 0 {
5716 arrows += "β"
5717 }
5718 if arrows != "" {
5719 arrows = "[gray]" + arrows + "[-] "
5720 }
5721 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[-]",
5722 status, arrows, u.pagePath())
5723 printMarkup(sc, 0, h-3, w, line, styleText)
5724
5725 // The footer table: a white rule for its top edge, then two equal
5726 // cells split by the divider the page draws between them.
5727 drawRule(sc, 0, h-2, w, ruleWhite)
5728 total, count := u.cartTotal()
5729 cart := fmt.Sprintf("β΄ View Cart Total: $%d.%02d", total/100, total%100)
5730 if count > 0 {
5731 cart = fmt.Sprintf("β΄ View Cart (%d) Total: $%d.%02d", count, total/100, total%100)
5732 }
5733 ship := "β΄ Add Shipping Info"
5734 u.footRegions = u.footRegions[:0]
5735 half := w / 2
5736 for i, e := range []struct{ label, act string }{{cart, "cart"}, {ship, "shipping"}} {
5737 x := i*half + (half-len([]rune(e.label)))/2
5738 if x < 0 {
5739 x = 0
5740 }
5741 printMarkup(sc, x, h-1, w-x, "[aqua]"+esc(e.label)+"[-]", styleText)
5742 u.footRegions = append(u.footRegions, struct {
5743 x1, x2 int
5744 act string
5745 }{x, x + len([]rune(e.label)), e.act})
5746 }
5747 sc.SetContent(half, h-1, 'β', nil, ruleWhite)
5748}
5749
5750func (u *ui) cartTotal() (cents, count int) {
5751 for partno, qty := range u.cartQty {
5752 if p := u.cat.find(partno); p != nil {
5753 cents += priceCents(p.Price) * qty
5754 count += qty
5755 }
5756 }
5757 if u.shipping != nil {
5758 cents += u.shipping.Cents
5759 }
5760 return cents, count
5761}
5762
5763func priceCents(s string) int {
5764 s = strings.TrimPrefix(s, "$")
5765 fl, err := strconv.ParseFloat(s, 64)
5766 if err != nil {
5767 return 0
5768 }
5769 return int(fl*100 + 0.5)
5770}
5771
5772// ---- input routing ----
5773
5774func (u *ui) handleKey(ev *tcell.EventKey) {
5775 if ev.Key() == tcell.KeyCtrlC {
5776 u.quit = true
5777 return
5778 }
5779 if u.notice != "" {
5780 u.notice = ""
5781 return
5782 }
5783 if u.drop != nil {
5784 if u.drop.lst.key(ev) {
5785 return
5786 }
5787 r := keyRune(ev)
5788 if ev.Key() == tcell.KeyEscape ||
5789 (r == 'c' && u.drop.kind == "cats") || (r == 'e' && u.drop.kind == "etc") {
5790 u.drop = nil
5791 return
5792 }
5793 }
5794 if u.commerce != nil {
5795 if u.commerce.key(u, ev) {
5796 return
5797 }
5798 if ev.Key() == tcell.KeyEscape {
5799 u.closeCommerce()
5800 return
5801 }
5802 }
5803 switch ev.Key() {
5804 case tcell.KeyEscape:
5805 u.back()
5806 return
5807 case tcell.KeyBackspace, tcell.KeyBackspace2:
5808 u.back()
5809 return
5810 }
5811 if ev.Key() == tcell.KeyRune {
5812 switch keyRune(ev) {
5813 case 'q':
5814 u.quit = true
5815 return
5816 case 'c':
5817 u.openCategories()
5818 return
5819 case 'h':
5820 u.show("home")
5821 return
5822 case 'e':
5823 u.openEtc()
5824 return
5825 case 'f':
5826 u.forward()
5827 return
5828 case 'v':
5829 u.toggleCart()
5830 return
5831 case 'i':
5832 if u.mode == modeHalfBlock {
5833 u.mode = modeCaca
5834 } else {
5835 u.mode = modeHalfBlock
5836 }
5837 return // the changed mode re-keys art requests at next draw
5838 case 'w':
5839 u.cellsOnly = !u.cellsOnly
5840 return
5841 case 'd':
5842 // The frame is drawn out of the table's own area, so it
5843 // costs rows of products. Worth having and worth being able
5844 // to put away, which the page never needs to offer.
5845 u.bare = !u.bare
5846 return
5847 }
5848 }
5849 if u.drop != nil || u.commerce != nil {
5850 return
5851 }
5852 u.mu.Lock()
5853 cur := u.current
5854 u.mu.Unlock()
5855 if p := u.panels[cur]; p != nil {
5856 p.key(u, ev)
5857 }
5858}
5859
5860func (u *ui) handleMouse(ev *tcell.EventMouse) {
5861 x, y := ev.Position()
5862 w, h := u.screen.Size()
5863 click := ev.Buttons()&tcell.Button1 != 0
5864 // Where the pointer is, for a:hover. Every event carries it, so bare
5865 // motion is not the only thing that keeps it current.
5866 u.hoverX, u.hoverY = x, y
5867 if u.notice != "" {
5868 if click {
5869 u.notice = ""
5870 }
5871 return
5872 }
5873 wasOpen := ""
5874 if u.drop != nil {
5875 r := u.dropRect().inner()
5876 if r.contains(x, y) {
5877 u.drop.lst.mouse(y-r.y, ev)
5878 return
5879 }
5880 if !click {
5881 return
5882 }
5883 // Close, and let the click land: clicking the other header label
5884 // switches menus in one click, as details dropdowns do β but a
5885 // click on this menu's own label must collapse it, not reopen it.
5886 wasOpen = u.drop.kind
5887 u.drop = nil
5888 }
5889 if click && y == 0 {
5890 for _, reg := range u.navRegions {
5891 if x >= reg.x1 && x < reg.x2 {
5892 switch reg.act {
5893 case "cats":
5894 if wasOpen != "cats" {
5895 u.openCategories()
5896 }
5897 case "home":
5898 u.show("home")
5899 case "etc":
5900 if wasOpen != "etc" {
5901 u.openEtc()
5902 }
5903 }
5904 return
5905 }
5906 }
5907 return
5908 }
5909 if click && y == h-1 {
5910 for _, reg := range u.footRegions {
5911 if x >= reg.x1 && x < reg.x2 {
5912 switch reg.act {
5913 case "cart":
5914 u.toggleCart()
5915 case "shipping":
5916 if _, open := u.commerce.(*shippingOverlay); open {
5917 u.closeCommerce()
5918 } else {
5919 u.openShipping()
5920 }
5921 }
5922 return
5923 }
5924 }
5925 return
5926 }
5927 if u.commerce != nil {
5928 r := u.commerceRect()
5929 if !r.contains(x, y) && click {
5930 u.closeCommerce()
5931 }
5932 return
5933 }
5934 // An anchor first: on the page these are <a>, and a click on one
5935 // follows it rather than reaching the widget it was drawn over.
5936 if click {
5937 for _, l := range u.links {
5938 if y == l.y && x >= l.x && x < l.x+l.w {
5939 l.act()
5940 return
5941 }
5942 }
5943 }
5944 content := rect{0, 5, w, h - 8}
5945 if content.contains(x, y) {
5946 u.mu.Lock()
5947 cur := u.current
5948 u.mu.Unlock()
5949 if p := u.panels[cur]; p != nil {
5950 p.mouse(u, content, ev)
5951 }
5952 }
5953}
5954
5955// ---- navigation ----
5956
5957func (u *ui) show(page string) {
5958 u.drop = nil
5959 u.closeCommerce()
5960 u.mu.Lock()
5961 if page == u.current {
5962 u.mu.Unlock()
5963 return
5964 }
5965 u.history = append(u.history, u.current)
5966 u.current = page
5967 u.mu.Unlock()
5968 u.fwd = nil
5969 u.ensurePanel(page)
5970}
5971
5972func (u *ui) back() {
5973 u.mu.Lock()
5974 if len(u.history) == 0 {
5975 u.mu.Unlock()
5976 return
5977 }
5978 page := u.history[len(u.history)-1]
5979 u.history = u.history[:len(u.history)-1]
5980 u.fwd = append(u.fwd, u.current)
5981 u.current = page
5982 u.mu.Unlock()
5983}
5984
5985func (u *ui) forward() {
5986 u.mu.Lock()
5987 if len(u.fwd) == 0 {
5988 u.mu.Unlock()
5989 return
5990 }
5991 page := u.fwd[len(u.fwd)-1]
5992 u.fwd = u.fwd[:len(u.fwd)-1]
5993 u.history = append(u.history, u.current)
5994 u.current = page
5995 u.mu.Unlock()
5996 u.ensurePanel(page)
5997}
5998
5999func (u *ui) ensurePanel(page string) {
6000 if _, ok := u.panels[page]; ok {
6001 return
6002 }
6003 switch {
6004 case strings.HasPrefix(page, "list:"):
6005 spec := strings.TrimPrefix(page, "list:")
6006 parts := strings.SplitN(spec, "|", 2)
6007 cat, sub := parts[0], ""
6008 if len(parts) == 2 {
6009 sub = parts[1]
6010 }
6011 u.panels[page] = newListPanel(u, page, cat, sub)
6012 case strings.HasPrefix(page, "prod:"):
6013 u.panels[page] = newProductPanel(u, strings.TrimPrefix(page, "prod:"))
6014 case strings.HasPrefix(page, "content:"):
6015 u.panels[page] = newContentPanel(u, strings.TrimPrefix(page, "content:"))
6016 }
6017}
6018
6019func listPageID(t navTarget) string {
6020 if t.cat == "" {
6021 return "list:"
6022 }
6023 if t.subcat == "" {
6024 return "list:" + t.cat
6025 }
6026 return "list:" + t.cat + "|" + t.subcat
6027}
6028
6029// pagePath renders the current panel as the URL path the website would
6030// give it β the footer shows it like an address bar.
6031func (u *ui) pagePath() string {
6032 u.mu.Lock()
6033 cur := u.current
6034 u.mu.Unlock()
6035 switch {
6036 case cur == "home":
6037 return "/"
6038 case strings.HasPrefix(cur, "content:"):
6039 return "/#" + strings.TrimPrefix(cur, "content:")
6040 case strings.HasPrefix(cur, "prod:"):
6041 return "/p/" + strings.TrimPrefix(cur, "prod:")
6042 case strings.HasPrefix(cur, "list:"):
6043 spec := strings.TrimPrefix(cur, "list:")
6044 if spec == "" {
6045 return "/cat"
6046 }
6047 return "/cat/" + strings.ReplaceAll(spec, "|", "/")
6048 }
6049 return "/"
6050}
6051
6052// ---- header dropdowns ----
6053
6054// dropRect puts an open header menu against the edge its entry is
6055// nearest: Categories at the left, Etc at the right.
6056//
6057// The page does this as a consequence of how its header is built β each
6058// menu is absolutely positioned inside its own cell of a full-width
6059// table, so the first lands at the far left and the last at the far
6060// right β and the result reads as deliberate: a pair of menus bracketing
6061// the header rather than two labels with boxes hanging under them. The
6062// terminal now does it on purpose.
6063func (u *ui) dropRect() rect {
6064 sw, sh := u.screen.Size()
6065 x := 0
6066 if u.drop.kind == "etc" {
6067 x = sw - u.drop.width
6068 }
6069 if x < 0 {
6070 x = 0
6071 }
6072 h := len(u.drop.lst.items) + 2
6073 if max := sh - 4; h > max {
6074 h = max
6075 }
6076 return rect{x, 2, u.drop.width, h}
6077}
6078
6079// openCategories drops down the catsubcats tree. Like the template's
6080// nested <details>, categories open and close: the current category
6081// starts open, Enter on a closed category opens it (Enter again
6082// navigates), β/space toggle, β closes.
6083func (u *ui) openCategories() {
6084 if u.drop != nil && u.drop.kind == "cats" {
6085 u.drop = nil
6086 return
6087 }
6088 if cat, _ := u.currentTarget(); cat != "" {
6089 u.expanded[cat] = true
6090 }
6091 // Width from the fully expanded tree, so the box doesn't resize as
6092 // branches open and close.
6093 width := 20
6094 for _, it := range u.cat.buildTree(nil) {
6095 if w := len([]rune(it.label)) + 4; w > width {
6096 width = w
6097 }
6098 }
6099 d := &dropState{kind: "cats", lst: &list{}, width: width}
6100 u.drop = d
6101 rebuild := func(selCat string) {
6102 items := u.cat.buildTree(u.expanded)
6103 d.lst.items = d.lst.items[:0]
6104 d.targets = d.targets[:0]
6105 sel := 0
6106 for i, it := range items {
6107 d.lst.items = append(d.lst.items, esc(it.label))
6108 d.targets = append(d.targets, it.target)
6109 if it.target.cat == selCat && it.target.subcat == "" {
6110 sel = i
6111 }
6112 }
6113 d.lst.sel = sel
6114 }
6115 rebuild("")
6116 itemAt := func(i int) (navTarget, bool) {
6117 if i < 0 || i >= len(d.targets) {
6118 return navTarget{}, false
6119 }
6120 return d.targets[i], true
6121 }
6122 hasSubs := func(t navTarget) bool {
6123 return t.cat != "" && t.subcat == "" && len(u.cat.subcatsByCat[t.cat]) > 0
6124 }
6125 d.lst.onPick = func(i int) {
6126 t, ok := itemAt(i)
6127 if !ok {
6128 return
6129 }
6130 if hasSubs(t) && !u.expanded[t.cat] {
6131 u.expanded[t.cat] = true
6132 rebuild(t.cat)
6133 return
6134 }
6135 u.show(listPageID(t))
6136 }
6137 d.lst.onKey = func(ev *tcell.EventKey, sel int) bool {
6138 t, ok := itemAt(sel)
6139 if !ok {
6140 return false
6141 }
6142 switch {
6143 case ev.Key() == tcell.KeyRight:
6144 if hasSubs(t) && !u.expanded[t.cat] {
6145 u.expanded[t.cat] = true
6146 rebuild(t.cat)
6147 }
6148 return true
6149 case ev.Key() == tcell.KeyLeft:
6150 if t.cat != "" && (u.expanded[t.cat] || t.subcat != "") {
6151 u.expanded[t.cat] = false
6152 rebuild(t.cat)
6153 }
6154 return true
6155 case keyRune(ev) == ' ':
6156 if t.cat == "" {
6157 return true
6158 }
6159 if t.subcat != "" {
6160 u.expanded[t.cat] = false
6161 } else if hasSubs(t) {
6162 u.expanded[t.cat] = !u.expanded[t.cat]
6163 }
6164 rebuild(t.cat)
6165 return true
6166 }
6167 return false
6168 }
6169}
6170
6171// currentTarget reports which category/subcategory panel is showing.
6172func (u *ui) currentTarget() (string, string) {
6173 u.mu.Lock()
6174 cur := u.current
6175 u.mu.Unlock()
6176 if !strings.HasPrefix(cur, "list:") {
6177 return "", ""
6178 }
6179 parts := strings.SplitN(strings.TrimPrefix(cur, "list:"), "|", 2)
6180 if len(parts) == 2 {
6181 return parts[0], parts[1]
6182 }
6183 return parts[0], ""
6184}
6185
6186// openEtc drops down the Etc... menu: About, Policy, Telegram, Contact, Links.
6187func (u *ui) openEtc() {
6188 if u.drop != nil && u.drop.kind == "etc" {
6189 u.drop = nil
6190 return
6191 }
6192 type entry struct {
6193 label string
6194 fn func()
6195 }
6196 var entries []entry
6197 add := func(label string, fn func()) { entries = append(entries, entry{label, fn}) }
6198 add("About", func() { u.show("content:about") })
6199 add("Policy", func() { u.show("content:policy") })
6200 // The Telegram entries are links out, and now follow like any other:
6201 // the browser build opens the chat, and in a terminal openURL shows
6202 // the address, which is what this did in both places before.
6203 if f.Tgchannel != "" {
6204 href := "https://t.me/" + f.Tgchannel
6205 add("Telegram", func() { u.drop = nil; u.openURL(href) })
6206 }
6207 if f.Tgcontact != "" {
6208 href := "https://t.me/" + f.Tgcontact
6209 add("Contact", func() { u.drop = nil; u.openURL(href) })
6210 }
6211 add("Links", func() { u.show("content:links") })
6212 // Whatever the deployment's drop-ins added to the page's menu, in the
6213 // same place and the same order the header puts them: after Links.
6214 // follow decides what each one means here β a page the TUI has of its
6215 // own, or a link out.
6216 for _, nl := range navLinks {
6217 act := u.follow(nl.Href)
6218 if act == nil {
6219 continue
6220 }
6221 add(nl.Title, func() { u.drop = nil; act() })
6222 }
6223 lst := &list{}
6224 for _, e := range entries {
6225 lst.items = append(lst.items, esc(e.label))
6226 }
6227 lst.onPick = func(i int) { entries[i].fn() }
6228 u.drop = &dropState{kind: "etc", lst: lst, width: 30}
6229}
6230
6231// ---- the render worker ----
6232
6233func (u *ui) requestArt(h *artHolder, path string, w, ht int) {
6234 key := fmt.Sprintf("%s|%d|%d|%d", path, w, ht, u.mode)
6235 if h.key == key || h.want == key {
6236 return
6237 }
6238 h.want = key
6239 req := artReq{key: key, path: path, w: w, h: ht, mode: u.mode, holder: h}
6240 select {
6241 case u.reqCh <- req:
6242 default:
6243 select { // replace the stale pending request
6244 case <-u.reqCh:
6245 default:
6246 }
6247 u.reqCh <- req
6248 }
6249}
6250
6251func (u *ui) renderWorker() {
6252 cache := map[string]artwork{}
6253 for req := range u.reqCh {
6254 art, ok := cache[req.key]
6255 if !ok {
6256 art = renderArt(req.path, req.w, req.h, req.mode)
6257 if len(cache) > 128 {
6258 cache = map[string]artwork{}
6259 }
6260 cache[req.key] = art
6261 }
6262 r := req
6263 a := art
6264 u.post(func() {
6265 r.holder.key = r.key
6266 r.holder.art = &a
6267 })
6268 }
6269}
6270
6271// ---- the home panel ----
6272
6273type homePanel struct{}
6274
6275func (hp *homePanel) draw(u *ui, sc tcell.Screen, r rect) {
6276 u.mu.Lock()
6277 u.homeW, u.homeH = r.w, r.h
6278 frame := u.frame
6279 u.mu.Unlock()
6280 if frame == nil {
6281 return
6282 }
6283 rows := (frame.Bounds().Dy() + 1) / 2
6284 drawRGBA(sc, r.x, r.y, r.w, r.h, frame)
6285 // The website's own logo and globe over these cells, where the page
6286 // offers its globe (place.go).
6287 // Across the panel's whole width, the black either side of the logo
6288 // included: the picture there is the widget's, so a click on it is too
6289 // (it stops the globe). The logo is letterboxed to the same place.
6290 u.want("home", placed{r: rect{r.x, r.y, r.w, min(rows, r.h)}, widget: "globe", input: true})
6291 if rows < r.h {
6292 ty := r.y + rows
6293 for i, l := range u.asciiLines {
6294 if ty+i >= r.y+r.h {
6295 break
6296 }
6297 printMarkupCenter(sc, r.x, ty+i, r.w, esc(l), styleText)
6298 }
6299 if ty+len(u.asciiLines)+1 < r.y+r.h {
6300 printMarkupCenter(sc, r.x, ty+len(u.asciiLines)+1, r.w, esc(u.caps), styleText)
6301 // The page's double-struck capitals over them, where a page can
6302 // set them (the "caps" widget, widgets_js.go); a terminal keeps these.
6303 u.want("caps", placed{r: rect{r.x, ty + len(u.asciiLines) + 1, r.w, 1}, widget: "caps"})
6304 }
6305 }
6306}
6307
6308func (hp *homePanel) key(*ui, *tcell.EventKey) bool { return false }
6309func (hp *homePanel) mouse(*ui, rect, *tcell.EventMouse) bool { return false }
6310
6311// animateHome turns the globe over the logo while the home panel is
6312// shown, like the website's landing animation over the svg.
6313func (u *ui) animateHome() {
6314 ticker := time.NewTicker(140 * time.Millisecond)
6315 defer ticker.Stop()
6316 for {
6317 select {
6318 case <-u.done:
6319 return
6320 case <-ticker.C:
6321 }
6322 u.mu.Lock()
6323 cur, w, h := u.current, u.homeW, u.homeH
6324 u.mu.Unlock()
6325 if cur != "home" || w <= 0 || h <= 0 {
6326 continue
6327 }
6328 globeRows := h - len(u.asciiLines) - 2
6329 if globeRows < 4 {
6330 globeRows = h
6331 }
6332 if u.backdrop == nil || u.bdW != w || u.bdH != globeRows {
6333 u.backdrop = makeBackdrop(u.logo, w, globeRows)
6334 u.bdW, u.bdH = w, globeRows
6335 }
6336 if u.homeCovered.Load() {
6337 // The page's globe turns over these cells: drawing ours under
6338 // it would cost the tab a core for nothing seen. The logo alone,
6339 // once, which then costs nothing.
6340 u.mu.Lock()
6341 if u.frame != u.backdrop {
6342 u.frame = u.backdrop
6343 u.mu.Unlock()
6344 u.post(nil)
6345 } else {
6346 u.mu.Unlock()
6347 }
6348 continue
6349 }
6350 u.poseX += u.rateX
6351 u.poseY += u.rateY
6352 u.poseZ += u.rateZ
6353 frame := globeFrame(u.backdrop, u.poseX, u.poseY, u.poseZ)
6354 u.mu.Lock()
6355 u.frame = frame
6356 u.mu.Unlock()
6357 u.post(nil) // just redraw
6358 }
6359}
6360
6361// ---- category / subcategory listings ----
6362
6363// listPanel renders a category page: category.html's h1, the sidebar's
6364// list of subcategories, and the product table, with the selected row's
6365// image beside it (the terminal's stand-in for the table's image column).
6366type listPanel struct {
6367 page, cat, sub string
6368 heading []string
6369 links []func() // what this page's anchors do; see linkTag
6370 cols []storepage.Col // the shared column set, image included
6371 shown []storepage.Col // the ones this view draws, in table order
6372 prods []*product.Product // panel's full set
6373 rows []*product.Product // filtered
6374 tbl *table
6375 art artHolder
6376 filterOn bool
6377 filter finput
6378}
6379
6380// link registers an action for this page and returns the markup that
6381// wraps s in it.
6382func (lp *listPanel) link(s string, act func()) string {
6383 lp.links = append(lp.links, act)
6384 return linkTag(len(lp.links), s)
6385}
6386
6387func newListPanel(u *ui, page, cat, sub string) *listPanel {
6388 lp := &listPanel{page: page, cat: cat, sub: sub}
6389 all := u.cat.products(cat, sub)
6390 for i := range all {
6391 lp.prods = append(lp.prods, &all[i])
6392 }
6393 // category.html's h1, carrying the same links: the category name goes
6394 // to the category β which is how a subcategory page climbs back β and
6395 // each name links to its own page as the template's do.
6396 catLink := func(t navTarget, label string) string {
6397 return lp.link("[aqua]"+esc(label)+"[-]", func() { u.show(listPageID(t)) })
6398 }
6399 title := "All Products"
6400 switch {
6401 case sub != "":
6402 title = "Category: " + catLink(navTarget{cat: cat}, cat) +
6403 " | Subcategory: " + catLink(navTarget{cat: cat, subcat: sub}, sub)
6404 case cat != "":
6405 title = "Category: " + catLink(navTarget{cat: cat}, cat)
6406 }
6407 lp.heading = []string{"[::b]" + title + "[-:-:-]"}
6408 // The page's aside lists the category's subcategories under a
6409 // "Subcategories:" heading; the Categories dropdown is the terminal's
6410 // aside, so the list goes on the page where it can be reached.
6411 if cat != "" && sub == "" {
6412 if subs := u.cat.subcatsByCat[cat]; len(subs) > 0 {
6413 var parts []string
6414 for _, s := range subs {
6415 target := navTarget{cat: cat, subcat: s}
6416 parts = append(parts, fmt.Sprintf("%s (%d)",
6417 lp.link("[aqua]"+esc(s)+"[-]", func() { u.show(listPageID(target)) }),
6418 u.cat.subcatCounts[cat][s]))
6419 }
6420 lp.heading = append(lp.heading, "Subcategories: "+strings.Join(parts, ", "))
6421 }
6422 }
6423 // The columns are storepage's, so the page and the terminal list the
6424 // same things in the same order. Image is the one column a terminal
6425 // answers differently β it has one picture to spend, beside the
6426 // table, not a thumbnail per row β so it is dropped here and the art
6427 // panel stands in for it. Subcategory only where the rows can differ
6428 // in it, which is what storepage is being asked.
6429 lp.cols = storepage.Columns(sub == "")
6430 var cols []tcol
6431 for _, c := range lp.cols {
6432 if c.Kind == storepage.ColImage {
6433 continue
6434 }
6435 cols = append(cols, tcol{
6436 title: c.Title,
6437 width: c.Width,
6438 alignRight: c.Align == storepage.AlignRight,
6439 link: c.Kind == storepage.ColName || c.Kind == storepage.ColSubcategory || c.Kind == storepage.ColBuy,
6440 })
6441 lp.shown = append(lp.shown, c)
6442 }
6443 lp.tbl = &table{cols: cols}
6444 lp.tbl.onActivate = func(i int) {
6445 if i >= 0 && i < len(lp.rows) {
6446 u.show("prod:" + lp.rows[i].Partno)
6447 }
6448 }
6449 lp.fill("")
6450 return lp
6451}
6452
6453func (lp *listPanel) fill(query string) {
6454 lp.rows = lp.rows[:0]
6455 var rows []trow
6456 query = strings.ToLower(query)
6457 for _, p := range lp.prods {
6458 if query != "" && !strings.Contains(strings.ToLower(
6459 p.Partno+" "+p.Name+" "+p.Subcategory+" "+p.Description1), query) {
6460 continue
6461 }
6462 row := trow{}
6463 for _, c := range lp.shown {
6464 cell := storepage.CellFor(c, p)
6465 row.cells = append(row.cells, tcell_{cell.Text, cellStyle(c, p)})
6466 }
6467 rows = append(rows, row)
6468 lp.rows = append(lp.rows, p)
6469 }
6470 lp.tbl.setRows(rows)
6471}
6472
6473// cellStyle dresses a shared cell in the site's colors: links cyan, the
6474// buy button a button, and an out-of-stock name grey β which is the page
6475// greying a row it will not sell.
6476func cellStyle(c storepage.Col, p *product.Product) tcell.Style {
6477 switch c.Kind {
6478 case storepage.ColName:
6479 if !storepage.InStock(p) {
6480 return styleText.Foreground(tcell.GetColor("#808080"))
6481 }
6482 return styleLink
6483 case storepage.ColSubcategory:
6484 return styleLink
6485 case storepage.ColBuy:
6486 return styleButton
6487 }
6488 return styleText
6489}
6490
6491func (lp *listPanel) selected() *product.Product {
6492 if lp.tbl.sel >= 0 && lp.tbl.sel < len(lp.rows) {
6493 return lp.rows[lp.tbl.sel]
6494 }
6495 return nil
6496}
6497
6498// layout places the panel's parts. tblR is where the table's cells go,
6499// which is inside the frame when there is room for one β brd and brdR
6500// carry that frame and where it is drawn. Both draw and mouse take the
6501// table's rectangle from here, or a click would land a row or two off
6502// whatever the frame pushed the table to.
6503func (lp *listPanel) layout(u *ui, r rect) (head, tblR, artR, filtR, brdR rect, brd *borderArt) {
6504 hh := len(lp.heading)
6505 head = rect{r.x + 1, r.y, r.w - 2, hh}
6506 body := rect{r.x, r.y + hh + 1, r.w, r.h - hh - 1}
6507 fh := 0
6508 if lp.filterOn {
6509 fh = 1
6510 }
6511 tw := body.w * 3 / 5
6512 tblR = rect{body.x + 1, body.y, tw - 2, body.h - fh}
6513 artR = rect{body.x + tw, body.y, body.w - tw, body.h - fh}
6514 filtR = rect{body.x + 1, body.y + body.h - 1, body.w - 2, 1}
6515 if !u.bare {
6516 tblR, brd, brdR = framed(tblR)
6517 }
6518 return
6519}
6520
6521func (lp *listPanel) draw(u *ui, sc tcell.Screen, r rect) {
6522 head, tblR, artR, filtR, brdR, brd := lp.layout(u, r)
6523 if brd != nil {
6524 brd.draw(sc, brdR)
6525 }
6526 sink := u.linkSink(lp.links)
6527 for i, line := range lp.heading {
6528 printMarkupLinks(sc, head.x, head.y+i, head.w, line, styleText, sink)
6529 }
6530 // A row has the two the page's row has: the name opens the product,
6531 // as the product image does there, and Buy adds it to the cart.
6532 // Registering them as anchors is what makes one click do it and the
6533 // pointer light them up. Both select the row first, so the photo
6534 // beside the table shows what was just acted on.
6535 lp.tbl.onLink = func(x, y, w, row, col int) {
6536 if col >= len(lp.shown) {
6537 return
6538 }
6539 kind := lp.shown[col].Kind
6540 u.links = append(u.links, linkRegion{x, y, w, func() {
6541 lp.tbl.sel = row
6542 p := lp.selected()
6543 if p == nil {
6544 return
6545 }
6546 // What the cell says it is, rather than what column it
6547 // landed in: the shared cell already carries where it goes
6548 // and whether it is the row's buy.
6549 cell := storepage.CellFor(storepage.Col{Kind: kind}, p)
6550 switch {
6551 case cell.Buy:
6552 u.addToCart(p)
6553 case cell.Href != "":
6554 if act := u.follow(cell.Href); act != nil {
6555 act()
6556 }
6557 }
6558 }})
6559 }
6560 lp.tbl.draw(sc, tblR)
6561 if p := lp.selected(); p != nil {
6562 u.requestArt(&lp.art, imagePath(p), artR.w, artR.h)
6563 }
6564 if ar, ok := drawArtwork(sc, artR, lp.art.art); ok && lp.selected() != nil {
6565 u.want("photo", placed{r: ar, url: imagePath(lp.selected()), fit: "cover"})
6566 }
6567 if lp.filterOn {
6568 printMarkup(sc, filtR.x, filtR.y, 3, "[aqua]/ [-]", styleText)
6569 curX, _ := lp.filter.drawValue(sc, filtR.x+2, filtR.y, filtR.w-2, true)
6570 sc.ShowCursor(curX, filtR.y)
6571 }
6572}
6573
6574func (lp *listPanel) key(u *ui, ev *tcell.EventKey) bool {
6575 if lp.filterOn {
6576 switch ev.Key() {
6577 case tcell.KeyEscape:
6578 lp.filterOn = false
6579 lp.filter.text = nil
6580 lp.filter.cur = 0
6581 lp.fill("")
6582 return true
6583 case tcell.KeyEnter:
6584 lp.filterOn = false
6585 return true
6586 }
6587 if lp.filter.key(ev) {
6588 lp.fill(string(lp.filter.text))
6589 return true
6590 }
6591 return ev.Key() == tcell.KeyRune
6592 }
6593 if ev.Key() == tcell.KeyRune {
6594 switch keyRune(ev) {
6595 case '/':
6596 lp.filterOn = true
6597 lp.filter = finput{width: 40}
6598 return true
6599 case 'b':
6600 if p := lp.selected(); p != nil {
6601 u.addToCart(p)
6602 }
6603 return true
6604 }
6605 }
6606 return lp.tbl.key(ev)
6607}
6608
6609func (lp *listPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
6610 _, tblR, _, _, _, _ := lp.layout(u, r)
6611 x, y := ev.Position()
6612 if ev.Buttons()&(tcell.WheelUp|tcell.WheelDown) != 0 {
6613 // the wheel drives the table wherever it is rolled, the picture
6614 // side included
6615 return lp.tbl.mouse(1, ev)
6616 }
6617 if tblR.contains(x, y) {
6618 return lp.tbl.mouse(y-tblR.y, ev)
6619 }
6620 return false
6621}
6622
6623// ---- the product panel ----
6624
6625type productPanel struct {
6626 prod *product.Product
6627 ta *textArea
6628 art artHolder
6629 links []func() // what this page's anchors do; see linkTag
6630}
6631
6632func newProductPanel(u *ui, partno string) panel {
6633 p := u.cat.find(partno)
6634 if p == nil {
6635 return newContentPanel(u, "") // empty
6636 }
6637 pp := &productPanel{prod: p}
6638 pp.ta = newTextArea(productLines(u, p, func(text string, act func()) string {
6639 pp.links = append(pp.links, act)
6640 return linkTag(len(pp.links), text)
6641 }), true)
6642 return pp
6643}
6644
6645func (pp *productPanel) draw(u *ui, sc tcell.Screen, r rect) {
6646 ih := r.h * 3 / 5
6647 imgR := rect{r.x, r.y, r.w, ih}
6648 txtR := rect{r.x + 1, r.y + ih + 1, r.w - 2, r.h - ih - 1}
6649 u.requestArt(&pp.art, imagePath(pp.prod), imgR.w, imgR.h)
6650 if ar, ok := drawArtwork(sc, imgR, pp.art.art); ok {
6651 u.want("photo", placed{r: ar, url: imagePath(pp.prod), fit: "cover"})
6652 }
6653 pp.ta.onLink = u.linkSink(pp.links)
6654 pp.ta.draw(sc, txtR)
6655}
6656
6657func (pp *productPanel) key(u *ui, ev *tcell.EventKey) bool {
6658 if ev.Key() == tcell.KeyEnter || keyRune(ev) == 'b' {
6659 u.addToCart(pp.prod)
6660 return true
6661 }
6662 return pp.ta.key(ev)
6663}
6664
6665func (pp *productPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
6666 switch {
6667 case ev.Buttons()&tcell.WheelUp != 0:
6668 pp.ta.wheel(true)
6669 case ev.Buttons()&tcell.WheelDown != 0:
6670 pp.ta.wheel(false)
6671 default:
6672 return false
6673 }
6674 return true
6675}
6676
6677func (u *ui) addToCart(p *product.Product) {
6678 if p.Quantity == "0" {
6679 u.notice = esc(p.Name) + " is out of stock"
6680 return
6681 }
6682 if _, ok := u.cartQty[p.Partno]; !ok {
6683 u.cartOrder = append(u.cartOrder, p.Partno)
6684 }
6685 u.cartQty[p.Partno]++
6686 u.cartChanged()
6687}
6688
6689// ---- content pages: about, policy, links ----
6690
6691type contentPanel struct {
6692 ta *textArea
6693 links []func() // what this page's anchors do; see linkTag
6694 // The page is re-rendered when the pane changes width, because the
6695 // tables in it are laid out to a column count. raw is kept so that
6696 // costs a re-render and not a re-fetch.
6697 u *ui
6698 raw string
6699 year string
6700 lastW int
6701}
6702
6703// contentFile mirrors pkg/web: the deployment-local file when present,
6704// falling back to the committed .example.
6705func contentFile(path string) string {
6706 if data, err := os.ReadFile(path); err == nil { //nolint
6707 return string(data)
6708 }
6709 data, err := os.ReadFile(path + ".example") //nolint
6710 if err != nil {
6711 return ""
6712 }
6713 return string(data)
6714}
6715
6716func newContentPanel(u *ui, name string) *contentPanel {
6717 cp := &contentPanel{
6718 u: u,
6719 raw: fetchContent(name),
6720 year: fmt.Sprintf("%d", time.Now().Year()),
6721 ta: newTextArea("[gray]no content/"+esc(name)+".html[-]", true),
6722 }
6723 return cp
6724}
6725
6726// render lays the page out for a pane w columns wide, keeping the reader
6727// where they were: setContent rewinds to the top, which is the right thing
6728// on a new page and the wrong thing on a resize.
6729func (cp *contentPanel) render(w int) {
6730 cp.lastW = w
6731 if cp.raw == "" {
6732 return
6733 }
6734 text, hrefs := htmlToText(cp.raw, cp.year, w)
6735 at := cp.ta.scroll
6736 cp.ta.setContent(text)
6737 cp.ta.scroll = at
6738 cp.links = cp.links[:0]
6739 for _, href := range hrefs {
6740 cp.links = append(cp.links, cp.u.follow(href))
6741 }
6742}
6743
6744// follow is what clicking an href does: a path this store serves is a
6745// page the TUI has of its own, and anything else is a link out.
6746func (u *ui) follow(href string) func() {
6747 switch {
6748 case href == "", href == "#":
6749 return nil
6750 case href == "/", href == "/#", href == "#home", href == "/#home":
6751 return func() { u.show("home") }
6752 // The site's own menu spells these "/#about" so that they work from a
6753 // page other than the front one, where a bare "#about" would only
6754 // append a fragment to the path already there. Both forms are the
6755 // same page here.
6756 case href == "#about", href == "#policy", href == "#links",
6757 href == "/#about", href == "/#policy", href == "/#links":
6758 page := "content:" + strings.TrimPrefix(strings.TrimPrefix(href, "/"), "#")
6759 return func() { u.show(page) }
6760 case strings.HasPrefix(href, "/p/"):
6761 if p := u.cat.find(strings.TrimPrefix(href, "/p/")); p != nil {
6762 partno := p.Partno
6763 return func() { u.show("prod:" + partno) }
6764 }
6765 abs := storeURL() + href
6766 return func() { u.openURL(abs) }
6767 case strings.HasPrefix(href, "/cat/"):
6768 // The site spells a subcategory in a URL through escapesubcat
6769 // ("ΒΌ watt 5%" is quarter-watt-5-pct), so a path only names a
6770 // page here when the catalog has those names as written. When it
6771 // does not, the store's own URL resolves it and the link goes
6772 // out rather than landing on an empty table.
6773 if t, ok := u.cat.resolve(strings.TrimPrefix(href, "/cat/")); ok {
6774 return func() { u.show(listPageID(t)) }
6775 }
6776 abs := storeURL() + href
6777 return func() { u.openURL(abs) }
6778 case strings.HasPrefix(href, "/"):
6779 // Some other page of this site β /logo, /sitemap.xml. The TUI has
6780 // no view of it, so hand over the address on the store's origin.
6781 abs := storeURL() + href
6782 return func() { u.openURL(abs) }
6783 }
6784 return func() { u.openURL(href) }
6785}
6786
6787func (cp *contentPanel) draw(u *ui, sc tcell.Screen, r rect) {
6788 inner := rect{r.x + 1, r.y, r.w - 2, r.h}
6789 if inner.w > 0 && inner.w != cp.lastW {
6790 cp.render(inner.w)
6791 }
6792 cp.ta.onLink = u.linkSink(cp.links)
6793 cp.ta.draw(sc, inner)
6794}
6795
6796func (cp *contentPanel) key(u *ui, ev *tcell.EventKey) bool {
6797 return cp.ta.key(ev)
6798}
6799
6800func (cp *contentPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
6801 switch {
6802 case ev.Buttons()&tcell.WheelUp != 0:
6803 cp.ta.wheel(true)
6804 case ev.Buttons()&tcell.WheelDown != 0:
6805 cp.ta.wheel(false)
6806 default:
6807 return false
6808 }
6809 return true
6810}
6811
6812
6813// ===== pkg/tui/widgets.go =====
6814// Package tui pkg/tui/widgets.go β the widget kit: a table, a list, a
6815// scrollable text area, and a form, written directly on tcell. Small on
6816// purpose: these are the site's shapes (the category table, the
6817// catsubcats dropdown, product pages, Add Shipping Info), not a general
6818// toolkit.
6819package tui
6820
6821import (
6822 "strings"
6823
6824 "github.com/gdamore/tcell/v3"
6825)
6826
6827// ---- textArea: scrollable, optionally word-wrapped markup text ----
6828
6829type textArea struct {
6830 content string
6831 base tcell.Style
6832 wrap bool
6833 scroll int
6834 lines [][]seg
6835 lastW int
6836 // onLink, when set, is told where each anchor in the text landed on
6837 // the screen, so the page can make it clickable. Set before every
6838 // draw: what is on screen changes with the scroll.
6839 onLink func(x, y, w, link int)
6840}
6841
6842func newTextArea(content string, wrap bool) *textArea {
6843 return &textArea{content: content, base: styleText, wrap: wrap}
6844}
6845
6846func (t *textArea) setContent(s string) {
6847 t.content = s
6848 t.lastW = 0
6849 t.scroll = 0
6850}
6851
6852func (t *textArea) draw(sc tcell.Screen, r rect) {
6853 if r.w != t.lastW || t.lines == nil {
6854 t.lines = wrapSegs(t.content, r.w, t.base, t.wrap)
6855 t.lastW = r.w
6856 }
6857 if max := len(t.lines) - r.h; t.scroll > max {
6858 t.scroll = max
6859 }
6860 if t.scroll < 0 {
6861 t.scroll = 0
6862 }
6863 for i := 0; i < r.h; i++ {
6864 li := t.scroll + i
6865 if li >= len(t.lines) {
6866 break
6867 }
6868 printSegsLinks(sc, r.x, r.y+i, r.w, t.lines[li], t.onLink)
6869 }
6870}
6871
6872func (t *textArea) key(ev *tcell.EventKey) bool {
6873 switch ev.Key() {
6874 case tcell.KeyUp:
6875 t.scroll--
6876 case tcell.KeyDown:
6877 t.scroll++
6878 case tcell.KeyPgUp:
6879 t.scroll -= 10
6880 case tcell.KeyPgDn:
6881 t.scroll += 10
6882 case tcell.KeyHome:
6883 t.scroll = 0
6884 case tcell.KeyEnd:
6885 t.scroll = len(t.lines)
6886 default:
6887 return false
6888 }
6889 return true
6890}
6891
6892func (t *textArea) wheel(up bool) {
6893 if up {
6894 t.scroll -= 3
6895 } else {
6896 t.scroll += 3
6897 }
6898}
6899
6900// ---- table: the site's product table ----
6901
6902type tcol struct {
6903 title string
6904 width int // 0 = expands
6905 alignRight bool
6906 // link marks the column the row's <a> lives in. Clicking a cell there
6907 // opens the row, as clicking the link on the page does; clicking any
6908 // other column selects the row, which is what shows its picture.
6909 link bool
6910}
6911
6912type tcell_ struct {
6913 text string
6914 style tcell.Style
6915}
6916
6917type trow struct {
6918 cells []tcell_
6919}
6920
6921type table struct {
6922 cols []tcol
6923 rows []trow
6924 sel int
6925 offset int
6926 onSelect func(int)
6927 onActivate func(int)
6928 // onLink, when set, is told where each drawn link cell landed and
6929 // which row and column it belongs to, so the page can register it as
6930 // an anchor β which is what makes one click act on it and the pointer
6931 // light it up. A row has more than one: its name opens the product
6932 // and its Buy cell adds it to the cart, as the page's row does. Set
6933 // before every draw, since the rows move as the table scrolls.
6934 onLink func(x, y, w, row, col int)
6935}
6936
6937func (t *table) setRows(rows []trow) {
6938 t.rows = rows
6939 t.offset = 0
6940 if len(rows) == 0 {
6941 t.sel = -1
6942 return
6943 }
6944 t.sel = 0
6945 if t.onSelect != nil {
6946 t.onSelect(0)
6947 }
6948}
6949
6950// colWidths distributes r.w across the columns; width-0 columns share
6951// the remainder.
6952func (t *table) colWidths(w int) []int {
6953 ws := make([]int, len(t.cols))
6954 fixed, flex := 0, 0
6955 for i, c := range t.cols {
6956 ws[i] = c.width
6957 if c.width == 0 {
6958 flex++
6959 } else {
6960 fixed += c.width + 1
6961 }
6962 }
6963 if flex > 0 {
6964 share := (w - fixed - flex) / flex
6965 if share < 4 {
6966 share = 4
6967 }
6968 for i, c := range t.cols {
6969 if c.width == 0 {
6970 ws[i] = share
6971 }
6972 }
6973 }
6974 return ws
6975}
6976
6977func (t *table) draw(sc tcell.Screen, r rect) {
6978 if r.h < 2 {
6979 return
6980 }
6981 ws := t.colWidths(r.w)
6982 // The page's thead is a filled purple band, so the band is the header
6983 // rather than only the words in it.
6984 fillRect(sc, rect{r.x, r.y, r.w, 1}, styleThead)
6985 x := r.x
6986 for i, c := range t.cols {
6987 st := styleThead
6988 txt := c.title
6989 if len(txt) > ws[i] {
6990 txt = txt[:ws[i]]
6991 }
6992 pad := ws[i] - len(txt)
6993 if c.alignRight {
6994 printMarkup(sc, x+pad, r.y, ws[i], esc(txt), st)
6995 } else {
6996 printMarkup(sc, x, r.y, ws[i], esc(txt), st)
6997 }
6998 x += ws[i] + 1
6999 }
7000 visible := r.h - 1
7001 if t.sel >= 0 {
7002 if t.sel < t.offset {
7003 t.offset = t.sel
7004 }
7005 if t.sel >= t.offset+visible {
7006 t.offset = t.sel - visible + 1
7007 }
7008 }
7009 for row := 0; row < visible; row++ {
7010 ri := t.offset + row
7011 if ri >= len(t.rows) {
7012 break
7013 }
7014 y := r.y + 1 + row
7015 selected := ri == t.sel
7016 if selected {
7017 fillRect(sc, rect{r.x, y, r.w, 1}, styleSel)
7018 }
7019 x = r.x
7020 for i, c := range t.rows[ri].cells {
7021 if i >= len(ws) {
7022 break
7023 }
7024 st := c.style
7025 if selected {
7026 st = styleSel
7027 }
7028 txt := c.text
7029 if len(txt) > ws[i] {
7030 txt = txt[:ws[i]]
7031 }
7032 pad := 0
7033 if t.cols[i].alignRight {
7034 pad = ws[i] - len(txt)
7035 }
7036 n := printMarkup(sc, x+pad, y, ws[i]-pad, esc(txt), st)
7037 // The anchor covers the text, not the empty column beside
7038 // it: a link that lights up out to the price would say the
7039 // blank cells were clickable too. An empty cell offers
7040 // nothing, which is how an out-of-stock row has no Buy.
7041 if t.cols[i].link && t.onLink != nil && n > 0 {
7042 t.onLink(x+pad, y, n, ri, i)
7043 }
7044 x += ws[i] + 1
7045 }
7046 }
7047}
7048
7049func (t *table) move(d int) {
7050 if len(t.rows) == 0 {
7051 return
7052 }
7053 t.sel += d
7054 if t.sel < 0 {
7055 t.sel = 0
7056 }
7057 if t.sel >= len(t.rows) {
7058 t.sel = len(t.rows) - 1
7059 }
7060 if t.onSelect != nil {
7061 t.onSelect(t.sel)
7062 }
7063}
7064
7065func (t *table) key(ev *tcell.EventKey) bool {
7066 switch ev.Key() {
7067 case tcell.KeyUp:
7068 t.move(-1)
7069 case tcell.KeyDown:
7070 t.move(1)
7071 case tcell.KeyPgUp:
7072 t.move(-10)
7073 case tcell.KeyPgDn:
7074 t.move(10)
7075 case tcell.KeyHome:
7076 t.move(-len(t.rows))
7077 case tcell.KeyEnd:
7078 t.move(len(t.rows))
7079 case tcell.KeyEnter:
7080 if t.sel >= 0 && t.onActivate != nil {
7081 t.onActivate(t.sel)
7082 }
7083 default:
7084 return false
7085 }
7086 return true
7087}
7088
7089// mouse handles a click or wheel at a position local to the table rect.
7090//
7091// A click on the row's link cell never gets here β that cell is an anchor
7092// and the page follows it first. What is left is the rest of the row:
7093// clicking it selects, which is what puts the row's photo beside the
7094// table, and clicking the selected row again opens it.
7095func (t *table) mouse(localY int, ev *tcell.EventMouse) bool {
7096 btn := ev.Buttons()
7097 switch {
7098 // One product per notch. Three is the usual step for scrolling TEXT
7099 // and wrong here, because the wheel moves the SELECTION: the row
7100 // picked is the one whose photo is shown and whose name a second
7101 // click opens, so three at a time skips two products rather than
7102 // covering ground faster. The dropdown has always moved by one.
7103 case btn&tcell.WheelUp != 0:
7104 t.move(-1)
7105 case btn&tcell.WheelDown != 0:
7106 t.move(1)
7107 case btn&tcell.Button1 != 0:
7108 ri := t.offset + localY - 1 // row 0 is the header
7109 if localY < 1 || ri < 0 || ri >= len(t.rows) {
7110 return false
7111 }
7112 if ri == t.sel {
7113 if t.onActivate != nil {
7114 t.onActivate(ri)
7115 }
7116 break
7117 }
7118 t.sel = ri
7119 if t.onSelect != nil {
7120 t.onSelect(ri)
7121 }
7122 default:
7123 return false
7124 }
7125 return true
7126}
7127
7128// ---- list: the dropdown menus ----
7129
7130type list struct {
7131 items []string // markup labels
7132 sel int
7133 offset int
7134 onPick func(int)
7135 // onKey sees keys first β the categories tree uses it for its
7136 // expand/collapse keys.
7137 onKey func(ev *tcell.EventKey, sel int) bool
7138}
7139
7140func (l *list) draw(sc tcell.Screen, r rect) {
7141 if l.sel >= 0 {
7142 if l.sel < l.offset {
7143 l.offset = l.sel
7144 }
7145 if l.sel >= l.offset+r.h {
7146 l.offset = l.sel - r.h + 1
7147 }
7148 }
7149 for i := 0; i < r.h; i++ {
7150 li := l.offset + i
7151 if li >= len(l.items) {
7152 break
7153 }
7154 st := styleLink
7155 if li == l.sel {
7156 st = styleSel
7157 fillRect(sc, rect{r.x, r.y + i, r.w, 1}, styleSel)
7158 }
7159 printMarkup(sc, r.x, r.y+i, r.w, l.items[li], st)
7160 }
7161}
7162
7163func (l *list) key(ev *tcell.EventKey) bool {
7164 if l.onKey != nil && l.onKey(ev, l.sel) {
7165 return true
7166 }
7167 switch ev.Key() {
7168 case tcell.KeyUp:
7169 if l.sel > 0 {
7170 l.sel--
7171 }
7172 case tcell.KeyDown:
7173 if l.sel < len(l.items)-1 {
7174 l.sel++
7175 }
7176 case tcell.KeyHome:
7177 l.sel = 0
7178 case tcell.KeyEnd:
7179 l.sel = len(l.items) - 1
7180 case tcell.KeyEnter:
7181 if l.onPick != nil && l.sel >= 0 {
7182 l.onPick(l.sel)
7183 }
7184 default:
7185 return false
7186 }
7187 return true
7188}
7189
7190func (l *list) mouse(localY int, ev *tcell.EventMouse) bool {
7191 btn := ev.Buttons()
7192 switch {
7193 case btn&tcell.WheelUp != 0:
7194 if l.sel > 0 {
7195 l.sel--
7196 }
7197 case btn&tcell.WheelDown != 0:
7198 if l.sel < len(l.items)-1 {
7199 l.sel++
7200 }
7201 case btn&tcell.Button1 != 0:
7202 li := l.offset + localY
7203 if li < 0 || li >= len(l.items) {
7204 return false
7205 }
7206 l.sel = li
7207 if l.onPick != nil {
7208 l.onPick(li)
7209 }
7210 default:
7211 return false
7212 }
7213 return true
7214}
7215
7216// ---- form: Add Shipping Info ----
7217
7218type formItem interface {
7219 label() string
7220 // drawValue paints the value cell; the form places the cursor.
7221 drawValue(sc tcell.Screen, x, y, w int, focused bool) (curX int, showCur bool)
7222 key(ev *tcell.EventKey) bool
7223 value() string
7224}
7225
7226type finput struct {
7227 lbl string
7228 text []rune
7229 cur int
7230 width int
7231}
7232
7233func (f *finput) label() string { return f.lbl }
7234func (f *finput) value() string { return strings.TrimSpace(string(f.text)) }
7235
7236func (f *finput) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
7237 if f.width < w {
7238 w = f.width
7239 }
7240 st := styleField
7241 fillRect(sc, rect{x, y, w, 1}, st)
7242 start := 0
7243 if f.cur >= w {
7244 start = f.cur - w + 1
7245 }
7246 for i := 0; i < w && start+i < len(f.text); i++ {
7247 sc.SetContent(x+i, y, f.text[start+i], nil, st)
7248 }
7249 return x + f.cur - start, focused
7250}
7251
7252func (f *finput) key(ev *tcell.EventKey) bool {
7253 switch ev.Key() {
7254 case tcell.KeyRune:
7255 ins := []rune(ev.Str())
7256 f.text = append(f.text[:f.cur], append(ins, f.text[f.cur:]...)...)
7257 f.cur += len(ins)
7258 case tcell.KeyBackspace, tcell.KeyBackspace2:
7259 if f.cur > 0 {
7260 f.text = append(f.text[:f.cur-1], f.text[f.cur:]...)
7261 f.cur--
7262 }
7263 case tcell.KeyDelete:
7264 if f.cur < len(f.text) {
7265 f.text = append(f.text[:f.cur], f.text[f.cur+1:]...)
7266 }
7267 case tcell.KeyLeft:
7268 if f.cur > 0 {
7269 f.cur--
7270 }
7271 case tcell.KeyRight:
7272 if f.cur < len(f.text) {
7273 f.cur++
7274 }
7275 case tcell.KeyHome:
7276 f.cur = 0
7277 case tcell.KeyEnd:
7278 f.cur = len(f.text)
7279 default:
7280 return false
7281 }
7282 return true
7283}
7284
7285// fdropdown selects among options with β/β, or by typing a prefix (so
7286// "t","x" lands on TX).
7287type fdropdown struct {
7288 lbl string
7289 opts []string
7290 sel int
7291}
7292
7293func (f *fdropdown) label() string { return f.lbl }
7294func (f *fdropdown) value() string { return f.opts[f.sel] }
7295
7296func (f *fdropdown) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
7297 st := styleField
7298 label := f.opts[f.sel]
7299 if label == "" {
7300 label = "β"
7301 }
7302 txt := "β " + label + " βΈ"
7303 if len(txt) > w {
7304 txt = txt[:w]
7305 }
7306 fillRect(sc, rect{x, y, w, 1}, st)
7307 printMarkup(sc, x, y, w, esc(txt), st)
7308 return x, false
7309}
7310
7311func (f *fdropdown) key(ev *tcell.EventKey) bool {
7312 switch {
7313 case ev.Key() == tcell.KeyLeft:
7314 if f.sel > 0 {
7315 f.sel--
7316 }
7317 case ev.Key() == tcell.KeyRight:
7318 if f.sel < len(f.opts)-1 {
7319 f.sel++
7320 }
7321 case ev.Key() == tcell.KeyRune:
7322 want := strings.ToUpper(ev.Str())
7323 for i := 1; i <= len(f.opts); i++ {
7324 o := f.opts[(f.sel+i)%len(f.opts)]
7325 if strings.HasPrefix(strings.ToUpper(o), want) {
7326 f.sel = (f.sel + i) % len(f.opts)
7327 break
7328 }
7329 }
7330 default:
7331 return false
7332 }
7333 return true
7334}
7335
7336type fbutton struct {
7337 lbl string
7338 fn func()
7339}
7340
7341// form lays items out one per row with a button row below, inside a
7342// bordered box drawn by its owner.
7343type form struct {
7344 items []formItem
7345 buttons []fbutton
7346 focus int // 0..len(items)-1, then buttons
7347 cancel func()
7348 labelCol int
7349}
7350
7351func newForm(items []formItem, buttons []fbutton, cancel func()) *form {
7352 f := &form{items: items, buttons: buttons, cancel: cancel}
7353 for _, it := range items {
7354 if n := len(it.label()); n > f.labelCol {
7355 f.labelCol = n
7356 }
7357 }
7358 return f
7359}
7360
7361func (f *form) draw(sc tcell.Screen, r rect) {
7362 sc.HideCursor()
7363 for i, it := range f.items {
7364 y := r.y + i*2
7365 if y >= r.y+r.h-1 {
7366 break
7367 }
7368 printMarkup(sc, r.x, y, r.w, esc(it.label()), styleLink)
7369 curX, show := it.drawValue(sc, r.x+f.labelCol+1, y, r.w-f.labelCol-1, f.focus == i)
7370 if show && f.focus == i {
7371 sc.ShowCursor(curX, y)
7372 }
7373 }
7374 by := r.y + len(f.items)*2
7375 x := r.x + 2
7376 for i, b := range f.buttons {
7377 st := styleButton
7378 if f.focus == len(f.items)+i {
7379 st = styleSel
7380 }
7381 lbl := " " + b.lbl + " "
7382 printMarkup(sc, x, by, r.w, esc(lbl), st)
7383 x += len(lbl) + 2
7384 }
7385}
7386
7387func (f *form) key(ev *tcell.EventKey) bool {
7388 switch ev.Key() {
7389 case tcell.KeyEscape:
7390 if f.cancel != nil {
7391 f.cancel()
7392 }
7393 return true
7394 case tcell.KeyTab, tcell.KeyDown:
7395 f.focus = (f.focus + 1) % (len(f.items) + len(f.buttons))
7396 return true
7397 case tcell.KeyBacktab, tcell.KeyUp:
7398 f.focus--
7399 if f.focus < 0 {
7400 f.focus = len(f.items) + len(f.buttons) - 1
7401 }
7402 return true
7403 case tcell.KeyEnter:
7404 if f.focus >= len(f.items) {
7405 f.buttons[f.focus-len(f.items)].fn()
7406 } else {
7407 f.focus++
7408 }
7409 return true
7410 }
7411 if f.focus < len(f.items) {
7412 if f.items[f.focus].key(ev) {
7413 return true
7414 }
7415 }
7416 // Swallow stray runes so shortcuts never fire while a form is up.
7417 return ev.Key() == tcell.KeyRune
7418}
7419
7420
7421// ===== pkg/tui/widgets_js.go =====
7422//go:build js && wasm
7423
7424package tui
7425
7426import (
7427 "image"
7428 "math"
7429 "strconv"
7430 "strings"
7431 "syscall/js"
7432
7433 "github.com/0magnet/calvin"
7434 "github.com/0magnet/chaosrack/pkg/rasterview"
7435 "github.com/0magnet/chaosrack/pkg/stlview"
7436 "github.com/0magnet/websh/widget"
7437)
7438
7439// The parts of the store that a page can show and a terminal cannot are this
7440// program's own widgets, registered by the program itself (here, and the card
7441// form in pay_js.go) rather than by the page it runs in: any websh that runs
7442// this TUI in its page has them, and a terminal without placements shows the
7443// cells drawn under them instead.
7444
7445func init() {
7446 registerGlobe()
7447 placementsShown = widget.Shown // asked each frame: the host answers childtty's probe after init
7448}
7449
7450// offerWidgets registers the widgets that need to know the store.
7451func (u *ui) offerWidgets() { registerCaps(u.sitedomain) }
7452
7453// siteURL is the store's address: the one being browsed, else the page's own.
7454func siteURL() string {
7455 if s := storeURL(); s != "" {
7456 return s
7457 }
7458 return js.Global().Get("location").Get("origin").String()
7459}
7460
7461// registerGlobe offers the website's landing picture as the widget "globe":
7462// the home panel lays it over the cells where it draws its own logo and globe
7463// (websh's placements). From the bottom up:
7464//
7465// - the panel's own cells β the half-block logo β where nothing above
7466// covers them;
7467// - the site's logo.svg;
7468// - the globe above it, drawn again by the panel's own code in its cells'
7469// half blocks: the same mesh, pose and camera, so it lies exactly behind
7470// that globe and shows through its wireframe;
7471// - the landing view's globe β the same renderer, shaders and sphere
7472// (chaosrack's stlview) β which the person turns by dragging and zooms
7473// with the wheel.
7474//
7475// And it answers the pointer (see look):
7476//
7477// - a click on the black around the logo stops it all β no frames, so no
7478// work on the CPU or the GPU β and a click on the logo or the globe
7479// starts it again;
7480// - a double click (or tap) on the logo hides it, showing the panel's own
7481// half-block logo under it, and another brings it back;
7482// - a double click on the globe steps through: the wireframe alone; the
7483// panel's globe alone, turning; no globe; and back to both.
7484func registerGlobe() {
7485 widget.Register("globe", mountGlobe)
7486}
7487
7488// look is how the person has left the picture. It is the page's, not one
7489// placement's: the TUI takes the placement away whenever a menu or the cart
7490// crosses it, and puts a new one back after.
7491var look struct {
7492 paused bool
7493 logoHidden bool
7494 stage int // globeBoth, globeWire, globeCells, globeNone
7495}
7496
7497const (
7498 globeBoth = iota
7499 globeWire
7500 globeCells
7501 globeNone
7502)
7503
7504func mountGlobe(el js.Value) func() {
7505 doc := js.Global().Get("document")
7506 layer := func(tag, css string) js.Value {
7507 e := doc.Call("createElement", tag)
7508 e.Get("style").Set("cssText", "position:absolute;inset:0;width:100%;height:100%;"+css)
7509 el.Call("append", e)
7510 return e
7511 }
7512 logo := layer("img", "object-fit:contain")
7513 logo.Set("src", siteURL()+"/i/logo.svg")
7514 logo.Set("alt", "")
7515
7516 // The panel's globe: one pixel per half cell, shown as blocks, as the
7517 // panel draws it.
7518 shadow := layer("canvas", "image-rendering:pixelated;opacity:.85")
7519 ctx := shadow.Call("getContext", "2d")
7520
7521 canvas := layer("canvas", "")
7522 dpr := js.Global().Get("devicePixelRatio").Float()
7523 if dpr <= 0 {
7524 dpr = 1
7525 }
7526 canvas.Set("width", int(el.Get("clientWidth").Float()*dpr))
7527 canvas.Set("height", int(el.Get("clientHeight").Float()*dpr))
7528 g, err := stlview.NewGlobe(canvas)
7529 if err != nil {
7530 js.Global().Get("console").Call("warn", "globe: "+err.Error())
7531 return func() { el.Set("textContent", "") }
7532 }
7533 g.Interact(canvas)
7534
7535 var img *image.RGBA
7536 var buf js.Value
7537 drawShadow := func() {
7538 cols, _ := strconv.Atoi(el.Get("dataset").Get("cols").String()) //nolint:errcheck // websh sets it; zero draws nothing
7539 rows, _ := strconv.Atoi(el.Get("dataset").Get("rows").String()) //nolint:errcheck // as above
7540 if cols <= 0 || rows <= 0 {
7541 return
7542 }
7543 w, h := cols, rows*2
7544 if img == nil || img.Bounds().Dx() != w || img.Bounds().Dy() != h {
7545 img = image.NewRGBA(image.Rect(0, 0, w, h))
7546 buf = js.Global().Get("Uint8ClampedArray").New(len(img.Pix))
7547 shadow.Set("width", w)
7548 shadow.Set("height", h)
7549 }
7550 // A half cell is as wide as the terminal's font makes it, not
7551 // square: its width over its height on the page.
7552 aspect := 1.0
7553 if ew, eh := el.Get("clientWidth").Float(), el.Get("clientHeight").Float(); ew > 0 && eh > 0 {
7554 aspect = (ew / float64(w)) / (eh / float64(h))
7555 }
7556 // The globe's size on the page: its share of half the height, in a
7557 // frame whose shorter side, measured on the page, sets the scale.
7558 scale := g.Radius() * float64(h) / math.Min(float64(w)*aspect, float64(h))
7559 ax, ay, az := g.Pose()
7560 verts, lines := g.Mesh()
7561 globeAt(img, verts, lines, ax, ay, az, g.Distance(), scale, aspect)
7562 js.CopyBytesToJS(buf, img.Pix)
7563 ctx.Call("putImageData", js.Global().Get("ImageData").New(buf, w, h), 0, 0)
7564 }
7565
7566 // The panel's globe turns at the panel's own pace (its 140ms tick): it
7567 // is the panel's globe, and every display frame would cost the tab for
7568 // it. Its frames run only while it is seen and turning.
7569 var (
7570 gone bool
7571 shadowOn bool
7572 raf js.Value
7573 frame js.Func
7574 last float64
7575 )
7576 frame = js.FuncOf(func(_ js.Value, args []js.Value) any {
7577 if gone || !shadowOn {
7578 return nil
7579 }
7580 raf = js.Global().Call("requestAnimationFrame", frame)
7581 if now := args[0].Float(); now-last >= 140 {
7582 last = now
7583 drawShadow()
7584 }
7585 return nil
7586 })
7587 runShadow := func(on bool) {
7588 if on == shadowOn {
7589 return
7590 }
7591 shadowOn = on
7592 if on {
7593 raf = js.Global().Call("requestAnimationFrame", frame)
7594 } else {
7595 js.Global().Call("cancelAnimationFrame", raf)
7596 }
7597 }
7598
7599 shown := func(e js.Value, on bool) {
7600 v := "hidden"
7601 if on {
7602 v = ""
7603 }
7604 e.Get("style").Set("visibility", v)
7605 }
7606 apply := func() {
7607 wire := look.stage == globeBoth || look.stage == globeWire
7608 cells := look.stage == globeBoth || look.stage == globeCells
7609 shown(logo, !look.logoHidden)
7610 shown(canvas, wire)
7611 shown(shadow, cells)
7612 // Not drawn, the wireframe still turns, for the panel's globe to
7613 // follow; with no globe at all, nothing runs.
7614 g.SetDrawn(wire)
7615 if look.paused || look.stage == globeNone {
7616 g.Pause()
7617 } else {
7618 g.Resume()
7619 }
7620 runShadow(cells && !look.paused)
7621 if cells {
7622 drawShadow() // as it is now, turning or not
7623 }
7624 }
7625
7626 // Where on the picture a point (in the element's own pixels) is.
7627 const (
7628 onBlack = iota
7629 onLogo
7630 onGlobe
7631 )
7632 region := func(x, y float64) int {
7633 w, h := el.Get("clientWidth").Float(), el.Get("clientHeight").Float()
7634 if r := g.Radius() * h / 2; math.Hypot(x-w/2, y-h/2) <= r {
7635 return onGlobe
7636 }
7637 lx, ly, lw, lh := 0.0, 0.0, w, h
7638 if nw, nh := logo.Get("naturalWidth").Float(), logo.Get("naturalHeight").Float(); nw > 0 && nh > 0 {
7639 s := math.Min(w/nw, h/nh) // object-fit: contain
7640 lw, lh = nw*s, nh*s
7641 lx, ly = (w-lw)/2, (h-lh)/2
7642 }
7643 if x >= lx && x < lx+lw && y >= ly && y < ly+lh {
7644 return onLogo
7645 }
7646 return onBlack
7647 }
7648
7649 // Clicks are counted here rather than taken from dblclick, which a
7650 // double tap does not reliably give; and a drag that turned the globe is
7651 // not a click.
7652 var (
7653 downX, downY float64
7654 lastT float64
7655 lastAt = -1
7656 )
7657 type handler struct {
7658 name string
7659 fn js.Func
7660 }
7661 var handlers []handler
7662 on := func(name string, f func(e js.Value)) {
7663 fn := js.FuncOf(func(_ js.Value, args []js.Value) any {
7664 f(args[0])
7665 return nil
7666 })
7667 el.Call("addEventListener", name, fn, true)
7668 handlers = append(handlers, handler{name, fn})
7669 }
7670 on("pointerdown", func(e js.Value) {
7671 downX, downY = e.Get("clientX").Float(), e.Get("clientY").Float()
7672 })
7673 on("click", func(e js.Value) {
7674 cx, cy := e.Get("clientX").Float(), e.Get("clientY").Float()
7675 if math.Hypot(cx-downX, cy-downY) > 5 {
7676 return // a drag
7677 }
7678 box := el.Call("getBoundingClientRect")
7679 at := region(cx-box.Get("left").Float(), cy-box.Get("top").Float())
7680 look.paused = at == onBlack
7681 if t := e.Get("timeStamp").Float(); at == lastAt && t-lastT < 400 {
7682 switch at {
7683 case onGlobe:
7684 look.stage = (look.stage + 1) % 4
7685 case onLogo:
7686 look.logoHidden = !look.logoHidden
7687 }
7688 lastAt = -1
7689 } else {
7690 lastT, lastAt = t, at
7691 }
7692 apply()
7693 })
7694 apply()
7695
7696 return func() {
7697 gone = true
7698 runShadow(false)
7699 frame.Release()
7700 for _, h := range handlers {
7701 el.Call("removeEventListener", h.name, h.fn, true)
7702 h.fn.Release()
7703 }
7704 g.Stop()
7705 el.Set("textContent", "")
7706 }
7707}
7708
7709// registerCaps offers the domain in the page's double-struck capitals as the
7710// widget "caps", for the line the home panel prints it on. A terminal has
7711// them only in fonts that draw them twice the width of a cell, so the panel
7712// prints plain capitals; a page can set them as the website does, over them.
7713func registerCaps(domain string) {
7714 text := calvin.BlackboardBold(strings.ToUpper(domain))
7715 widget.Register("caps", func(el js.Value) func() {
7716 el.Get("style").Set("containerType", "size")
7717 d := js.Global().Get("document").Call("createElement", "div")
7718 d.Get("style").Set("cssText", "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;"+
7719 "background:#000;color:#fff;white-space:nowrap;line-height:1;font-size:85cqh;font-family:inherit")
7720 d.Set("textContent", text)
7721 el.Call("append", d)
7722 return func() { el.Set("textContent", "") }
7723 })
7724}
7725
7726// globeAt draws a globe in the home panel's manner β its depth cue, and its
7727// colors in the panel's sixteen steps β over a clear dst. The lines are the
7728// mesh given (vertices as xyz, indices as endpoint pairs), posed by the angles
7729// of RxΒ·RyΒ·Rz, seen from dist (0 for none) and filling scale of half the frame,
7730// 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
7731// draws itself (Mesh, Pose, Distance, Radius), so a page draws the panel's
7732// version exactly behind its own β the "globe" widget does (widgets_js.go).
7733func globeAt(dst *image.RGBA, vertices []float32, indices []uint16, ax, ay, az, dist, scale, aspect float64) {
7734 clear(dst.Pix)
7735 view := rasterview.View{AngleX: ax, AngleY: ay, AngleZ: az, Dist: dist, BackDim: 0.5, Scale: scale, PixelAspect: aspect}
7736 g := rasterview.DefaultGradient()
7737 g.Source = 1 // y, as the page's shader colors it
7738 view.Render(dst, vertices, indices, g)
7739 for i := 0; i+3 < len(dst.Pix); i += 4 {
7740 if dst.Pix[i+3] != 0 {
7741 for k := range 3 {
7742 dst.Pix[i+k] = dst.Pix[i+k]&0xF0 | dst.Pix[i+k]>>4 // step16
7743 }
7744 }
7745 }
7746}
7747
7748
7749// ===== pkg/tui/widgets_native.go =====
7750//go:build !js || !wasm
7751
7752package tui
7753
7754// offerWidgets has nothing to offer where there is no page: the cells are
7755// the picture. See widgets_js.go.
7756func (u *ui) offerWidgets() {}
7757
7758
7759// ===== pkg/web/app.go =====
7760// Package web pkg/web/app.go β the fiber application, buildable for two hosts.
7761//
7762// Serve (native) and the in-tab site server (js/wasm β pkg/storepane's
7763// `serve` command, listening on the bottle vnet loopback) construct the same
7764// application from the same templates and handlers. AppOpts carries the only
7765// differences: where the request log goes, and the origin to lean on for
7766// everything a browser tab must not or cannot hold β the Stripe secret key,
7767// files on disk, and the toolchain that compiles wasm drop-ins.
7768package web
7769
7770import (
7771 "context"
7772 "errors"
7773 "fmt"
7774 "io"
7775 "net/http"
7776 "os"
7777 "strings"
7778 "time"
7779
7780 "github.com/gofiber/fiber/v3"
7781 "github.com/gofiber/fiber/v3/middleware/static"
7782
7783 "github.com/0magnet/bottle"
7784)
7785
7786// AppOpts selects the host the app is being built for.
7787type AppOpts struct {
7788 // LogOutput receives the request log; nil keeps the native default
7789 // (stdout). The in-tab server points this at its shell's terminal, so
7790 // browsing the vnet site scrolls an access log like any server.
7791 LogOutput io.Writer
7792
7793 // ProxyOrigin, when non-empty, marks the in-tab role. Routes that need
7794 // the host machine (wasm compilation, source tarballs, images and fonts
7795 // on disk, the logo pipeline, CUPS printing) are not registered β a GET
7796 // that matches nothing is fetched from this origin instead β and the
7797 // payment endpoints forward there, so the tab renders the store but
7798 // never holds a secret key.
7799 ProxyOrigin string
7800}
7801
7802// NewApp builds the store's fiber application. It does not listen; Serve
7803// (native) and the in-tab server each own their listener.
7804func NewApp(o AppOpts) *fiber.App {
7805 initTMPL()
7806 logOut := o.LogOutput
7807 if logOut == nil {
7808 logOut = os.Stdout
7809 }
7810 inTab := o.ProxyOrigin != ""
7811 // In the tab the drop-ins are named from the origin, not from a disk
7812 // that carries none of them.
7813 if inTab {
7814 serveFromOrigin(o.ProxyOrigin)
7815 }
7816
7817 r := fiber.New(fiber.Config{
7818 ErrorHandler: func(c fiber.Ctx, err error) error {
7819 code := fiber.StatusInternalServerError
7820 var e *fiber.Error
7821 if errors.As(err, &e) {
7822 code = e.Code
7823 }
7824 c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
7825 return c.Status(code).SendString(err.Error())
7826 },
7827 })
7828
7829 r.Use(func(c fiber.Ctx) error {
7830 start := time.Now()
7831 err := c.Next()
7832 status := c.Response().StatusCode()
7833 // Unmatched routes and handler errors are turned into their real
7834 // status by the app ErrorHandler after this middleware returns, so
7835 // c.Response() still reads 200 here. Recover the true code from the
7836 // error so 404s (and 5xx) are logged accurately rather than as 200.
7837 if err != nil {
7838 var fe *fiber.Error
7839 if errors.As(err, &fe) {
7840 status = fe.Code
7841 } else {
7842 status = fiber.StatusInternalServerError
7843 }
7844 }
7845 lat := time.Since(start)
7846 colors := c.App().Config().ColorScheme
7847 ip := fmt.Sprintf("%*s", 15, c.IP())
7848 ipsStr := strings.Join(c.IPs(), ", ")
7849 ips := fmt.Sprintf("%*s", 15, ipsStr)
7850 method := fmt.Sprintf("%-*s", 6, c.Method())
7851 statCol := statusColor(status, colors) + fmt.Sprintf("%3d", status) + colors.Reset
7852 methCol := methodColor(c.Method(), colors) + method + colors.Reset
7853 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
7854 return err
7855 })
7856
7857 // What anyone may read β the catalog, the site, its pages, its images and
7858 // the store program β any origin may read too: a websh served elsewhere
7859 // runs the store TUI against this site (wasm/store). Nothing that writes,
7860 // and nothing behind a cookie, is shared this way.
7861 r.Use(func(c fiber.Ctx) error {
7862 if m := c.Method(); (m == fiber.MethodGet || m == fiber.MethodHead) && sharedRead(c.Path()) {
7863 c.Set(fiber.HeaderAccessControlAllowOrigin, "*")
7864 }
7865 return c.Next()
7866 })
7867
7868 if !inTab {
7869 // The desk page's OS layer and browser engine, served ahead of any
7870 // wasm module: jsfs/vnet must exist before Go captures globalThis.fs.
7871 jsAsset := func(body []byte) fiber.Handler {
7872 return func(c fiber.Ctx) error {
7873 c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
7874 c.Set(fiber.HeaderCacheControl, "public, max-age=3600")
7875 return c.Send(body)
7876 }
7877 }
7878 r.Get("/bottle/jsfs.js", jsAsset(bottle.JSFS()))
7879 r.Get("/bottle/vnet.js", jsAsset(bottle.VNetJS()))
7880 r.Get("/bottle/proc.js", jsAsset(bottle.ProcJS()))
7881 serveSourceCode(r)
7882 serveWASM(r)
7883 serveBrowseResponder(r)
7884 r.Get("/logo", logo)
7885 r.Get("/logo/:width", logo)
7886 r.Get("/logo/:width/:height", logo)
7887 // The ports that draw: a product image as ANSI art turned into HTML, and
7888 // a heading as a FIGlet banner. Nothing on the site links to either β
7889 // they are here for the decorator on /test to call.
7890 r.Get("/art/:cat/:name", art)
7891 r.Get("/banner", banner)
7892 r.Get("/logo.png", sendFile)
7893 r.Get("/logo.html", sendFile)
7894 r.Get("/mobilelogo.html", sendFile)
7895 r.Get("/logolarge.html", sendFile)
7896 r.Get("/favicon.ico", sendImage)
7897 if f.Siteimagesrc == "" {
7898 r.Use("/i", static.New("./img"))
7899 r.Use("/img", static.New("./img"))
7900 }
7901 r.Use("/font", static.New("./font"))
7902 r.Get("/stl/:filename", func(c fiber.Ctx) error {
7903 name := c.Params("filename")
7904 if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
7905 return c.SendStatus(fiber.StatusBadRequest)
7906 }
7907 return c.SendFile("./img/stl/" + name)
7908 })
7909 r.Get("/stl/base64/:filename", stlbase64)
7910 r.Get("/tui", tuipage)
7911 r.Get("/desk", deskpage)
7912 }
7913
7914 r.Get("/robots.txt", robots)
7915 r.Get("/site.webmanifest", func(c fiber.Ctx) error {
7916 return c.JSON(fiber.Map{
7917 "name": f.Sitelongname,
7918 "short_name": f.Sitename,
7919 "icons": []fiber.Map{
7920 {"src": f.Siteimagesrc + "/i/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"},
7921 {"src": f.Siteimagesrc + "/i/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"},
7922 },
7923 "theme_color": "#ffffff",
7924 "background_color": "#ffffff",
7925 "display": "standalone",
7926 })
7927 })
7928 r.Get("/api/products", apiproducts)
7929 r.Get("/api/site", apisite)
7930 r.Get("/api/content/:name", apicontent)
7931 r.Get("/sitemap", sitemap)
7932 r.Get("/sitemap.xml", sitemap)
7933 r.Get("/", homepage)
7934 r.Get("/p/:partno", productpage)
7935 r.Get("/post/:partno", handlecat)
7936 r.Get("/p", handlecat)
7937 r.Get("/cat", handlecat)
7938 r.Get("/cat/:cat", handlecat)
7939 r.Get("/cat/:cat/:subcat", handlecat)
7940 r.Get("/style.css", style)
7941 r.Get("/font.css", fontcss)
7942
7943 if !inTab {
7944 for _, register := range extraRoutes {
7945 register(r)
7946 }
7947 handleOrder(r)
7948 // Crypto checkout, if a wallet is configured; the poller that prints
7949 // a receipt when a payment confirms starts with it.
7950 handleInvoice(r)
7951 go WatchInvoices(context.Background())
7952 } else {
7953 // The drop-ins this binary carries, ahead of the catch-all so they are
7954 // answered from the embed rather than fetched back off the origin.
7955 serveCarriedWASM(r)
7956 // Money crosses back to the real server: the tab renders the cart,
7957 // the origin holds the Stripe key and the order book.
7958 r.Post("/create-payment-intent", proxyToOrigin(o.ProxyOrigin))
7959 r.Post("/submit-order", proxyToOrigin(o.ProxyOrigin))
7960 // Everything the tab does not carry β images, fonts, STL models,
7961 // the logo pipeline, /complete's Stripe lookup β reads through to
7962 // the origin. Registered last, so it is the 404 path.
7963 r.Use(proxyToOrigin(o.ProxyOrigin))
7964 }
7965 return r
7966}
7967
7968// proxyToOrigin relays the request to the real origin and copies the answer
7969// back. Under js/wasm net/http rides the browser's fetch, so a same-origin
7970// relay needs no credentials or CORS ceremony.
7971func proxyToOrigin(origin string) fiber.Handler {
7972 return func(c fiber.Ctx) error {
7973 method := c.Method()
7974 if method != fiber.MethodGet && method != fiber.MethodPost {
7975 return fiber.ErrNotFound
7976 }
7977 var body io.Reader
7978 if method == fiber.MethodPost {
7979 body = strings.NewReader(string(c.Body()))
7980 }
7981 req, err := http.NewRequest(method, origin+c.OriginalURL(), body)
7982 if err != nil {
7983 return fiber.ErrBadGateway
7984 }
7985 if ct := c.Get(fiber.HeaderContentType); ct != "" {
7986 req.Header.Set(fiber.HeaderContentType, ct)
7987 }
7988 resp, err := http.DefaultClient.Do(req)
7989 if err != nil {
7990 return fiber.ErrBadGateway
7991 }
7992 defer resp.Body.Close() //nolint:errcheck,gosec
7993 payload, err := io.ReadAll(resp.Body)
7994 if err != nil {
7995 return fiber.ErrBadGateway
7996 }
7997 if ct := resp.Header.Get("Content-Type"); ct != "" {
7998 c.Set(fiber.HeaderContentType, ct)
7999 }
8000 return c.Status(resp.StatusCode).Send(payload)
8001 }
8002}
8003
8004// sharedRead reports whether path is public, read-only data any origin may
8005// fetch.
8006func sharedRead(path string) bool {
8007 if path == "/api/products" || path == "/api/site" || path == "/font.css" || path == "/store.wasm" || path == "/store-tiny.wasm" {
8008 return true
8009 }
8010 for _, p := range []string{"/api/content/", "/i/", "/img/"} {
8011 if strings.HasPrefix(path, p) {
8012 return true
8013 }
8014 }
8015 return false
8016}
8017
8018
8019// ===== pkg/web/art.go =====
8020package web
8021
8022// Two endpoints that draw with the ports: a product image as ANSI art turned
8023// into HTML, and a heading as a FIGlet banner.
8024//
8025// Server side rather than in the wasm decorator, for two different reasons.
8026// The image art needs a JPEG decode and a dither per product, which is real
8027// work to do fourteen times in a browser and is the same answer every time β
8028// so it is done once here and cached. The banner needs the FIGlet fonts, and
8029// there are eight hundred kilobytes of them; embedding that in a decorator to
8030// draw a dozen headings is the wrong trade, while here they are already in the
8031// binary.
8032//
8033// Nothing on the site links to either. They exist for the decorator on /test to
8034// call, so the served HTML is unchanged and a browser that does not run the
8035// wasm sees the page it always saw.
8036
8037import (
8038 "bytes"
8039 "fmt"
8040 "io"
8041 "net/http"
8042 "os"
8043 "path/filepath"
8044 "strconv"
8045 "strings"
8046 "sync"
8047 "time"
8048
8049 "github.com/gofiber/fiber/v3"
8050
8051 "github.com/0magnet/ansifilter-go/ansifilter"
8052 "github.com/0magnet/img2txt-go/caca"
8053 "github.com/0magnet/toilet-go/figlet"
8054 "github.com/0magnet/toilet-go/fonts"
8055)
8056
8057// artCache keys rendered art by everything that changes it. Both renders are
8058// pure functions of their inputs and neither is cheap, so nothing is drawn
8059// twice.
8060var (
8061 artCache = map[string]string{}
8062 artMu sync.Mutex
8063)
8064
8065func cached(key string, make func() (string, error)) (string, error) {
8066 artMu.Lock()
8067 hit, ok := artCache[key]
8068 artMu.Unlock()
8069 if ok {
8070 return hit, nil
8071 }
8072 out, err := make()
8073 if err != nil {
8074 return "", err
8075 }
8076 artMu.Lock()
8077 artCache[key] = out
8078 artMu.Unlock()
8079 return out, nil
8080}
8081
8082// artWidth keeps a request from asking for a canvas big enough to be a denial
8083// of service. Eighty is a terminal; two hundred is already wider than the
8084// column the page gives it.
8085const (
8086 artMin = 8
8087 artMax = 200
8088)
8089
8090// art renders one product image as ANSI art and exports it as HTML.
8091//
8092// The chain is the one the store already had most of: img2txt-go decodes and
8093// dithers the image into a libcaca canvas, which exports ANSI β the same
8094// renderer the terminal UI draws products with β and ansifilter-go turns that
8095// ANSI into HTML. Going straight from the canvas to caca's own HTML export
8096// would skip the middle step and lose the point: the ANSI is what the terminal
8097// shows, so passing it through is what makes the two agree.
8098func art(c fiber.Ctx) error {
8099 cols := 60
8100 if v, err := strconv.Atoi(c.Query("cols")); err == nil {
8101 cols = v
8102 }
8103 cols = min(max(cols, artMin), artMax)
8104 cat, name := c.Params("cat"), c.Params("name")
8105 if !safeSegment(cat) || !safeSegment(name) {
8106 return c.Status(fiber.StatusBadRequest).SendString("bad path")
8107 }
8108 src := imgPath(cat, name)
8109 out, err := cached(fmt.Sprintf("art\x00%s\x00%d", src, cols), func() (string, error) {
8110 return renderArt(src, cols)
8111 })
8112 if err != nil {
8113 return c.Status(fiber.StatusNotFound).SendString(err.Error())
8114 }
8115 c.Set("Content-Type", "text/html;charset=utf-8")
8116 c.Set("Cache-Control", "public, max-age=86400")
8117 return c.SendString(out)
8118}
8119
8120func renderArt(src string, cols int) (string, error) {
8121 data, err := fetch(src)
8122 if err != nil {
8123 return "", err
8124 }
8125 im, err := caca.DecodeImage(bytes.NewReader(data))
8126 if err != nil {
8127 return "", err
8128 }
8129 if im.W == 0 || im.H == 0 {
8130 return "", fmt.Errorf("image has no size")
8131 }
8132 // img2txt's 6x10 font cell, so the art keeps the picture's proportions
8133 // instead of coming out squashed by the shape of a character.
8134 rows := cols * im.H * 6 / im.W / 10
8135 if rows < 1 {
8136 rows = 1
8137 }
8138 cv := caca.NewCanvas(cols, rows)
8139 cv.SetColorANSI(caca.Default, caca.Transparent)
8140 cv.Clear()
8141 im.Dither.SetAlgorithm("fstein")
8142 cv.DitherBitmap(0, 0, cols, rows, im.Dither, im.Pixels)
8143 ansi, ok := cv.Export("ansi")
8144 if !ok {
8145 return "", fmt.Errorf("canvas will not export ansi")
8146 }
8147 g := ansifilter.New(ansifilter.HTML)
8148 g.SetFragmentCode(true)
8149 return g.GenerateString(string(ansi)), nil
8150}
8151
8152// banner renders a line of text as a FIGlet banner.
8153func banner(c fiber.Ctx) error {
8154 // A query parameter, not a path segment. Fiber hands back path params still
8155 // percent-encoded, and rendering those gives a banner reading
8156 // DIODE%20%E2%86%92%201N40XX, which is exactly what went live.
8157 text := c.Query("text")
8158 if text == "" {
8159 return c.Status(fiber.StatusBadRequest).SendString("no text")
8160 }
8161 if len(text) > 64 {
8162 text = text[:64]
8163 }
8164 font := c.Query("font")
8165 if font == "" {
8166 font = "future"
8167 }
8168 width := 200
8169 if v, err := strconv.Atoi(c.Query("width")); err == nil && v > 0 {
8170 width = min(v, 400)
8171 }
8172 out, err := cached(fmt.Sprintf("banner\x00%s\x00%s\x00%d", text, font, width), func() (string, error) {
8173 return renderBanner(text, font, width)
8174 })
8175 if err != nil {
8176 return c.Status(fiber.StatusNotFound).SendString(err.Error())
8177 }
8178 c.Set("Content-Type", "text/plain;charset=utf-8")
8179 c.Set("Cache-Control", "public, max-age=86400")
8180 return c.SendString(out)
8181}
8182
8183func renderBanner(text, font string, width int) (string, error) {
8184 raw, ok := fonts.Get(font)
8185 if !ok {
8186 return "", fmt.Errorf("no font %q", font)
8187 }
8188 fnt, err := figlet.ParseFont(raw)
8189 if err != nil {
8190 return "", err
8191 }
8192 r := figlet.NewRenderer(fnt)
8193 r.SetWidth(width)
8194 r.SetMode(figlet.ParseMode("smush"))
8195 for _, ch := range text {
8196 r.PutChar(ch)
8197 }
8198 cv := r.Flush()
8199 if cv == nil {
8200 return "", fmt.Errorf("nothing rendered")
8201 }
8202 utf8, ok := cv.Export("utf8")
8203 if !ok {
8204 return "", fmt.Errorf("canvas will not export utf8")
8205 }
8206 return strings.TrimRight(string(utf8), "\n"), nil
8207}
8208
8209// safeSegment rejects anything that could climb out of the image directory.
8210// The two path parts come off the wire, so they are the one thing here a
8211// visitor controls.
8212func safeSegment(s string) bool {
8213 if s == "" || s == "." || s == ".." {
8214 return false
8215 }
8216 return !strings.ContainsAny(s, "/\\") && !strings.Contains(s, "..")
8217}
8218
8219// imgPath is where a product image lives: on disk under ./img when the site
8220// serves its own, and behind Siteimagesrc when it does not β the same two cases
8221// the template's ImgSRC covers.
8222func imgPath(cat, name string) string {
8223 if f.Siteimagesrc == "" {
8224 return filepath.Join("img", cat, name)
8225 }
8226 return f.Siteimagesrc + "/i/" + cat + "/" + name
8227}
8228
8229// fetch reads an image from wherever imgPath put it, on disk or over http.
8230func fetch(src string) ([]byte, error) {
8231 if !strings.HasPrefix(src, "http://") && !strings.HasPrefix(src, "https://") {
8232 return os.ReadFile(src) //nolint:gosec // safeSegment has already refused anything with a separator in it
8233 }
8234 client := &http.Client{Timeout: 20 * time.Second}
8235 resp, err := client.Get(src) //nolint:gosec // ImgSRC is the operator's own
8236 if err != nil {
8237 return nil, err
8238 }
8239 defer resp.Body.Close() //nolint:errcheck // read-side close
8240 if resp.StatusCode != http.StatusOK {
8241 return nil, fmt.Errorf("%s", resp.Status)
8242 }
8243 return io.ReadAll(io.LimitReader(resp.Body, 16<<20))
8244}
8245
8246
8247// ===== pkg/web/art_test.go =====
8248package web
8249
8250import (
8251 "os"
8252 "path/filepath"
8253 "strings"
8254 "testing"
8255)
8256
8257// The two renders have to work end to end, since nothing else exercises the
8258// chain: img2txt decodes and dithers, the canvas exports ANSI, ansifilter turns
8259// that into HTML.
8260func TestRenderArt(t *testing.T) {
8261 var sample string
8262 err := filepath.WalkDir("../../img", func(p string, d os.DirEntry, err error) error {
8263 if err != nil || d.IsDir() || sample != "" {
8264 return nil //nolint:nilerr // a missing image directory is not this test's business
8265 }
8266 switch strings.ToLower(filepath.Ext(p)) {
8267 case ".jpg", ".jpeg", ".png", ".gif":
8268 sample = p
8269 }
8270 return nil
8271 })
8272 if err != nil || sample == "" {
8273 t.Skip("no product images on disk to render")
8274 }
8275 out, err := renderArt(sample, 40)
8276 if err != nil {
8277 t.Fatalf("rendering %s: %v", sample, err)
8278 }
8279 if !strings.Contains(out, "<span") {
8280 t.Errorf("no colored spans in the output; ansifilter produced %.80q", out)
8281 }
8282 if n := strings.Count(out, "\n"); n < 4 {
8283 t.Errorf("only %d lines of art, expected the image to fill a few", n)
8284 }
8285}
8286
8287// A banner has to come back as several lines of art, not as the text itself.
8288func TestRenderBanner(t *testing.T) {
8289 out, err := renderBanner("resistor", "future", 200)
8290 if err != nil {
8291 t.Fatal(err)
8292 }
8293 lines := strings.Split(out, "\n")
8294 if len(lines) < 3 {
8295 t.Fatalf("a banner should be several rows tall, got %d: %q", len(lines), out)
8296 }
8297 if strings.Contains(out, "resistor") {
8298 t.Error("the banner is the literal text, so no font was applied")
8299 }
8300 t.Logf("banner is %d lines, widest %d", len(lines), widest(lines))
8301 for _, l := range lines {
8302 t.Log(l)
8303 }
8304}
8305
8306func widest(lines []string) int {
8307 w := 0
8308 for _, l := range lines {
8309 if n := len([]rune(l)); n > w {
8310 w = n
8311 }
8312 }
8313 return w
8314}
8315
8316// The path guard is the only place a visitor's input reaches the filesystem.
8317func TestSafeSegment(t *testing.T) {
8318 for _, bad := range []string{"", ".", "..", "../etc", "a/b", `a\b`, "x..y"} {
8319 if safeSegment(bad) {
8320 t.Errorf("%q was accepted", bad)
8321 }
8322 }
8323 for _, good := range []string{"diode", "1N4148.jpg", "a-b_c.png"} {
8324 if !safeSegment(good) {
8325 t.Errorf("%q was refused", good)
8326 }
8327 }
8328}
8329
8330
8331// ===== pkg/web/browse.go =====
8332package web
8333
8334// The real-origin browse substrate: giving the desk's nested browser a genuine
8335// origin to render the in-tab store at.
8336//
8337// netscrape renders a fetched page into a sandboxed srcdoc with an opaque
8338// origin, which cannot reach the tab's virtual loopback at all; its relay
8339// carries stylesheets and images and nothing else. That is enough for a page
8340// of text and pictures and is structurally unable to run a wasm drop-in: a
8341// <script src> is never fetched, and an in-page WebAssembly.instantiateStreaming
8342// could not reach vnet even if it were. So the store inside the desk had a
8343// cart that could not add and a logo the attractor never drew over.
8344//
8345// github.com/0magnet/realorigin fixes the class of problem rather than the
8346// symptom. The store gets its own browser origin B, distinct from the site's
8347// origin V, whose network layer is a service worker relaying every fetch back
8348// to a responder on V β which answers it from the in-tab server over vnet. The
8349// page is then an ordinary document: native subresource loading, real fetch,
8350// real wasm, cookies and storage of its own.
8351//
8352// Two things make it cheap here where it is expensive in general:
8353//
8354// - There is exactly ONE target, the tab's own store, so there is exactly one
8355// browse origin. realorigin content-addresses origins precisely so a
8356// wildcard can cover a whole address space; one fixed target hashes to one
8357// fixed hostname, which needs one ordinary certificate and no wildcard, no
8358// DNS-01 and no DNS provider credentials.
8359// - B must be a different REGISTRABLE domain from V, not a subdomain, so that
8360// untrusted content is fully cross-site from the site holding the keys.
8361// That is a deployment fact, and BrowseSuffix is where it is stated.
8362//
8363// Everything here is inert until BrowseSuffix and BrowseAddr are both set.
8364
8365import (
8366 "context"
8367 "github.com/gofiber/fiber/v3"
8368 "log"
8369 "net/url"
8370 "strconv"
8371 "strings"
8372
8373 "github.com/0magnet/realorigin"
8374
8375 "github.com/0magnet/m2/pkg/config"
8376)
8377
8378// browseResponderPath is where the app origin serves realorigin's responder β
8379// the trust boundary, which must run first-party on V. The desk page loads it
8380// before the wasm that configures it.
8381const browseResponderPath = "/browse-responder.js"
8382
8383// browseEnabled reports whether the deployment configured the substrate.
8384func browseEnabled() bool {
8385 return config.F.BrowseSuffix != "" && config.F.BrowseAddr != ""
8386}
8387
8388// browseScheme and browsePort describe how a browse origin is reached.
8389//
8390// Hosted, that is https on 443 and the port is not in the URL. The local
8391// model is the exception the platform itself makes: *.localhost resolves to
8392// the loopback without any DNS and counts as a secure context, so a service
8393// worker runs there over plain http. It is how realorigin and skywire are
8394// developed against (.mesh.localhost), and it is the only way to exercise the
8395// whole path without a wildcard certificate.
8396func browseScheme() string {
8397 if isLoopbackSuffix(config.F.BrowseSuffix) {
8398 return "http"
8399 }
8400 return "https"
8401}
8402
8403// browsePort is the port a browse origin is served on, empty when it is the
8404// scheme's default.
8405func browsePort() string {
8406 if !isLoopbackSuffix(config.F.BrowseSuffix) {
8407 return ""
8408 }
8409 if i := strings.LastIndex(config.F.BrowseAddr, ":"); i >= 0 {
8410 return config.F.BrowseAddr[i+1:]
8411 }
8412 return ""
8413}
8414
8415// isLoopbackSuffix reports whether the browse domain is the browser's own
8416// loopback namespace rather than a real one.
8417func isLoopbackSuffix(suffix string) bool {
8418 return strings.HasSuffix(strings.TrimSuffix(suffix, "."), ".localhost")
8419}
8420
8421// appOrigin is V: the origin the responder runs on, and the exact string the
8422// browse origin posts its handshake to. It has to be what the BROWSER sees,
8423// not what the site calls itself.
8424//
8425// Those are the same thing hosted, where SITEDOMAIN is both the brand and the
8426// address. They are not the same under the loopback model: SITEDOMAIN is
8427// assembled from SITENAME and SITEEXT for the masthead, so a development run
8428// still calls itself magnetosphere.net while the browser is on
8429// localhost:<WEBPORT>. Posting the handshake to the brand would silently drop
8430// it and hang the frame on its interstitial forever, which is a bad way to
8431// find out.
8432func appOrigin() string {
8433 if isLoopbackSuffix(config.F.BrowseSuffix) {
8434 return "http://localhost:" + strconv.Itoa(config.F.WebPort)
8435 }
8436 raw := config.F.Sitedomain
8437 if raw == "" {
8438 return ""
8439 }
8440 if !strings.Contains(raw, "://") {
8441 raw = "https://" + raw
8442 }
8443 u, err := url.Parse(raw)
8444 if err != nil || u.Host == "" {
8445 return ""
8446 }
8447 return u.Scheme + "://" + u.Host
8448}
8449
8450// serveBrowseResponder puts realorigin's responder β the trust boundary β on
8451// the app origin, where it has to run first-party. An earlier design in that
8452// project put it in a cross-origin helper instead and Storage Partitioning
8453// stranded it in another partition, which is worth not rediscovering.
8454func serveBrowseResponder(r *fiber.App) {
8455 if !browseEnabled() {
8456 return
8457 }
8458 body := realorigin.ResponderJS()
8459 r.Get(browseResponderPath, func(c fiber.Ctx) error {
8460 c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
8461 return c.Send(body)
8462 })
8463}
8464
8465// StartBrowseOrigin runs the browse-origin bootstrap listener.
8466//
8467// It serves two static files and never proxies anything: the service worker at
8468// its own path and the bootstrap shell at every other. All content travels the
8469// other way, through the visitor's own tab, so this listener sees none of it
8470// and nothing about it scales with use.
8471//
8472// Returns immediately when the substrate is not configured, which is the
8473// state of any deployment that has not set up the second domain.
8474func StartBrowseOrigin(ctx context.Context) {
8475 if !browseEnabled() {
8476 return
8477 }
8478 app := appOrigin()
8479 if app == "" {
8480 log.Printf("browse: SITEURL is not a URL; the browse origin needs one to name its app origin")
8481 return
8482 }
8483 cfg := realorigin.Config{
8484 Addr: config.F.BrowseAddr,
8485 Suffix: config.F.BrowseSuffix,
8486 AppOrigin: app,
8487 }
8488 if _, err := realorigin.Handler(cfg); err != nil {
8489 log.Printf("browse: %v", err)
8490 return
8491 }
8492 log.Printf("browse: serving %s on %s for %s β each target the tab reaches gets its own origin",
8493 config.F.BrowseSuffix, config.F.BrowseAddr, app)
8494 go func() {
8495 if err := cfg.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
8496 log.Printf("browse: %v", err)
8497 }
8498 }()
8499}
8500
8501
8502// ===== pkg/web/browse_test.go =====
8503package web
8504
8505import (
8506 "strings"
8507 "testing"
8508)
8509
8510// The substrate is off unless BOTH the domain and the listener are named. A
8511// deployment with neither must behave exactly as it did before.
8512func TestBrowseIsOffUntilConfigured(t *testing.T) {
8513 saved := *f
8514 t.Cleanup(func() { *f = saved })
8515
8516 for _, tc := range []struct {
8517 suffix, addr string
8518 want bool
8519 }{
8520 {"", "", false},
8521 {".haltingstate.net", "", false},
8522 {"", "127.0.0.1:7997", false},
8523 {".haltingstate.net", "127.0.0.1:7997", true},
8524 } {
8525 f.BrowseSuffix, f.BrowseAddr = tc.suffix, tc.addr
8526 if got := browseEnabled(); got != tc.want {
8527 t.Errorf("browseEnabled(%q,%q) = %v, want %v", tc.suffix, tc.addr, got, tc.want)
8528 }
8529 }
8530}
8531
8532// Hosted is https on the default port; the loopback model is plain http on
8533// the listener's own port, which is the only way to exercise the whole path
8534// without a wildcard certificate.
8535func TestBrowseSchemeAndPort(t *testing.T) {
8536 saved := *f
8537 t.Cleanup(func() { *f = saved })
8538 for _, tc := range []struct {
8539 suffix, addr string
8540 scheme, port string
8541 }{
8542 {".haltingstate.net", "127.0.0.1:7997", "https", ""},
8543 {".mesh.localhost", "127.0.0.1:7997", "http", "7997"},
8544 {".mesh.localhost", ":7997", "http", "7997"},
8545 } {
8546 f.BrowseSuffix, f.BrowseAddr = tc.suffix, tc.addr
8547 if got := browseScheme(); got != tc.scheme {
8548 t.Errorf("browseScheme(%q) = %q, want %q", tc.suffix, got, tc.scheme)
8549 }
8550 if got := browsePort(); got != tc.port {
8551 t.Errorf("browsePort(%q,%q) = %q, want %q", tc.suffix, tc.addr, got, tc.port)
8552 }
8553 }
8554}
8555
8556// B must be a different registrable domain from V, not a subdomain of it:
8557// different hosts are merely cross-origin, and only different registrable
8558// domains make untrusted content fully cross-site from the site holding the
8559// Stripe key.
8560func TestBrowseSuffixIsNotUnderTheSiteDomain(t *testing.T) {
8561 saved := *f
8562 t.Cleanup(func() { *f = saved })
8563 f.Sitedomain = "magnetosphere.net"
8564 f.BrowseSuffix, f.BrowseAddr = ".haltingstate.net", "127.0.0.1:7997"
8565
8566 if got := appOrigin(); got != "https://magnetosphere.net" {
8567 t.Errorf("appOrigin() = %q", got)
8568 }
8569 if strings.HasSuffix(f.BrowseSuffix, "."+f.Sitedomain) {
8570 t.Error("the browse domain is under the site's own; it must be a separate registrable domain")
8571 }
8572}
8573
8574func TestAppOriginFromSiteDomain(t *testing.T) {
8575 saved := *f
8576 t.Cleanup(func() { *f = saved })
8577 f.BrowseSuffix = ".haltingstate.net" // hosted: the brand IS the address
8578 for _, tc := range []struct{ in, want string }{
8579 {"magnetosphere.net", "https://magnetosphere.net"},
8580 {"https://magnetosphere.net", "https://magnetosphere.net"},
8581 {"http://localhost:8099", "http://localhost:8099"},
8582 {"", ""},
8583 } {
8584 f.Sitedomain = tc.in
8585 if got := appOrigin(); got != tc.want {
8586 t.Errorf("appOrigin(%q) = %q, want %q", tc.in, got, tc.want)
8587 }
8588 }
8589}
8590
8591// Under the loopback model V is wherever the browser actually has it, not
8592// what the masthead says. SITEDOMAIN is assembled from SITENAME and SITEEXT
8593// for branding, so a development run calls itself magnetosphere.net while
8594// being served from localhost β and a handshake posted to the brand is
8595// silently dropped.
8596func TestAppOriginIsTheServingOriginUnderLoopback(t *testing.T) {
8597 saved := *f
8598 t.Cleanup(func() { *f = saved })
8599 f.Sitedomain, f.WebPort = "magnetosphere.net", 8099
8600 f.BrowseSuffix, f.BrowseAddr = ".mesh.localhost", "127.0.0.1:7997"
8601
8602 if got, want := appOrigin(), "http://localhost:8099"; got != want {
8603 t.Errorf("appOrigin() = %q, want %q β the brand is not the address here", got, want)
8604 }
8605 f.BrowseSuffix = ".haltingstate.net"
8606 if got, want := appOrigin(), "https://magnetosphere.net"; got != want {
8607 t.Errorf("hosted appOrigin() = %q, want %q", got, want)
8608 }
8609}
8610
8611
8612// ===== pkg/web/catalog.go =====
8613// Package web pkg/web/catalog.go β the in-memory catalog the server sells from.
8614package web
8615
8616import (
8617 "log"
8618 "os"
8619 "sync"
8620 "time"
8621
8622 p "github.com/0magnet/m2/pkg/product"
8623)
8624
8625var (
8626 allproducts p.Products
8627 allproductsMu sync.RWMutex
8628)
8629
8630var lastModTime time.Time
8631
8632// LoadCatalog reads the products CSV named in the config into memory,
8633// logging any data-integrity warnings.
8634func LoadCatalog() error {
8635 fileInfo, err := os.Stat(f.ProductsCSV)
8636 if err != nil {
8637 return err
8638 }
8639 lastModTime = fileInfo.ModTime()
8640 prods := p.ReadCSV(f.ProductsCSV)
8641 if warnings := p.ValidateCSV(prods); len(warnings) > 0 {
8642 for _, w := range warnings {
8643 log.Println("CSV warning:", w)
8644 }
8645 }
8646 allproductsMu.Lock()
8647 allproducts = prods
8648 allproductsMu.Unlock()
8649 return nil
8650}
8651
8652// WatchCatalog polls the products CSV and reloads it when it changes.
8653// Run it in a goroutine; it never returns.
8654func WatchCatalog() {
8655 for {
8656 fileInfo, err := os.Stat(f.ProductsCSV)
8657 if err != nil {
8658 log.Println("Error getting file info:", err)
8659 time.Sleep(10 * time.Second)
8660 continue
8661 }
8662
8663 currentModTime := fileInfo.ModTime()
8664 if currentModTime != lastModTime {
8665 log.Println("CSV file has been modified!")
8666 if err := LoadCatalog(); err != nil {
8667 log.Println("Error reloading catalog:", err)
8668 }
8669 }
8670
8671 time.Sleep(10 * time.Second)
8672 }
8673}
8674
8675// SetCatalog replaces the in-memory catalog directly, for hosts that have no
8676// CSV on disk to load β the in-tab site server seeds this from /api/products,
8677// which is already the public projection (no cost, location or source info).
8678func SetCatalog(prods p.Products) {
8679 allproductsMu.Lock()
8680 allproducts = prods
8681 allproductsMu.Unlock()
8682}
8683
8684
8685// ===== pkg/web/catsubcats_test.go =====
8686package web
8687
8688import (
8689 "bytes"
8690 "flag"
8691 htmpl "html/template"
8692 "os"
8693 "strings"
8694 "testing"
8695
8696 p "github.com/0magnet/m2/pkg/product"
8697)
8698
8699// sampleProducts is a catalog small enough to write the expected menu out by
8700// hand, and shaped to exercise the alignment: counts of one, two and three
8701// digits, a category with subcategories and one without.
8702func sampleProducts() p.Products {
8703 var out p.Products
8704 add := func(cat, sub string, n int) {
8705 for i := 0; i < n; i++ {
8706 out = append(out, p.Product{Enable: "TRUE", Category: cat, Subcategory: sub,
8707 Partno: cat + sub + string(rune('a'+i%26))})
8708 }
8709 }
8710 add("resistor", "quarter watt 5%", 67)
8711 add("resistor", "ceramic", 1)
8712 add("inductor", "", 34)
8713 return out
8714}
8715
8716// renderMenu runs just the category menu, the way a page does.
8717func renderMenu(t *testing.T, prods p.Products) string {
8718 t.Helper()
8719 saved := allproducts
8720 allproducts = prods
8721 defer func() { allproducts = saved }()
8722 data := htmlTemplateData{}
8723 data.LenAllProducts = len(prods)
8724 data.CatsCounts, data.Cats, data.SubCatsCounts, data.SubCatsByCat = getcategories(prods)
8725 data.Page = "front"
8726 tmpl := htmpl.New("catsubcats").Funcs(funcs)
8727 // Read by path rather than through h: the accessors resolve relative to
8728 // the server's working directory, which is not a test's.
8729 src, err := os.ReadFile("../../htmpl/catsubcats.html")
8730 if err != nil {
8731 t.Fatalf("reading catsubcats: %v", err)
8732 }
8733 if _, err := tmpl.Parse(string(src)); err != nil {
8734 t.Fatalf("parsing catsubcats: %v", err)
8735 }
8736 var b bytes.Buffer
8737 if err := tmpl.Execute(&b, map[string]interface{}{"Page": data, "Category": "", "Subcategory": ""}); err != nil {
8738 t.Fatalf("executing catsubcats: %v", err)
8739 }
8740 return b.String()
8741}
8742
8743// The menu's counts are right-aligned by padding the branch rule itself, so
8744// every count ends in the same column. These glyph runs are what the site drew
8745// before the layout moved to tallytree.
8746func TestCatSubCatsDrawing(t *testing.T) {
8747 out := renderMenu(t, sampleProducts())
8748 for _, want := range []string{
8749 "βββ102 ", // All Products
8750 "βββ¬β68 ", // resistor, has subcategories
8751 "β ββ67 ", // quarter watt 5%
8752 "β βββ1 ", // ceramic, last subcategory, rule stretched two
8753 "ββββ34 ", // inductor, no subcategories, last category
8754 } {
8755 if !strings.Contains(out, want) {
8756 t.Errorf("menu is missing %q\n\n%s", want, out)
8757 }
8758 }
8759}
8760
8761// TestCatSubCatsGolden holds the menu's whole markup, not just its glyphs.
8762// The layout moved out to tallytree; the anchors, titles, classes and nesting
8763// around it must not have moved with it.
8764//
8765// Regenerate with: go test ./pkg/web -run CatSubCatsGolden -update
8766func TestCatSubCatsGolden(t *testing.T) {
8767 got := renderMenu(t, sampleProducts())
8768 const golden = "testdata/catsubcats.golden"
8769 if *update {
8770 if err := os.WriteFile(golden, []byte(got), 0o600); err != nil {
8771 t.Fatal(err)
8772 }
8773 t.Log("golden rewritten")
8774 return
8775 }
8776 want, err := os.ReadFile(golden)
8777 if err != nil {
8778 t.Fatalf("%v (run with -update to create it)", err)
8779 }
8780 if got != string(want) {
8781 t.Errorf("menu markup changed.\n got:\n%s\nwant:\n%s", got, want)
8782 }
8783}
8784
8785var update = flag.Bool("update", false, "rewrite the golden files")
8786
8787
8788// ===== pkg/web/dropinfs.go =====
8789package web
8790
8791// The drop-ins the binary carries, for a server whose disk has none.
8792//
8793// The store runs in three places and only two of them have the binaries on a
8794// filesystem. On a host, CompileWASM writes them next to the working directory
8795// and the routes read them from there. In a browser tab β pkg/storepane's
8796// `serve`, behind the desk's netscrape window β there is no toolchain to build
8797// them with and no disk that carries them, and the sources are not there
8798// either, so nothing will ever produce them locally.
8799//
8800// So the build stages them into dropins/ and they ride along inside the
8801// binary. A tab then serves the same cart, attractor and border decoration the
8802// origin serves, out of itself, with no round trip.
8803//
8804// The page binaries (term, and the separate tui/desk) are deliberately NOT
8805// staged. term.wasm is the binary doing the embedding, and a file cannot
8806// contain itself; the tab does not serve /tui or /desk anyway β those routes
8807// belong to the host role.
8808//
8809// An empty stage is fine and is what a fresh checkout builds: the directive
8810// matches dropins/README.md, nothing is carried, and the lookup below simply
8811// says no. The disk and then the origin answer instead.
8812
8813import (
8814 "embed"
8815 "io/fs"
8816 "path"
8817 "strings"
8818)
8819
8820//go:embed dropins
8821var dropinFS embed.FS
8822
8823// embeddedDropin returns the staged bytes of one drop-in, by the file name a
8824// page asks for ("stl2-tiny.wasm").
8825func embeddedDropin(file string) ([]byte, bool) {
8826 // Only the binaries, by a bare name. The directory also holds its own
8827 // README, which is not something a page may ask this for.
8828 if file == "" || !strings.HasSuffix(file, ".wasm") || strings.ContainsAny(file, `/\`) {
8829 return nil, false
8830 }
8831 b, err := dropinFS.ReadFile(path.Join("dropins", file))
8832 if err != nil {
8833 return nil, false
8834 }
8835 return b, true
8836}
8837
8838// embeddedDropinNames lists the staged binaries, so the routes can be
8839// registered for exactly what the build carried.
8840func embeddedDropinNames() []string {
8841 ents, err := fs.ReadDir(dropinFS, "dropins")
8842 if err != nil {
8843 return nil
8844 }
8845 var out []string
8846 for _, e := range ents {
8847 if !e.IsDir() && strings.HasSuffix(e.Name(), ".wasm") {
8848 out = append(out, e.Name())
8849 }
8850 }
8851 return out
8852}
8853
8854// lookupDropin is embeddedDropin behind a variable so a test can say what the
8855// build carried. Whether a binary carries a drop-in is fixed at compile time,
8856// which makes every test of the pick order depend on what happened to be
8857// staged in the tree it was compiled from; this makes that an input.
8858var lookupDropin = embeddedDropin
8859
8860
8861// ===== pkg/web/dropinfs_test.go =====
8862package web
8863
8864import (
8865 "os"
8866 "path/filepath"
8867 "testing"
8868)
8869
8870// A fresh checkout stages nothing, and that has to build and behave β the
8871// directive matches only the README, and every lookup says no.
8872func TestEmbeddedDropinOnAnEmptyStage(t *testing.T) {
8873 // Whatever this working tree has staged, README.md is never a drop-in.
8874 if _, ok := embeddedDropin("README.md"); ok {
8875 t.Error("README.md should not be reachable as a drop-in")
8876 }
8877 for _, bad := range []string{"", "../wasmpick.go", "sub/x.wasm", `a\b.wasm`} {
8878 if _, ok := embeddedDropin(bad); ok {
8879 t.Errorf("embeddedDropin(%q) should be refused", bad)
8880 }
8881 }
8882 for _, n := range embeddedDropinNames() {
8883 if filepath.Ext(n) != ".wasm" {
8884 t.Errorf("staged name %q is not a .wasm", n)
8885 }
8886 }
8887}
8888
8889// pageWasm is what keeps term.wasm from being staged into itself.
8890func TestPageBinariesAreNotStaged(t *testing.T) {
8891 for _, name := range []string{"term", "tui", "desk"} {
8892 if !pageWasm[name] {
8893 t.Errorf("%s must be treated as a page binary, not a drop-in", name)
8894 }
8895 }
8896 for _, name := range []string{"cart", "stl2", "deco"} {
8897 if pageWasm[name] {
8898 t.Errorf("%s is a drop-in and must be stageable", name)
8899 }
8900 }
8901}
8902
8903// stageDropins takes the smallest build of each drop-in and clears what a
8904// previous run left, so a source dropped from WasmSRC stops being carried.
8905func TestStageDropinsTakesTheSmallBuildAndClearsStale(t *testing.T) {
8906 t.Chdir(t.TempDir())
8907 dir := filepath.Join("pkg", "web", "dropins")
8908 if err := os.MkdirAll(dir, 0o750); err != nil {
8909 t.Fatal(err)
8910 }
8911 write := func(name, body string) {
8912 if err := os.WriteFile(name, []byte(body), 0o600); err != nil {
8913 t.Fatal(err)
8914 }
8915 }
8916 // Left over from a run when "gone" was still configured.
8917 write(filepath.Join(dir, "gone-tiny.wasm"), "stale")
8918
8919 write("cart.wasm", "cart-go")
8920 write("cart-tiny.wasm", "cart-tiny")
8921 write("deco.wasm", "deco-go") // no tinygo build of this one
8922
8923 stageDropins([]string{"wasm/cart", "wasm/deco", "wasm/never"})
8924
8925 got := map[string]string{}
8926 ents, err := os.ReadDir(dir)
8927 if err != nil {
8928 t.Fatal(err)
8929 }
8930 for _, e := range ents {
8931 b, rerr := os.ReadFile(filepath.Join(dir, e.Name())) //nolint:gosec // test tree
8932 if rerr != nil {
8933 t.Fatal(rerr)
8934 }
8935 got[e.Name()] = string(b)
8936 }
8937 want := map[string]string{
8938 "cart-tiny.wasm": "cart-tiny", // small build preferred
8939 "deco.wasm": "deco-go", // only build there is
8940 }
8941 if len(got) != len(want) {
8942 t.Fatalf("staged %v, want %v", got, want)
8943 }
8944 for k, v := range want {
8945 if got[k] != v {
8946 t.Errorf("staged %s = %q, want %q", k, got[k], v)
8947 }
8948 }
8949}
8950
8951
8952// ===== pkg/web/invoice.go =====
8953package web
8954
8955// Crypto checkout: the other button beside Stripe.
8956//
8957// The two paths meet where it matters. Both validate every line against the
8958// server's own catalog before believing a total, and both end by writing an
8959// order into orders/ and printing a receipt β so a paid order looks the same
8960// however it was paid, and the page in the tray keeps meaning what it means.
8961//
8962// Where they differ is when. A card authorizes while the reader waits; a
8963// chain payment arrives minutes later, from a page that may already be
8964// closed. So the receipt prints on CONFIRMATION, not on placement β
8965// otherwise every abandoned checkout would put paper in the tray and the
8966// glance-at-the-printer workflow would stop working.
8967
8968import (
8969 "context"
8970 "crypto/rand"
8971 "encoding/hex"
8972 "encoding/json"
8973 "errors"
8974 "fmt"
8975 "log"
8976 "os"
8977 "path/filepath"
8978 "time"
8979
8980 "github.com/gofiber/fiber/v3"
8981
8982 "github.com/0magnet/m2/pkg/pay"
8983 "github.com/0magnet/m2/pkg/pay/sky"
8984 "github.com/0magnet/m2/pkg/storepage"
8985)
8986
8987// cryptoPay is the configured crypto checkout, or nil when the deployment
8988// has no wallet set. Nil means the button is not offered at all, which is
8989// what a store with nothing configured should show rather than an error.
8990var cryptoPay *cryptoConfig
8991
8992type cryptoConfig struct {
8993 store *pay.Store
8994 rater *pay.Rater
8995 backend pay.Backend
8996 window time.Duration
8997 ordersIn string
8998}
8999
9000// initCrypto builds the crypto checkout from the configuration, and reports
9001// whether it is on.
9002func initCrypto(ordersDir string) *cryptoConfig {
9003 if f.SkyXpub == "" {
9004 return nil
9005 }
9006 store, err := pay.NewStore(ordersDir)
9007 if err != nil {
9008 log.Printf("crypto checkout off: %v", err)
9009 return nil
9010 }
9011
9012 // A fixed rate REPLACES the exchange rather than joining it: the
9013 // lowest-wins rule settles a disagreement between exchanges quoting one
9014 // market, and a manual figure mixed in just wins whenever it is lower.
9015 var sources []pay.RateSource
9016 switch {
9017 case f.SkyRateFix != "":
9018 sources = []pay.RateSource{pay.Fixed{Label: "operator", USD: f.SkyRateFix}}
9019 case f.SkyRatePair != "":
9020 sources = []pay.RateSource{pay.LBank{Pair: f.SkyRatePair}}
9021 default:
9022 log.Printf("crypto checkout off: set a rate pair or a fixed rate")
9023 return nil
9024 }
9025
9026 mins := f.InvoiceMins
9027 if mins <= 0 {
9028 mins = 15
9029 }
9030 return &cryptoConfig{
9031 store: store,
9032 rater: &pay.Rater{Sources: sources, Min: f.RateMin, Max: f.RateMax},
9033 backend: &sky.Backend{Xpub: f.SkyXpub, NodeURL: f.SkyNodeURL},
9034 window: time.Duration(mins) * time.Minute,
9035 ordersIn: ordersDir,
9036 }
9037}
9038
9039// handleInvoice registers the crypto endpoints, if crypto is configured.
9040func handleInvoice(r *fiber.App) {
9041 cryptoPay = initCrypto("./orders")
9042 if cryptoPay == nil {
9043 return
9044 }
9045 r.Post("/create-invoice", createInvoice)
9046 r.Get("/invoice/:id", invoiceStatus)
9047 log.Printf("crypto checkout on: skycoin, %s window", cryptoPay.window)
9048}
9049
9050// invoiceRequest is what the cart posts: the same items it sends Stripe.
9051type invoiceRequest struct {
9052 Coin string `json:"coin"`
9053 Items []pay.Item `json:"items"`
9054}
9055
9056// createInvoice prices an order in coin and hands back an address.
9057func createInvoice(c fiber.Ctx) error {
9058 var req invoiceRequest
9059 if err := json.Unmarshal(c.Body(), &req); err != nil {
9060 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid JSON"})
9061 }
9062 if len(req.Items) == 0 {
9063 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
9064 }
9065 if req.Coin != "" && pay.Coin(req.Coin) != cryptoPay.backend.Coin() {
9066 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Unsupported coin"})
9067 }
9068
9069 total, err := validateOrderItems(req.Items)
9070 if err != nil {
9071 log.Printf("invoice validation failed: %v", err)
9072 if errors.Is(err, errNeedShippingEmail) {
9073 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
9074 }
9075 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
9076 }
9077 if total < 50 {
9078 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
9079 }
9080
9081 ctx, cancel := context.WithTimeout(c.Context(), 20*time.Second)
9082 defer cancel()
9083
9084 rate, source, err := cryptoPay.rater.Quote(ctx)
9085 if err != nil {
9086 // No usable price means no sale. Quoting anyway is the mistake the
9087 // rate layer exists to prevent.
9088 log.Printf("invoice refused, no rate: %v", err)
9089 return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "No price available right now"})
9090 }
9091 coin := cryptoPay.backend.Coin()
9092 atomic, err := pay.AtomicFor(total, rate, coin.Decimals())
9093 if err != nil {
9094 log.Printf("invoice pricing failed: %v", err)
9095 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to price the order"})
9096 }
9097
9098 now := time.Now()
9099 taken, err := cryptoPay.store.TakenAddresses(coin, now)
9100 if err != nil {
9101 log.Printf("invoice address reservation failed: %v", err)
9102 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to allocate an address"})
9103 }
9104 addr, err := cryptoPay.backend.NextAddress(ctx, taken)
9105 if err != nil {
9106 log.Printf("invoice address allocation failed: %v", err)
9107 return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Unable to allocate an address"})
9108 }
9109
9110 inv := &pay.Invoice{
9111 ID: newInvoiceID(), Coin: coin, Address: addr, Atomic: atomic,
9112 FiatCents: total, Rate: rate.FloatString(10), RateSource: source,
9113 Items: req.Items, Created: now, Expires: now.Add(cryptoPay.window),
9114 Status: pay.StatusPending,
9115 }
9116 if err := cryptoPay.store.Put(inv); err != nil {
9117 log.Printf("invoice save failed: %v", err)
9118 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save the invoice"})
9119 }
9120 return c.JSON(invoiceView(inv))
9121}
9122
9123// invoiceStatus reports one invoice, polling the chain first so a reader
9124// watching the page sees the payment as soon as it confirms.
9125func invoiceStatus(c fiber.Ctx) error {
9126 inv, err := cryptoPay.store.Get(c.Params("id"))
9127 if err != nil {
9128 return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "No such invoice"})
9129 }
9130 if inv.Status != pay.StatusPaid {
9131 ctx, cancel := context.WithTimeout(c.Context(), 15*time.Second)
9132 defer cancel()
9133 if err := refreshInvoice(ctx, inv); err != nil {
9134 log.Printf("invoice %s refresh failed: %v", inv.ID, err)
9135 }
9136 }
9137 return c.JSON(invoiceView(inv))
9138}
9139
9140// invoiceView is what a client is told: never the whole record, because the
9141// items and the rate source are the store's business, not the page's.
9142func invoiceView(inv *pay.Invoice) fiber.Map {
9143 return fiber.Map{
9144 "id": inv.ID,
9145 "coin": string(inv.Coin),
9146 "address": inv.Address,
9147 "amount": pay.AmountString(inv.Atomic, inv.Coin.Decimals()),
9148 "uri": inv.URI(),
9149 "fiat_cents": inv.FiatCents,
9150 "status": string(inv.Status),
9151 "paid": pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()),
9152 "outstanding": pay.AmountString(inv.Outstanding(), inv.Coin.Decimals()),
9153 "expires": inv.Expires.Format(time.RFC3339),
9154 "txid": inv.TxID,
9155 }
9156}
9157
9158// refreshInvoice asks the chain, records what it finds, and β the first time
9159// an invoice settles β writes the order and prints the receipt.
9160func refreshInvoice(ctx context.Context, inv *pay.Invoice) error {
9161 atomic, txid, confirmed, err := cryptoPay.backend.Received(ctx, inv.Address)
9162 if err != nil {
9163 return err
9164 }
9165 changed := inv.Apply(atomic, txid, confirmed, time.Now())
9166 if inv.Status == pay.StatusPaid && !inv.Mailed {
9167 inv.Mailed, changed = true, true
9168 go mailOrder(cryptoPay.ordersIn, string(inv.Coin)+"-"+inv.ID, coinPaidLine(inv), invoiceLocal(inv))
9169 }
9170 settled := inv.Status == pay.StatusPaid && !inv.Printed
9171 if settled {
9172 if err := completeInvoice(inv); err != nil {
9173 // The money arrived; failing to print must not lose that.
9174 log.Printf("invoice %s settled but could not be completed: %v", inv.ID, err)
9175 } else {
9176 inv.Printed = true
9177 changed = true
9178 }
9179 }
9180 if changed {
9181 return cryptoPay.store.Put(inv)
9182 }
9183 return nil
9184}
9185
9186// completeInvoice writes the order beside the card orders and prints it.
9187func completeInvoice(inv *pay.Invoice) error {
9188 local := invoiceLocal(inv)
9189 orderData := map[string]any{
9190 "clientData": local,
9191 "verifiedCents": inv.FiatCents,
9192 "currency": "usd",
9193 "cryptoStatus": string(inv.Status),
9194 "coin": string(inv.Coin),
9195 "coinAmount": pay.AmountString(inv.Atomic, inv.Coin.Decimals()),
9196 "coinPaid": pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()),
9197 "rateUSD": inv.Rate,
9198 "rateSource": inv.RateSource,
9199 "address": inv.Address,
9200 "txid": inv.TxID,
9201 "submittedAt": time.Now().Format(time.RFC3339),
9202 }
9203 data, err := json.MarshalIndent(orderData, "", " ")
9204 if err != nil {
9205 return err
9206 }
9207 id := string(inv.Coin) + "-" + inv.ID
9208 path := filepath.Join(cryptoPay.ordersIn, id+".json")
9209 if err := os.WriteFile(path, data, 0o600); err != nil {
9210 return err
9211 }
9212 receipt, err := buildReceipt(local, id)
9213 if err != nil {
9214 return err
9215 }
9216 if err := sendToCUPS(receipt, "Order "+id); err != nil {
9217 _ = os.WriteFile(filepath.Join(cryptoPay.ordersIn, id+".print_failed"), []byte(err.Error()), 0o600) //nolint:errcheck,gosec // best-effort marker; the failure is logged
9218 return err
9219 }
9220 return nil
9221}
9222
9223// WatchInvoices polls live invoices until ctx is done.
9224//
9225// The page polls too, but a reader who closes the tab still bought
9226// something: without this, an order paid after the last poll would sit in
9227// orders/ unprinted and unnoticed.
9228func WatchInvoices(ctx context.Context) {
9229 if cryptoPay == nil {
9230 return
9231 }
9232 t := time.NewTicker(time.Minute)
9233 defer t.Stop()
9234 for {
9235 select {
9236 case <-ctx.Done():
9237 return
9238 case <-t.C:
9239 live, err := cryptoPay.store.Live()
9240 if err != nil {
9241 log.Printf("invoice poll failed: %v", err)
9242 continue
9243 }
9244 for _, inv := range live {
9245 if err := refreshInvoice(ctx, inv); err != nil {
9246 log.Printf("invoice %s poll failed: %v", inv.ID, err)
9247 }
9248 }
9249 }
9250 }
9251}
9252
9253// errNeedShippingEmail is the one validation failure a customer can fix, so
9254// it is the one a client is told in words.
9255var errNeedShippingEmail = errors.New("shipping info with an email address is required")
9256
9257// validateOrderItems totals an order against the server's own catalog.
9258//
9259// Shared with the Stripe path: a client is never believed about a price in
9260// either. The shipping line is the one the client sets, so it is held to the
9261// same minimum the form enforces β otherwise a hand-written request could
9262// ship for a cent.
9263func validateOrderItems(items []pay.Item) (int64, error) {
9264 total := int64(0)
9265 var ship *storepage.Shipping
9266 for _, it := range items {
9267 if it.Amount <= 0 {
9268 return 0, fmt.Errorf("non-positive amount for %q", it.ID)
9269 }
9270 if storepage.IsShippingID(it.ID) {
9271 if it.Amount < int64(storepage.MinShippingCents) {
9272 return 0, fmt.Errorf("shipping below the minimum: %d cents", it.Amount)
9273 }
9274 if ship != nil {
9275 return 0, fmt.Errorf("more than one shipping line")
9276 }
9277 sh, ok := storepage.ParseShipping(it.ID)
9278 if !ok {
9279 return 0, fmt.Errorf("unreadable shipping line")
9280 }
9281 ship = &sh
9282 total += it.Amount
9283 continue
9284 }
9285 expected, err := validateItemAmount(it.ID, it.Amount)
9286 if err != nil {
9287 return 0, err
9288 }
9289 total += expected
9290 }
9291 // Every order ships, and every order is confirmed by email: the address
9292 // and the email travel together in the shipping line. Refused here, before
9293 // a payment or an invoice exists, rather than discovered after.
9294 if ship == nil || !storepage.ValidEmail(ship.Email) {
9295 return 0, errNeedShippingEmail
9296 }
9297 return total, nil
9298}
9299
9300// newInvoiceID is a random id: it names a file and appears in a URL, and
9301// must not be guessable from an order number.
9302func newInvoiceID() string {
9303 var b [12]byte
9304 if _, err := rand.Read(b[:]); err != nil {
9305 return fmt.Sprintf("%d", time.Now().UnixNano())
9306 }
9307 return hex.EncodeToString(b[:])
9308}
9309
9310// invoiceLocal is an invoice's lines in the shape a card order's client data
9311// has, for the receipt and the confirmation email.
9312func invoiceLocal(inv *pay.Invoice) map[string]any {
9313 items := make([]map[string]any, 0, len(inv.Items))
9314 for _, it := range inv.Items {
9315 items = append(items, map[string]any{"id": it.ID, "amount": it.Amount, "quantity": 1})
9316 }
9317 return map[string]any{"items": items}
9318}
9319
9320// coinPaidLine says how a coin order was paid, for the confirmation email.
9321func coinPaidLine(inv *pay.Invoice) string {
9322 s := fmt.Sprintf("%s %s (%s)", pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()), inv.Coin, dollars(inv.FiatCents))
9323 if inv.TxID != "" {
9324 s += ", transaction " + inv.TxID
9325 }
9326 return s
9327}
9328
9329
9330// ===== pkg/web/invoice_test.go =====
9331package web
9332
9333import (
9334 "errors"
9335 "testing"
9336
9337 "github.com/0magnet/m2/pkg/pay"
9338 "github.com/0magnet/m2/pkg/storepage"
9339)
9340
9341// Both payment paths total an order through this, so a client is never
9342// believed about a price in one and checked in the other.
9343func TestValidateOrderItemsHoldsShippingToTheMinimum(t *testing.T) {
9344 ship := storepage.Shipping{
9345 Cents: storepage.MinShippingCents, Name: "A", City: "Dallas",
9346 State: "TX", Country: storepage.DefaultCountry,
9347 Email: "a@example.com",
9348 }
9349
9350 // A shipping line at the minimum is fine.
9351 total, err := validateOrderItems([]pay.Item{
9352 {ID: ship.ID(), Amount: int64(storepage.MinShippingCents)},
9353 })
9354 if err != nil {
9355 t.Fatalf("the minimum was refused: %v", err)
9356 }
9357 if total != int64(storepage.MinShippingCents) {
9358 t.Errorf("total = %d, want %d", total, storepage.MinShippingCents)
9359 }
9360
9361 // A cent of shipping is not. The form enforces this in the browser;
9362 // a hand-written request does not go through the form.
9363 if _, err := validateOrderItems([]pay.Item{
9364 {ID: ship.ID(), Amount: 1},
9365 }); err == nil {
9366 t.Error("a one-cent shipping line was accepted")
9367 }
9368}
9369
9370func TestValidateOrderItemsRejectsNonPositive(t *testing.T) {
9371 if _, err := validateOrderItems([]pay.Item{{ID: "X-1 X 1", Amount: 0}}); err == nil {
9372 t.Error("a zero-amount line was accepted")
9373 }
9374 if _, err := validateOrderItems([]pay.Item{{ID: "X-1 X 1", Amount: -500}}); err == nil {
9375 t.Error("a negative line was accepted")
9376 }
9377}
9378
9379// An unknown part cannot be priced, so it cannot be sold.
9380func TestValidateOrderItemsRejectsUnknownPart(t *testing.T) {
9381 if _, err := validateOrderItems([]pay.Item{
9382 {ID: "NO-SUCH-PART-12345 X 1", Amount: 100},
9383 }); err == nil {
9384 t.Error("an unknown part was priced")
9385 }
9386}
9387
9388// Crypto is off unless a wallet is configured: a store with nothing set
9389// should show no button rather than one that errors.
9390func TestCryptoOffWithoutAnXpub(t *testing.T) {
9391 saved := f.SkyXpub
9392 f.SkyXpub = ""
9393 defer func() { f.SkyXpub = saved }()
9394 if got := initCrypto(t.TempDir()); got != nil {
9395 t.Error("crypto checkout came up with no xpub configured")
9396 }
9397}
9398
9399// ...and off when there is a wallet but no way to price it, rather than
9400// quoting from a guess.
9401func TestCryptoOffWithoutARate(t *testing.T) {
9402 savedX, savedP, savedF := f.SkyXpub, f.SkyRatePair, f.SkyRateFix
9403 f.SkyXpub = "xpub6DH2sNie8sDh7cUbZRXeMEHaJrU72g8UZ45Z8VS26oUc8NMZrfbgpWH1U1osnJDisro6sUDVNL6MbnsAmfDNXDLjC8UfRLuM2YSoPMeY4DS"
9404 f.SkyRatePair, f.SkyRateFix = "", ""
9405 defer func() { f.SkyXpub, f.SkyRatePair, f.SkyRateFix = savedX, savedP, savedF }()
9406 if got := initCrypto(t.TempDir()); got != nil {
9407 t.Error("crypto checkout came up with no rate source")
9408 }
9409}
9410
9411// No payment and no invoice without an email to confirm the order to: the
9412// shipping line carries it, and an order without one is refused before any
9413// money moves.
9414func TestValidateOrderItemsWantsAShippingEmail(t *testing.T) {
9415 ship := storepage.Shipping{Cents: storepage.MinShippingCents, Name: "A", Country: storepage.DefaultCountry}
9416 cases := map[string][]pay.Item{
9417 "no shipping line": {},
9418 "no email": {{ID: ship.ID(), Amount: int64(storepage.MinShippingCents)}},
9419 }
9420 ship.Email = "not an address"
9421 cases["a bad email"] = []pay.Item{{ID: ship.ID(), Amount: int64(storepage.MinShippingCents)}}
9422 for name, items := range cases {
9423 if _, err := validateOrderItems(items); !errors.Is(err, errNeedShippingEmail) {
9424 t.Errorf("%s: err = %v, want errNeedShippingEmail", name, err)
9425 }
9426 }
9427 ship.Email = "a@example.com"
9428 two := []pay.Item{{ID: ship.ID(), Amount: 700}, {ID: ship.ID(), Amount: 700}}
9429 if _, err := validateOrderItems(two); err == nil {
9430 t.Error("two shipping lines were accepted")
9431 }
9432}
9433
9434
9435// ===== pkg/web/navmenu.go =====
9436package web
9437
9438// The category menu's layout.
9439//
9440// The drawing β β/β branches, a β¬ where a category opens into subcategories,
9441// and the counts right-aligned by padding the rule itself β is tallytree's. It
9442// used to be written out twice: once here in htmpl/catsubcats.html with the
9443// repeat and sub template functions, and once again in pkg/tui for the terminal,
9444// whose comment read "reproduces htmpl/catsubcats.html". Two implementations of
9445// one picture, kept in agreement by hand and tested by neither.
9446//
9447// What stays here is the markup. The template asks for the rows and wraps each
9448// one in the disclosure elements and anchors it needs; it no longer works out
9449// where anything goes.
9450
9451import (
9452 htmpl "html/template"
9453
9454 "github.com/0magnet/tallytree"
9455)
9456
9457// navRow is one line of the menu: the glyphs that lead up to it, its count,
9458// and what it points at.
9459//
9460// Prefix is HTML because its blanks are non-breaking spaces. An ordinary space
9461// there would collapse and the tree would lose its columns.
9462type navRow struct {
9463 Prefix htmpl.HTML
9464 Count int
9465 Label string
9466 HasSubs bool
9467}
9468
9469// navCat is a category row together with the subcategory rows beneath it, which
9470// is the shape the markup needs: each category is a <details> holding its own
9471// subcategories.
9472type navCat struct {
9473 navRow
9474 Subs []navRow
9475}
9476
9477// navMenu is the whole menu: the "everything" row, then the categories.
9478type navMenu struct {
9479 All navRow
9480 Cats []navCat
9481}
9482
9483// navmenu lays the menu out for a page.
9484//
9485// Depth is pinned to two because the menu always COULD have subcategories, so
9486// it reserves room for them even in a catalog where nothing does β otherwise a
9487// flat catalog would draw a narrower tree than the same shop with one
9488// subcategory added, and the menu would change shape as stock came and went.
9489//
9490// Every branch is drawn open: the disclosure elements do the hiding, so the
9491// rows are always all there.
9492func navmenu(d htmlTemplateData) navMenu {
9493 forest := []tallytree.Node{{Label: "All Products", Count: d.LenAllProducts}}
9494 for _, cat := range d.Cats {
9495 n := tallytree.Node{Label: cat, Count: d.CatsCounts[cat]}
9496 for _, sub := range d.SubCatsByCat[cat] {
9497 n.Children = append(n.Children, tallytree.Node{Label: sub, Count: d.SubCatsCounts[cat][sub]})
9498 }
9499 forest = append(forest, n)
9500 }
9501 rows := tallytree.Rows(forest, tallytree.Options{
9502 Depth: 2,
9503 Glyphs: tallytree.GlyphSet{Blank: " "},
9504 })
9505 var m navMenu
9506 for i, r := range rows {
9507 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
9508 switch {
9509 case i == 0:
9510 m.All = row
9511 case r.Depth == 0:
9512 m.Cats = append(m.Cats, navCat{navRow: row})
9513 default:
9514 m.Cats[len(m.Cats)-1].Subs = append(m.Cats[len(m.Cats)-1].Subs, row)
9515 }
9516 }
9517 return m
9518}
9519
9520
9521// ===== pkg/web/order.go =====
9522// Package web pkg/web/order.go β checkout, order persistence, receipt printing.
9523package web
9524
9525import (
9526 "bytes"
9527 "encoding/json"
9528 "errors"
9529 "fmt"
9530 htmpl "html/template"
9531 "log"
9532 "os"
9533 "path/filepath"
9534 "regexp"
9535 "strconv"
9536 "strings"
9537 "time"
9538
9539 "github.com/bitfield/script"
9540 "github.com/gofiber/fiber/v3"
9541 "github.com/stripe/stripe-go/v81"
9542 "github.com/stripe/stripe-go/v81/paymentintent"
9543
9544 "github.com/0magnet/m2/pkg/pay"
9545)
9546
9547// validPIID matches Stripe PaymentIntent IDs: "pi_" followed by alphanumeric chars.
9548// Also allows plain alphanumeric+underscore+hyphen for test order IDs.
9549var validPIID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
9550
9551func handleOrder(r *fiber.App) {
9552 r.Get("/checkout.css", func(c fiber.Ctx) error {
9553 c.Set("Content-Type", "text/css;charset=utf-8")
9554 _, err := c.Status(fiber.StatusOK).Write([]byte(h.CheckoutCSS()))
9555 return err
9556 })
9557
9558 r.Get("/complete", func(c fiber.Ctx) error {
9559 // Complete template
9560 completetmpl := htmpl.New("index")
9561 if _, err := completetmpl.Parse(h.CompletePage()); err != nil {
9562 msg := fmt.Sprintf("Error parsing complete page template: %v", err)
9563 log.Println(msg)
9564 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9565 }
9566 if _, err := completetmpl.New("wasm").Parse(h.Wasm()); err != nil {
9567 log.Println("Error parsing wasm template:", err)
9568 msg := fmt.Sprintf("Error parsing wasm template: %v", err)
9569 log.Println(msg)
9570 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9571 }
9572 h1 := htmlPageTemplateData
9573 // The checkout completion page is what the cart wasm drives: it
9574 // fills in the payment status icon, text and intent id.
9575 h1.WasmBinary = wasmFor("cart")
9576 /*
9577 proto := "http"
9578 if c.Secure() {
9579 proto += "s"
9580 }
9581 */
9582 proto := "https"
9583 h1.Canonical = proto + `://` + c.Hostname() + c.OriginalURL()
9584 h1.BaseURL = proto + `://` + c.Hostname()
9585 h1.RequestHost = c.Hostname()
9586 h1.Protocol = proto
9587 h1.Time = time.Now().Format(time.RFC3339Nano)
9588 h1.Year = fmt.Sprintf("%v", time.Now().Year())
9589 tmplData := map[string]interface{}{
9590 "Page": h1,
9591 }
9592 var result bytes.Buffer
9593 err := completetmpl.Execute(&result, tmplData)
9594 if err != nil {
9595 msg := fmt.Sprintf("Could not execute html template %v", err)
9596 log.Println(msg)
9597 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9598 }
9599 c.Set("Content-Type", "text/html;charset=utf-8")
9600 return c.Status(fiber.StatusOK).Send(result.Bytes())
9601 })
9602
9603 r.Get("/order/:piid", func(c fiber.Ctx) error {
9604 piid := c.Params("piid")
9605 if !validPIID.MatchString(piid) {
9606 return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
9607 }
9608 order, err := script.File("orders/" + piid + ".json").Bytes()
9609 if err != nil {
9610 return c.Status(fiber.StatusNotFound).SendString("Order not found")
9611 }
9612 return c.Status(fiber.StatusOK).Send(order)
9613 })
9614
9615 r.Get("/order/:piid/html", func(c fiber.Ctx) error {
9616 piid := c.Params("piid")
9617 if !validPIID.MatchString(piid) {
9618 return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
9619 }
9620 order, err := script.File("orders/" + piid + ".json").Bytes()
9621 if err != nil {
9622 return c.Status(fiber.StatusNotFound).SendString("Order not found")
9623 }
9624 var m map[string]interface{}
9625 if err := json.Unmarshal(order, &m); err != nil {
9626 return c.Status(500).SendString("failed to unmarshal order json: " + err.Error())
9627 }
9628 receipt, err := buildReceipt(m, piid)
9629 if err != nil {
9630 return c.Status(500).SendString("failed to build receipt: " + err.Error())
9631 }
9632 return c.Status(200).SendString(string(receipt))
9633 })
9634
9635 r.Post("/create-payment-intent", func(c fiber.Ctx) error {
9636 rawBody := c.Body()
9637 if rawBody == nil {
9638 log.Printf("Failed to read raw request body")
9639 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to read request body"})
9640 }
9641
9642 var req struct {
9643 Items []item `json:"items"`
9644 }
9645 if err := json.Unmarshal(rawBody, &req); err != nil {
9646 log.Printf("Failed to parse JSON: %v", err)
9647 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
9648 }
9649
9650 if len(req.Items) == 0 {
9651 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
9652 }
9653
9654 // Validate each item's amount against the server-side product
9655 // catalog. Shared with the crypto path (see invoice.go) so a client
9656 // is never believed about a price in one and checked in the other.
9657 items := make([]pay.Item, 0, len(req.Items))
9658 for _, it := range req.Items {
9659 items = append(items, pay.Item{ID: it.ID, Amount: it.Amount})
9660 }
9661 total, err := validateOrderItems(items)
9662 if err != nil {
9663 log.Printf("Item validation failed: %v", err)
9664 if errors.Is(err, errNeedShippingEmail) {
9665 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
9666 }
9667 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
9668 }
9669
9670 if total < 50 {
9671 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
9672 }
9673
9674 params := &stripe.PaymentIntentParams{
9675 Amount: stripe.Int64(total),
9676 Currency: stripe.String(string(stripe.CurrencyUSD)),
9677 }
9678 pi, err := paymentintent.New(params)
9679 if err != nil {
9680 log.Printf("Failed to create PaymentIntent: %v", err)
9681 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
9682 }
9683
9684 log.Printf("Created PaymentIntent %s for %d cents", pi.ID, total)
9685 return c.Status(fiber.StatusOK).JSON(fiber.Map{
9686 "clientSecret": pi.ClientSecret,
9687 "dpmCheckerLink": fmt.Sprintf("https://dashboard.stripe.com/settings/payment_methods/review?transaction_id=%s", pi.ID),
9688 })
9689 })
9690
9691 r.Post("/submit-order", func(c fiber.Ctx) error {
9692 var requestData struct {
9693 LocalStorageData map[string]interface{} `json:"localStorageData"`
9694 PaymentIntentID string `json:"paymentIntentId"`
9695 }
9696
9697 if err := c.Bind().Body(&requestData); err != nil {
9698 log.Println(err)
9699 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request data"})
9700 }
9701
9702 if !validPIID.MatchString(requestData.PaymentIntentID) {
9703 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payment intent ID"})
9704 }
9705
9706 log.Printf("Received payment intent ID: %s\n", requestData.PaymentIntentID)
9707
9708 paymentIntent, err := paymentintent.Get(requestData.PaymentIntentID, nil)
9709 if err != nil {
9710 log.Printf("Error retrieving payment intent: %v", err)
9711 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to verify payment"})
9712 }
9713 if paymentIntent.Status != stripe.PaymentIntentStatusSucceeded {
9714 log.Printf("Payment was not successful, status: %s", paymentIntent.Status)
9715 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Payment not successful"})
9716 }
9717
9718 ordersDir := "./orders"
9719 if err := os.MkdirAll(ordersDir, 0o750); err != nil {
9720 log.Printf("Error creating orders directory: %v", err)
9721 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
9722 }
9723
9724 filePath := filepath.Join(ordersDir, fmt.Sprintf("%s.json", requestData.PaymentIntentID))
9725
9726 // Idempotency: if the order file already exists, don't overwrite or reprint
9727 if _, err := os.Stat(filePath); err == nil {
9728 log.Printf("Order %s already exists, skipping duplicate submission", requestData.PaymentIntentID)
9729 return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order already submitted"})
9730 }
9731
9732 // Include the verified Stripe amount alongside the client-supplied data
9733 orderData := map[string]interface{}{
9734 "clientData": requestData.LocalStorageData,
9735 "verifiedCents": paymentIntent.Amount,
9736 "currency": string(paymentIntent.Currency),
9737 "stripeStatus": string(paymentIntent.Status),
9738 "submittedAt": time.Now().Format(time.RFC3339),
9739 }
9740
9741 data, err := json.MarshalIndent(orderData, "", " ")
9742 if err != nil {
9743 log.Printf("Error marshaling data to json: %v", err)
9744 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
9745 }
9746 if err := os.WriteFile(filePath, data, 0o600); err != nil {
9747 log.Printf("Error writing data to file: %v", err)
9748 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
9749 }
9750
9751 // ---- Print the receipt via CUPS and email the confirmation, off the
9752 // request so the response is snappy
9753 paid := paymentIntent.Amount
9754 go func(pid string, local map[string]interface{}) {
9755 receipt, err := buildReceipt(local, pid)
9756 if err != nil {
9757 log.Printf("build receipt failed: %v", err)
9758 return
9759 }
9760 if err := sendToCUPS(receipt, "Order "+pid); err != nil {
9761 log.Printf("print failed: %v", err)
9762 _ = 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
9763 }
9764 mailOrder(ordersDir, pid, dollars(paid)+" by card", local)
9765 }(requestData.PaymentIntentID, requestData.LocalStorageData)
9766
9767 return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order submitted successfully"})
9768 })
9769
9770 /*
9771 r.Post("/reprint/:pid", func(c fiber.Ctx) error {
9772 pid := c.Params("pid")
9773 b, err := os.ReadFile(filepath.Join("./orders", pid+".json"))
9774 if err != nil { return c.Status(404).SendString("not found") }
9775 var m map[string]interface{}
9776 if err := json.Unmarshal(b, &m); err != nil { return c.Status(500).SendString(err.Error()) }
9777 receipt, err := buildReceipt(m, pid)
9778 if err != nil { return c.Status(500).SendString(err.Error()) }
9779 if err := sendToCUPS(receipt, "Order "+pid); err != nil {
9780 return c.Status(500).SendString(err.Error())
9781 }
9782 return c.SendStatus(204)
9783 })
9784 */
9785}
9786
9787func buildReceipt(local map[string]interface{}, paymentIntentID string) ([]byte, error) {
9788 // Pretty JSON body from what you already persisted
9789 body, err := json.MarshalIndent(local, "", " ")
9790 if err != nil {
9791 return nil, err
9792 }
9793 // Simple text receipt header
9794 ts := time.Now().Format("2006-01-02 15:04:05")
9795 hdr := fmt.Sprintf(
9796 "==================== ORDER ====================\n"+
9797 "PaymentIntent: %s\nTime: %s\n===============================================\n\n",
9798 paymentIntentID, ts,
9799 )
9800 // Footer (optional)
9801 ftr := "\n\n---------------------- END ---------------------\n"
9802 receipt := append([]byte(hdr), body...)
9803 receipt = append(receipt, []byte(ftr)...)
9804 return receipt, nil
9805}
9806
9807// serverPriceCents looks up a product's price from the server-side catalog by part number.
9808func serverPriceCents(partno string) (int64, error) {
9809 allproductsMu.RLock()
9810 prods := allproducts
9811 allproductsMu.RUnlock()
9812 for _, prod := range prods {
9813 if prod.Partno == partno {
9814 return parsePriceCents(prod.Price), nil
9815 }
9816 }
9817 return 0, fmt.Errorf("product %q not found in catalog", partno)
9818}
9819
9820// parsePriceCents converts a price string like "$1.23" or "1.23" to cents.
9821func parsePriceCents(s string) int64 {
9822 if s == "" {
9823 return 0
9824 }
9825 s = strings.TrimPrefix(s, "$")
9826 f, err := strconv.ParseFloat(s, 64)
9827 if err != nil {
9828 return 0
9829 }
9830 if f < 0 {
9831 return -int64(-f*100 + 0.5)
9832 }
9833 return int64(f*100 + 0.5)
9834}
9835
9836// validateItemAmount parses a client item ID ("partno X qty"), looks up the
9837// server-side price, computes the expected total, and returns it. If the
9838// client-supplied amount doesn't match, an error is returned.
9839func validateItemAmount(itemID string, clientAmount int64) (int64, error) {
9840 // Parse "partno X qty"
9841 parts := strings.SplitN(itemID, " X ", 2)
9842 if len(parts) != 2 {
9843 return 0, fmt.Errorf("unexpected item ID format: %q", itemID)
9844 }
9845 partno := parts[0]
9846 qty, err := strconv.Atoi(parts[1])
9847 if err != nil || qty <= 0 {
9848 return 0, fmt.Errorf("invalid quantity in item ID %q", itemID)
9849 }
9850
9851 unitCents, err := serverPriceCents(partno)
9852 if err != nil {
9853 return 0, err
9854 }
9855 expected := unitCents * int64(qty)
9856 if expected != clientAmount {
9857 return 0, fmt.Errorf("amount mismatch for %q: client sent %d cents, server expects %d cents", partno, clientAmount, expected)
9858 }
9859 return expected, nil
9860}
9861
9862// escape for inclusion inside *double quotes* in a bash command string
9863func bashEscapeDoubleQuoted(s string) string {
9864 s = strings.ReplaceAll(s, `\`, `\\`)
9865 s = strings.ReplaceAll(s, `"`, `\"`)
9866 s = strings.ReplaceAll(s, "$", `\$`)
9867 s = strings.ReplaceAll(s, "`", "\\`")
9868 return s
9869}
9870
9871func sendToCUPS(receipt []byte, title string) error {
9872 if title == "" {
9873 title = "Order"
9874 }
9875 var cmd strings.Builder
9876 cmd.WriteString("lp")
9877
9878 if f.PrinterName != "" {
9879 cmd.WriteString(` -d "`)
9880 cmd.WriteString(bashEscapeDoubleQuoted(f.PrinterName))
9881 cmd.WriteString(`"`)
9882 }
9883
9884 cmd.WriteString(` -t "`)
9885 cmd.WriteString(bashEscapeDoubleQuoted(title))
9886 cmd.WriteString(`"`)
9887
9888 if f.CupsOptions != "" {
9889 for _, opt := range strings.Split(f.CupsOptions, ",") {
9890 opt = strings.TrimSpace(opt)
9891 if opt == "" {
9892 continue
9893 }
9894 cmd.WriteString(` -o "`)
9895 cmd.WriteString(bashEscapeDoubleQuoted(opt))
9896 cmd.WriteString(`"`)
9897 }
9898 }
9899
9900 full := fmt.Sprintf(`bash -lc %q`, cmd.String())
9901
9902 _, err := script.Echo(string(receipt)).Exec(full).Stdout()
9903 if err != nil {
9904 return fmt.Errorf("lp failed: %v", err)
9905 }
9906 return nil
9907}
9908
9909
9910// ===== pkg/web/ordermail.go =====
9911package web
9912
9913// pkg/web/ordermail.go β the order confirmation email.
9914//
9915// Sent when an order is PAID, from the same two places the receipt prints:
9916// /submit-order for a card, and the first poll that finds an invoice settled
9917// for coin. To the customer, at the address on the shipping line, with a
9918// blind copy to the store β so the store's inbox holds every order, as the
9919// printer tray does.
9920//
9921// It is handed to the system's sendmail (SENDMAIL), once per recipient, and
9922// the mail server takes it from there: queueing, retries, signing, the relay.
9923// The store holds no mail credential; the sendmail command's own
9924// configuration does. Once per recipient because mox's sendmail takes one,
9925// and because the store's copy is a blind one only if it is a separate
9926// delivery: a Bcc header handed to it would be sent on as it is.
9927//
9928// The customer's email never goes to Stripe. It travels in the shipping
9929// line, as the address does, and only this server reads it.
9930//
9931// Off unless MAILFROM and SENDMAIL are set, so a deployment that has not
9932// configured mail sends none rather than failing every order.
9933
9934import (
9935 "bytes"
9936 "context"
9937 "crypto/rand"
9938 "encoding/hex"
9939 "errors"
9940 "fmt"
9941 "log"
9942 "mime"
9943 "mime/quotedprintable"
9944 "net/mail"
9945 "os"
9946 "os/exec"
9947 "path/filepath"
9948 "strconv"
9949 "strings"
9950 "time"
9951
9952 "github.com/0magnet/m2/pkg/storepage"
9953)
9954
9955// orderLine is one line of a paid order, read back from what was stored.
9956type orderLine struct {
9957 ID string
9958 Qty int
9959 Cents int64
9960}
9961
9962// orderLines reads the lines out of an order's client data: "cartItems"
9963// for a card order (the website's and the terminal's saved cart alike) and
9964// "items" for a coin order (completeInvoice). Numbers arrive as JSON's
9965// float64, or as int64 when the map was built here.
9966func orderLines(local map[string]any) []orderLine {
9967 raw, _ := local["cartItems"].([]any)
9968 if raw == nil {
9969 raw, _ = local["items"].([]any)
9970 }
9971 if raw == nil {
9972 if ms, ok := local["items"].([]map[string]any); ok {
9973 for _, m := range ms {
9974 raw = append(raw, m)
9975 }
9976 }
9977 }
9978 var out []orderLine
9979 for _, r := range raw {
9980 m, ok := r.(map[string]any)
9981 if !ok {
9982 continue
9983 }
9984 id, _ := m["id"].(string)
9985 if id == "" {
9986 continue
9987 }
9988 l := orderLine{ID: id, Qty: int(num(m["quantity"])), Cents: int64(num(m["amount"]))}
9989 // A coin order's ids carry the quantity, "partno X qty".
9990 if p, q, ok := strings.Cut(id, " X "); ok && !storepage.IsShippingID(id) {
9991 if n, err := strconv.Atoi(q); err == nil {
9992 l.ID, l.Qty = p, n
9993 }
9994 }
9995 if l.Qty <= 0 {
9996 l.Qty = 1
9997 }
9998 out = append(out, l)
9999 }
10000 return out
10001}
10002
10003func num(v any) float64 {
10004 switch n := v.(type) {
10005 case float64:
10006 return n
10007 case int:
10008 return float64(n)
10009 case int64:
10010 return float64(n)
10011 }
10012 return 0
10013}
10014
10015// shippingOf is the order's shipping line, read back.
10016func shippingOf(lines []orderLine) (storepage.Shipping, bool) {
10017 for _, l := range lines {
10018 if storepage.IsShippingID(l.ID) {
10019 return storepage.ParseShipping(l.ID)
10020 }
10021 }
10022 return storepage.Shipping{}, false
10023}
10024
10025// productName is the catalog's name for a part, or "" when it has none.
10026func productName(partno string) string {
10027 allproductsMu.RLock()
10028 defer allproductsMu.RUnlock()
10029 for _, p := range allproducts {
10030 if p.Partno == partno {
10031 return p.Name
10032 }
10033 }
10034 return ""
10035}
10036
10037func dollars(c int64) string { return fmt.Sprintf("$%d.%02d", c/100, c%100) }
10038
10039// orderMailBody is the confirmation's text. paid says how, in a line:
10040// "$12.34 by card", or the coin amount and transaction.
10041func orderMailBody(site, orderID, paid string, lines []orderLine) string {
10042 var b strings.Builder
10043 fmt.Fprintf(&b, "Thank you for your order from %s.\n\n", site)
10044 fmt.Fprintf(&b, "Order: %s\n", orderID)
10045 fmt.Fprintf(&b, "Paid: %s\n\n", paid)
10046 var total int64
10047 for _, l := range lines {
10048 total += l.Cents
10049 if storepage.IsShippingID(l.ID) {
10050 fmt.Fprintf(&b, " %-44s %9s\n", "Shipping", dollars(l.Cents))
10051 continue
10052 }
10053 desc := l.ID
10054 if n := productName(l.ID); n != "" {
10055 desc += " " + n
10056 }
10057 if r := []rune(desc); len(r) > 44 {
10058 desc = string(r[:43]) + "β¦"
10059 }
10060 fmt.Fprintf(&b, " %3d x %-44s %9s\n", l.Qty, desc, dollars(l.Cents))
10061 }
10062 fmt.Fprintf(&b, " %44s %9s\n\n", "Total", dollars(total))
10063 if sh, ok := shippingOf(lines); ok {
10064 b.WriteString("Ships to:\n")
10065 for _, s := range []string{
10066 sh.Name, sh.Address,
10067 strings.TrimSpace(strings.Join([]string{sh.City + ",", sh.State, sh.Zip}, " ")),
10068 sh.Country, sh.Phone,
10069 } {
10070 if s = strings.Trim(s, " ,"); s != "" {
10071 b.WriteString(" " + s + "\n")
10072 }
10073 }
10074 b.WriteString("\n")
10075 }
10076 b.WriteString("Questions about this order? Reply to this email.\n")
10077 return b.String()
10078}
10079
10080// mailOrder sends the confirmation for a paid order, and leaves a marker
10081// beside the order when it cannot: the money arrived, so a mail failure is
10082// logged and marked, never allowed to lose the order.
10083func mailOrder(ordersDir, orderID, paid string, local map[string]any) {
10084 if f.MailFrom == "" || f.Sendmail == "" {
10085 return
10086 }
10087 lines := orderLines(local)
10088 to := ""
10089 name := ""
10090 if sh, ok := shippingOf(lines); ok && storepage.ValidEmail(sh.Email) {
10091 to, name = sh.Email, sh.Name
10092 }
10093 msg, rcpts, err := buildOrderMail(f.Sitedomain, orderID, paid, lines, to, name)
10094 if err == nil {
10095 err = sendMail(msg, rcpts)
10096 }
10097 if err != nil {
10098 log.Printf("order %s: confirmation email failed: %v", orderID, err)
10099 _ = os.WriteFile(filepath.Join(ordersDir, orderID+".mail_failed"), []byte(err.Error()), 0o600) //nolint:errcheck,gosec // best-effort marker; the failure is logged
10100 return
10101 }
10102 log.Printf("order %s: confirmation emailed (%d recipients)", orderID, len(rcpts))
10103}
10104
10105// buildOrderMail composes the message and who it goes to. With no customer
10106// address the store's copy is the only one, and its subject says so.
10107func buildOrderMail(site, orderID, paid string, lines []orderLine, to, name string) ([]byte, []string, error) {
10108 var rcpts []string
10109 if to != "" {
10110 rcpts = append(rcpts, to)
10111 }
10112 if f.MailBcc != "" {
10113 rcpts = append(rcpts, f.MailBcc)
10114 }
10115 if len(rcpts) == 0 {
10116 return nil, nil, fmt.Errorf("no recipient: the order has no email and MAILBCC is unset")
10117 }
10118 subject := fmt.Sprintf("Your order from %s (%s)", site, orderID)
10119 toHeader := (&mail.Address{Name: name, Address: to}).String()
10120 if to == "" {
10121 subject = "[no customer email] Order " + orderID
10122 toHeader = (&mail.Address{Address: f.MailBcc}).String()
10123 }
10124 from := (&mail.Address{Name: site, Address: f.MailFrom}).String()
10125
10126 var body bytes.Buffer
10127 qp := quotedprintable.NewWriter(&body)
10128 if _, err := qp.Write([]byte(strings.ReplaceAll(orderMailBody(site, orderID, paid, lines), "\n", "\r\n"))); err != nil {
10129 return nil, nil, err
10130 }
10131 if err := qp.Close(); err != nil {
10132 return nil, nil, err
10133 }
10134
10135 var m bytes.Buffer
10136 hdr := func(k, v string) { m.WriteString(k + ": " + v + "\r\n") }
10137 hdr("From", from)
10138 hdr("To", toHeader)
10139 hdr("Subject", mime.QEncoding.Encode("utf-8", subject))
10140 hdr("Date", time.Now().Format(time.RFC1123Z))
10141 hdr("Message-ID", "<"+orderID+"."+randHex(6)+"@"+domainOf(f.MailFrom)+">")
10142 hdr("MIME-Version", "1.0")
10143 hdr("Content-Type", "text/plain; charset=utf-8")
10144 hdr("Content-Transfer-Encoding", "quoted-printable")
10145 m.WriteString("\r\n")
10146 m.Write(body.Bytes())
10147 return m.Bytes(), rcpts, nil
10148}
10149
10150func domainOf(addr string) string {
10151 if _, d, ok := strings.Cut(addr, "@"); ok {
10152 return d
10153 }
10154 return "localhost"
10155}
10156
10157func randHex(n int) string {
10158 b := make([]byte, n)
10159 if _, err := rand.Read(b); err != nil {
10160 return strconv.FormatInt(time.Now().UnixNano(), 16)
10161 }
10162 return hex.EncodeToString(b)
10163}
10164
10165// sendMail hands msg to the sendmail command, once for each recipient.
10166// Every recipient is tried: the store's copy still goes when the customer's
10167// address is refused.
10168func sendMail(msg []byte, rcpts []string) error {
10169 argv := strings.Fields(f.Sendmail)
10170 var errs []error
10171 for _, r := range rcpts {
10172 ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
10173 cmd := exec.CommandContext(ctx, argv[0], append(argv[1:len(argv):len(argv)], r)...) //nolint:gosec // the command is the operator's configuration; r passed ValidEmail or is MAILBCC
10174 cmd.Stdin = bytes.NewReader(msg)
10175 out, err := cmd.CombinedOutput()
10176 cancel()
10177 if err != nil {
10178 errs = append(errs, fmt.Errorf("%s: %w: %s", r, err, strings.TrimSpace(string(out))))
10179 }
10180 }
10181 return errors.Join(errs...)
10182}
10183
10184
10185// ===== pkg/web/ordermail_test.go =====
10186package web
10187
10188import (
10189 "io"
10190 "mime/quotedprintable"
10191 "os"
10192 "path/filepath"
10193 "strings"
10194 "testing"
10195
10196 "github.com/0magnet/m2/pkg/storepage"
10197)
10198
10199// fakeSendmail is a sendmail that keeps what it is given: one file per
10200// recipient, named for it, holding the message. fail makes it refuse that
10201// recipient as a real one would.
10202func fakeSendmail(t *testing.T, fail string) (cmd, dir string) {
10203 t.Helper()
10204 dir = t.TempDir()
10205 script := filepath.Join(dir, "sendmail")
10206 body := "#!/bin/sh\n" +
10207 "[ \"$2\" = '" + fail + "' ] && { echo refused >&2; exit 1; }\n" +
10208 "cat > \"" + dir + "/to-$2\"\n"
10209 if err := os.WriteFile(script, []byte(body), 0o700); err != nil { //nolint:gosec // a test script must be executable
10210 t.Fatal(err)
10211 }
10212 return script + " -i", dir
10213}
10214
10215func withMailConfig(t *testing.T, sendmail string) {
10216 t.Helper()
10217 saved := [4]string{f.MailFrom, f.MailBcc, f.Sendmail, f.Sitedomain}
10218 f.MailFrom, f.MailBcc, f.Sendmail, f.Sitedomain = "orders@shop.example", "inbox@shop.example", sendmail, "shop.example"
10219 t.Cleanup(func() { f.MailFrom, f.MailBcc, f.Sendmail, f.Sitedomain = saved[0], saved[1], saved[2], saved[3] })
10220}
10221
10222func delivered(t *testing.T, dir, rcpt string) string {
10223 t.Helper()
10224 b, err := os.ReadFile(filepath.Join(dir, "to-"+rcpt)) //nolint:gosec // a test temp dir
10225 if err != nil {
10226 t.Fatalf("nothing was handed over for %s: %v", rcpt, err)
10227 }
10228 return string(b)
10229}
10230
10231// A paid card order goes to the customer, and separately to the store: the
10232// store's copy is blind because it is its own delivery, and no header names it.
10233func TestMailOrderReachesTheCustomerAndTheStore(t *testing.T) {
10234 cmd, dir := fakeSendmail(t, "")
10235 withMailConfig(t, cmd)
10236 ship := storepage.Shipping{
10237 Name: "Ada Buyer", Address: "1 Main St", City: "Dallas", State: "TX", Zip: "75001",
10238 Country: storepage.DefaultCountry, Email: "ada@example.org",
10239 }
10240 local := map[string]any{"cartItems": []any{
10241 map[string]any{"id": "PN-1", "amount": float64(300), "quantity": float64(2)},
10242 map[string]any{"id": ship.ID(), "amount": float64(700), "quantity": float64(1)},
10243 }}
10244 mailOrder(dir, "pi_123", "$10.00 by card", local)
10245
10246 msg := delivered(t, dir, "ada@example.org")
10247 if delivered(t, dir, "inbox@shop.example") != msg {
10248 t.Error("the store's copy differs from the customer's")
10249 }
10250 head, body, _ := strings.Cut(msg, "\r\n\r\n")
10251 if strings.Contains(head, "inbox@shop.example") {
10252 t.Errorf("the store's copy is named in the headers:\n%s", head)
10253 }
10254 for _, want := range []string{"From: \"shop.example\" <orders@shop.example>", "To: \"Ada Buyer\" <ada@example.org>", "pi_123"} {
10255 if !strings.Contains(head, want) {
10256 t.Errorf("headers lack %q:\n%s", want, head)
10257 }
10258 }
10259 text, err := io.ReadAll(quotedprintable.NewReader(strings.NewReader(body)))
10260 if err != nil {
10261 t.Fatal(err)
10262 }
10263 for _, want := range []string{"2 x PN-1", "$3.00", "Shipping", "$7.00", "$10.00", "Ada Buyer", "Dallas, TX 75001"} {
10264 if !strings.Contains(string(text), want) {
10265 t.Errorf("body lacks %q:\n%s", want, text)
10266 }
10267 }
10268}
10269
10270// An order with no email still reaches the store, saying so.
10271func TestMailOrderWithoutACustomerEmailGoesToTheStore(t *testing.T) {
10272 cmd, dir := fakeSendmail(t, "")
10273 withMailConfig(t, cmd)
10274 local := map[string]any{"items": []map[string]any{{"id": "PN-1 X 3", "amount": int64(900), "quantity": 1}}}
10275 mailOrder(dir, "SKY-abc", "1 SKY ($9.00)", local)
10276 msg := delivered(t, dir, "inbox@shop.example")
10277 if !strings.Contains(msg, "[no customer email]") || !strings.Contains(msg, "3 x PN-1") {
10278 t.Errorf("store copy:\n%s", msg)
10279 }
10280}
10281
10282// A refused customer address does not cost the store its copy, and leaves
10283// a marker beside the order.
10284func TestMailOrderMarksAFailureAndStillCopiesTheStore(t *testing.T) {
10285 cmd, dir := fakeSendmail(t, "ada@example.org")
10286 withMailConfig(t, cmd)
10287 ship := storepage.Shipping{Name: "A", Email: "ada@example.org"}
10288 mailOrder(dir, "pi_9", "$1.00 by card", map[string]any{"cartItems": []any{
10289 map[string]any{"id": ship.ID(), "amount": float64(700), "quantity": float64(1)},
10290 }})
10291 delivered(t, dir, "inbox@shop.example")
10292 b, err := os.ReadFile(filepath.Join(dir, "pi_9.mail_failed")) //nolint:gosec // a test temp dir
10293 if err != nil || !strings.Contains(string(b), "refused") {
10294 t.Errorf("failure marker: %q, %v", b, err)
10295 }
10296}
10297
10298// Unconfigured, nothing is sent and nothing is marked.
10299func TestMailOrderOffByDefault(t *testing.T) {
10300 withMailConfig(t, "")
10301 dir := t.TempDir()
10302 mailOrder(dir, "pi_0", "", map[string]any{})
10303 if ents, _ := os.ReadDir(dir); len(ents) != 0 { //nolint:errcheck // an unreadable dir fails the check anyway
10304 t.Errorf("unconfigured mail left %d files", len(ents))
10305 }
10306}
10307
10308
10309// ===== pkg/web/other.go =====
10310// Package web pkg/web/other.go β site-specific drop-in routes (not
10311// committed). Registers via the extraRoutes registry in server.go;
10312// deleting this file removes these routes with no other code changes.
10313// See other.go.example.
10314package web
10315
10316import (
10317 "bytes"
10318 "fmt"
10319 "log"
10320 "strconv"
10321 "strings"
10322
10323 "github.com/gofiber/fiber/v3"
10324)
10325
10326func init() {
10327 extraRoutes = append(extraRoutes, handleOthers)
10328 // The software page is the only one here worth crawling; see demospage.
10329 // /demos is kept as the older spelling of the same page.
10330 extraSitemapURLs = append(extraSitemapURLs, "/software")
10331 extraNavLinks = append(extraNavLinks, NavLink{
10332 Title: "Software",
10333 Href: "/software",
10334 Desc: "programs written here, running in your browser",
10335 })
10336}
10337
10338func handleOthers(r *fiber.App) {
10339 r.Get("/coffee", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusTeapot) })
10340 r.Get("/clock", clock)
10341 r.Get("/attractors", attractorspage)
10342 r.Get("/COVID", covidpage)
10343 r.Get("/test", testpage)
10344 r.Get("/software", demospage)
10345 r.Get("/software/", demospage)
10346 // The page was published as /demos first and is linked from the READMEs
10347 // of several repositories, so that spelling keeps working.
10348 r.Get("/demos", demospage)
10349 r.Get("/demos/", demospage)
10350}
10351
10352func clock(c fiber.Ctx) error {
10353 c.Set("Content-Type", "text/html;charset=utf-8")
10354 _, err := c.Status(fiber.StatusOK).Write([]byte(mustReadFileToString("content/clock.html")))
10355 return err
10356}
10357
10358func covidpage(c fiber.Ctx) error {
10359 tmpl, err := mainTmpl()
10360 if err != nil {
10361 msg := fmt.Sprintf("Error parse html template: %v", err)
10362 log.Println(msg)
10363 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10364 }
10365 tmpl0, err := tmpl.Clone()
10366 if err != nil {
10367 msg := fmt.Sprintf("Error cloning template: %v", err)
10368 log.Println(msg)
10369 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10370 }
10371 _, err = tmpl0.New("main").Parse(mustReadFileToString("content/mementomori.html"))
10372 if err != nil {
10373 msg := fmt.Sprintf("Error parsing main template: %v", err)
10374 log.Println(msg)
10375 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10376 }
10377 tmpl = tmpl0
10378 log.Println(c.Get("User-Agent"))
10379 c.Set("Content-Type", "text/html;charset=utf-8")
10380 h1 := pageMeta(c, htmlPageTemplateData)
10381 h1.Page = "hidden"
10382 h1.MetaDesc = "The COVID ΜΆvΜΆaΜΆcΜΆcΜΆiΜΆnΜΆeΜΆ bioweapon injection genocide and the new dark age of humanity"
10383 // h1.Mobile = strings.Contains(strings.ToLower(c.Get("User-Agent")), "mobile")
10384 tmplData := map[string]interface{}{
10385 "Page": h1,
10386 "Prods": allproducts,
10387 }
10388 var result bytes.Buffer
10389 err = tmpl.Execute(&result, tmplData)
10390 if err != nil {
10391 msg := fmt.Sprintf("Error executing template: %v", err)
10392 log.Println(msg)
10393 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10394 }
10395 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
10396 return err
10397}
10398
10399// attractorspage renders a chromeless fullscreen page for the
10400// strange-attractor visualizer (no header, footer, cart, or store
10401// nav). Reuses the existing stl2 wasm β its URL-path dispatcher
10402// sees "/attractors" and falls into the default branch which
10403// invokes attractor.Run(). Loads the TinyGo or stdlib wasm based
10404// on f.UseTinygo.
10405func attractorspage(c fiber.Ctx) error {
10406 // Newest build wins, with the shim that matches it β the same rule the
10407 // template pages use. Hardcoding "-tiny.wasm" here meant this page served
10408 // whatever the last tinygo run produced even when the Go build was newer,
10409 // and paired it with the site-global shim rather than its own.
10410 pick, ok := pickWasm("stl2")
10411 if !ok {
10412 return c.Status(fiber.StatusNotFound).SendString("no stl2 wasm build available")
10413 }
10414 wasmFile := pick.File
10415 html := fmt.Sprintf(`<!DOCTYPE html>
10416<html lang="en">
10417<head>
10418<meta charset="utf-8">
10419<meta name="viewport" content="width=device-width, initial-scale=1">
10420<title>Strange Attractors β %s</title>
10421<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.">
10422<meta name="robots" content="index, follow">
10423<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>
10424<script src="%s"></script>
10425<script>
10426if (!WebAssembly.instantiateStreaming) {
10427 WebAssembly.instantiateStreaming = async (resp, importObject) => {
10428 const source = await (await resp).arrayBuffer();
10429 return await WebAssembly.instantiate(source, importObject);
10430 };
10431}
10432const go = new Go();
10433WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject).then((result) => {
10434 go.run(result.instance);
10435}).catch((err) => { console.error("Failed to run WASM:", err); });
10436</script>
10437</head>
10438<body>
10439<canvas id="gocanvas"></canvas>
10440</body>
10441</html>`, f.Sitelongname, f.WasmExecPath, wasmFile)
10442 c.Set("Content-Type", "text/html; charset=utf-8")
10443 return c.Status(fiber.StatusOK).SendString(html)
10444}
10445
10446// ---------------------------------------------------------------------------
10447// /demos β the live demos hosted on magnetosphere.net subdomains.
10448//
10449// Every one of these projects publishes its demo from GitHub Pages under a
10450// CNAME of this domain (chaosrack.magnetosphere.net and so on), and until now
10451// nothing on the web linked to them. A page nobody links to is a page a
10452// crawler reaches only through the certificate transparency logs, if at all β
10453// so the subdomains existed but were effectively unfindable.
10454//
10455// This is the index that fixes that. It is a real page at a real URL rather
10456// than another anchor on the front page, for two reasons: an anchor section
10457// shares the front page's <title> and description, and this content is not
10458// about electronic surplus; and the front page hides every non-target section
10459// with CSS, so the text would be in the markup but never rendered to a reader.
10460//
10461// It lives in the drop-in rather than in the m2 repo because it is specific to
10462// this deployment β no other m2 site has these subdomains. The one piece that
10463// did go upstream is extraSitemapURLs, which is the generic half: a drop-in
10464// route that wants to be crawled needs to be in the sitemap, and there was no
10465// way to say so.
10466// ---------------------------------------------------------------------------
10467
10468// demoAlt is a second build of the same demo, linked beside the first. Several
10469// of these ship a TinyGo build and a standard-Go build of the same program;
10470// the difference is size against completeness, and both are worth reaching.
10471type demoAlt struct {
10472 Label string
10473 Path string
10474}
10475
10476// demoSite is one demo: where it runs, where its source is, and one sentence
10477// saying what it actually does. The sentence is the part that matters β it is
10478// what a search result shows and the only description of these subdomains that
10479// exists anywhere.
10480type demoSite struct {
10481 Name string
10482 Host string
10483 Repo string
10484 What string
10485 Alts []demoAlt
10486
10487 // Pending holds a demo back until its subdomain actually answers over
10488 // HTTPS. GitHub issues a Pages certificate some time after the DNS lands,
10489 // and until it does the link is a TLS error rather than a page.
10490 //
10491 // The entry stays here rather than being deleted and rewritten later,
10492 // because the description is the part that took the thought. Clearing this
10493 // flag is the whole of the work when the certificate arrives β and a page
10494 // whose entire purpose is to be crawled must not be the thing that teaches
10495 // a crawler these hostnames are broken.
10496 Pending bool
10497}
10498
10499// demoGroup is a heading over a run of demos.
10500type demoGroup struct {
10501 Title string
10502 Note string
10503 Sites []demoSite
10504}
10505
10506var demoGroups = []demoGroup{{
10507 Title: "Applications",
10508 Note: "Whole programs, running in the tab. Nothing is uploaded and nothing runs on a server.",
10509 Sites: []demoSite{
10510 {
10511 Name: "chaosrack",
10512 Host: "chaosrack.magnetosphere.net",
10513 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.",
10514 },
10515 {
10516 Name: "shipyard",
10517 Host: "shipyard.magnetosphere.net",
10518 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.",
10519 },
10520 {
10521 Name: "shipwright",
10522 Host: "shipwright.magnetosphere.net",
10523 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.",
10524 },
10525 {
10526 Name: "netscrape",
10527 Host: "netscrape.magnetosphere.net",
10528 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.",
10529 },
10530 {
10531 Name: "desk",
10532 Host: "desk.magnetosphere.net",
10533 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.",
10534 Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10535 },
10536 {
10537 Name: "websh",
10538 Host: "websh.magnetosphere.net",
10539 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.",
10540 Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10541 },
10542 {
10543 Name: "dict",
10544 Host: "dict.magnetosphere.net",
10545 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.",
10546 },
10547 {
10548 Name: "pisano",
10549 Host: "pisano.magnetosphere.net",
10550 What: "Designs made by reducing an integer sequence modulo m β the Pisano period made visible β with a shell to drive the renderer.",
10551 Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10552 },
10553 },
10554}, {
10555 Title: "Libraries ported to Go and WebAssembly",
10556 Note: "JavaScript libraries reimplemented in Go, so a Go program in a tab can use them without a JavaScript dependency.",
10557 Sites: []demoSite{
10558 {
10559 Name: "xterm-go",
10560 Host: "xterm-go.magnetosphere.net",
10561 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.",
10562 },
10563 {
10564 Name: "winbox-go",
10565 Host: "winbox-go.magnetosphere.net",
10566 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.",
10567 },
10568 {
10569 Name: "cosmos-go",
10570 Host: "cosmos-go.magnetosphere.net",
10571 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.",
10572 },
10573 {
10574 Name: "tuiwasm",
10575 Host: "tuiwasm.magnetosphere.net",
10576 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.",
10577 Alts: []demoAlt{{Label: "TinyGo build", Path: "/tinygo/"}},
10578 },
10579 },
10580}, {
10581 Title: "Command-line tools ported to Go",
10582 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.",
10583 Sites: []demoSite{
10584 {
10585 Name: "plot-go",
10586 Host: "plot-go.magnetosphere.net",
10587 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.",
10588 },
10589 {
10590 Name: "img2txt-go",
10591 Host: "img2txt-go.magnetosphere.net",
10592 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.",
10593 },
10594 {
10595 Name: "ansifilter-go",
10596 Host: "ansifilter-go.magnetosphere.net",
10597 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.",
10598 },
10599 {
10600 Name: "calvin",
10601 Host: "calvin.magnetosphere.net",
10602 What: "Text converted to the Calvin S box-drawing font. Type in the box and watch the letters turn.",
10603 },
10604 },
10605}, {
10606 Title: "Interface libraries written here",
10607 Note: "Not ports of anything β original libraries, each with a demo that is the library doing its one job.",
10608 Sites: []demoSite{
10609 {
10610 Name: "rack-go",
10611 Host: "rack-go.magnetosphere.net",
10612 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.",
10613 },
10614 },
10615}, {
10616 // Not written here: these are upstream projects this site's author is a
10617 // contributor to. They are on the software page because the page is the
10618 // honest answer to "what does whoever runs this store work on", and
10619 // leaving them off made that answer look smaller than it is.
10620 //
10621 // Repo is set on both β demoRepo defaults to github.com/0magnet/<Name>,
10622 // which is wrong for anything upstream.
10623 Title: "Upstream projects contributed to",
10624 Note: "Not written here, and not WebAssembly. Listed because a good deal of the work behind this site went into them.",
10625 Sites: []demoSite{{
10626 Name: "skycoin",
10627 Host: "skycoin.magnetosphere.net",
10628 Repo: "https://github.com/skycoin/skycoin",
10629 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.",
10630 }, {
10631 Name: "skywire",
10632 Host: "theskywirenetwork.net",
10633 Repo: "https://github.com/skycoin/skywire",
10634 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.",
10635 }},
10636}}
10637
10638// demoRepo is the source URL for a demo. All of these live under the same
10639// GitHub account and the repository is named after the program.
10640func (d demoSite) demoRepo() string {
10641 if d.Repo != "" {
10642 return d.Repo
10643 }
10644 return "https://github.com/0magnet/" + d.Name
10645}
10646
10647// demosBody renders the page. It is plain markup in the vocabulary the rest of
10648// the site already uses β the α table and the β€ heading from the links page β
10649// so it inherits the stylesheet rather than carrying one of its own.
10650func demosBody() string {
10651 var b strings.Builder
10652 // A column of its own. main is full-bleed and centered, which is right for
10653 // the store front but runs prose to both edges of a wide window and puts the
10654 // last table column under the scrollbar.
10655 b.WriteString("<div style='max-width:1200px;margin:0 auto;padding:0 14px'>\n")
10656 b.WriteString("<h1>Software</h1>\n")
10657 b.WriteString("<p>Software written here, running in your browser. ")
10658 b.WriteString("Every one of these is Go compiled to WebAssembly, served from a subdomain of this site, ")
10659 b.WriteString("and open source. Nothing below needs an account, an install, or a round trip to a server.</p>\n")
10660
10661 for _, g := range demoGroups {
10662 // A group every one of whose demos is still waiting on a certificate
10663 // would render as a heading over nothing.
10664 if live(g.Sites) == 0 {
10665 continue
10666 }
10667 b.WriteString("<h2 class='β€'>" + g.Title + "</h2>\n")
10668 if g.Note != "" {
10669 b.WriteString("<p>" + g.Note + "</p>\n")
10670 }
10671 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")
10672 for _, d := range g.Sites {
10673 if d.Pending {
10674 continue
10675 }
10676 b.WriteString("<tr><td style='width:12%'><pre><a title='" + d.Host + "' href='https://" + d.Host + "/'>" + d.Name + "</a>")
10677 for _, a := range d.Alts {
10678 b.WriteString("<br><a title='" + d.Host + a.Path + "' href='https://" + d.Host + a.Path + "'>" + a.Label + "</a>")
10679 }
10680 b.WriteString("</pre></td>")
10681 b.WriteString("<td style='width:62%;padding-right:16px'><pre style='white-space:pre-wrap'>" + d.What + "</pre></td>")
10682 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")
10683 }
10684 b.WriteString("</tbody></table>\n")
10685 }
10686 b.WriteString(deepBody())
10687 b.WriteString("<p><a href='/'>Back to the store</a></p>\n")
10688 b.WriteString("</div>\n")
10689 return b.String()
10690}
10691
10692// demosLD is the structured data for the page: an ItemList of the demos, each
10693// a SoftwareApplication at its own URL.
10694//
10695// This is the half a crawler reads. The list gives every subdomain a name, a
10696// description and a URL in one document, which is the fastest way to get a set
10697// of never-before-linked hostnames into an index.
10698func demosLD(canonical string) string {
10699 var b strings.Builder
10700 b.WriteString("<script type='application/ld+json'>\n{\n")
10701 b.WriteString("\"@context\": \"https://schema.org\",\n")
10702 b.WriteString("\"@type\": \"ItemList\",\n")
10703 b.WriteString("\"name\": \"Demos\",\n")
10704 b.WriteString("\"url\": \"" + canonical + "\",\n")
10705 b.WriteString("\"itemListElement\": [\n")
10706 pos := 0
10707 for _, g := range demoGroups {
10708 for _, d := range g.Sites {
10709 if d.Pending {
10710 continue
10711 }
10712 if pos > 0 {
10713 b.WriteString(",\n")
10714 }
10715 pos++
10716 b.WriteString("{\"@type\": \"ListItem\", \"position\": " + strconv.Itoa(pos) + ", \"item\": {")
10717 b.WriteString("\"@type\": \"SoftwareApplication\", ")
10718 b.WriteString("\"name\": \"" + d.Name + "\", ")
10719 b.WriteString("\"url\": \"https://" + d.Host + "/\", ")
10720 b.WriteString("\"applicationCategory\": \"DeveloperApplication\", ")
10721 b.WriteString("\"operatingSystem\": \"Any (WebAssembly)\", ")
10722 b.WriteString("\"codeRepository\": \"" + d.demoRepo() + "\", ")
10723 b.WriteString("\"description\": \"" + jsonString(d.What) + "\"}}")
10724 }
10725 }
10726 b.WriteString("\n]\n}\n</script>\n")
10727 return b.String()
10728}
10729
10730// jsonString escapes the characters a description could plausibly contain and
10731// JSON cannot carry raw. The blurbs above are written by hand and hold none of
10732// them today; this is here so that editing one cannot silently produce invalid
10733// structured data, which a crawler drops without reporting.
10734func jsonString(s string) string {
10735 r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", " ", "\t", " ")
10736 return r.Replace(s)
10737}
10738
10739// demospage serves /demos.
10740//
10741// It borrows the site chrome β head, header, footer β and replaces the "schema"
10742// partial with the ItemList above, rather than the front page's Organization
10743// block.
10744//
10745// It states WasmBinary as empty. That is the default now β a page opts in
10746// with wasmFor β but saying so here keeps the intent readable: this page is
10747// text, and a crawler meant to read it quickly should not be handed the STL
10748// viewer. Emptying the list is how the templates ask for that; overriding the
10749// "wasm" partial with an empty body does NOT work, because Go templates treat
10750// a whitespace-only body as no definition at all and leave the existing one
10751// standing.
10752func demospage(c fiber.Ctx) error {
10753 tmpl, err := mainTmpl()
10754 if err != nil {
10755 msg := fmt.Sprintf("Error parsing html template: %v", err)
10756 log.Println(msg)
10757 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10758 }
10759 tmpl0, err := tmpl.Clone()
10760 if err != nil {
10761 msg := fmt.Sprintf("Error cloning template: %v", err)
10762 log.Println(msg)
10763 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10764 }
10765 h1 := pageMeta(c, htmlPageTemplateData)
10766 h1.Page = "software"
10767 // One canonical URL for two routes. /demos was the first spelling and is
10768 // linked from several repository READMEs, so it still answers β but it
10769 // must not compete with /software for the same content, which is exactly
10770 // what a self-referencing canonical on each would set up.
10771 h1.Canonical = h1.BaseURL + "/software"
10772 h1.Title = "Software β Go and WebAssembly in the browser | " + f.Sitelongname
10773 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."
10774 h1.KeyWords = "Go, WebAssembly, wasm, TinyGo, browser demo, xterm.js port, WinBox port, cosmos.gl port, in-browser shell, strange attractor, libcaca img2txt, ansifilter"
10775 h1.WasmBinary = nil
10776
10777 for _, part := range []struct{ name, body string }{
10778 {"main", demosBody()},
10779 {"schema", demosLD(h1.Canonical)},
10780 } {
10781 if _, err := tmpl0.New(part.name).Parse(part.body); err != nil {
10782 msg := fmt.Sprintf("Error parsing %s template: %v", part.name, err)
10783 log.Println(msg)
10784 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10785 }
10786 }
10787
10788 c.Set("Content-Type", "text/html;charset=utf-8")
10789 tmplData := map[string]interface{}{
10790 "Page": h1,
10791 "Prods": allproducts,
10792 }
10793 var result bytes.Buffer
10794 if err := tmpl0.Execute(&result, tmplData); err != nil {
10795 msg := fmt.Sprintf("Error executing template: %v", err)
10796 log.Println(msg)
10797 return c.Status(fiber.StatusInternalServerError).SendString(msg)
10798 }
10799 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
10800 return err
10801}
10802
10803// ---------------------------------------------------------------------------
10804// Deep links.
10805//
10806// Three of the demos take a parameter, so a link can open on a particular
10807// thing rather than on the front of the app. tuiwasm is the striking case: one
10808// wasm binary holds 31 separate programs, and without this the page links to
10809// the binary and every program inside it is invisible.
10810//
10811// This is the cheapest indexable surface here by a distance. Each of these is
10812// a distinct URL that renders distinct content, and the link text is the
10813// demo's own name β which is what someone searching for "terminal matrix
10814// animation" or "Langton's ant in the browser" actually types.
10815// ---------------------------------------------------------------------------
10816
10817// deepLink is one parameterized entry point: what to call it, what it opens,
10818// and the query that gets there.
10819type deepLink struct {
10820 Label string
10821 Desc string
10822 Query string
10823}
10824
10825// deepGroup is a run of deep links into one demo.
10826type deepGroup struct {
10827 Host string
10828 Title string
10829 Note string
10830 Links []deepLink
10831}
10832
10833var deepGroups = []deepGroup{{
10834 Host: "tuiwasm.magnetosphere.net",
10835 Title: "tuiwasm β 43 terminal-UI programs, one per link",
10836 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.",
10837 Links: []deepLink{
10838 {"aquarium", "fish swimming past swaying seaweed", "demo=aquarium"},
10839 {"aurora", "curtains of light, folding, with the creases where they turn", "demo=aurora"},
10840 {"boids", "flocking by separation, alignment and cohesion", "demo=boids"},
10841 {"bonsai", "a bonsai tree growing branch by branch", "demo=bonsai"},
10842 {"bounce", "the screensaver logo, and the wait for it to hit a corner", "demo=bounce"},
10843 {"boxes", "tcell's own boxes demo β random boxes, timed", "demo=boxes"},
10844 {"charts", "asciigraph β a line plot in cells", "demo=charts"},
10845 {"clock", "an analog clock, after aclock", "demo=clock"},
10846 {"colors", "tcell's own colors demo β boxes cycling through the palette", "demo=colors"},
10847 {"cube", "a rotating wireframe solid, shaded by depth", "demo=cube"},
10848 {"donut", "a lit torus with a z-buffer", "demo=donut"},
10849 {"fire", "a heat grid seeded with noise and cooled upward", "demo=fire"},
10850 {"fireworks", "shells that rise, burst and droop into willows", "demo=fireworks"},
10851 {"flow", "particles carried through a divergence-free curl-noise field", "demo=flow"},
10852 {"frost", "a crystal growing by diffusion-limited aggregation", "demo=frost"},
10853 {"julia", "a Julia set morphing as its parameter walks the cardioid", "demo=julia"},
10854 {"langton", "Langton's ants: chaos, then the highway", "demo=langton"},
10855 {"lavalamp", "wax that heats, rises, cools and sinks", "demo=lavalamp"},
10856 {"life", "Conway's life, colored by how long a cell has lived", "demo=life"},
10857 {"lightning", "a branching discharge: leader, return stroke, afterglow", "demo=lightning"},
10858 {"matrix", "falling columns of glyphs", "demo=matrix"},
10859 {"maze", "a maze carved by backtracking, then solved", "demo=maze"},
10860 {"metaballs", "blobs that bulge and merge as they approach", "demo=metaballs"},
10861 {"moire", "two drifting ripples interfering", "demo=moire"},
10862 {"parrot", "the party parrot, rolling once around the hue wheel", "demo=parrot"},
10863 {"pendulum", "double pendulums released together, shearing apart", "demo=pendulum"},
10864 {"physarum", "slime mold laying trails and building a transport network", "demo=physarum"},
10865 {"pipes", "pipes growing and turning, with correct elbows", "demo=pipes"},
10866 {"plasma", "summed sine waves of position, offset by time", "demo=plasma"},
10867 {"proxima", "Escape from Proxima 5 β gdamore's tcell space shooter", "demo=proxima"},
10868 {"proxima2", "Escape from Proxima 5 on tcell v2 β the same game, one major back", "demo=proxima2"},
10869 {"rain", "drops with depth, slant, streaks and splashes", "demo=rain"},
10870 {"reaction", "Gray-Scott reaction-diffusion: spots that divide into a labyrinth", "demo=reaction"},
10871 {"ripple", "a ripple tank: rain falling, rings meeting and interfering", "demo=ripple"},
10872 {"sand", "grains heaping at their angle of repose", "demo=sand"},
10873 {"snow", "flakes that sway, settle and drift into banks", "demo=snow"},
10874 {"starfield", "stars streaming past the viewer", "demo=starfield"},
10875 {"styles", "lipgloss β borders, color, alignment", "demo=styles"},
10876 {"tables", "go-pretty β the wasm compatibility matrix", "demo=tables"},
10877 {"tunnel", "flying down a textured tube", "demo=tunnel"},
10878 {"unicode", "tcell's own unicode demo β wide, combining and emoji glyphs", "demo=unicode"},
10879 {"widgets", "tview β a flexbox of lists, tables and text views on tcell v2", "demo=widgets"},
10880 {"wolfram", "elementary cellular automata scrolling upward - 30, 90, 110", "demo=wolfram"},
10881 },
10882}, {
10883 Host: "plot-go.magnetosphere.net",
10884 Title: "plot-go β the same counters through different pipelines",
10885 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.",
10886 Links: []deepLink{
10887 {"avg:5", "a five-sample moving average β the default", "p=avg:5"},
10888 {"roc:5", "rate of change over five samples", "p=roc:5"},
10889 {"avg:5|roc:5", "smoothed first, then differentiated", "p=avg:5%7Croc:5"},
10890 {"cma", "a cumulative moving average over the whole run", "p=cma"},
10891 },
10892}, {
10893 Host: "dict.magnetosphere.net",
10894 Title: "dict β a word, looked up on arrival",
10895 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.",
10896 Links: []deepLink{
10897 {"serendipity", "Webster's 1913 on serendipity", "run=dict%20serendipity"},
10898 {"rain", "every sense of rain, across all four sources", "run=dict%20%3Arain"},
10899 {"recieve", "a transposition: the misspelling a subsequence matcher cannot find", "run=dict%20recieve"},
10900 {"aluminium", "the British spelling, folded to the American entry", "run=dict%20aluminium"},
10901 },
10902}}
10903
10904// deepBody renders the deep-link section: one paragraph of links per demo,
10905// rather than a table. A table of 31 one-word rows is mostly borders.
10906func deepBody() string {
10907 var b strings.Builder
10908 b.WriteString("<h2 class='β€'>Straight to one thing</h2>\n")
10909 b.WriteString("<p>Some of these take a parameter, so a link can open on a particular demo, ")
10910 b.WriteString("pipeline or word instead of the front of the app.</p>\n")
10911 for _, g := range deepGroups {
10912 b.WriteString("<h3 style='font-size:10pt'>" + g.Title + "</h3>\n")
10913 b.WriteString("<p style='max-width:70em;margin:0 auto'>" + g.Note + "</p>\n")
10914 b.WriteString("<p style='max-width:70em;margin:6px auto;line-height:1.9'>")
10915 for i, l := range g.Links {
10916 if i > 0 {
10917 b.WriteString(" · ")
10918 }
10919 b.WriteString("<a title=\"" + l.Desc + "\" href='https://" + g.Host + "/?" + l.Query + "'>" + l.Label + "</a>")
10920 }
10921 b.WriteString("</p>\n")
10922 }
10923 return b.String()
10924}
10925
10926// live counts the demos in a group that are ready to be linked.
10927func live(sites []demoSite) int {
10928 n := 0
10929 for _, d := range sites {
10930 if !d.Pending {
10931 n++
10932 }
10933 }
10934 return n
10935}
10936
10937// ---------------------------------------------------------------------------
10938// /test β the site with the decoration wasm loaded.
10939//
10940// Deliberately not a gallery. The thing to judge is the site, so this parses
10941// exactly what homepage parses and differs from it in one line: the wasm list.
10942// Diffing /test against / therefore shows the decoration and nothing else, and
10943// when a piece of it is worth keeping the change is to add it to homepage.
10944
10945// testpage serves /test.
10946func testpage(c fiber.Ctx) error {
10947 tmpl, err := mainTmpl()
10948 if err != nil {
10949 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("template: %v", err))
10950 }
10951 tmpl0, err := tmpl.Clone()
10952 if err != nil {
10953 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("clone: %v", err))
10954 }
10955 for _, part := range []struct{ name, body string }{
10956 {"main", h.FrontPage()},
10957 {"about", h.AboutPage()},
10958 {"policy", h.PolicyPage()},
10959 {"links", h.LinksPage()},
10960 } {
10961 if _, err := tmpl0.New(part.name).Parse(part.body); err != nil {
10962 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("%s: %v", part.name, err))
10963 }
10964 }
10965 h1 := pageMeta(c, htmlPageTemplateData)
10966 h1.Page = "front"
10967 h1.Canonical = h1.BaseURL + "/test"
10968 h1.Title = "sandbox β " + f.Sitelongname
10969 // The front page's own canvas, plus the decorator. This is the whole of
10970 // the difference from homepage.
10971 h1.WasmBinary = wasmFor("stl2", "deco")
10972 c.Set("Content-Type", "text/html;charset=utf-8")
10973 c.Set("Cache-Control", "no-store")
10974 var out bytes.Buffer
10975 if err := tmpl0.Execute(&out, map[string]interface{}{"Page": h1, "Prods": allproducts}); err != nil {
10976 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("execute: %v", err))
10977 }
10978 body := collapseNewlines.ReplaceAll(out.Bytes(), []byte("\n"))
10979 // Injected rather than added to style.css: the rules are generated from the
10980 // live category list, which a static file cannot be. If this graduates it
10981 // wants a template hook rather than a string splice.
10982 body = bytes.Replace(body, []byte("</head>"), []byte(catTargetCSS()+"</head>"), 1)
10983 _, err = c.Status(fiber.StatusOK).Write(body)
10984 return err
10985}
10986
10987// catTargetCSS makes a targeted category show its subcategories' sections too.
10988//
10989// The problem it solves: sections are flat siblings under .β and only the one
10990// matching :target is shown, so #cat-resistor displays its heading, its list of
10991// subcategory links, and the "Other Products" table β which holds only the
10992// products that have no subcategory. Every resistor has one, so the category
10993// view is a page with no products on it.
10994//
10995// The fix is one rule per category, naming that category's subcategory
10996// sections by the prefix their ids already share:
10997//
10998// [id="cat-resistor"]:target ~ [id^="cat-resistor-"]{display:block}
10999//
11000// Nesting the sections and using :has() would also work and reads better, but
11001// :has() only reached Firefox in December 2023 while :target has worked since
11002// about 2010 β and a browser without :has() would find the nested section
11003// inside a display:none parent, which breaks navigation rather than degrading
11004// it. This uses :target, ~ and [attr^=] only, so it works wherever the
11005// existing navigation does, and needs no second copy of the markup.
11006func catTargetCSS() string {
11007 cats := htmlPageTemplateData.Cats
11008 var b strings.Builder
11009 b.WriteString("<style>/* a targeted category shows its subcategories too */\n")
11010 for _, cat := range cats {
11011 if len(htmlPageTemplateData.SubCatsByCat[cat]) == 0 {
11012 continue
11013 }
11014 // A category whose name plus a dash begins another category's name
11015 // would have its rule match that one's sections as well. Skip it
11016 // rather than emit a rule that reveals the wrong products.
11017 clash := false
11018 for _, other := range cats {
11019 if other != cat && strings.HasPrefix(other, cat+"-") {
11020 clash = true
11021 break
11022 }
11023 }
11024 if clash {
11025 b.WriteString("/* skipped " + cat + ": prefixes another category */\n")
11026 continue
11027 }
11028 id := "cat-" + cat
11029 b.WriteString(`[id="` + id + `"]:target ~ [id^="` + id + `-"]{display:block}` + "\n")
11030 }
11031 b.WriteString("</style>")
11032 return b.String()
11033}
11034
11035
11036// ===== pkg/web/pprof_js.go =====
11037//go:build js && wasm
11038
11039package web
11040
11041import "errors"
11042
11043// StartPprof has nothing to serve profiles on in a page.
11044func StartPprof(string) error { return errors.New("pprof: not in a browser tab") }
11045
11046
11047// ===== pkg/web/pprof_native.go =====
11048//go:build !js || !wasm
11049
11050package web
11051
11052import (
11053 "errors"
11054 "fmt"
11055 "log"
11056 "net"
11057 "net/http"
11058 "net/http/pprof"
11059 "time"
11060)
11061
11062// StartPprof serves Go's profiles (CPU, heap, goroutines, β¦) on addr, a
11063// listener of their own: they show how the server works and cost CPU to
11064// take, so they are never on the store's. The address must be loopback;
11065// reach it from elsewhere through the box, not the network.
11066func StartPprof(addr string) error {
11067 host, _, err := net.SplitHostPort(addr)
11068 if err != nil {
11069 return fmt.Errorf("pprof: %w", err)
11070 }
11071 if ip := net.ParseIP(host); host != "localhost" && (ip == nil || !ip.IsLoopback()) {
11072 return errors.New("pprof: profiles are served on a loopback address only, such as 127.0.0.1:6061")
11073 }
11074 ln, err := net.Listen("tcp", addr)
11075 if err != nil {
11076 return fmt.Errorf("pprof: %w", err)
11077 }
11078 log.Println("pprof on http://" + ln.Addr().String() + "/debug/pprof/")
11079 go func() {
11080 hs := &http.Server{Handler: pprofMux(), ReadHeaderTimeout: 10 * time.Second}
11081 if err := hs.Serve(ln); err != nil {
11082 log.Println("pprof:", err)
11083 }
11084 }()
11085 return nil
11086}
11087
11088// pprofMux is the profiles' own handler, not http.DefaultServeMux, which
11089// importing net/http/pprof also fills.
11090func pprofMux() *http.ServeMux {
11091 mux := http.NewServeMux()
11092 mux.HandleFunc("/debug/pprof/", pprof.Index)
11093 mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
11094 mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
11095 mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
11096 mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
11097 return mux
11098}
11099
11100
11101// ===== pkg/web/pprof_test.go =====
11102//go:build !js || !wasm
11103
11104package web
11105
11106import (
11107 "net/http"
11108 "net/http/httptest"
11109 "strings"
11110 "testing"
11111)
11112
11113// TestPprofLoopbackOnly: profiles are refused anywhere the network reaches.
11114func TestPprofLoopbackOnly(t *testing.T) {
11115 for _, addr := range []string{":6061", "0.0.0.0:6061", "192.168.1.118:6061", "example.com:6061", "6061"} {
11116 if err := StartPprof(addr); err == nil {
11117 t.Errorf("%q was allowed", addr)
11118 }
11119 }
11120}
11121
11122// TestPprofMux: the profiles are on their own handler.
11123func TestPprofMux(t *testing.T) {
11124 w := httptest.NewRecorder()
11125 pprofMux().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/debug/pprof/", nil))
11126 if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "goroutine") {
11127 t.Errorf("pprof's own handler: %d %.200s", w.Code, w.Body.String())
11128 }
11129}
11130
11131
11132// ===== pkg/web/server.go =====
11133// Package web pkg/web/server.go β the store server: routes and page handlers.
11134package web
11135
11136import (
11137 "bytes"
11138 "context"
11139 "encoding/base64"
11140 "fmt"
11141 "log"
11142 "os"
11143 "path/filepath"
11144 "regexp"
11145 "sort"
11146 "strconv"
11147 "strings"
11148 "sync"
11149
11150 "github.com/gofiber/fiber/v3"
11151
11152 "github.com/bitfield/script"
11153
11154 "github.com/0magnet/m2/pkg/config"
11155 p "github.com/0magnet/m2/pkg/product"
11156 "github.com/0magnet/m2/pkg/storepage"
11157)
11158
11159// f aliases the shared configuration; the name keeps the handler code
11160// identical to its pre-refactor form at the repo root, and drop-in
11161// other.go files keep working unchanged.
11162var f = &config.F
11163
11164var collapseNewlines = regexp.MustCompile(`\n{2,}`)
11165
11166func methodColor(method string, colors fiber.Colors) string {
11167 switch method {
11168 case fiber.MethodGet:
11169 return colors.Cyan
11170 case fiber.MethodPost:
11171 return colors.Green
11172 case fiber.MethodPut:
11173 return colors.Yellow
11174 case fiber.MethodDelete:
11175 return colors.Red
11176 case fiber.MethodPatch:
11177 return colors.White
11178 case fiber.MethodHead:
11179 return colors.Magenta
11180 case fiber.MethodOptions:
11181 return colors.Blue
11182 default:
11183 return colors.Reset
11184 }
11185}
11186
11187func statusColor(code int, colors fiber.Colors) string {
11188 switch {
11189 case code >= fiber.StatusOK && code < fiber.StatusMultipleChoices:
11190 return colors.Green
11191 case code >= fiber.StatusMultipleChoices && code < fiber.StatusBadRequest:
11192 return colors.Blue
11193 case code >= fiber.StatusBadRequest && code < fiber.StatusInternalServerError:
11194 return colors.Yellow
11195 default:
11196 return colors.Red
11197 }
11198}
11199
11200// Serve runs the web store: templates, routes, wasm compilation, and the
11201// http listener. It blocks for the life of the server.
11202func Serve() {
11203 wg := new(sync.WaitGroup)
11204 wg.Add(1)
11205 r := NewApp(AppOpts{})
11206 // The browse origin the desk's nested browser renders the store at. Its
11207 // own listener, because it is a different domain: a wholly static pair of
11208 // files that never sees any of the content it makes renderable.
11209 StartBrowseOrigin(context.Background())
11210 go func() {
11211 err := r.Listen(fmt.Sprintf(":%d", f.WebPort))
11212 if err != nil {
11213 log.Println("Error serving http: ", err)
11214 }
11215 wg.Done()
11216 }()
11217 CompileWASM()
11218 wg.Wait()
11219}
11220
11221func sitemap(c fiber.Ctx) error {
11222 c.Type("xml", "utf-8")
11223 return c.SendString(generateSitemapXML())
11224}
11225
11226// apisite serves the site identity for store clients β the browser tui
11227// has no MENV file to source its masthead from.
11228func apisite(c fiber.Ctx) error {
11229 return c.JSON(fiber.Map{
11230 "Sitename": f.Sitename,
11231 "Siteext": f.Siteext,
11232 "Sitelongname": f.Sitelongname,
11233 "Sitetagline": f.Sitetagline,
11234 "Tgcontact": f.Tgcontact,
11235 "Tgchannel": f.Tgchannel,
11236 "Teststripekey": f.Teststripekey,
11237 // The identity fields below appear verbatim in every rendered page,
11238 // so serving them here reveals nothing new; they let the in-tab site
11239 // server render the same masthead and metadata the origin does.
11240 "Sitemeta": f.Sitemeta,
11241 "Sitedomain": f.Sitedomain,
11242 "Siteprettyname": f.Siteprettyname,
11243 "Siteprettynamecap": f.Siteprettynamecap,
11244 "Siteprettynamecaps": f.Siteprettynamecaps,
11245 "SiteASCIILogo": f.SiteASCIILogo,
11246 "Stripepk": f.StripePK,
11247 // The drop-in navigation entries, so a client β the terminal build,
11248 // and the TUI served into a browser tab β can offer the same Etc menu
11249 // the page does instead of a shorter one missing every drop-in page.
11250 "NavLinks": extraNavLinks,
11251 // The coin the store takes, or "" where it takes none: the page offers
11252 // "Pay in <coin>" only then, and so does the terminal.
11253 "Coin": htmlTemplateData{}.CoinName(),
11254 })
11255}
11256
11257// apicontent serves the stock-page fragments (about/policy/links) raw,
11258// for the tui to render as text.
11259func apicontent(c fiber.Ctx) error {
11260 name := c.Params("name")
11261 switch name {
11262 case "about", "policy", "links":
11263 default:
11264 return c.SendStatus(fiber.StatusNotFound)
11265 }
11266 c.Set("Content-Type", "text/html;charset=utf-8")
11267 return c.SendString(contentFile("content/" + name + ".html"))
11268}
11269
11270// tuipage serves the terminal storefront: the same store TUI `m2 tui`
11271// runs natively, compiled to wasm (the wasm/tui drop-in, listed in
11272// WASMSRC) and drawn into an xterm-go terminal filling the page.
11273func tuipage(c fiber.Ctx) error {
11274 // The joint build where the deployment has one, else this page's own.
11275 // See pageBinary.
11276 binary, execPath := pageBinary("term", "tui")
11277 html := fmt.Sprintf(`<!DOCTYPE html>
11278<html lang="en">
11279<head>
11280<meta charset="utf-8">
11281<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
11282<title>%s β terminal</title>
11283<meta name="description" content="%s as a terminal: browse the catalog, view products, and fill a cart in a TUI running in your browser.">
11284<meta name="robots" content="index, follow">
11285<link rel="stylesheet" href="/font.css">
11286<style>html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;}#terminal{position:fixed;inset:0;}</style>
11287<script src="/bottle/jsfs.js"></script>
11288<script src="%s"></script>
11289`+procScripts()+`
11290<script>
11291if (!WebAssembly.instantiateStreaming) {
11292 WebAssembly.instantiateStreaming = async (resp, importObject) => {
11293 const source = await (await resp).arrayBuffer();
11294 return await WebAssembly.instantiate(source, importObject);
11295 };
11296}
11297const go = new Go();
11298`+homeReadyJS+`
11299homeReady.then(() => WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject)).then((result) => {
11300 go.run(result.instance);
11301}).catch((err) => { console.error("Failed to run WASM:", err); });
11302</script>
11303</head>
11304<body>
11305<div id="terminal"></div>
11306</body>
11307</html>`, f.Sitedomain, f.Sitelongname, execPath, binary)
11308 c.Set("Content-Type", "text/html; charset=utf-8")
11309 return c.Status(fiber.StatusOK).SendString(html)
11310}
11311
11312// procScripts lets a page's shells run programs from their filesystem as
11313// processes of their own (bottle's proc): the store program, wasm/store, or
11314// any other, built by Go or by TinyGo.
11315func procScripts() string {
11316 return fmt.Sprintf(`<script src="/bottle/proc.js"></script>
11317<script>proc.assets.wasmExecGo = %q; proc.assets.wasmExecTinyGo = %q;</script>`, f.WasmExecPathGo, f.WasmExecPathTinyGo)
11318}
11319
11320// deskpage serves the site as a desktop: the website in a maximized window
11321// and the storefront terminal in another, both launchable from the panel.
11322// Same drop-in mechanism as tuipage, against the 'wasm/desk' source.
11323func deskpage(c fiber.Ctx) error {
11324 // The joint build where the deployment has one, else this page's own.
11325 binary, execPath := pageBinary("term", "desk")
11326 // ?bare / ?fsonly / ?nonet split the page's script layer for debugging:
11327 // bare = none of jsfs/vnet, fsonly = jsfs alone, nonet = no browser.
11328 //
11329 // netscrape is no longer among these. It used to be a JS engine served at
11330 // /netscrape.js; it is now a Go browser compiled INTO the desk wasm, so
11331 // there is no script to leave out β ?nonet is honored by the desk itself,
11332 // which reads it from location.search and does not open the window.
11333 scripts := `<script src="/bottle/jsfs.js"></script>
11334<script src="/bottle/vnet.js"></script>` + "\n" + procScripts()
11335 // The browse substrate, when the deployment has a second domain for it.
11336 // The responder must be first-party here and must exist before the wasm
11337 // configures it, so it goes in with the rest of the page scripts; the
11338 // config object tells the desk which origin to render the store at.
11339 if browseEnabled() {
11340 scripts += fmt.Sprintf("\n<script>window.__M2_BROWSE__={suffix:%q,scheme:%q,port:%q};</script>"+
11341 "\n<script src=%q></script>", config.F.BrowseSuffix, browseScheme(), browsePort(), browseResponderPath)
11342 }
11343 switch {
11344 case c.Query("bare") != "":
11345 scripts = ""
11346 case c.Query("fsonly") != "":
11347 scripts = `<script src="/bottle/jsfs.js"></script>`
11348 }
11349 html := fmt.Sprintf(`<!DOCTYPE html>
11350<html lang="en">
11351<head>
11352<meta charset="utf-8">
11353<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
11354<title>%s β desktop</title>
11355<meta name="description" content="%s as a desktop: the website and the storefront terminal, each in a window.">
11356<meta name="robots" content="index, follow">
11357<link rel="stylesheet" href="/font.css">
11358<style>
11359html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;
11360 font-family:mononokiregular,ui-monospace,monospace;color:#fff;}
11361/* Windows are positioned against this, so it is the element that fills the
11362 page rather than the body. */
11363#desktop{position:fixed;inset:0;}
11364#boot{padding:14px;color:#777;font-size:13px;}
11365</style>
11366%s
11367<script>
11368// Channel providers for the netscrape browser window the desk opens. The
11369// clearnet channel is the page's own fetch β enough for this origin and for
11370// anything CORS permits; there is no skysocks here. dmsg has no transport in
11371// this page at all, so it answers 502 rather than hanging.
11372globalThis.__m2Glue = {
11373 fetchClearnet: async function (exit, method, url, body, winId, headers) {
11374 try {
11375 const r = await fetch(url, { method: method || "GET", body: body || undefined, headers: headers || undefined });
11376 const buf = new Uint8Array(await r.arrayBuffer());
11377 const hs = {};
11378 r.headers.forEach(function (v, k) { hs[k] = v; });
11379 return { status: r.status, body: buf, headers: hs };
11380 } catch (e) {
11381 return { status: 502, body: new Uint8Array(), headers: {} };
11382 }
11383 },
11384 fetchDmsg: async function () { return { status: 502, body: new Uint8Array(), headers: {} }; },
11385};
11386
11387// netscrape asks globalThis.__netscrapeFetch(url) for every page and
11388// subresource, and expects a Response. Without one it falls back to a
11389// same-origin /fetch proxy this server does not have, so the transport is
11390// wired here rather than left to that default.
11391//
11392// Note the argument order: netscrape's own loader.js calls
11393// fetchClearnet(url, method, body), but __m2Glue predates it and takes
11394// (exit, method, url, ...). The glue is what the rest of this page uses, so
11395// the shim adapts to the glue rather than the other way round.
11396globalThis.__netscrapeFetch = function (url) {
11397 var u;
11398 try { u = new URL(url, location.href); } catch (e) { return fetch(url); }
11399 var path = (u.pathname || "/") + (u.search || "");
11400 var mesh = /\.(dmsg|skysocks|skynet)$/i.test(u.hostname) || /^[0-9a-f]{66}$/i.test(u.hostname);
11401 // vnet:<port> is the site running INSIDE this tab, on the page's virtual
11402 // loopback. It needs its own name: the desk's own origin is a real
11403 // 127.0.0.1:<port>, so addressing the in-tab server as 127.0.0.1 too sent
11404 // it to the page's ordinary fetch and out to the host, which answered
11405 // ERR_CONNECTION_REFUSED. Nothing outside this page can be reached at
11406 // "vnet", so the routing is unambiguous.
11407 var call;
11408 if (u.hostname === "vnet") {
11409 call = globalThis.vnet.httpFetch(parseInt(u.port || "80", 10), "GET", path, null);
11410 } else if (mesh) {
11411 call = globalThis.__m2Glue.fetchDmsg(u.hostname, "GET", path, null);
11412 } else {
11413 call = globalThis.__m2Glue.fetchClearnet(null, "GET", u.href, null, null, null);
11414 }
11415 return Promise.resolve(call).then(function (r) {
11416 var h = new Headers();
11417 if (r && r.headers) { for (var k in r.headers) { try { h.set(k, r.headers[k]); } catch (e) {} } }
11418 return new Response((r && r.body) || new Uint8Array(0), { status: (r && r.status) || 200, headers: h });
11419 });
11420};
11421</script>
11422<script src="%s"></script>
11423<script>
11424if (!WebAssembly.instantiateStreaming) {
11425 WebAssembly.instantiateStreaming = async (resp, importObject) => {
11426 const source = await (await resp).arrayBuffer();
11427 return await WebAssembly.instantiate(source, importObject);
11428 };
11429}
11430const go = new Go();
11431`+homeReadyJS+`
11432homeReady.then(() => WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject)).then((result) => {
11433 const b = document.getElementById("boot"); if (b) b.remove();
11434 go.run(result.instance);
11435}).catch((err) => { console.error("Failed to run WASM:", err); });
11436</script>
11437</head>
11438<body>
11439<div id="desktop"><div id="boot">loadingβ¦</div></div>
11440</body>
11441</html>`, f.Sitedomain, f.Sitelongname, scripts, execPath, binary)
11442 c.Set("Content-Type", "text/html; charset=utf-8")
11443 return c.Status(fiber.StatusOK).SendString(html)
11444}
11445
11446// apiproducts serves the catalog as JSON for store clients (the tui's
11447// --store mode, and the browser tui to come). The business-sensitive
11448// columns the HTML never renders stay private.
11449func apiproducts(c fiber.Ctx) error {
11450 allproductsMu.RLock()
11451 prods := make(p.Products, len(allproducts))
11452 copy(prods, allproducts)
11453 allproductsMu.RUnlock()
11454 for i := range prods {
11455 prods[i].Cost = ""
11456 prods[i].Location = ""
11457 prods[i].Sourceinfo = ""
11458 }
11459 return c.JSON(prods)
11460}
11461
11462// extraRoutes collects route registrars from optional drop-in files
11463// (see other.go.example). A drop-in appends its registrar from init();
11464// deleting the file removes its routes with no other code changes.
11465var extraRoutes []func(*fiber.App)
11466
11467// extraSitemapURLs collects site-relative paths that drop-in routes want
11468// listed in /sitemap.xml. A route that is not linked from anywhere and not
11469// in the sitemap is one a crawler has no way to reach, which for a page that
11470// exists to be found is the whole of the problem.
11471//
11472// Paths only, each beginning with "/": the sitemap is per-host and the base
11473// URL is the deployment's own, so a drop-in cannot and should not name it.
11474var extraSitemapURLs []string
11475
11476// NavLink is an entry a drop-in adds to the site navigation.
11477type NavLink struct {
11478 // Title is the link text, Href where it goes, and Desc the tooltip.
11479 Title, Href, Desc string
11480}
11481
11482// extraNavLinks collects navigation entries from drop-in files, the way
11483// extraRoutes collects their routes.
11484//
11485// A drop-in route that nothing links to is reachable only by someone who
11486// already knows the path, and the stock header cannot name it: the header is
11487// shared by every deployment, and a hardcoded entry would be a broken link
11488// everywhere the drop-in is absent. Registering it here keeps the link and the
11489// route in the same file, so deleting that file removes both.
11490var extraNavLinks []NavLink
11491
11492func logo(c fiber.Ctx) error {
11493 tmpl, err := auxTmpl()
11494 if err != nil {
11495 msg := fmt.Sprintf("Error parsing html template: %v", err)
11496 log.Println(msg)
11497 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11498 }
11499 tmpl0, err := tmpl.Clone()
11500 if err != nil {
11501 msg := fmt.Sprintf("Error cloning template: %v", err)
11502 log.Println(msg)
11503 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11504 }
11505 _, err = tmpl0.New("main").Parse(h.Logo())
11506 if err != nil {
11507 msg := fmt.Sprintf("Error parsing product page template: %v", err)
11508 log.Println(msg)
11509 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11510 }
11511 tmpl = tmpl0
11512 c.Set("Content-Type", "text/html;charset=utf-8")
11513
11514 img2txtFlags := ""
11515 if w, err := strconv.Atoi(c.Params("width")); err == nil {
11516 img2txtFlags = fmt.Sprintf("--width=%d ", w)
11517 }
11518 if h, err := strconv.Atoi(c.Params("height")); err == nil {
11519 img2txtFlags = fmt.Sprintf("--height=%d ", h)
11520 }
11521
11522 logoHTMLslice, err := script.Exec(fmt.Sprintf("bash -c 'img2txt %s logo.jpg | ansifilter -H'", img2txtFlags)).Slice()
11523 if err != nil {
11524 log.Println("error: ", err)
11525 _, err = c.Status(fiber.StatusInternalServerError).Write([]byte(err.Error() + "/n" + strings.Join(logoHTMLslice, "\n")))
11526 return err
11527 }
11528 if len(logoHTMLslice) > 2 {
11529 logoHTMLslice = logoHTMLslice[:len(logoHTMLslice)-3]
11530 }
11531 if len(logoHTMLslice) > 18 {
11532 logoHTMLslice = logoHTMLslice[19:]
11533 }
11534
11535 var result bytes.Buffer
11536 h1 := pageMeta(c, htmlTemplateData{})
11537 h1.Page = "logo"
11538 h1.Title = "logo"
11539 tmplData := map[string]interface{}{
11540 "Content": strings.Join(logoHTMLslice, "\n"),
11541 }
11542 err = tmpl.Execute(&result, tmplData)
11543 if err != nil {
11544 log.Println("error: ", err)
11545 _, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
11546 return err
11547 }
11548 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11549 return err
11550}
11551
11552func robots(c fiber.Ctx) error {
11553 c.Set("Content-Type", "text/plain;charset=utf-8")
11554 // "User-agent: *" alone is a group with no rules in it. That is legal and
11555 // means allow everything, but it says so by omission β and a group that
11556 // states nothing is one whose meaning a later edit can change by accident.
11557 // Allow: / states it. The trailing newline is so the Sitemap line is a
11558 // line, which some parsers care about.
11559 _, err := c.Status(fiber.StatusOK).Write([]byte(fmt.Sprintf(
11560 "User-agent: *\nAllow: /\n\nSitemap: https://%s/sitemap.xml\n", c.Hostname())))
11561 return err
11562}
11563
11564func style(c fiber.Ctx) error {
11565 c.Set("Content-Type", "text/css;charset=utf-8")
11566 _, err := c.Status(fiber.StatusOK).Write([]byte(h.StyleCSS()))
11567 return err
11568}
11569
11570// fontcss serves just the @font-face block, so the terminal page can have
11571// the site's face without pulling in the whole stylesheet. The TUI measures
11572// its cell from this font when it opens, which is why it is a separate,
11573// cacheable route rather than something inlined per page load.
11574func fontcss(c fiber.Ctx) error {
11575 c.Set("Content-Type", "text/css;charset=utf-8")
11576 c.Set("Cache-Control", "public, max-age=86400")
11577 _, err := c.Status(fiber.StatusOK).Write([]byte(h.FontCSS()))
11578 return err
11579}
11580
11581func serveWASM(r *fiber.App) {
11582 if f.WasmExecPath != "" {
11583 _, err := script.File(f.WasmExecPath).Bytes()
11584 if err != nil {
11585 log.Printf("Error reading %s: %v\n", f.WasmExecPath, err)
11586 } else { //the wasm exec must be present or none of the webassembly stuff will work ; provided by the golang installaton
11587 r.Get(f.WasmExecPathTinyGo, func(c fiber.Ctx) error {
11588 wasmExecData, err := script.File(f.WasmExecPathTinyGo).Bytes()
11589 if err != nil {
11590 log.Printf("Error reading %s: %v\n", f.WasmExecPathTinyGo, err)
11591 return c.SendStatus(fiber.StatusNotFound)
11592 }
11593 c.Set("Content-Type", "application/js")
11594 _, err = c.Status(fiber.StatusOK).Write(wasmExecData)
11595 return err
11596 })
11597
11598 r.Get(f.WasmExecPathGo, func(c fiber.Ctx) error {
11599 wasmExecData, err := script.File(f.WasmExecPathGo).Bytes()
11600 if err != nil {
11601 log.Printf("Error reading %s: %v\n", f.WasmExecPathGo, err)
11602 return c.SendStatus(fiber.StatusNotFound)
11603 }
11604 c.Set("Content-Type", "application/js")
11605 _, err = c.Status(fiber.StatusOK).Write(wasmExecData)
11606 return err
11607 })
11608
11609 // Register both variants per source: a drop-in tinygo cannot
11610 // compile still serves its Go build, and pages pick whichever
11611 // binary exists.
11612 for _, wasmSRC := range f.WasmSRC {
11613 base := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
11614 for _, suffix := range []string{".wasm", "-tiny.wasm"} {
11615 outputFile := base + suffix
11616 r.Get("/"+outputFile, func(c fiber.Ctx) error {
11617 // A binary this size must revalidate, not cache
11618 // blindly: a browser holding yesterday's wasm
11619 // makes a deploy look like nothing changed.
11620 fi, err := os.Stat(outputFile)
11621 if err != nil {
11622 return c.SendStatus(fiber.StatusInternalServerError)
11623 }
11624 etag := fmt.Sprintf(`"%x-%x"`, fi.ModTime().Unix(), fi.Size())
11625 c.Set("Cache-Control", "no-cache")
11626 c.Set("ETag", etag)
11627 if c.Get("If-None-Match") == etag {
11628 return c.SendStatus(fiber.StatusNotModified)
11629 }
11630 data, err := script.File(outputFile).Bytes()
11631 if err != nil {
11632 script.File(outputFile).Stdout() //nolint
11633 return c.SendStatus(fiber.StatusInternalServerError)
11634 }
11635 c.Set("Content-Type", "application/wasm")
11636 return c.Status(fiber.StatusOK).Send(data)
11637 })
11638 }
11639 }
11640 }
11641 }
11642}
11643
11644func sendFile(c fiber.Ctx) error {
11645 return c.SendFile("." + c.Path())
11646}
11647func sendImage(c fiber.Ctx) error {
11648 c.Set("Content-Type", "image/jpeg")
11649 return c.SendFile("./img" + c.Path())
11650}
11651
11652func stlbase64(c fiber.Ctx) error {
11653 name := c.Params("filename")
11654 if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
11655 return c.SendStatus(fiber.StatusBadRequest)
11656 }
11657 stlfile, err := script.File("img/stl/" + name).Bytes()
11658 if err != nil {
11659 return c.SendStatus(fiber.StatusNotFound)
11660 }
11661 _, err = c.Status(fiber.StatusOK).Write([]byte("data:model/stl;base64," + base64.StdEncoding.EncodeToString(stlfile)))
11662 return err
11663}
11664
11665type item struct {
11666 ID string
11667 Amount int64
11668}
11669
11670func cathtmlfunc(c fiber.Ctx) error {
11671 tmpl, err := mainTmpl()
11672 if err != nil {
11673 msg := fmt.Sprintf("Error parsing html template: %v", err)
11674 log.Println(msg)
11675 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11676 }
11677 tmpl0, err := tmpl.Clone()
11678 if err != nil {
11679 msg := fmt.Sprintf("Error cloning html template: %v", err)
11680 log.Println(msg)
11681 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11682 }
11683 _, err = tmpl0.New("main").Parse(h.CategoryPage())
11684 if err != nil {
11685 msg := fmt.Sprintf("Error parsing Category page template: %v", err)
11686 log.Println(msg)
11687 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11688 }
11689 tmpl = tmpl0
11690 var tmplData map[string]interface{}
11691 var result bytes.Buffer
11692 var categoryproducts p.Products
11693 c.Set("Content-Type", "text/html;charset=utf-8")
11694 h1 := pageMeta(c, htmlPageTemplateData)
11695 h1.Title = fmt.Sprintf("%s | %s", func() string {
11696 var str string
11697 if c.Params("partno") != "" {
11698 return "No product matching partno.: " + c.Params("partno") + " | Showing All Products"
11699 }
11700 if c.Params("cat") == "" {
11701 return "All Products"
11702 }
11703 str = fmt.Sprintf("Category: %s", c.Params("cat"))
11704 if c.Params("subcat") != "" {
11705 str += fmt.Sprintf("; Subcategory: %s", c.Params("subcat"))
11706 }
11707 return str
11708 }(), h1.Title)
11709 h1.Page = "category"
11710 // The listing has an Add to cart on every row, and the cart is what
11711 // defines addToCart. Without it every one of those buttons was inert
11712 // β the page rendered them, the click found no such function, and
11713 // nothing reached the cart from a category page at all. The small
11714 // build, because this is carried by every listing view.
11715 h1.WasmBinary = wasmSmallFor("cart")
11716 if c.Params("cat") == "" && c.Params("subcat") == "" {
11717 tmplData = map[string]interface{}{
11718 "Products": allproducts,
11719 "Page": h1,
11720 "Category": c.Params("cat"),
11721 "Subcategory": c.Params("subcat"),
11722 "Prods": allproducts,
11723 "Product": c.Params("partno"),
11724 }
11725 } else {
11726
11727 for _, prod := range allproducts {
11728 if strings.EqualFold(prod.Category, c.Params("cat")) && (c.Params("subcat") == "" || strings.EqualFold(escapesubcat(prod.Subcategory), c.Params("subcat"))) {
11729 categoryproducts = append(categoryproducts, prod)
11730 }
11731 }
11732 tmplData = map[string]interface{}{
11733 "Products": categoryproducts,
11734 "Page": h1,
11735 "Category": c.Params("cat"),
11736 "Subcategory": c.Params("subcat"),
11737 "Prods": allproducts,
11738 }
11739 }
11740 err = tmpl.Execute(&result, tmplData)
11741 if err != nil {
11742 msg := fmt.Sprintf("Error execute html template: %v", err)
11743 log.Println(msg)
11744 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11745 }
11746 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11747 return err
11748}
11749
11750func getcats() (cats []string) {
11751 var catsMap = make(map[string]int)
11752 for _, prod := range allproducts {
11753 catsMap[prod.Category]++
11754 }
11755 for cat := range catsMap {
11756 cats = append(cats, cat)
11757 }
11758 return cats
11759}
11760func contains(slice []string, str string) bool {
11761 for _, s := range slice {
11762 if s == str {
11763 return true
11764 }
11765 }
11766 return false
11767}
11768func getcategories(allproducts p.Products) (map[string]int, []string, map[string]map[string]int, map[string][]string) {
11769 categoryCounts := make(map[string]int)
11770 subcategoryCounts := make(map[string]map[string]int)
11771 subcategoriesByCategory := make(map[string][]string)
11772
11773 for _, prod := range allproducts {
11774 if prod.Category != "" {
11775 categoryCounts[prod.Category]++
11776 if prod.Subcategory != "" {
11777 if subcategoryCounts[prod.Category] == nil {
11778 subcategoryCounts[prod.Category] = make(map[string]int)
11779 }
11780 subcategoryCounts[prod.Category][prod.Subcategory]++
11781 if !contains(subcategoriesByCategory[prod.Category], prod.Subcategory) {
11782 subcategoriesByCategory[prod.Category] = append(subcategoriesByCategory[prod.Category], prod.Subcategory)
11783 }
11784 }
11785 }
11786 }
11787
11788 var sortableCategories []struct {
11789 Name string
11790 Count int
11791 }
11792 for cat, count := range categoryCounts {
11793 sortableCategories = append(sortableCategories, struct {
11794 Name string
11795 Count int
11796 }{Name: cat, Count: count})
11797 }
11798 sort.Slice(sortableCategories, func(i, j int) bool {
11799 return sortableCategories[i].Count > sortableCategories[j].Count
11800 })
11801 var sortedCategories []string
11802 for _, cat := range sortableCategories {
11803 sortedCategories = append(sortedCategories, cat.Name)
11804 var sortableSubcategories []struct {
11805 Name string
11806 Count int
11807 }
11808 for subcat, count := range subcategoryCounts[cat.Name] {
11809 sortableSubcategories = append(sortableSubcategories, struct {
11810 Name string
11811 Count int
11812 }{Name: subcat, Count: count})
11813 }
11814 sort.Slice(sortableSubcategories, func(i, j int) bool {
11815 return sortableSubcategories[i].Count > sortableSubcategories[j].Count
11816 })
11817 var sortedSubcategories []string
11818 for _, subcat := range sortableSubcategories {
11819 sortedSubcategories = append(sortedSubcategories, subcat.Name)
11820 }
11821 subcategoriesByCategory[cat.Name] = sortedSubcategories
11822 }
11823 return categoryCounts, sortedCategories, subcategoryCounts, subcategoriesByCategory
11824}
11825
11826func getsubcats(cat string) (subcats []string) {
11827 var subcatsMap = make(map[string]int)
11828 for _, prod := range allproducts {
11829 if cat == "" || strings.EqualFold(cat, prod.Category) {
11830 if prod.Subcategory != "" {
11831 subcatsMap[escapesubcat(prod.Subcategory)]++
11832 }
11833 }
11834 }
11835 for subcat := range subcatsMap {
11836 subcats = append(subcats, subcat)
11837 }
11838 return subcats
11839}
11840
11841// escapesubcat spells a subcategory the way it appears in a URL.
11842//
11843// The rules live in pkg/storepage now, because both views need them and
11844// only this one had them: the terminal could recognize no /cat/x/y path
11845// whose subcategory had been escaped, so it handed those links to the
11846// browser rather than opening the listing it has.
11847func escapesubcat(sc string) string { return storepage.EscapeSubcat(sc) }
11848
11849func handlecat(c fiber.Ctx) error {
11850 if c.Params("cat") == "" && c.Params("subcat") == "" {
11851 return cathtmlfunc(c)
11852 }
11853 var catexists bool
11854 var subcatexists bool
11855 catexists = false
11856 for _, cat := range getcats() {
11857 if strings.EqualFold(cat, c.Params("cat")) {
11858 catexists = true
11859 break
11860 }
11861 }
11862 subcatexists = false
11863 if c.Params("subcat") != "" {
11864 for _, subcat := range getsubcats("") {
11865 if strings.EqualFold(escapesubcat(subcat), c.Params("subcat")) {
11866 subcatexists = true
11867 break
11868 }
11869 }
11870 }
11871 if c.Params("subcat") != "" && !subcatexists {
11872 log.Printf("subcategory %s does not match any existing subcategory\n", c.Params("subcat"))
11873 return c.Redirect().To("/cat/" + c.Params("cat"))
11874 }
11875 if !catexists {
11876 log.Printf("category %s does not match any existing category\n", c.Params("cat"))
11877 return c.Redirect().To("/cat")
11878 }
11879 if catexists || (catexists && subcatexists) {
11880 return cathtmlfunc(c)
11881 }
11882 return c.SendStatus(fiber.StatusNotFound)
11883}
11884
11885func homepage(c fiber.Ctx) error {
11886 tmpl, err := mainTmpl()
11887 if err != nil {
11888 msg := fmt.Sprintf("Could not parsing html template: %v", err)
11889 log.Println(msg)
11890 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11891 }
11892 tmpl0, err := tmpl.Clone()
11893 if err != nil {
11894 msg := fmt.Sprintf("Error cloning template: %v", err)
11895 log.Println(msg)
11896 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11897 }
11898 _, err = tmpl0.New("main").Parse(h.FrontPage())
11899 if err != nil {
11900 msg := fmt.Sprintf("Error parsing Front Page template: %v", err)
11901 log.Println(msg)
11902 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11903 }
11904 _, err = tmpl0.New("about").Parse(h.AboutPage())
11905 if err != nil {
11906 msg := fmt.Sprintf("Error parsing About Page template: %v", err)
11907 log.Println(msg)
11908 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11909 }
11910 _, err = tmpl0.New("policy").Parse(h.PolicyPage())
11911 if err != nil {
11912 msg := fmt.Sprintf("Error parsing Policy Page template: %v", err)
11913 log.Println(msg)
11914 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11915 }
11916 _, err = tmpl0.New("links").Parse(h.LinksPage())
11917 if err != nil {
11918 msg := fmt.Sprintf("Error parsing Links Page template: %v", err)
11919 log.Println(msg)
11920 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11921 }
11922 tmpl = tmpl0
11923 log.Println(c.Get("User-Agent"))
11924 c.Set("Content-Type", "text/html;charset=utf-8")
11925 h1 := pageMeta(c, htmlPageTemplateData)
11926 // The front page has the canvas stl2 animates into, and deco, which draws
11927 // the borders round the product tables, the ASCII art behind the
11928 // photographs and the headings as FIGlet banners. Both are decorations: the
11929 // HTML is the same either way, and a browser that runs neither gets the
11930 // page it always got.
11931 // The small build of each: these ride on every page view, and which
11932 // build newest-wins picks is decided by whichever toolchain finished
11933 // last β a page was serving the 23MB stl2 rather than the 9MB one
11934 // because the Go build had just been rebuilt. Size on a page a
11935 // customer loads is not a thing to leave to build order.
11936 h1.WasmBinary = wasmSmallFor("cart", "stl2", "deco")
11937 tmplData := map[string]interface{}{
11938 "Page": h1,
11939 "Prods": allproducts,
11940 }
11941 var result bytes.Buffer
11942 err = tmpl.Execute(&result, tmplData)
11943 if err != nil {
11944 msg := fmt.Sprintf("Error executing template: %v", err)
11945 log.Println(msg)
11946 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11947 }
11948 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11949 return err
11950}
11951
11952func productpage(c fiber.Ctx) error {
11953 tmpl, err := mainTmpl()
11954 if err != nil {
11955 msg := fmt.Sprintf("Error parsing html template: %v", err)
11956 log.Println(msg)
11957 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11958 }
11959 tmpl0, err := tmpl.Clone()
11960 if err != nil {
11961 msg := fmt.Sprintf("Error cloning template: %v", err)
11962 log.Println(msg)
11963 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11964 }
11965 _, err = tmpl0.New("main").Parse(h.ProductPage())
11966 if err != nil {
11967 msg := fmt.Sprintf("Error parsing product page template: %v", err)
11968 log.Println(msg)
11969 return c.Status(fiber.StatusInternalServerError).SendString(msg)
11970 }
11971 tmpl = tmpl0
11972 c.Set("Content-Type", "text/html;charset=utf-8")
11973 for _, prod := range allproducts {
11974 if strings.EqualFold(prod.Partno, c.Params("partno")) {
11975 var result bytes.Buffer
11976 h1 := pageMeta(c, htmlPageTemplateData)
11977 h1.Page = "product"
11978 // A product page has the canvas and the package-type element the
11979 // STL viewer reads to pick its model β and an Add to cart,
11980 // which needs the cart that defines addToCart.
11981 h1.WasmBinary = wasmSmallFor("cart", "stl2")
11982 h1.Title = fmt.Sprintf("%s | %s", prod.Name, h1.Title)
11983 tmplData := map[string]interface{}{
11984 "Prod": prod,
11985 "Page": h1,
11986 "Prods": allproducts,
11987 }
11988 err := tmpl.Execute(&result, tmplData)
11989 if err != nil {
11990 log.Println("error: ", err)
11991 _, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
11992 return err
11993 }
11994 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11995 return err
11996 }
11997 }
11998 log.Printf("product %s does not match any existing product\n", c.Params("partno"))
11999 return c.Status(fiber.StatusNotFound).Redirect().To("/cat")
12000}
12001
12002// homeReadyJS restores the terminal's home directory before the wasm starts,
12003// as a desktop finds ~ where it left it: bottle's jsfs (the filesystem Go's os
12004// package uses in the page) is persisted to IndexedDB for /home only. The
12005// rest of the tree β /tmp, /dev, the templates a page seeds β is made fresh
12006// each load. The store keeps its cart there (pkg/tui/cartsave.go). Where jsfs
12007// is absent (?bare) or IndexedDB is refused, the wasm starts anyway, with a
12008// cart that lasts the page.
12009const homeReadyJS = `const homeReady = (globalThis.jsfs && jsfs.persist)
12010 ? jsfs.persist.enable("m2-home", { exclude: (p) => !(p === "/home" || p.startsWith("/home/")) }).catch(() => {})
12011 : Promise.resolve();`
12012
12013
12014// ===== pkg/web/shipping.go =====
12015package web
12016
12017// The shipping form, as the page draws it.
12018//
12019// These are methods on the page data rather than FuncMap entries for the
12020// reason wasmpick.go's WasmExecs records: templates here are parsed in
12021// several places and only some of them call .Funcs(funcs), so a template
12022// function is a parse error waiting for whichever page was built bare.
12023// A method travels with the data and every parse site can call it.
12024
12025import (
12026 "github.com/0magnet/m2/pkg/storepage"
12027)
12028
12029// ShippingFields is the form, in order β the same list pkg/tui builds its
12030// overlay from and the same field names wasm/cart reads back.
12031func (h htmlTemplateData) ShippingFields() []storepage.Field {
12032 return storepage.ShippingFields
12033}
12034
12035// ShippingStates is the State select's options.
12036func (h htmlTemplateData) ShippingStates() []storepage.Jurisdiction {
12037 return storepage.States
12038}
12039
12040// ShippingCountries is the Country select's options.
12041func (h htmlTemplateData) ShippingCountries() []string { return storepage.Countries }
12042
12043// StatePrompt is the unselected entry at the top of the State select.
12044func (h htmlTemplateData) StatePrompt() string { return storepage.StatePrompt }
12045
12046// MinShipping is the least the store ships for, as the number a min
12047// attribute wants ("7").
12048func (h htmlTemplateData) MinShipping() string {
12049 return itoa(storepage.MinShippingCents / 100)
12050}
12051
12052// MinShippingValue is that same figure as a price ("7.00"), for the
12053// amount field's default.
12054func (h htmlTemplateData) MinShippingValue() string {
12055 c := storepage.MinShippingCents
12056 return itoa(c/100) + "." + pad2(c%100)
12057}
12058
12059func itoa(n int) string {
12060 if n == 0 {
12061 return "0"
12062 }
12063 var b []byte
12064 for n > 0 {
12065 b = append([]byte{byte('0' + n%10)}, b...)
12066 n /= 10
12067 }
12068 return string(b)
12069}
12070
12071func pad2(n int) string {
12072 s := itoa(n)
12073 if len(s) < 2 {
12074 return "0" + s
12075 }
12076 return s
12077}
12078
12079// CoinCheckout reports whether the deployment takes crypto, so the cart
12080// offers the button only where it will work. A method rather than a
12081// FuncMap entry, for the reason at the top of this file.
12082func (h htmlTemplateData) CoinCheckout() bool { return cryptoPay != nil }
12083
12084// CoinName is the coin the store takes, for the button's label.
12085func (h htmlTemplateData) CoinName() string {
12086 if cryptoPay == nil {
12087 return ""
12088 }
12089 return string(cryptoPay.backend.Coin())
12090}
12091
12092
12093// ===== pkg/web/shipping_test.go =====
12094package web
12095
12096import (
12097 "bytes"
12098 htmpl "html/template"
12099 "os"
12100 "strings"
12101 "testing"
12102
12103 "github.com/0magnet/m2/pkg/storepage"
12104)
12105
12106// renderFooter runs the footer partial the way a page does. It is parsed
12107// BARE β no .Funcs β on purpose: that is the state a template set built
12108// somewhere that forgot the FuncMap is in, and the shipping form must
12109// survive it. See pkg/web/shipping.go.
12110func renderFooter(t *testing.T) string {
12111 t.Helper()
12112 src, err := os.ReadFile("../../htmpl/footer.html")
12113 if err != nil {
12114 t.Fatal(err)
12115 }
12116 tmpl, err := htmpl.New("footer").Parse(string(src))
12117 if err != nil {
12118 t.Fatalf("footer does not parse without a FuncMap: %v", err)
12119 }
12120 var b bytes.Buffer
12121 if err := tmpl.Execute(&b, struct{ Page htmlTemplateData }{}); err != nil {
12122 t.Fatal(err)
12123 }
12124 return b.String()
12125}
12126
12127// The cart wasm reads each control by [name='...']; a renamed or dropped
12128// control silently produces an empty field in the shipping line, so the
12129// generated form must still carry every one of them.
12130func TestShippingFormKeepsItsControlNames(t *testing.T) {
12131 got := renderFooter(t)
12132 for _, f := range storepage.ShippingFields {
12133 if !strings.Contains(got, "name='"+f.Name+"'") {
12134 t.Errorf("no control named %q in the rendered form", f.Name)
12135 }
12136 if !strings.Contains(got, "id='"+f.Name+"'") {
12137 t.Errorf("no id %q in the rendered form", f.Name)
12138 }
12139 }
12140}
12141
12142// Every jurisdiction reaches the page, by code and by name.
12143func TestShippingFormListsEveryState(t *testing.T) {
12144 got := renderFooter(t)
12145 if n := strings.Count(got, "<option value='"); n != len(storepage.States)+len(storepage.Countries)+1 {
12146 t.Errorf("option count = %d, want %d states + %d countries + 1 prompt",
12147 n, len(storepage.States), len(storepage.Countries))
12148 }
12149 for _, s := range storepage.States {
12150 if !strings.Contains(got, "<option value='"+s.Code+"'>"+s.Name+"</option>") {
12151 t.Errorf("missing state option %s / %s", s.Code, s.Name)
12152 }
12153 }
12154}
12155
12156// The minimum is one number now; the form must show it rather than its own.
12157func TestShippingFormUsesTheSharedMinimum(t *testing.T) {
12158 got := renderFooter(t)
12159 if !strings.Contains(got, "min='7'") || !strings.Contains(got, "value='7.00'") {
12160 t.Errorf("amount field does not carry the shared minimum:\n%s", got)
12161 }
12162}
12163
12164// A pipe in any field shifts every field after it in the cart line.
12165func TestShippingRejectsAndScrubsTheSeparator(t *testing.T) {
12166 s := storepage.Shipping{
12167 Cents: 700, Name: "A|B", City: "Dallas", State: "TX", Country: "United States",
12168 }
12169 if msg := s.Validate(); msg == "" {
12170 t.Error("a pipe in Name was accepted")
12171 }
12172 if strings.Count(s.ID(), "|") != 8 {
12173 t.Errorf("ID() did not scrub the separator: %q", s.ID())
12174 }
12175 back, ok := storepage.ParseShipping(s.ID())
12176 if !ok || back.City != "Dallas" || back.State != "TX" {
12177 t.Errorf("round trip lost fields: %+v ok=%v", back, ok)
12178 }
12179}
12180
12181// Below the minimum is refused rather than silently charged.
12182func TestShippingEnforcesTheMinimum(t *testing.T) {
12183 if msg := (storepage.Shipping{Cents: 0}).Validate(); msg == "" {
12184 t.Error("zero shipping was accepted")
12185 }
12186 if msg := (storepage.Shipping{Cents: storepage.MinShippingCents, Email: "a@example.com"}).Validate(); msg != "" {
12187 t.Errorf("the minimum itself was refused: %s", msg)
12188 }
12189}
12190
12191// The email field is on the page's form, and the browser holds it required.
12192func TestShippingFormAsksForAnEmail(t *testing.T) {
12193 got := renderFooter(t)
12194 if !strings.Contains(got, "<input type='email' id='shipping-email' name='shipping-email' required") {
12195 t.Errorf("the shipping form has no required email input:\n%s", got)
12196 }
12197}
12198
12199
12200// ===== pkg/web/sourcecode.go =====
12201// Package web pkg/web/sourcecode.go β the store serves its own source.
12202package web
12203
12204import (
12205 "bytes"
12206 "embed"
12207 "fmt"
12208 "io/fs"
12209 "os"
12210 "strings"
12211
12212 "github.com/alecthomas/chroma/v2"
12213 "github.com/alecthomas/chroma/v2/formatters/html"
12214 "github.com/alecthomas/chroma/v2/lexers"
12215 "github.com/alecthomas/chroma/v2/styles"
12216 "github.com/gofiber/fiber/v3"
12217
12218 "github.com/0magnet/m2/pkg/config"
12219 "github.com/0magnet/m2/pkg/product"
12220 "github.com/0magnet/m2/pkg/tui"
12221)
12222
12223//go:embed *.go
12224var srcWeb embed.FS
12225
12226type srcEntry struct {
12227 Prefix string
12228 FS fs.FS
12229}
12230
12231// goSources holds each package's embedded Go files for /sourcecode/go. A Go
12232// package cannot embed files outside its own directory, so since the code
12233// moved out of the repo root every package embeds its own *.go and is listed
12234// here. The web package's own embed picks up the other.go drop-in when a
12235// deployment builds with one.
12236var goSources = []srcEntry{
12237 {"pkg/config", config.Source},
12238 {"pkg/product", product.Source},
12239 {"pkg/tui", tui.Source},
12240 {"pkg/web", srcWeb},
12241}
12242
12243// RegisterSource adds a package's embedded sources to /sourcecode/go β used
12244// by the commands package, which web cannot import without a cycle.
12245func RegisterSource(prefix string, fsys fs.FS) {
12246 goSources = append(goSources, srcEntry{prefix, fsys})
12247}
12248
12249var sourcesWasm []fs.FS
12250var sourceCore = os.DirFS("ui")
12251var sourceHtml = os.DirFS("htmpl")
12252var sourceContent = os.DirFS("content")
12253
12254func serveSourceCode(r *fiber.App) {
12255 for _, wasmSRC := range f.WasmSRC {
12256 sourcesWasm = append(sourcesWasm, os.DirFS(wasmSRC))
12257 }
12258 r.Get("/sourcecode", func(c fiber.Ctx) error {
12259 ret := `<!doctype html>
12260<html lang='en'>
12261<head>
12262<link rel="stylesheet" href="/style.css" type="text/css">
12263</head>
12264<body class='grid-container' style='background-color:black;color:white;'>
12265<a href='/sourcecode/go'>GO</a><br><br>
12266
12267<a href='/sourcecode/html'>HTML</a><br><br>
12268
12269<a href='/sourcecode/content'>Content</a><br><br>
12270
12271<a href='/sourcecode/core'>C.O.R.E.</a><br><br>
12272
12273<a href='/sourcecode/wasm'>WASM</a><br><br>
12274
12275</body>
12276</html>
12277`
12278 c.Set("Content-Type", "text/html;charset=utf-8")
12279 _, err := c.Status(fiber.StatusOK).Write([]byte(ret))
12280 return err
12281 })
12282
12283 r.Get("/sourcecode/html", sourcecodehtml)
12284 r.Get("/sourcecode/content", sourcecodecontent)
12285 r.Get("/sourcecode/go", sourcecodego)
12286 r.Get("/sourcecode/core", sourcecodecore)
12287 r.Get("/sourcecode/wasm", func(c fiber.Ctx) error {
12288 ret := `<!doctype html>
12289<html lang='en'>
12290<head>
12291<link rel="stylesheet" href="/style.css" type="text/css">
12292</head>
12293<body class='grid-container' style='background-color:black;color:white;'>
12294`
12295 for _, wasmSRC := range f.WasmSRC {
12296 pathNameSlc := strings.Split(wasmSRC, "/")
12297 pathName := pathNameSlc[len(pathNameSlc)-1]
12298 ret += `<a href='/sourcecode/wasm/` + pathName + `'>` + pathName + `</a><br>
12299 `
12300 }
12301 ret += `</body></html>
12302 `
12303 c.Set("Content-Type", "text/html;charset=utf-8")
12304 _, err := c.Status(fiber.StatusOK).Write([]byte(ret))
12305 return err
12306 })
12307
12308 for i, wasmSRC := range f.WasmSRC {
12309 pathNameSlc := strings.Split(wasmSRC, "/")
12310 pathName := pathNameSlc[len(pathNameSlc)-1]
12311 r.Get("/sourcecode/wasm/"+pathName, func(c fiber.Ctx) error {
12312 return sourcecode(c, sourcesWasm[i], "dracula", "go")
12313 })
12314 }
12315}
12316
12317func sourcecodehtml(c fiber.Ctx) error {
12318 return sourcecode(c, sourceHtml, "monokai", "html")
12319}
12320func sourcecodecontent(c fiber.Ctx) error {
12321 return sourcecode(c, sourceContent, "monokai", "html")
12322}
12323
12324func sourcecodego(c fiber.Ctx) error {
12325 var builder strings.Builder
12326 for _, src := range goSources {
12327 if err := collectSource(&builder, src.FS, src.Prefix, "go"); err != nil {
12328 return err
12329 }
12330 }
12331 return renderSource(c, builder.String(), "monokai", "go")
12332}
12333
12334func sourcecodecore(c fiber.Ctx) error {
12335 return sourcecode(c, sourceCore, "solarized-dark256", "go")
12336}
12337
12338func sourcecode(c fiber.Ctx, fsys fs.FS, styleName string, lang string) error {
12339 var builder strings.Builder
12340 if err := collectSource(&builder, fsys, "", lang); err != nil {
12341 return err
12342 }
12343 return renderSource(c, builder.String(), styleName, lang)
12344}
12345
12346// collectSource concatenates every .<lang> file in fsys into builder, each
12347// under a banner naming it (with prefix, so files from different packages
12348// keep their repo paths).
12349func collectSource(builder *strings.Builder, fsys fs.FS, prefix string, lang string) error {
12350 return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
12351 if err != nil {
12352 return err
12353 }
12354 if !d.IsDir() && strings.HasSuffix(path, "."+lang) {
12355 content, err := fs.ReadFile(fsys, path)
12356 if err != nil {
12357 return err
12358 }
12359 name := path
12360 if prefix != "" {
12361 name = prefix + "/" + path
12362 }
12363 builder.WriteString(fmt.Sprintf("// ===== %s =====\n", name))
12364 builder.Write(content)
12365 builder.WriteString("\n\n")
12366 }
12367 return nil
12368 })
12369}
12370
12371func renderSource(c fiber.Ctx, source string, styleName string, lang string) error {
12372 c.Set("Content-Type", "text/html;charset=utf-8")
12373 var buf bytes.Buffer
12374
12375 // Pick lexer & style
12376 lexer := lexers.Get(lang)
12377 if lexer == nil {
12378 lexer = lexers.Fallback
12379 }
12380 lexer = chroma.Coalesce(lexer)
12381
12382 style := styles.Get(styleName)
12383 if style == nil {
12384 style = styles.Fallback
12385 }
12386
12387 // Formatter with line numbers & CSS classes
12388 formatter := html.New(
12389 html.WithLineNumbers(true),
12390 html.WithClasses(true),
12391 )
12392
12393 iterator, err := lexer.Tokenise(nil, source)
12394 if err != nil {
12395 return err
12396 }
12397
12398 // Optional: include CSS in output
12399 var css bytes.Buffer
12400 _ = formatter.WriteCSS(&css, style) //nolint:errcheck // the stylesheet is decoration; the highlighted source is still readable without it
12401 buf.WriteString("<style>")
12402 buf.Write(css.Bytes())
12403 buf.WriteString("</style>")
12404
12405 if err := formatter.Format(&buf, style, iterator); err != nil {
12406 return err
12407 }
12408
12409 _, err = c.Status(fiber.StatusOK).Write(buf.Bytes())
12410 return err
12411}
12412
12413
12414// ===== pkg/web/tmpl.go =====
12415// Package web pkg/web/tmpl.go β template plumbing.
12416package web
12417
12418import (
12419 "bytes"
12420 "fmt"
12421 htmpl "html/template"
12422 "log"
12423 "os"
12424 "path/filepath"
12425 "sort"
12426 "strconv"
12427 "strings"
12428 ttmpl "text/template"
12429 "time"
12430
12431 "github.com/gofiber/fiber/v3"
12432
12433 p "github.com/0magnet/m2/pkg/product"
12434)
12435
12436/*
12437//go:embed htmpl/*
12438var templatesFS embed.FS
12439
12440//go:embed content/*
12441var contentFS embed.FS
12442*/
12443/*
12444var (
12445 templatesFS = os.DirFS("htmpl")
12446 contentFS = os.DirFS("content")
12447)
12448*/
12449/*
12450func mustReadEmbeddedFileToString(path string, fs embed.FS) string {
12451 return string(mustReadEmbeddedFileToBytes(path, fs))
12452}
12453
12454func mustReadEmbeddedFileToBytes(path string, fs embed.FS) []byte {
12455 data, err := fs.ReadFile(path)
12456 if err != nil {
12457 panic(err)
12458 }
12459 return data
12460}
12461*/
12462
12463func mustReadFileToString(path string) string {
12464 return string(mustReadFileToBytes(path))
12465}
12466
12467// optionalReadFileToString returns "" when the file does not exist.
12468// Used for deployment-local assets (e.g. content/font.css) that are
12469// not part of the source repo.
12470func optionalReadFileToString(path string) string {
12471 data, err := os.ReadFile(path) //nolint
12472 if err != nil {
12473 return ""
12474 }
12475 return string(data)
12476}
12477
12478// contentFile returns the deployment-local file when present, falling
12479// back to the committed <path>.example. Lets site operators override
12480// stock pages (about, policy, links) without touching tracked files.
12481func contentFile(path string) string {
12482 if s := optionalReadFileToString(path); s != "" {
12483 return s
12484 }
12485 return mustReadFileToString(path + ".example")
12486}
12487
12488func mustReadFileToBytes(path string) []byte {
12489 data, err := os.ReadFile(path) //nolint
12490 if err != nil {
12491 panic(err)
12492 }
12493 return data
12494}
12495
12496type htmlTemplate struct {
12497 Empty func() string
12498 Head func() string
12499 Logo func() string
12500 Header func() string
12501 Categories func() string
12502 CatSubcats func() string
12503 Footer func() string
12504 MainPage func() string
12505 AuxPage func() string
12506 FrontPage func() string
12507 CategoryPage func() string
12508 CategoryPageMD func() string
12509 ProductPage func() string
12510 ProductPageMD func() string
12511 Schema func() string
12512 Cart func() string
12513 XMLSitemap func() string
12514 Wasm func() string
12515 Keys func() string
12516 AboutPage func() string
12517 PolicyPage func() string
12518 LinksPage func() string
12519 CheckoutPage func() string
12520 CompletePage func() string
12521 CheckoutCSS func() string
12522 StyleCSS func() string
12523 FontCSS func() string
12524}
12525
12526var h = htmlTemplate{
12527 Empty: func() string { return mustReadFileToString("htmpl/empty.html") },
12528 Head: func() string { return mustReadFileToString("htmpl/head.html") },
12529 Logo: func() string { return mustReadFileToString("htmpl/logo.html") },
12530 Header: func() string { return mustReadFileToString("htmpl/header.html") },
12531 Categories: func() string { return mustReadFileToString("htmpl/categories.html") },
12532 CatSubcats: func() string { return mustReadFileToString("htmpl/catsubcats.html") },
12533 Footer: func() string { return mustReadFileToString("htmpl/footer.html") },
12534 MainPage: func() string { return mustReadFileToString("htmpl/main.html") },
12535 AuxPage: func() string { return mustReadFileToString("htmpl/auxpage.html") },
12536 FrontPage: func() string { return mustReadFileToString("htmpl/front.html") },
12537 CategoryPage: func() string { return mustReadFileToString("htmpl/category.html") },
12538 CategoryPageMD: func() string { return mustReadFileToString("htmpl/category.md") },
12539 ProductPage: func() string { return mustReadFileToString("htmpl/product.html") },
12540 ProductPageMD: func() string { return mustReadFileToString("htmpl/product.md") },
12541 Schema: func() string { return mustReadFileToString("htmpl/schema.html") },
12542 Cart: func() string { return mustReadFileToString("htmpl/cart.html") },
12543 XMLSitemap: func() string { return mustReadFileToString("htmpl/sitemap.xml") },
12544 Wasm: func() string { return mustReadFileToString("htmpl/wasm.html") },
12545 Keys: func() string { return mustReadFileToString("htmpl/keys.html") },
12546 CompletePage: func() string { return mustReadFileToString("htmpl/complete.html") },
12547 AboutPage: func() string { return contentFile("content/about.html") },
12548 PolicyPage: func() string { return contentFile("content/policy.html") },
12549 LinksPage: func() string { return contentFile("content/links.html") },
12550 CheckoutPage: func() string { return mustReadFileToString("content/checkout.html") },
12551 CheckoutCSS: func() string { return mustReadFileToString("content/checkout.css") },
12552 StyleCSS: func() string {
12553 return fontCSS() + mustReadFileToString("content/style.css")
12554 },
12555 // The terminal page wants the face on its own, without the stylesheet.
12556 FontCSS: func() string { return fontCSS() },
12557}
12558
12559var htmlPageTemplateData htmlTemplateData
12560
12561var funcs = htmpl.FuncMap{
12562 "replace": replace, "mul": mul, "div": div, "safeHTML": safeHTML,
12563 "safeJS": safeJS, "stripProtocol": stripProtocol, "add": add, "sub": sub,
12564 "toFloat": toFloat, "equalsIgnoreCase": equalsIgnoreCase,
12565 "getsubcats": getsubcats, "escapesubcat": escapesubcat,
12566 "sortsubcats": sortsubcats, "repeat": repeat, "subcatlink": subcatlink,
12567 "navmenu": navmenu,
12568}
12569
12570func mainTmpl() (tmpl *htmpl.Template, err error) {
12571 tmpl = htmpl.New("index").Funcs(funcs)
12572 if _, err := tmpl.Parse(h.MainPage()); err != nil {
12573 log.Println("Error parsing index template:", err)
12574 return tmpl, err
12575 }
12576
12577 for _, p := range mainPartials() {
12578 if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
12579 log.Printf("Error parsing %s template: %v", p.Name, err)
12580 return tmpl, err
12581 }
12582 }
12583 return tmpl, err
12584}
12585
12586// mainPartialSrc is every template main.html may invoke, besides the
12587// "main" its caller supplies.
12588//
12589// One list of name and loader rather than a list of names and a map to
12590// look them up in: the names have to be readable without loading the
12591// bodies, because a test has no working directory to read them from, and
12592// two collections would let a name arrive with no body behind it.
12593//
12594// The test matters because a missing partial is not a parse error.
12595// html/template notices only at execute time, and then on every page of
12596// the site at once, which is a poor place to find out.
12597var mainPartialSrc = []struct {
12598 Name string
12599 Body func() string
12600}{
12601 {"head", h.Head},
12602 {"schema", h.Schema},
12603 {"header", h.Header},
12604 {"catsubcats", h.CatSubcats},
12605 {"categories", h.Categories},
12606 {"footer", h.Footer},
12607 {"cart", h.Cart},
12608 {"wasm", h.Wasm},
12609 {"keys", h.Keys},
12610}
12611
12612// mainPartials loads them.
12613func mainPartials() []struct{ Name, Content string } {
12614 out := make([]struct{ Name, Content string }, 0, len(mainPartialSrc))
12615 for _, p := range mainPartialSrc {
12616 out = append(out, struct{ Name, Content string }{p.Name, p.Body()})
12617 }
12618 return out
12619}
12620
12621func auxTmpl() (tmpl *htmpl.Template, err error) {
12622 tmpl = htmpl.New("index").Funcs(funcs)
12623 if _, err := tmpl.Parse(h.AuxPage()); err != nil {
12624 log.Println("Error parsing index template:", err)
12625 return tmpl, err
12626 }
12627
12628 partials := []struct {
12629 Name string
12630 Content string
12631 }{
12632 {"head", h.Head()},
12633 {"schema", h.Empty()},
12634 {"wasm", h.Empty()},
12635 }
12636
12637 for _, p := range partials {
12638 if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
12639 log.Printf("Error parsing %s template: %v", p.Name, err)
12640 return tmpl, err
12641 }
12642 }
12643 return tmpl, err
12644}
12645
12646func pageMeta(c fiber.Ctx, base htmlTemplateData) htmlTemplateData {
12647 h := base
12648 host := string(c.Request().Host())
12649 /*
12650 proto := "http"
12651 if c.Secure() {
12652 proto += "s"
12653 }
12654 */
12655 proto := "https"
12656 h.Canonical = proto + "://" + host + c.OriginalURL()
12657 h.BaseURL = proto + "://" + host
12658 h.RequestHost = host
12659 h.Protocol = proto
12660 h.CatsCounts, h.Cats, h.SubCatsCounts, h.SubCatsByCat = getcategories(allproducts)
12661 h.LenAllProducts = len(allproducts)
12662 h.Time = time.Now().Format(time.RFC3339Nano)
12663 h.Year = fmt.Sprintf("%v", time.Now().Year())
12664 h.MetaDesc = f.Sitemeta
12665 h.ImgBase = h.ImgSRC
12666 if strings.HasPrefix(h.ImgBase, "/") {
12667 h.ImgBase = h.BaseURL + h.ImgBase
12668 }
12669 h.KeyWords = strings.Replace(f.Sitelongname, " ", ", ", -1)
12670 return h
12671}
12672
12673func initTMPL() {
12674 htmlPageTemplateData = htmlTemplateData{
12675 TestMode: f.Teststripekey,
12676 Title: f.Sitelongname,
12677 StripePK: f.StripePK,
12678 SiteName: f.Sitedomain,
12679 SiteTagLine: f.Sitetagline,
12680 SiteName1: htmpl.HTML(checkerBoard(f.Sitedomain)), //nolint
12681 SiteLongName: f.Sitelongname,
12682 SiteASCIILogo: htmpl.HTML(f.SiteASCIILogo), //nolint
12683 SitePrettyName: f.Siteprettyname,
12684 SitePrettyNameCap: f.Siteprettynamecap,
12685 SitePrettyNameCaps: f.Siteprettynamecaps,
12686 TelegramContact: f.Tgcontact,
12687 TelegramChannel: f.Tgchannel,
12688 WasmExecPath: f.WasmExecPath,
12689 WasmExecRel: f.WasmExecPath,
12690 Cats: getcats(),
12691 LenAllProducts: len(allproducts),
12692 ImgSRC: func() (ret string) {
12693 ret = f.Siteimagesrc
12694 if ret == "" {
12695 ret = "/i"
12696 }
12697 return ret
12698 }(),
12699 Page: "front",
12700 Time: time.Now().Format(time.RFC3339Nano),
12701 Year: fmt.Sprintf("%v", time.Now().Year()),
12702 NavLinks: extraNavLinks,
12703 }
12704 htmlPageTemplateData.CatsCounts, htmlPageTemplateData.Cats, htmlPageTemplateData.SubCatsCounts, htmlPageTemplateData.SubCatsByCat = getcategories(allproducts)
12705 // No wasm by default; a page opts in with wasmFor. Defaulting to all of
12706 // them is what had every page in the site fetching stl2 (7.9MB) for a
12707 // canvas it does not have, and asking for the tui and desk binaries,
12708 // which have no TinyGo build and answer 500. Those two pages build their
12709 // own HTML and loader anyway, so they never read this list.
12710 htmlPageTemplateData.WasmBinary = nil
12711
12712}
12713
12714// wasmFor returns only the configured binaries named, so a page carries the
12715// wasm it can actually use and nothing else.
12716//
12717// Every page used to carry all of them, and each binary opens by looking for
12718// the element it drives and returning when that element is not there β so an
12719// ordinary category page fetched several megabytes, instantiated it, and got
12720// a handful of early returns. stl2 is the expensive one at 7.9MB, and only
12721// the front page and a product page have the canvas it draws into.
12722//
12723// It also stops the binaries that have no TinyGo build yet from being asked
12724// for on every page in the site: those fetches answer 500, which is two
12725// failed requests per page view for something the page could not use anyway.
12726//
12727// Names match on the part before the suffix, so a caller says "stl2" and gets
12728// whichever of stl2.wasm or stl2-tiny.wasm this build is configured for.
12729func wasmFor(names ...string) []wasmPick { return wasmPicks(pickWasm, names) }
12730
12731// wasmSmallFor is wasmFor for a binary a page carries on every view
12732// rather than when the reader asks for it: it takes the tinygo build
12733// where there is one. See pickWasmSmall.
12734func wasmSmallFor(names ...string) []wasmPick { return wasmPicks(pickWasmSmall, names) }
12735
12736func wasmPicks(pick func(string) (wasmPick, bool), names []string) []wasmPick {
12737 // WasmSRC lists the sources THIS process builds, and a page must not
12738 // name a drop-in the deployment never compiles. The tab compiles
12739 // nothing and its list is empty, so the same gate there would drop
12740 // every binary before it was looked for; the origin, which pick asks
12741 // directly, is the authority instead.
12742 inTab := servingInTab()
12743 configured := map[string]bool{}
12744 if !inTab {
12745 for _, src := range f.WasmSRC {
12746 configured[strings.TrimSuffix(filepath.Base(src), filepath.Ext(src))] = true
12747 }
12748 }
12749 ret := make([]wasmPick, 0, len(names))
12750 for _, want := range names {
12751 if !inTab && !configured[want] {
12752 continue
12753 }
12754 if p, ok := pick(want); ok {
12755 ret = append(ret, p)
12756 }
12757 }
12758 return ret
12759}
12760
12761type xmlTemplateData struct {
12762 BaseURL string
12763 Extra []string
12764 Cats []string
12765 SubCatsByCat map[string][]string
12766 Products p.Products
12767 Update string
12768}
12769
12770func generateSitemapXML() string {
12771 xmlSitemapTemplateData := xmlTemplateData{
12772 BaseURL: "https://" + f.Sitedomain,
12773 Products: allproducts,
12774 Extra: extraSitemapURLs,
12775 Update: time.Now().Format("2006-01-02"),
12776 }
12777 _, xmlSitemapTemplateData.Cats, _, xmlSitemapTemplateData.SubCatsByCat = getcategories(allproducts)
12778 var err1 error
12779 xtmpl, err1 := ttmpl.New("index").Funcs(ttmpl.FuncMap{"getsubcats": getsubcats}).Parse(h.XMLSitemap())
12780 if err1 != nil {
12781 log.Println("Error parsing index template:", err1)
12782 }
12783 var result bytes.Buffer
12784 err1 = xtmpl.Execute(&result, xmlSitemapTemplateData)
12785 if err1 != nil {
12786 log.Println("error: ", err1)
12787 }
12788 return result.String()
12789}
12790
12791func toFloat(s string) float64 {
12792 if s == "" {
12793 return 0.0
12794 }
12795 f, err := strconv.ParseFloat(s, 64)
12796 if err != nil {
12797 return 0.0
12798 }
12799 return f
12800}
12801
12802func checkerBoard(input string) string {
12803 var result strings.Builder
12804 for i, char := range input {
12805 // Wrap every other letter with the specified HTML
12806 if i%2 == 0 {
12807 result.WriteString(fmt.Sprintf("<span class='nv'>%c</span>", char))
12808 } else {
12809 result.WriteRune(char)
12810 }
12811 }
12812 return result.String()
12813}
12814
12815type htmlTemplateData struct {
12816 Title string
12817 MetaDesc string
12818 Canonical string
12819 BaseURL string
12820 ImgSRC string // url where images are hosted
12821 // ImgBase is ImgSRC made absolute. og:image and schema.org image must be
12822 // absolute URLs: a link unfurler has no page to resolve a relative one
12823 // against and most simply drop it, which is why every share of this site
12824 // showed no picture. ImgSRC may already name another host, so this cannot
12825 // be done by pasting BaseURL in front of it in the template.
12826 ImgBase string
12827 OrdersURL string // url where checkout is served from
12828 SiteName string
12829 SiteTagLine string
12830 SiteName1 htmpl.HTML //checkerboard - alternate swap text & bg color
12831 SiteLongName string
12832 SitePrettyName string //ππππππ₯π π€π‘πππ£π.πππ₯
12833 SitePrettyNameCap string //ππππππ₯π π€π‘πππ£π.πππ₯
12834 SitePrettyNameCaps string //ππΈπΎβπΌπππββπΌβπΌ.βπΌπ
12835 SiteASCIILogo htmpl.HTML
12836 TelegramContact string
12837 TelegramChannel string
12838 Protocol string
12839 RequestHost string
12840 KeyWords string
12841 Style htmpl.HTML
12842 Heading htmpl.HTML
12843 StripePK string
12844 Cats []string
12845 CatsCounts map[string]int
12846 SubCatsCounts map[string]map[string]int
12847 SubCatsByCat map[string][]string
12848 LenAllProducts int
12849 Mobile bool
12850 Gocanvas htmpl.HTML
12851 WasmBinary []wasmPick
12852 WasmExecPath string
12853 WasmExecRel string
12854 StyleFontFace htmpl.CSS
12855 Message htmpl.HTML
12856 Page string
12857 Year string
12858 Time string
12859 AboutHTML htmpl.HTML
12860 LinksHTML htmpl.HTML
12861 PolicyHTML htmpl.HTML
12862 TestMode bool
12863
12864 // NavLinks are the entries drop-in routes added to the header; see
12865 // extraNavLinks.
12866 NavLinks []NavLink
12867}
12868
12869func equalsIgnoreCase(a, b string) bool {
12870 return strings.EqualFold(strings.Join(strings.Fields(a), ""), strings.Join(strings.Fields(b), ""))
12871}
12872
12873func replace(s, o, n string) string {
12874 return strings.ReplaceAll(s, o, n)
12875}
12876func mul(a, b float64) float64 {
12877 return a * b
12878}
12879func div(a, b float64) float64 {
12880 return a / b
12881}
12882func add(a, b int) int {
12883 return a + b
12884}
12885func sub(a, b int) int {
12886 return a - b
12887}
12888func safeHTML(s string) htmpl.HTML {
12889 return htmpl.HTML(s) //nolint
12890}
12891func safeJS(s string) htmpl.JS {
12892 return htmpl.JS(s) //nolint
12893}
12894func stripProtocol(s string) string {
12895 return strings.Replace(strings.Replace(s, "https://", "", -1), "http://", "", -1)
12896}
12897func repeat(s string, count int) string {
12898 var result string
12899 for i := 0; i < count; i++ {
12900 result += s
12901 }
12902 return result
12903}
12904func sortsubcats(subcats []string, counts map[string]map[string]int) []string {
12905 sort.Slice(subcats, func(i, j int) bool {
12906 catI, catJ := subcats[i], subcats[j]
12907 countI, countJ := counts[catI]["count"], counts[catJ]["count"]
12908 return countI > countJ
12909 })
12910 return subcats
12911}
12912
12913func subcatlink(subcategory string) string {
12914 s := subcategory
12915 s = strings.ReplaceAll(s, "ΒΌ", "quarter-")
12916 s = strings.ReplaceAll(s, "Β½", "half-")
12917 s = strings.ReplaceAll(s, "1/16", "sixteenth-")
12918 s = strings.ReplaceAll(s, "%", "-pct")
12919 s = strings.ReplaceAll(s, " ", " ")
12920 s = strings.ReplaceAll(s, "watt1", "watt-1")
12921 s = strings.ReplaceAll(s, "watt5", "watt-5")
12922 s = strings.ReplaceAll(s, " ", "-")
12923 s = strings.ReplaceAll(s, "--", "-")
12924 return s
12925}
12926
12927// fontCSS is the deployment's @font-face block, read from disk. It is a
12928// plain function rather than a method on h because h's own initializer
12929// needs it, and a var cannot refer to itself.
12930func fontCSS() string { return optionalReadFileToString("content/font.css") }
12931
12932
12933// ===== pkg/web/tmpl_test.go =====
12934package web
12935
12936import (
12937 "os"
12938 "regexp"
12939 "testing"
12940)
12941
12942var tmplInvoke = regexp.MustCompile(`\{\{\s*template\s+"([^"]+)"`)
12943
12944// Every partial main.html asks for has to be registered with it.
12945//
12946// html/template does not notice a missing one when the page is parsed β
12947// only when it is executed, and then on every page of the site at once.
12948// That is a poor place to find out, and a new partial is exactly the
12949// change that forgets the registration.
12950func TestMainTemplateInvokesOnlyRegisteredPartials(t *testing.T) {
12951 src, err := os.ReadFile("../../htmpl/main.html")
12952 if err != nil {
12953 t.Fatalf("read main.html: %v", err)
12954 }
12955 known := map[string]bool{
12956 // Supplied by the handler for the page being rendered.
12957 "main": true,
12958 }
12959 for _, p := range mainPartialSrc {
12960 known[p.Name] = true
12961 }
12962 found := tmplInvoke.FindAllSubmatch(src, -1)
12963 if len(found) == 0 {
12964 t.Fatal("main.html invokes no templates at all, which cannot be right")
12965 }
12966 for _, m := range found {
12967 if name := string(m[1]); !known[name] {
12968 t.Errorf("main.html invokes %q, which mainTmpl does not register", name)
12969 }
12970 }
12971}
12972
12973// Every registration names a distinct template and carries a loader for
12974// it. The loaders read from the working directory, so this checks the
12975// shape rather than calling them.
12976func TestMainPartialsAreWellFormed(t *testing.T) {
12977 seen := map[string]bool{}
12978 for _, p := range mainPartialSrc {
12979 if p.Name == "" || p.Body == nil {
12980 t.Errorf("partial %+v has no name or no body", p.Name)
12981 }
12982 if seen[p.Name] {
12983 t.Errorf("%q is registered twice", p.Name)
12984 }
12985 seen[p.Name] = true
12986 }
12987}
12988
12989
12990// ===== pkg/web/toolchain_js.go =====
12991//go:build js && wasm
12992
12993package web
12994
12995// haveToolchain reports whether this build can shell out to a Go
12996// compiler. In a page it cannot: there is no Go, no tinygo, and nothing
12997// to exec them with.
12998//
12999// This exists because the whole m2 command tree now runs in the browser
13000// shell, so `m2 wasm` and `m2 run` are reachable there β and every
13001// failure path in wasmbuild.go is a log.Fatal, which is os.Exit, which
13002// in a page takes down the wasm instance and every terminal in it. A
13003// command that cannot do its job should say so and return non-zero, not
13004// kill the tab.
13005const haveToolchain = false
13006
13007
13008// ===== pkg/web/toolchain_native.go =====
13009//go:build !js || !wasm
13010
13011package web
13012
13013// haveToolchain reports whether this build can shell out to a Go
13014// compiler. Natively it can.
13015const haveToolchain = true
13016
13017
13018// ===== pkg/web/wasmbuild.go =====
13019// Package web pkg/web/wasmbuild.go β compiling the drop-in wasm apps.
13020package web
13021
13022import (
13023 "encoding/json"
13024 "fmt"
13025 "io"
13026 "io/fs"
13027 "log"
13028 "net/http"
13029 "os"
13030 "os/exec"
13031 "path/filepath"
13032 "regexp"
13033 "strconv"
13034
13035 "github.com/gofiber/fiber/v3"
13036 "strings"
13037 "time"
13038
13039 "github.com/bitfield/script"
13040 "github.com/briandowns/spinner"
13041
13042 "github.com/0magnet/m2/pkg/config"
13043)
13044
13045func CompileWASM() {
13046 if !haveToolchain {
13047 // Nothing here to build with; see toolchain_js.go.
13048 log.Println("wasm: no Go toolchain in this environment; nothing compiled")
13049 return
13050 }
13051 s := spinner.New(spinner.CharSets[14], 25*time.Millisecond)
13052 s.Suffix = " Compiling wasm..."
13053 // Each build runs on the system's default Go first; only if that fails does
13054 // it retry on a pinned Go toolchain. The pinned version is normally resolved
13055 // dynamically β tinygo trails the latest Go release, so on failure we read
13056 // the Go range tinygo supports from its own error and pin the newest matching
13057 // Go patch (see resolveCompatibleToolchain). That keeps the site on the newest
13058 // Go whenever tinygo supports it and self-heals across Go bumps with no
13059 // hardcoded version. GOTOOLCHAINFALLBACK in the MENV config is an OPTIONAL
13060 // offline backstop, used only if the dynamic lookup can't reach the release
13061 // list; leave it empty for fully dynamic behavior.
13062 offlineFallback := config.ScriptExecString("${GOTOOLCHAINFALLBACK}")
13063
13064 // Stage the drop-ins BEFORE building anything, from whatever the last run
13065 // left on disk, because a page binary embeds them (dropinfs.go) and
13066 // go:embed is read when it compiles.
13067 //
13068 // Staging after the builds instead would make the embed current within a
13069 // single run, and it is tempting for that reason. It also puts the tinygo
13070 // builds β stl2's is about twenty minutes β in front of every term.wasm
13071 // rebuild, so a one-line server edit would not reach the site for half an
13072 // hour. Staging first costs the opposite and much less: when a drop-in's
13073 // SOURCE changes, the copy inside the page binary is one restart behind.
13074 //
13075 // That is affordable because the embedded copy is a fallback and nothing
13076 // else. The host serves the drop-ins off disk, where they are current the
13077 // moment the build finishes, and a tab whose carried copy is stale is
13078 // still a tab serving a working cart. Nothing reads the embed unless the
13079 // disk has nothing.
13080 stageDropins(dropInSources())
13081
13082 for _, wasmSRC := range f.WasmSRC {
13083 ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
13084 outputFile := baseName(wasmSRC) + ".wasm"
13085 mk := func(tc string) string {
13086 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)
13087 }
13088 buildWasmWithFallback(mk, offlineFallback, s, true)
13089 }
13090 if !f.UseTinygo {
13091 return
13092 }
13093 for _, wasmSRC := range f.WasmSRC {
13094 ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
13095 outputFile := baseName(wasmSRC) + "-tiny.wasm"
13096 mk := func(tc string) string {
13097 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)
13098 }
13099 // The Go build above succeeded and serves; a drop-in that tinygo
13100 // cannot compile (e.g. one using net/http, which tinygo's shim
13101 // does not build) must not take the whole store down.
13102 buildWasmWithFallback(mk, offlineFallback, s, false)
13103 }
13104}
13105
13106// dropInSources is WasmSRC minus the page binaries β the sources whose builds
13107// a page loads alongside itself, and so the ones worth carrying.
13108func dropInSources() []string {
13109 var out []string
13110 for _, src := range f.WasmSRC {
13111 if !pageWasm[baseName(src)] {
13112 out = append(out, src)
13113 }
13114 }
13115 return out
13116}
13117
13118// pageWasm names the binaries that ARE a page rather than something a page
13119// loads alongside itself. They are not staged into the embed: the tab does not
13120// serve /tui or /desk, and term.wasm is the binary doing the embedding β a
13121// file cannot contain itself.
13122var pageWasm = map[string]bool{"term": true, "tui": true, "desk": true}
13123
13124func baseName(wasmSRC string) string {
13125 return strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
13126}
13127
13128// stageDropins copies the smallest build of each drop-in into the directory
13129// dropinfs.go embeds, so the page binaries compiled next carry them.
13130//
13131// Best effort throughout. A tree without the staging directory β a binary
13132// running somewhere other than its own source β simply stages nothing and the
13133// build carries nothing, which is the same as a fresh checkout.
13134func stageDropins(srcs []string) {
13135 dir := filepath.Join("pkg", "web", "dropins")
13136 // os.Root confines every name below to this directory. The names are
13137 // derived from WasmSRC, which is configuration rather than a request, but
13138 // "configuration cannot traverse" is a claim about the config file and not
13139 // about this code; the root makes it a property of this code.
13140 root, err := os.OpenRoot(dir)
13141 if err != nil {
13142 log.Printf("wasm: no %s to stage into; page binaries will carry no drop-ins", dir)
13143 return
13144 }
13145 defer root.Close() //nolint:errcheck // read-mostly; the writes below are closed individually
13146
13147 // Clear first, so a drop-in dropped from WasmSRC stops being carried.
13148 if ents, rerr := fs.ReadDir(root.FS(), "."); rerr == nil {
13149 for _, e := range ents {
13150 if !e.IsDir() && strings.HasSuffix(e.Name(), ".wasm") {
13151 _ = root.Remove(e.Name()) //nolint:errcheck // stale copy; the writes below are what matter
13152 }
13153 }
13154 }
13155
13156 for _, src := range srcs {
13157 name := baseName(src)
13158 // Smallest first, matching what a page is given to load.
13159 for _, cand := range []string{name + "-tiny.wasm", name + ".wasm"} {
13160 b, rerr := os.ReadFile(cand) //nolint:gosec // a path this process just built
13161 if rerr != nil {
13162 continue
13163 }
13164 if werr := writeInRoot(root, cand, b); werr != nil {
13165 log.Printf("wasm: staging %s: %v", cand, werr)
13166 continue
13167 }
13168 log.Printf("wasm: staged %s (%d bytes) for the in-tab server", cand, len(b))
13169 break
13170 }
13171 }
13172}
13173
13174// writeInRoot creates one file under root and writes it whole.
13175func writeInRoot(root *os.Root, name string, b []byte) error {
13176 fh, err := root.Create(name)
13177 if err != nil {
13178 return err
13179 }
13180 if _, err = fh.Write(b); err != nil {
13181 _ = fh.Close() //nolint:errcheck // the write error is the one to report
13182 return err
13183 }
13184 return fh.Close()
13185}
13186
13187// runBash runs an inner bash script and returns its combined stdout+stderr, so
13188// the caller can both log it and parse compiler errors out of it.
13189func runBash(inner string) (string, error) {
13190 // The command is built by this package, not taken from a request.
13191 out, err := exec.Command("bash", "-c", inner).CombinedOutput() //nolint:gosec
13192 return string(out), err
13193}
13194
13195// buildWasmWithFallback builds wasm on the system Go first (mkCmd("")). On
13196// failure it resolves a Go toolchain tinygo can use β dynamically from tinygo's
13197// own error output, or the offlineFallback if the release list is unreachable β
13198// and retries with GOTOOLCHAIN pinned to it. When required, a build that still
13199// fails is fatal; otherwise it is logged and skipped (the pages fall back to
13200// whichever binary exists). mkCmd receives the string to place before the
13201// build command: "" for the default, or "GOTOOLCHAIN=β¦ ".
13202func buildWasmWithFallback(mkCmd func(toolchainPrefix string) string, offlineFallback string, s *spinner.Spinner, required bool) {
13203 log.Println("Compiling wasm with:")
13204 log.Println(mkCmd(""))
13205 s.Start()
13206 out, err := runBash(mkCmd(""))
13207 s.Stop()
13208 log.Print(out)
13209 if err == nil {
13210 log.Println("Compiled wasm!")
13211 return
13212 }
13213 tc := resolveCompatibleToolchain(out)
13214 if tc == "" {
13215 tc = offlineFallback
13216 }
13217 if tc == "" {
13218 if required {
13219 log.Fatalf("wasm build failed and no compatible Go toolchain could be resolved: %v", err)
13220 }
13221 log.Printf("optional wasm build failed (no compatible toolchain): %v", err)
13222 return
13223 }
13224 log.Printf("wasm build failed on the default Go; retrying with GOTOOLCHAIN=%s", tc)
13225 s.Start()
13226 out, err = runBash(mkCmd("GOTOOLCHAIN=" + tc + " "))
13227 s.Stop()
13228 log.Print(out)
13229 if err != nil {
13230 if required {
13231 log.Fatal(err)
13232 }
13233 log.Printf("optional wasm build failed: %v", err)
13234 return
13235 }
13236 log.Printf("Compiled wasm (Go toolchain %s)!", tc)
13237}
13238
13239// resolveCompatibleToolchain reads tinygo's supported Go range from a failed
13240// build's output (e.g. "requires go version 1.19 through 1.26, got go1.27") and
13241// returns the newest released Go patch of that ceiling minor (e.g. "go1.26.7"),
13242// or "" if the range can't be parsed or the release list can't be fetched.
13243func resolveCompatibleToolchain(buildOutput string) string {
13244 m := regexp.MustCompile(`through (\d+)\.(\d+)`).FindStringSubmatch(buildOutput)
13245 if m == nil {
13246 return ""
13247 }
13248 minor := m[1] + "." + m[2]
13249
13250 client := &http.Client{Timeout: 20 * time.Second}
13251 resp, err := client.Get("https://go.dev/dl/?mode=json&include=all")
13252 if err != nil {
13253 return ""
13254 }
13255 defer func() { _ = resp.Body.Close() }() //nolint:errcheck,gosec
13256 body, err := io.ReadAll(resp.Body)
13257 if err != nil {
13258 return ""
13259 }
13260 var rels []struct {
13261 Version string `json:"version"`
13262 }
13263 if err := json.Unmarshal(body, &rels); err != nil {
13264 return ""
13265 }
13266
13267 // Newest patch of go<minor> (require a patch number: GOTOOLCHAIN rejects a
13268 // bare "go1.26" β it must be a full toolchain version like go1.26.7).
13269 re := regexp.MustCompile(`^go` + regexp.QuoteMeta(minor) + `\.(\d+)$`)
13270 best, bestPatch := "", -1
13271 for _, r := range rels {
13272 mm := re.FindStringSubmatch(r.Version)
13273 if mm == nil {
13274 continue
13275 }
13276 // A version that does not parse yields 0, which loses the comparison
13277 // below, so an unparsable tag is skipped rather than erroring.
13278 if p, _ := strconv.Atoi(mm[1]); p > bestPatch { //nolint:errcheck
13279 bestPatch, best = p, r.Version
13280 }
13281 }
13282 return best
13283}
13284
13285func ldflags(s string) (ss string) {
13286 checkFiles, err := script.FindFiles(s).Slice()
13287 if err != nil {
13288 log.Fatal(err)
13289 }
13290 if f.LDFlagsX != "" {
13291 for _, s := range checkFiles {
13292 res, err := script.File(s).Match(strings.Split(f.LDFlagsX, "=")[0]).String()
13293 if err != nil {
13294 log.Fatal(err)
13295 }
13296 if res != "" {
13297 ss += fmt.Sprintf(` -X 'main.%s' `, f.LDFlagsX)
13298 break
13299 }
13300 }
13301 }
13302 for _, s := range checkFiles {
13303 res, err := script.File(s).Match("wasmName").String()
13304 if err != nil {
13305 log.Fatal(err)
13306 }
13307 if res != "" {
13308 ss += fmt.Sprintf(` -X 'main.wasmName=%s' `, strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))+".wasm")
13309 break
13310 }
13311 }
13312 if ss != "" {
13313 ss = `-ldflags="` + ss + `"`
13314 }
13315 return ss
13316}
13317
13318// serveCarriedWASM registers a route for every drop-in the build staged into
13319// the binary, and for the runtime shims a page needs to start one.
13320//
13321// It is the tab's copy of serveWASM. serveWASM reads the working directory and
13322// is registered only in the host role, which is right there and wrong here: a
13323// tab has no working directory carrying binaries and never will. Everything
13324// unmatched falls through to the read-through proxy, so a drop-in the build did
13325// not stage still reaches the origin; this only means the common ones do not
13326// have to.
13327//
13328// Registered BEFORE the proxy catch-all, and only for files actually carried,
13329// so a route never shadows a path the origin could answer better.
13330func serveCarriedWASM(r *fiber.App) {
13331 for _, file := range embeddedDropinNames() {
13332 body, ok := embeddedDropin(file)
13333 if !ok {
13334 continue
13335 }
13336 r.Get("/"+file, func(c fiber.Ctx) error {
13337 c.Set(fiber.HeaderContentType, "application/wasm")
13338 // Immutable: this one is compiled into the binary serving it, so
13339 // it cannot change without the page that loaded it changing too.
13340 c.Set(fiber.HeaderCacheControl, "public, max-age=31536000, immutable")
13341 return c.Send(body)
13342 })
13343 }
13344}
13345
13346
13347// ===== pkg/web/wasmpick.go =====
13348package web
13349
13350// Which build of each wasm drop-in a page loads, and which runtime shim starts
13351// it.
13352//
13353// USETINYGO does not mean "tinygo only". wasmbuild.go builds the Go variant of
13354// every source FIRST and unconditionally (that build is `required`), and only
13355// then, if the flag is set, additionally builds the tinygo variants as
13356// best-effort. So the usual state on the server is that BOTH stl2.wasm and
13357// stl2-tiny.wasm exist.
13358//
13359// The serving side did not know that. The old wasmBinary() named "<x>-tiny.wasm" for
13360// every source whenever the flag was on, without looking at the disk, which had
13361// two consequences:
13362//
13363// - A fresh Go build was ignored in favor of a stale tinygo one. The Go build
13364// of stl2 takes about 4 seconds and the tinygo build about 22 minutes, so
13365// during that window the page served a binary from before the edit β a
13366// 22-minute feedback loop for a 4-second change.
13367// - A tinygo build that FAILED still had its name emitted. wasmbuild.go says
13368// a drop-in tinygo cannot compile "must not take the whole store down" and
13369// that "the pages fall back to whichever binary exists", but only tuipage
13370// and deskpage ever did that stat; template pages asked for a file that was
13371// not there.
13372//
13373// So the choice is made per binary, from the disk, newest wins.
13374
13375import (
13376 "net/http"
13377 "os"
13378 "strings"
13379 "sync"
13380)
13381
13382// wasmPick is one binary a page will load together with the shim that can start
13383// it. The two travel together because they must match: tinygo's wasm_exec.js
13384// cannot start a stdlib Go binary (it throws a LinkError on the gojs imports)
13385// and the stdlib shim cannot start a tinygo one.
13386type wasmPick struct {
13387 File string // "stl2.wasm" or "stl2-tiny.wasm"
13388 ExecPath string // the wasm_exec.js matching its toolchain
13389 GoGlobal string // the JS global holding that shim's Go constructor
13390}
13391
13392// The two shims both define globalThis.Go, so a page needing both must load
13393// them one after another and stash each constructor before the next overwrites
13394// it. These are the names it stashes them under.
13395const (
13396 goGlobalStd = "__GoStd"
13397 goGlobalTiny = "__GoTiny"
13398)
13399
13400// newerFile reports which of two paths has the later modification time.
13401// Missing files lose; if neither exists, ok is false.
13402func newerFile(a, b string) (path string, ok bool) {
13403 sa, ea := os.Stat(a)
13404 sb, eb := os.Stat(b)
13405 switch {
13406 case ea == nil && eb == nil:
13407 if sb.ModTime().After(sa.ModTime()) {
13408 return b, true
13409 }
13410 return a, true
13411 case ea == nil:
13412 return a, true
13413 case eb == nil:
13414 return b, true
13415 }
13416 return "", false
13417}
13418
13419// pickWasm chooses the build of one drop-in to serve.
13420//
13421// When the tinygo build is not wanted at all the Go build is the only candidate,
13422// so a site that never sets USETINYGO behaves exactly as before.
13423func pickWasm(name string) (wasmPick, bool) {
13424 if servingInTab() {
13425 if p, ok := pickCarried(name); ok {
13426 return p, true
13427 }
13428 return pickFromOrigin(name)
13429 }
13430 std := name + ".wasm"
13431 if !f.UseTinygo {
13432 if _, err := os.Stat(std); err != nil {
13433 return wasmPick{}, false
13434 }
13435 return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13436 }
13437 which, ok := newerFile(std, name+"-tiny.wasm")
13438 if !ok {
13439 return wasmPick{}, false
13440 }
13441 if strings.HasSuffix(which, "-tiny.wasm") {
13442 return wasmPick{File: which, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13443 }
13444 return wasmPick{File: which, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13445}
13446
13447// pickWasmSmall chooses the SMALLER build of a drop-in: the tinygo one
13448// when it exists, whatever the modification times say.
13449//
13450// pickWasm's newest-wins rule is right for a binary a page loads because
13451// that binary is the point of the page β during the twenty-odd minutes a
13452// tinygo build takes, serving the four-second Go build is what keeps the
13453// edit visible. It is the wrong rule for one every page carries whether
13454// the reader uses it or not. The cart is that: 4.5MB as a Go build
13455// against 1MB as a tinygo one, fetched on every catalog page view to
13456// keep a list of part numbers in localStorage.
13457//
13458// Falls back to pickWasm, so a site with no tinygo β or one whose tinygo
13459// build of this source failed β gets the Go build rather than nothing.
13460func pickWasmSmall(name string) (wasmPick, bool) {
13461 if servingInTab() {
13462 if p, ok := pickCarried(name); ok {
13463 return p, true
13464 }
13465 return pickFromOrigin(name)
13466 }
13467 if f.UseTinygo {
13468 if tiny := name + "-tiny.wasm"; fileExists(tiny) {
13469 return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13470 }
13471 }
13472 return pickWasm(name)
13473}
13474
13475func fileExists(path string) bool {
13476 _, err := os.Stat(path)
13477 return err == nil
13478}
13479
13480// pageBinary picks the binary a full-page app loads, taking the first of
13481// names the deployment has actually built, and the tinygo build of it
13482// where there is one.
13483//
13484// The names are given joint-first. /tui and /desk were two binaries of
13485// about fifty megabytes each that were all but the same fifty β the desk
13486// links pkg/storepane to put a store in a window, and the storefront page
13487// is pkg/storepane and nothing else β so wasm/term is the two together,
13488// eight kilobytes larger than the desk alone and serving both. A
13489// deployment that builds it gets it; one that still lists the separate
13490// drop-ins gets those, and nothing has to change at once.
13491//
13492// The shim travels with the pick because the two are not interchangeable:
13493// tinygo's wasm_exec.js throws a LinkError on a stdlib Go binary's gojs
13494// imports, and the stdlib one cannot start a tinygo binary.
13495func pageBinary(names ...string) (file, execPath string) {
13496 for _, n := range names {
13497 if f.UseTinygo && fileExists(n+"-tiny.wasm") {
13498 return n + "-tiny.wasm", f.WasmExecPathTinyGo
13499 }
13500 if fileExists(n + ".wasm") {
13501 return n + ".wasm", f.WasmExecPathGo
13502 }
13503 }
13504 // Nothing built yet. Name the app's own Go build, so a page served
13505 // during the first compile asks for the file that is about to exist
13506 // rather than for nothing at all.
13507 return names[len(names)-1] + ".wasm", f.WasmExecPathGo
13508}
13509
13510// wasmExec is one runtime shim a page must load.
13511type wasmExec struct {
13512 Path string
13513 GoGlobal string
13514}
13515
13516// wasmExecsFor returns the shims a set of picks needs, deduplicated and in a
13517// stable order. A page with a tinygo binary and a Go binary on it gets both.
13518func wasmExecsFor(picks []wasmPick) []wasmExec {
13519 var out []wasmExec
13520 seen := map[string]bool{}
13521 for _, p := range picks {
13522 if p.ExecPath == "" || seen[p.GoGlobal] {
13523 continue
13524 }
13525 seen[p.GoGlobal] = true
13526 out = append(out, wasmExec{Path: p.ExecPath, GoGlobal: p.GoGlobal})
13527 }
13528 return out
13529}
13530
13531// WasmExecs is the shim list for this page's binaries, as a METHOD rather than
13532// a template function.
13533//
13534// It was a FuncMap entry, and that broke the whole site: templates are parsed
13535// in several places here and only some of them call .Funcs(funcs), so a page
13536// whose set was built bare β order.go's, for one β died at parse time with
13537// `function "wasmexecs" not defined` and served a 500. A method travels with
13538// the data instead of with the template set, so every set can call it and no
13539// new parse site has to remember anything.
13540func (h htmlTemplateData) WasmExecs() []wasmExec { return wasmExecsFor(h.WasmBinary) }
13541
13542// The in-tab server's filesystem is not the authority on which drop-ins a
13543// page may load.
13544//
13545// When the store runs inside the page (pkg/storepane's `serve`, behind the
13546// desk's netscrape window) its jsfs carries the templates and content the
13547// render code reads and nothing else β images, fonts and models already
13548// read through to the origin on a miss, and so do the wasm binaries. But
13549// the picking above asks the local disk, which in the tab holds none of
13550// them, so every page rendered there came out with no drop-in referenced
13551// at all: no cart, no decoration, and no attractor over the logo. The
13552// binaries were reachable the whole time; nothing ever named them.
13553//
13554// So in the tab the origin is asked instead, once per name.
13555
13556// originSrc is the origin an in-tab server's misses read through to, and
13557// what it has learned about which binaries that origin serves. base is
13558// empty in the host process, where the disk is the authority and none of
13559// this runs.
13560var originSrc struct {
13561 mu sync.Mutex
13562 base string
13563 seen map[string]bool
13564}
13565
13566// serveFromOrigin marks this process the in-tab server. NewApp calls it
13567// with the same origin it gives the read-through proxy.
13568func serveFromOrigin(base string) {
13569 originSrc.mu.Lock()
13570 defer originSrc.mu.Unlock()
13571 originSrc.base, originSrc.seen = base, map[string]bool{}
13572}
13573
13574// servingInTab reports whether this is the in-page server.
13575func servingInTab() bool {
13576 originSrc.mu.Lock()
13577 defer originSrc.mu.Unlock()
13578 return originSrc.base != ""
13579}
13580
13581// originHas reports whether the origin serves a file, asking it at most
13582// once per name and remembering the answer. A binary the origin does not
13583// have goes unreferenced, which is the answer a missing file gives on a
13584// host.
13585func originHas(file string) bool {
13586 originSrc.mu.Lock()
13587 base, seen := originSrc.base, originSrc.seen
13588 if have, ok := seen[file]; ok {
13589 originSrc.mu.Unlock()
13590 return have
13591 }
13592 originSrc.mu.Unlock()
13593
13594 have := false
13595 if req, err := http.NewRequest(http.MethodHead, base+"/"+file, nil); err == nil {
13596 if resp, derr := http.DefaultClient.Do(req); derr == nil {
13597 _ = resp.Body.Close() //nolint:errcheck // nothing was read from it
13598 have = resp.StatusCode == http.StatusOK
13599 }
13600 }
13601
13602 originSrc.mu.Lock()
13603 seen[file] = have
13604 originSrc.mu.Unlock()
13605 return have
13606}
13607
13608// pickFromOrigin chooses a drop-in the way the tab must: smallest build
13609// the origin actually serves.
13610//
13611// It does not consult UseTinygo. That flag says whether THIS process
13612// should run tinygo over the sources, and the tab has no toolchain and
13613// built nothing; what matters is which builds exist at the other end. The
13614// tab also pays for every byte twice β once over the network into the
13615// page, once through the proxy into the frame β so the small build is
13616// wanted wherever there is one.
13617// pickCarried chooses a drop-in the binary itself carries. It runs before the
13618// origin is asked, so a tab with a staged build answers out of itself.
13619func pickCarried(name string) (wasmPick, bool) {
13620 if tiny := name + "-tiny.wasm"; carriesDropin(tiny) {
13621 return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13622 }
13623 if std := name + ".wasm"; carriesDropin(std) {
13624 return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13625 }
13626 return wasmPick{}, false
13627}
13628
13629// carriesDropin reports whether the build staged this file into the binary.
13630func carriesDropin(file string) bool {
13631 _, ok := lookupDropin(file)
13632 return ok
13633}
13634
13635func pickFromOrigin(name string) (wasmPick, bool) {
13636 if tiny := name + "-tiny.wasm"; originHas(tiny) {
13637 return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13638 }
13639 if std := name + ".wasm"; originHas(std) {
13640 return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13641 }
13642 return wasmPick{}, false
13643}
13644
13645
13646// ===== pkg/web/wasmpick_test.go =====
13647package web
13648
13649import (
13650 "bytes"
13651 htmpl "html/template"
13652 "net/http"
13653 "net/http/httptest"
13654 "os"
13655 "path/filepath"
13656 "strings"
13657 "testing"
13658 "time"
13659)
13660
13661// touch writes a file with an explicit mtime, so "newest wins" can be tested
13662// without sleeping between writes.
13663func touch(t *testing.T, path string, age time.Duration) {
13664 t.Helper()
13665 if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
13666 t.Fatal(err)
13667 }
13668 when := time.Now().Add(-age)
13669 if err := os.Chtimes(path, when, when); err != nil {
13670 t.Fatal(err)
13671 }
13672}
13673
13674func TestNewerFile(t *testing.T) {
13675 dir := t.TempDir()
13676 a := filepath.Join(dir, "a.wasm")
13677 b := filepath.Join(dir, "b.wasm")
13678
13679 if _, ok := newerFile(a, b); ok {
13680 t.Error("neither file exists, but newerFile reported a winner")
13681 }
13682
13683 touch(t, a, time.Hour)
13684 if got, ok := newerFile(a, b); !ok || got != a {
13685 t.Errorf("only a exists: got %q ok=%v, want %q true", got, ok, a)
13686 }
13687 if got, ok := newerFile(b, a); !ok || got != a {
13688 t.Errorf("only a exists (as second arg): got %q ok=%v, want %q true", got, ok, a)
13689 }
13690
13691 // b newer than a: b must win from either argument position, or the
13692 // selection would depend on the order the caller happened to pass them.
13693 touch(t, b, time.Minute)
13694 if got, _ := newerFile(a, b); got != b {
13695 t.Errorf("b is newer: got %q, want %q", got, b)
13696 }
13697 if got, _ := newerFile(b, a); got != b {
13698 t.Errorf("b is newer (arg order swapped): got %q, want %q", got, b)
13699 }
13700}
13701
13702// A tie must not flap between the two on successive page loads.
13703func TestNewerFileTieIsStable(t *testing.T) {
13704 dir := t.TempDir()
13705 a := filepath.Join(dir, "a.wasm")
13706 b := filepath.Join(dir, "b.wasm")
13707 touch(t, a, time.Hour)
13708 touch(t, b, time.Hour)
13709 first, _ := newerFile(a, b)
13710 for i := 0; i < 5; i++ {
13711 if got, _ := newerFile(a, b); got != first {
13712 t.Fatalf("tie is not stable: %q then %q", first, got)
13713 }
13714 }
13715}
13716
13717// The shim must match the build, and a page carrying both toolchains must get
13718// both shims under distinct globals β the whole point of the aliasing.
13719func TestWasmExecsForDedupesAndKeepsBoth(t *testing.T) {
13720 picks := []wasmPick{
13721 {File: "stl2-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13722 {File: "deco-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13723 {File: "cart.wasm", ExecPath: "/go/wasm_exec.js", GoGlobal: goGlobalStd},
13724 }
13725 got := wasmExecsFor(picks)
13726 if len(got) != 2 {
13727 t.Fatalf("got %d shims, want 2 (one per toolchain): %+v", len(got), got)
13728 }
13729 if got[0].GoGlobal != goGlobalTiny || got[1].GoGlobal != goGlobalStd {
13730 t.Errorf("shims out of order or wrong globals: %+v", got)
13731 }
13732 if goGlobalTiny == goGlobalStd {
13733 t.Error("the two globals must differ, or the second shim overwrites the first")
13734 }
13735}
13736
13737func TestWasmExecsForSkipsEmptyPath(t *testing.T) {
13738 if got := wasmExecsFor([]wasmPick{{File: "x.wasm"}}); len(got) != 0 {
13739 t.Errorf("a pick with no shim path yielded %+v, want none", got)
13740 }
13741}
13742
13743// The wasm partial must render from a template set built WITHOUT .Funcs().
13744//
13745// This is the bug that took the site down: the shim list was a FuncMap entry,
13746// but templates are parsed in several places here and only some call
13747// .Funcs(funcs). A page whose set was built bare died at parse time with
13748// `function "wasmexecs" not defined` and served a 500 for the whole front page.
13749// A method on the data has no such dependency, and this test is the proof.
13750func TestWasmPartialParsesWithoutFuncMap(t *testing.T) {
13751 src, err := os.ReadFile("../../htmpl/wasm.html")
13752 if err != nil {
13753 t.Skip("template not present:", err)
13754 }
13755 tmpl := htmpl.New("index")
13756 if _, err := tmpl.New("wasm").Parse(string(src)); err != nil {
13757 t.Fatalf("wasm.html needs a FuncMap it may not get: %v", err)
13758 }
13759 data := struct{ Page htmlTemplateData }{Page: htmlTemplateData{
13760 WasmBinary: []wasmPick{
13761 {File: "stl2-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13762 {File: "cart.wasm", ExecPath: "/go/wasm_exec.js", GoGlobal: goGlobalStd},
13763 },
13764 }}
13765 var buf bytes.Buffer
13766 if err := tmpl.ExecuteTemplate(&buf, "wasm", data); err != nil {
13767 t.Fatalf("executing the wasm partial: %v", err)
13768 }
13769 out := buf.String()
13770 // Both shims, both stashed under distinct globals, and each binary using
13771 // the one that matches it.
13772 for _, want := range []string{
13773 "/tiny/wasm_exec.js", "/go/wasm_exec.js",
13774 `window["` + goGlobalTiny + `"] = Go`, `window["` + goGlobalStd + `"] = Go`,
13775 "stl2-tiny.wasm", "cart.wasm",
13776 } {
13777 if !strings.Contains(out, want) {
13778 t.Errorf("rendered wasm partial is missing %q", want)
13779 }
13780 }
13781}
13782
13783// A page with no wasm must render nothing at all β not an empty <script> that
13784// would still try to construct a Go runtime.
13785func TestWasmPartialEmptyWhenNoBinaries(t *testing.T) {
13786 src, err := os.ReadFile("../../htmpl/wasm.html")
13787 if err != nil {
13788 t.Skip("template not present:", err)
13789 }
13790 tmpl := htmpl.New("index")
13791 if _, err := tmpl.New("wasm").Parse(string(src)); err != nil {
13792 t.Fatal(err)
13793 }
13794 var buf bytes.Buffer
13795 data := struct{ Page htmlTemplateData }{}
13796 if err := tmpl.ExecuteTemplate(&buf, "wasm", data); err != nil {
13797 t.Fatal(err)
13798 }
13799 if strings.Contains(buf.String(), "<script") {
13800 t.Errorf("a page with no wasm still emitted a script tag:\n%s", buf.String())
13801 }
13802}
13803
13804// The cart rides on every catalog page, so it takes the tinygo build
13805// where there is one β 1MB against 4.5MB, fetched whether or not the
13806// reader ever opens the cart. pickWasm's newest-wins rule is right for a
13807// binary that IS the page and wrong for one that merely travels with it.
13808func TestPickWasmSmallPrefersTheTinyBuild(t *testing.T) {
13809 t.Chdir(t.TempDir())
13810 saved := *f
13811 t.Cleanup(func() { *f = saved })
13812 f.UseTinygo = true
13813 f.WasmExecPathGo = "/go.js"
13814 f.WasmExecPathTinyGo = "/tiny.js"
13815
13816 write := func(name string) {
13817 if err := os.WriteFile(name, []byte("x"), 0o600); err != nil {
13818 t.Fatal(err)
13819 }
13820 }
13821 // The Go build written LAST, so newest-wins would take it.
13822 write("cart-tiny.wasm")
13823 time.Sleep(10 * time.Millisecond)
13824 write("cart.wasm")
13825
13826 if got, ok := pickWasm("cart"); !ok || got.File != "cart.wasm" {
13827 t.Fatalf("pickWasm took %+v, want the newer cart.wasm β the premise of this test", got)
13828 }
13829 got, ok := pickWasmSmall("cart")
13830 if !ok {
13831 t.Fatal("pickWasmSmall found nothing")
13832 }
13833 if got.File != "cart-tiny.wasm" {
13834 t.Errorf("pickWasmSmall took %q, want cart-tiny.wasm", got.File)
13835 }
13836 if got.ExecPath != f.WasmExecPathTinyGo || got.GoGlobal != goGlobalTiny {
13837 t.Errorf("tiny build paired with %+v, want tinygo's shim", got)
13838 }
13839}
13840
13841// With no tinygo build β or no tinygo at all β the Go build is served
13842// rather than nothing.
13843func TestPickWasmSmallFallsBackToTheGoBuild(t *testing.T) {
13844 t.Chdir(t.TempDir())
13845 saved := *f
13846 t.Cleanup(func() { *f = saved })
13847 f.UseTinygo = true
13848 f.WasmExecPathGo = "/go.js"
13849 if err := os.WriteFile("cart.wasm", []byte("x"), 0o600); err != nil {
13850 t.Fatal(err)
13851 }
13852 got, ok := pickWasmSmall("cart")
13853 if !ok || got.File != "cart.wasm" || got.GoGlobal != goGlobalStd {
13854 t.Errorf("with no tinygo build got %+v, want the Go one", got)
13855 }
13856 f.UseTinygo = false
13857 if got, ok := pickWasmSmall("cart"); !ok || got.File != "cart.wasm" {
13858 t.Errorf("with tinygo off got %+v, want the Go one", got)
13859 }
13860}
13861
13862// The joint build wins when the deployment has one, and the page's own
13863// is the fallback β so a site that has not rebuilt yet keeps working.
13864func TestPageBinaryPrefersTheJointBuild(t *testing.T) {
13865 t.Chdir(t.TempDir())
13866 saved := *f
13867 t.Cleanup(func() { *f = saved })
13868 f.UseTinygo = false
13869 f.WasmExecPathGo = "/go.js"
13870 f.WasmExecPathTinyGo = "/tiny.js"
13871 write := func(name string) {
13872 if err := os.WriteFile(name, []byte("x"), 0o600); err != nil {
13873 t.Fatal(err)
13874 }
13875 }
13876
13877 // Only the separate builds so far: each page gets its own.
13878 write("tui.wasm")
13879 write("desk.wasm")
13880 if got, _ := pageBinary("term", "tui"); got != "tui.wasm" {
13881 t.Errorf("with no joint build /tui got %q, want tui.wasm", got)
13882 }
13883 if got, _ := pageBinary("term", "desk"); got != "desk.wasm" {
13884 t.Errorf("with no joint build /desk got %q, want desk.wasm", got)
13885 }
13886
13887 // The joint build arrives and both pages move to it.
13888 write("term.wasm")
13889 for _, page := range []string{"tui", "desk"} {
13890 got, exec := pageBinary("term", page)
13891 if got != "term.wasm" {
13892 t.Errorf("/%s got %q, want term.wasm", page, got)
13893 }
13894 if exec != f.WasmExecPathGo {
13895 t.Errorf("/%s paired term.wasm with %q, want the Go shim", page, exec)
13896 }
13897 }
13898}
13899
13900// The tinygo build of a name beats the Go build of that same name, and a
13901// name with neither is skipped rather than named.
13902func TestPageBinaryTinygoAndSkipping(t *testing.T) {
13903 t.Chdir(t.TempDir())
13904 saved := *f
13905 t.Cleanup(func() { *f = saved })
13906 f.UseTinygo = true
13907 f.WasmExecPathGo = "/go.js"
13908 f.WasmExecPathTinyGo = "/tiny.js"
13909
13910 if err := os.WriteFile("term-tiny.wasm", []byte("x"), 0o600); err != nil {
13911 t.Fatal(err)
13912 }
13913 got, exec := pageBinary("term", "tui")
13914 if got != "term-tiny.wasm" || exec != f.WasmExecPathTinyGo {
13915 t.Errorf("got %q with %q, want the tinygo joint build and its shim", got, exec)
13916 }
13917
13918 // Nothing built at all: name the page's own Go build rather than "",
13919 // so a page served during the first compile asks for the file that is
13920 // about to exist.
13921 t.Chdir(t.TempDir())
13922 if got, _ := pageBinary("term", "desk"); got != "desk.wasm" {
13923 t.Errorf("with nothing built got %q, want desk.wasm", got)
13924 }
13925}
13926
13927// The in-tab server has none of the binaries on its own filesystem. It must
13928// still name them: they read through to the origin like every other file it
13929// does not carry. Before this, every page it rendered referenced no drop-in
13930// at all β which is what left the attractor missing from the store inside
13931// the desk's browser window while the same page had it on the origin.
13932func TestPickFromOriginNamesWhatTheOriginServes(t *testing.T) {
13933 noDropins(t) // this case is about the origin, not what the build carried
13934 t.Chdir(t.TempDir()) // deliberately empty: the tab's disk has no wasm
13935 saved := *f
13936 t.Cleanup(func() { *f = saved })
13937 f.UseTinygo = false // the tab has no toolchain and never built anything
13938 f.WasmExecPathGo = "/go.js"
13939 f.WasmExecPathTinyGo = "/tiny.js"
13940
13941 var asked []string
13942 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13943 asked = append(asked, r.Method+" "+r.URL.Path)
13944 switch r.URL.Path {
13945 case "/stl2-tiny.wasm", "/cart.wasm":
13946 w.WriteHeader(http.StatusOK)
13947 default:
13948 w.WriteHeader(http.StatusNotFound)
13949 }
13950 }))
13951 t.Cleanup(srv.Close)
13952
13953 if servingInTab() {
13954 t.Fatal("a fresh process must not think it is in a tab")
13955 }
13956 serveFromOrigin(srv.URL)
13957 t.Cleanup(func() { serveFromOrigin("") })
13958 if !servingInTab() {
13959 t.Fatal("serveFromOrigin did not mark the in-tab role")
13960 }
13961
13962 // A small build at the origin wins even though UseTinygo is off here:
13963 // the flag describes this process's toolchain, not the other end.
13964 got, ok := pickWasmSmall("stl2")
13965 if !ok || got.File != "stl2-tiny.wasm" || got.ExecPath != "/tiny.js" || got.GoGlobal != goGlobalTiny {
13966 t.Errorf("stl2 = %+v, %v; want stl2-tiny.wasm on the tinygo shim", got, ok)
13967 }
13968 // Only a Go build at the origin: take it, with the matching shim.
13969 got, ok = pickWasmSmall("cart")
13970 if !ok || got.File != "cart.wasm" || got.ExecPath != "/go.js" || got.GoGlobal != goGlobalStd {
13971 t.Errorf("cart = %+v, %v; want cart.wasm on the Go shim", got, ok)
13972 }
13973 // Nothing at the origin: name nothing, as a missing file does on a host.
13974 if got, ok = pickWasm("deco"); ok {
13975 t.Errorf("deco = %+v, %v; want no pick", got, ok)
13976 }
13977
13978 // Each name is asked about once and the answer kept.
13979 before := len(asked)
13980 for range 3 {
13981 pickWasmSmall("stl2")
13982 pickWasm("deco")
13983 }
13984 if len(asked) != before {
13985 t.Errorf("origin re-probed: %v", asked[before:])
13986 }
13987 for _, a := range asked {
13988 if !strings.HasPrefix(a, http.MethodHead+" ") {
13989 t.Errorf("probe %q should be a HEAD: a drop-in is up to 22MB", a)
13990 }
13991 }
13992}
13993
13994// wasmPicks gates on WasmSRC, which names the sources this process builds.
13995// In the tab that list is empty β it builds nothing β and the gate dropped
13996// every binary before the pick was reached, which is why naming them from
13997// the origin alone was not enough.
13998func TestWasmPicksIgnoresWasmSRCInTheTab(t *testing.T) {
13999 noDropins(t)
14000 t.Chdir(t.TempDir())
14001 saved := *f
14002 t.Cleanup(func() { *f = saved })
14003 f.WasmSRC = nil // as in the tab: nothing configured to build
14004 f.WasmExecPathTinyGo = "/tiny.js"
14005
14006 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14007 if r.URL.Path == "/cart-tiny.wasm" {
14008 w.WriteHeader(http.StatusOK)
14009 return
14010 }
14011 w.WriteHeader(http.StatusNotFound)
14012 }))
14013 t.Cleanup(srv.Close)
14014
14015 // On a host with nothing configured, nothing is named.
14016 if got := wasmSmallFor("cart"); len(got) != 0 {
14017 t.Errorf("host with empty WasmSRC named %+v, want nothing", got)
14018 }
14019
14020 serveFromOrigin(srv.URL)
14021 t.Cleanup(func() { serveFromOrigin("") })
14022 got := wasmSmallFor("cart", "stl2")
14023 if len(got) != 1 || got[0].File != "cart-tiny.wasm" {
14024 t.Errorf("in-tab picks = %+v, want just cart-tiny.wasm", got)
14025 }
14026}
14027
14028// noDropins makes the test behave as a binary built from a tree that staged
14029// nothing, whatever this one was built from.
14030func noDropins(t *testing.T) {
14031 t.Helper()
14032 saved := lookupDropin
14033 lookupDropin = func(string) ([]byte, bool) { return nil, false }
14034 t.Cleanup(func() { lookupDropin = saved })
14035}
14036
14037// A carried drop-in is answered from the binary; the origin is only asked for
14038// what the build did not stage. The tab pays a round trip per name otherwise,
14039// and pays it on the first render of every page.
14040func TestCarriedDropinBeatsTheOrigin(t *testing.T) {
14041 t.Chdir(t.TempDir())
14042 saved := *f
14043 t.Cleanup(func() { *f = saved })
14044 f.WasmSRC, f.UseTinygo = nil, false
14045 f.WasmExecPathGo, f.WasmExecPathTinyGo = "/go.js", "/tiny.js"
14046
14047 var asked []string
14048 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14049 asked = append(asked, r.URL.Path)
14050 w.WriteHeader(http.StatusOK) // the origin has everything
14051 }))
14052 t.Cleanup(srv.Close)
14053
14054 carried := map[string][]byte{"stl2-tiny.wasm": []byte("staged")}
14055 savedLookup := lookupDropin
14056 lookupDropin = func(file string) ([]byte, bool) { b, ok := carried[file]; return b, ok }
14057 t.Cleanup(func() { lookupDropin = savedLookup })
14058
14059 serveFromOrigin(srv.URL)
14060 t.Cleanup(func() { serveFromOrigin("") })
14061
14062 if got, ok := pickWasmSmall("stl2"); !ok || got.File != "stl2-tiny.wasm" {
14063 t.Errorf("stl2 = %+v %v, want the carried stl2-tiny.wasm", got, ok)
14064 }
14065 if len(asked) != 0 {
14066 t.Errorf("origin asked about a carried drop-in: %v", asked)
14067 }
14068 // Not carried: the origin answers, as before.
14069 if got, ok := pickWasmSmall("cart"); !ok || got.File != "cart-tiny.wasm" {
14070 t.Errorf("cart = %+v %v, want cart-tiny.wasm from the origin", got, ok)
14071 }
14072 if len(asked) == 0 {
14073 t.Error("origin should have been asked about the uncarried drop-in")
14074 }
14075}
14076
14077
14078// ===== cmd/m2/commands/gen.go =====
14079// Package commands cmd/m2/commands/gen.go β the `m2 gen` config template.
14080package commands
14081
14082import (
14083 "fmt"
14084
14085 "github.com/spf13/cobra"
14086)
14087
14088var genCmd = &cobra.Command{
14089 Use: "gen",
14090 Short: "generate conf template",
14091 Long: "generate conf template",
14092 // To the command's own writer, not os.Stdout. They are the same
14093 // thing at a shell prompt and not in a page, where the command runs
14094 // inside a wasm instance whose stdout goes to the JS console β so
14095 // `m2 gen > site.conf` wrote an empty file and `m2 gen | head`
14096 // printed nothing at all.
14097 RunE: func(cmd *cobra.Command, _ []string) error {
14098 _, err := fmt.Fprint(cmd.OutOrStdout(), envfiletemplate)
14099 return err
14100 },
14101}
14102
14103const envfiletemplate = `#########################################################################
14104# M2 CONFIG
14105#
14106# Copy to <yoursite>.conf and run with: MENV=<yoursite>.conf m2 run
14107# This file is sourced by bash; use shell syntax.
14108# Comment a value with # to use the built-in default.
14109#########################################################################
14110
14111### Stripe Configuration ################################################
14112
14113#-- Live and test API keys - REQUIRED for checkout
14114# https://dashboard.stripe.com/apikeys
14115STRIPELIVEPK='pk_live_...'
14116STRIPELIVESK='sk_live_...'
14117STRIPETESTPK='pk_test_...'
14118STRIPETESTSK='sk_test_...'
14119
14120#-- Use the test keys instead of the live keys
14121TESTSTRIPEKEY=true
14122
14123### Product Data ########################################################
14124
14125#-- Products CSV path (see products.example.csv for the schema)
14126PRODUCTSCSV='products.csv'
14127
14128### Site Identity #######################################################
14129
14130#-- Image subdomain, no trailing slash (ex. 'https://img.example.com')
14131# empty = serve images from ./img
14132SITEIMAGESRC=''
14133
14134#-- Orders subdomain, no trailing slash (ex. 'https://pay.example.com')
14135SITEORDERSURL=''
14136
14137#-- Website (Host) Name - domain minus extension (ex. 'example')
14138SITENAME='example'
14139
14140#-- Website Domain Extension (ex. '.com' '.net')
14141SITEEXT='.com'
14142
14143#-- Site Long Name (ex. 'example electronic surplus')
14144SITELONGNAME='example web store'
14145
14146#-- Site Tag Line
14147SITETAGLINE='an example web store'
14148
14149#-- Site Meta Description (SEO)
14150SITEMETA='an example web store selling example things'
14151
14152#-- Telegram contact + channel; username only, no 'https://t.me/'
14153TGCONTACT=''
14154TGCHANNEL=''
14155
14156### Web Server ##########################################################
14157
14158#-- Port to serve http on
14159WEBPORT='9883'
14160
14161
14162### WebAssembly #########################################################
14163
14164#-- Compile wasm with tinygo (smaller output) in addition to go
14165USETINYGO=true
14166
14167#-- wasm source directories, relative paths
14168# 'wasm/cart' powers checkout β keep it. 'wasm/term' is the terminal
14169# storefront at /tui and the desktop at /desk in one binary: the desk
14170# links the storefront anyway, so the two separately were fifty
14171# megabytes of very nearly the same fifty. 'wasm/tui' and 'wasm/desk'
14172# still build one page each if you would rather have them apart.
14173# Additional entries are drop-in wasm apps: any dir under wasm/ with a
14174# main package (a self-contained module with its own vendor/ also
14175# works). Empty () disables all wasm.
14176WASMSRC=('wasm/cart' 'wasm/term')
14177
14178### Receipt Printing (CUPS) #############################################
14179
14180#-- CUPS printer name (default: system default)
14181PRINTERNAME=''
14182
14183#-- CUPS options, comma separated (ex. 'media=Custom.80x200mm,fit-to-page')
14184CUPSOPTIONS=''
14185
14186#-- timeout for lp command
14187LPTIMEOUT='10s'
14188
14189### Terminal UI ##########################################################
14190
14191#-- m2 tui client mode: browse this store over http instead of the
14192# local products csv / img / content files (ex. 'https://example.com')
14193STOREURL=''
14194`
14195
14196
14197// ===== cmd/m2/commands/root.go =====
14198// Package commands cmd/m2/commands/root.go β the m2 CLI.
14199package commands
14200
14201import (
14202 "embed"
14203 "log"
14204
14205 "github.com/spf13/cobra"
14206 "github.com/stripe/stripe-go/v81"
14207
14208 "github.com/0magnet/calvin/clihelp"
14209
14210 "github.com/0magnet/m2/pkg/config"
14211 "github.com/0magnet/m2/pkg/web"
14212)
14213
14214//go:embed *.go
14215var source embed.FS
14216
14217// f aliases the shared configuration, as in pkg/web.
14218var f = &config.F
14219
14220func init() {
14221 stripe.EnableTelemetry = false
14222 RootCmd.CompletionOptions.DisableDefaultCmd = true
14223 RootCmd.AddCommand(
14224 runCmd,
14225 genCmd,
14226 wasmCmd,
14227 tuiCmd,
14228 serveCmd,
14229 )
14230 RootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
14231 // The banner keeps the domain, which is what this serves and what the
14232 // binary is short for.
14233 clihelp.Init(RootCmd, "magnetosphere.net")
14234
14235 web.RegisterSource("cmd/m2/commands", source)
14236}
14237
14238func init() {
14239 runCmd.Flags().SortFlags = false
14240 config.AddStringFlag([]*cobra.Command{runCmd}, &f.ProductsCSV, "products csv file")
14241 config.AddBoolFlag([]*cobra.Command{runCmd, wasmCmd}, &f.Teststripekey, "use stripe test api keys instead of live key")
14242 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripeliveSK, "stripe live api sk")
14243 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripelivePK, "stripe live api pk")
14244 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestSK, "stripe test api sk")
14245 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestPK, "stripe test api pk")
14246 config.AddIntFlag([]*cobra.Command{runCmd}, &f.WebPort, "port to serve on")
14247 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteimagesrc, "domain for images - leave blank to serve images")
14248 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteordersurl, "domain for orders - leave blank for same domain")
14249 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitename, "site name")
14250 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteext, "site domain extension")
14251 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitelongname, "site long name")
14252 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitetagline, "site tag line")
14253 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitemeta, "site meta")
14254 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgcontact, "telegram contact")
14255 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgchannel, "telegram channel")
14256 config.AddBoolFlag([]*cobra.Command{runCmd}, &f.UseTinygo, "use tinygo instead of go to compile wasm")
14257 config.AddStringSliceFlag([]*cobra.Command{runCmd, wasmCmd}, &f.WasmSRC, "wasm source code files RELATIVE PATHS without '..'")
14258 config.AddStringFlag([]*cobra.Command{runCmd}, &f.PrinterName, "CUPS printer name (default: system default)")
14259 config.AddStringFlag([]*cobra.Command{runCmd}, &f.CupsOptions, "e.g. 'media=Custom.80x200mm,fit-to-page'")
14260 config.AddStringFlag([]*cobra.Command{runCmd}, &f.BrowseSuffix, "browse-origin domain for the desk browser - a DIFFERENT registrable domain from this site")
14261 config.AddStringFlag([]*cobra.Command{runCmd}, &f.BrowseAddr, "listen address for the browse-origin bootstrap")
14262 config.AddStringFlag([]*cobra.Command{runCmd}, &f.PprofAddr, "serve Go's profiles on this loopback address, e.g. 127.0.0.1:6061 (empty: none)")
14263 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyXpub, "account-level skycoin xpub for crypto checkout - NOT the reward account")
14264 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyNodeURL, "skycoin node API root for settlement")
14265 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyRatePair, "exchange pair to price skycoin from, e.g. skycoin_usdt")
14266 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyRateFix, "a fixed USD rate per SKY, instead of an exchange")
14267 config.AddStringFlag([]*cobra.Command{runCmd}, &f.RateMin, "refuse a crypto quote below this USD rate")
14268 config.AddStringFlag([]*cobra.Command{runCmd}, &f.RateMax, "refuse a crypto quote above this USD rate")
14269 config.AddIntFlag([]*cobra.Command{runCmd}, &f.InvoiceMins, "minutes a crypto quote is held before expiring")
14270 config.AddDurationFlag([]*cobra.Command{runCmd}, &f.LpTimeout, "timeout for lp command")
14271 config.AddStringFlag([]*cobra.Command{tuiCmd}, &f.Storeurl, "browse this store over http (ex. 'https://magnetosphere.net') instead of local files")
14272 config.AddStringFlag([]*cobra.Command{runCmd}, &f.MailFrom, "order confirmation email From address (empty: no email)")
14273 config.AddStringFlag([]*cobra.Command{runCmd}, &f.MailBcc, "the store's blind copy of every order confirmation")
14274 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sendmail, "command order confirmations are piped to, the recipient appended, e.g. '/usr/local/bin/mox sendmail'")
14275}
14276
14277// RootCmd is the top-level m2 command.
14278var RootCmd = &cobra.Command{
14279 Use: "m2",
14280 Short: "web store server",
14281 Long: "web store server",
14282}
14283
14284// Execute executes the root cli command
14285func Execute() {
14286 if err := RootCmd.Execute(); err != nil {
14287 log.Fatal("Failed to execute command: ", err)
14288 }
14289}
14290
14291
14292// ===== cmd/m2/commands/run.go =====
14293// Package commands cmd/m2/commands/run.go β the `m2 run` web server command.
14294package commands
14295
14296import (
14297 "fmt"
14298 "log"
14299 "os"
14300 "strings"
14301
14302 "github.com/spf13/cobra"
14303 "golang.org/x/text/cases"
14304 "golang.org/x/text/language"
14305
14306 "github.com/0magnet/calvin"
14307 "github.com/0magnet/calvin/clihelp"
14308
14309 "github.com/0magnet/m2/pkg/config"
14310 "github.com/0magnet/m2/pkg/web"
14311)
14312
14313var runCmd = &cobra.Command{
14314 Use: "run",
14315 Short: "run the web application",
14316 // The subcommand carries the same banner as the root, so `m2 run --help`
14317 // is not a plainer screen than `m2 --help`.
14318 Long: clihelp.Banner("magnetosphere.net") + "\n\n" + func() string {
14319 helptext := `Run the web application
14320Generate a config file first
14321
14322Config defaults file may also be specified with:
14323MENV=m2.conf m2 run
14324OR
14325MENV=/path/to/m2.conf m2 run
14326print the MENV file template with:
14327m2 gen`
14328 if config.MENV == "" {
14329 return helptext
14330 }
14331 if _, err := os.Stat(config.MENV); err == nil {
14332 return `Run the web application
14333
14334menv file detected: ` + config.MENV
14335 }
14336 return helptext
14337 }(),
14338 RunE: func(cmd *cobra.Command, _ []string) error {
14339 f.Sitedomain = f.Sitename + f.Siteext
14340 log.Println(" Initializing " + f.Sitedomain)
14341 out := cmd.OutOrStdout()
14342 // A banner that will not print is not a reason to refuse to serve.
14343 fmt.Fprintln(out, calvin.BlackboardBold(f.Sitedomain)) //nolint:errcheck
14344 fmt.Fprintln(out, calvin.AsciiFont(f.Sitedomain)) //nolint:errcheck
14345 config.InitStripe()
14346 f.Siteprettyname = calvin.BlackboardBold(f.Sitedomain) //"ππππππ₯π π€π‘πππ£π.πππ₯"
14347 c := cases.Title(language.English)
14348 f.Siteprettynamecap = calvin.BlackboardBold(c.String(f.Sitedomain)) //"ππππππ₯π π€π‘πππ£π.πππ₯"
14349 f.Siteprettynamecaps = calvin.BlackboardBold(strings.ToUpper(f.Sitedomain)) //"ππΈπΎβπΌπππββπΌβπΌ.βπΌπ"
14350 f.SiteASCIILogo = strings.Replace(strings.Replace(calvin.AsciiFont(f.Sitedomain), " ", " ", -1), "\n", "<br>\n", -1)
14351
14352 if f.UseTinygo {
14353 f.WasmExecPath = f.WasmExecPathTinyGo
14354 f.Buildwasmwith = f.Tinygobuild
14355 }
14356 if len(f.WasmSRC) == 0 {
14357 f.WasmExecPath = ""
14358 f.Buildwasmwith = ""
14359 }
14360 log.Println("Checking for products CSV")
14361 log.Println("Reading products CSV")
14362 if err := web.LoadCatalog(); err != nil {
14363 return fmt.Errorf("loading the catalog: %w", err)
14364 }
14365 if f.PprofAddr != "" {
14366 if err := web.StartPprof(f.PprofAddr); err != nil {
14367 return err
14368 }
14369 }
14370 go web.WatchCatalog()
14371 web.Serve()
14372 return nil
14373 },
14374}
14375
14376
14377// ===== cmd/m2/commands/serve.go =====
14378package commands
14379
14380import (
14381 "log"
14382 "net/http"
14383 "time"
14384
14385 "github.com/spf13/cobra"
14386)
14387
14388var serveAddr, serveDir string
14389
14390// serveCmd hosts a directory over HTTP so the wasm demo can be opened in a
14391// browser. Go serves .wasm with the right MIME type, and this needs only the
14392// Go toolchain this project already uses.
14393//
14394// m2 serve --dir public --addr :8000
14395var serveCmd = &cobra.Command{
14396 Use: "serve",
14397 Short: "serve a static directory over HTTP (the demo build)",
14398 Long: "Host a directory over HTTP so the wasm demo can be opened in a browser.",
14399 Args: cobra.NoArgs,
14400 RunE: func(*cobra.Command, []string) error {
14401 log.Printf("serving %s on http://localhost%s", serveDir, serveAddr)
14402 // An http.Server rather than ListenAndServe: the bare helper sets no
14403 // timeouts, so a stalled client holds a connection forever. The write
14404 // timeout is generous because a .wasm binary is large and a slow link
14405 // should not have its download cut off mid-file.
14406 srv := &http.Server{
14407 Addr: serveAddr,
14408 Handler: http.FileServer(http.Dir(serveDir)),
14409 ReadHeaderTimeout: 10 * time.Second,
14410 ReadTimeout: 30 * time.Second,
14411 WriteTimeout: 5 * time.Minute,
14412 IdleTimeout: 2 * time.Minute,
14413 }
14414 return srv.ListenAndServe()
14415 },
14416}
14417
14418func init() {
14419 serveCmd.Flags().StringVar(&serveAddr, "addr", ":8080", "listen address")
14420 serveCmd.Flags().StringVar(&serveDir, "dir", ".", "directory to serve")
14421}
14422
14423
14424// ===== cmd/m2/commands/tui.go =====
14425// Package commands cmd/m2/commands/tui.go β the `m2 tui` terminal storefront.
14426package commands
14427
14428import (
14429 "errors"
14430 "fmt"
14431
14432 "github.com/spf13/cobra"
14433
14434 "github.com/0magnet/m2/pkg/product"
14435 "github.com/0magnet/m2/pkg/tui"
14436)
14437
14438var tuiCmd = &cobra.Command{
14439 Use: "tui",
14440 Short: "browse the store in the terminal",
14441 Long: `Browse the store in a terminal UI.
14442
14443Reads the same config as 'run':
14444MENV=m2.conf m2 tui
14445
14446As a client of a running store (catalog, images, and checkout over
14447http; no local files needed):
14448m2 tui --storeurl https://magnetosphere.net`,
14449 // RunE with returned errors rather than log.Fatal: this command also
14450 // runs inside a page, and log.Fatal is os.Exit β which there takes down
14451 // the whole wasm instance, the shell it was typed into and every other
14452 // window on the desk with it.
14453 RunE: func(cmd *cobra.Command, _ []string) error {
14454 var prods product.Products
14455 if f.Storeurl != "" {
14456 var err error
14457 prods, err = tui.FetchCatalog(f.Storeurl)
14458 if err != nil {
14459 return fmt.Errorf("could not fetch the catalog from %s: %w", f.Storeurl, err)
14460 }
14461 } else {
14462 if f.ProductsCSV == "" {
14463 f.ProductsCSV = "products.csv"
14464 }
14465 prods = product.ReadCSV(f.ProductsCSV)
14466 }
14467 if len(prods) == 0 {
14468 return errors.New("no products in the catalog")
14469 }
14470 // The context carries how to get a screen where tcell.NewScreen is
14471 // the wrong answer β a browser, where it has to be bound to the
14472 // terminal this was typed into. See tui.WithScreen.
14473 return tui.Run(cmd.Context(), prods)
14474 },
14475}
14476
14477
14478// ===== cmd/m2/commands/wasm.go =====
14479// Package commands cmd/m2/commands/wasm.go β the `m2 wasm` compile command.
14480package commands
14481
14482import (
14483 "errors"
14484
14485 "github.com/spf13/cobra"
14486
14487 "github.com/0magnet/m2/pkg/config"
14488 "github.com/0magnet/m2/pkg/web"
14489)
14490
14491var wasmCmd = &cobra.Command{
14492 Use: "wasm",
14493 Short: "compile wasm",
14494 Long: "compile wasm",
14495 RunE: func(_ *cobra.Command, _ []string) error {
14496 if len(f.WasmSRC) == 0 {
14497 return errors.New("no wasm source code specified")
14498 }
14499 config.InitStripe()
14500 web.CompileWASM()
14501 return nil
14502 },
14503}
14504
14505