1// ===== pkg/config/config.go =====
2// Package config pkg/config/config.go β the store's configuration.
3//
4// Values are sourced from a bash config file named by the MENV environment
5// variable (see `m2 gen` for the template), overridable by CLI flags. The
6// flag-registration helpers here bind a Values field to a flag whose default
7// is read from the MENV file, so every command that registers flags gets the
8// file-sourced defaults for free.
9package config
10
11import (
12 "embed"
13 "fmt"
14 "os"
15 "reflect"
16 "runtime"
17 "strconv"
18 "strings"
19 "time"
20
21 "github.com/bitfield/script"
22 "github.com/spf13/cobra"
23 "github.com/stripe/stripe-go/v81"
24)
25
26//go:embed *.go
27var Source embed.FS
28
29// MENV names the bash-sourced config file, from the environment.
30var MENV = os.Getenv("MENV")
31
32// Values holds every configurable value; field names map to the MENV file's
33// uppercase variable names (SITENAME, PRODUCTSCSV, ...).
34type Values struct {
35 Teststripekey bool
36 ProductsCSV string
37 WebPort int
38 StripelivePK string
39 StripeliveSK string
40 StripetestPK string
41 StripetestSK string
42 StripeSK string
43 StripePK string
44 Siteimagesrc string
45 Siteordersurl string
46 Sitename string
47 Siteext string
48 Sitedomain string
49 Sitelongname string
50 Sitetagline string
51 Sitemeta string
52 Siteprettyname string
53 Siteprettynamecap string
54 Siteprettynamecaps string
55 SiteASCIILogo string
56 Tgcontact string
57 Tgchannel string
58 UseTinygo bool
59 WasmSRC []string
60 WasmExecPath string
61 WasmExecPathGo string
62 WasmExecPathTinyGo string
63 Gobuild string
64 Tinygobuild string
65 Buildwasmwith string
66 LDFlagsX string
67 PrinterName string // CUPS queue name (blank = default)
68 CupsOptions string // comma-separated -o options
69 LpTimeout time.Duration // timeout for `lp`
70 Storeurl string // tui client mode: browse this store over http instead of local files
71
72 // The real-origin browse substrate: where the desk's nested browser
73 // renders the in-tab store. See pkg/web/browse.go. Empty disables it and
74 // the browser falls back to netscrape's transcoder.
75 BrowseSuffix string // browse-origin domain, a DIFFERENT registrable domain from the site's
76 BrowseAddr string // listen address for the browse-origin bootstrap
77
78 // Crypto checkout. Empty SkyXpub leaves the whole thing off and the
79 // store card-only, which is what a deployment that has not configured
80 // a wallet should get rather than a button that errors.
81 SkyXpub string // BIP44 ACCOUNT-level xpub, watch-only; must not be the reward account
82 SkyNodeURL string // skycoin node API root; empty means the local node
83 SkyRatePair string // exchange pair for pricing, e.g. "skycoin_usdt"; never a bare ticker
84 SkyRateFix string // a rate the operator sets instead of asking an exchange
85 RateMin string // refuse a quote below this, as a decimal string
86 RateMax string // refuse a quote above this
87 InvoiceMins int // how long a quote is held before it expires
88}
89
90// F is the live configuration, shared by every package.
91var F = Values{
92 // WasmSRC: []string{"wasm/stl2.go","wasm/checkout_wasm.go"},
93 WasmExecPath: runtime.GOROOT() + "/lib/wasm/wasm_exec.js", //nolint
94 WasmExecPathGo: runtime.GOROOT() + "/lib/wasm/wasm_exec.js", //nolint
95 WasmExecPathTinyGo: strings.TrimSuffix(runtime.GOROOT(), "go") + "tinygo" + "/targets/wasm_exec.js", //nolint
96 Gobuild: "go build",
97 Tinygobuild: "tinygo build -target=wasm --no-debug",
98 Buildwasmwith: "go build",
99 LDFlagsX: "stripePK",
100}
101
102// InitStripe selects the live or test key pair, hands the secret key to the
103// stripe library, and bakes the publishable key into the wasm ldflags.
104func InitStripe() {
105 F.StripeSK = F.StripeliveSK
106 F.StripePK = F.StripelivePK
107 if F.Teststripekey {
108 F.StripeSK = F.StripetestSK
109 F.StripePK = F.StripetestPK
110 }
111 stripe.Key = F.StripeSK
112 // awkward way to do this
113 F.LDFlagsX += "=" + F.StripePK
114}
115
116var (
117 // Hardcoded array of valid shorthand characters, excluding "h"
118 shorthandChars = []rune("abcdefgijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
119 nextShortIndex = 0 // Index for the next shorthand flag
120)
121
122func getNextShortFlag() string {
123 if nextShortIndex >= len(shorthandChars) {
124 return ""
125 }
126 short := shorthandChars[nextShortIndex]
127 nextShortIndex++
128 return string(short)
129}
130
131var a = true
132var b = false
133
134// AddStringFlag registers a string flag bound to a field of F on each command,
135// defaulting to the MENV file's value for the field's uppercase name.
136func AddStringFlag(cmds []*cobra.Command, fieldPtr *string, description string) {
137 for i := range cmds {
138 cmds[i].Flags().StringVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecString(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(s string) string {
139 if s != "" {
140 s = "-" + s
141 }
142 return s
143 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
144 }
145}
146
147// AddStringSliceFlag is AddStringFlag for []string fields (bash arrays).
148func AddStringSliceFlag(cmds []*cobra.Command, fieldPtr *[]string, description string) {
149 for i := range cmds {
150 cmds[i].Flags().StringSliceVarP(
151 fieldPtr,
152 ccc(fieldPtr, &F, b),
153 getNextShortFlag(),
154 ScriptExecStringSlice(fmt.Sprintf("${%s[@]}", ccc(fieldPtr, &F, a))),
155 fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
156 )
157 }
158}
159
160// AddBoolFlag is AddStringFlag for bool fields.
161func AddBoolFlag(cmds []*cobra.Command, fieldPtr *bool, description string) {
162 for i := range cmds {
163 cmds[i].Flags().BoolVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecBool(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(b bool) string {
164 return "-" + strconv.FormatBool(b)
165 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
166 }
167}
168
169// AddIntFlag is AddStringFlag for int fields.
170func AddIntFlag(cmds []*cobra.Command, fieldPtr *int, description string) {
171 for i := range cmds {
172 cmds[i].Flags().IntVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecInt(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(i int) string {
173 return fmt.Sprintf("-%d", i)
174 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
175 }
176}
177
178// AddDurationFlag is AddStringFlag for time.Duration fields.
179func AddDurationFlag(cmds []*cobra.Command, fieldPtr *time.Duration, description string) {
180 for i := range cmds {
181 // Keep parity with the pattern of embedding a "-" when a non-zero default is present.
182 def := ScriptExecDuration(fmt.Sprintf("${%s%s}",
183 ccc(fieldPtr, &F, a),
184 func(d time.Duration) string {
185 if d != 0 {
186 return "-" + d.String() // e.g. "-5s"
187 }
188 return ""
189 }(*fieldPtr),
190 ))
191 cmds[i].Flags().DurationVarP(
192 fieldPtr,
193 ccc(fieldPtr, &F, b),
194 getNextShortFlag(),
195 def,
196 fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
197 )
198 }
199}
200
201// ccc finds the name of the struct field val points into, upper or lower case.
202func ccc(val interface{}, strct interface{}, upper bool) string {
203 v := reflect.ValueOf(strct)
204 if v.Kind() == reflect.Ptr {
205 v = v.Elem()
206 }
207 if v.Kind() != reflect.Struct {
208 panic("uc: second argument must be a pointer to a struct")
209 }
210 for i := 0; i < v.NumField(); i++ {
211 field := v.Field(i)
212 if field.CanAddr() && field.Addr().Interface() == val {
213 if upper {
214 return strings.ToUpper(v.Type().Field(i).Name)
215 }
216 return strings.ToLower(v.Type().Field(i).Name)
217 }
218 }
219 return ""
220}
221
222// ScriptExecString evaluates a bash expression with the MENV file sourced.
223func ScriptExecString(s string) string {
224 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
225 if err == nil {
226 return strings.TrimSpace(z)
227 }
228 return ""
229}
230
231// ScriptExecStringSlice evaluates a bash array expression with the MENV file
232// sourced. Empty entries are dropped: an empty or unset array still prints
233// one empty line, which would otherwise come back as [""] and defeat every
234// `len == 0` check downstream (e.g. WASMSRC=() disabling wasm).
235func ScriptExecStringSlice(s string) []string {
236 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s" "%s"'`, MENV, "%s\n", s)).Slice()
237 if err != nil {
238 return nil
239 }
240 out := z[:0]
241 for _, e := range z {
242 if strings.TrimSpace(e) != "" {
243 out = append(out, e)
244 }
245 }
246 return out
247}
248
249// ScriptExecBool evaluates a bash expression as a bool with the MENV file sourced.
250func ScriptExecBool(s string) bool {
251 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
252 if err == nil {
253 b, err := strconv.ParseBool(z)
254 if err == nil {
255 return b
256 }
257 }
258 return false
259}
260
261// ScriptExecInt evaluates a bash expression as an int with the MENV file sourced.
262func ScriptExecInt(s string) int {
263 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
264 if err == nil {
265 if z == "" {
266 return 0
267 }
268 i, err := strconv.Atoi(z)
269 if err == nil {
270 return i
271 }
272 }
273 return 0
274}
275
276// ScriptExecDuration evaluates a bash expression as a duration with the MENV
277// file sourced. Accepts Go duration strings ("750ms", "2s", "5m", "1h") and
278// bare integers (treated as seconds).
279func ScriptExecDuration(s string) time.Duration {
280 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
281 if err != nil {
282 return 0
283 }
284 z = strings.TrimSpace(z)
285 if z == "" {
286 return 0
287 }
288 z = strings.TrimPrefix(z, "-") // keep parity with how defaults are built
289
290 // Try full Go duration syntax first.
291 if d, err := time.ParseDuration(z); err == nil {
292 return d
293 }
294 // Fallback: plain integer means seconds.
295 if n, err := strconv.ParseInt(z, 10, 64); err == nil {
296 return time.Duration(n) * time.Second
297 }
298 return 0
299}
300
301
302// ===== pkg/product/csv.go =====
303// Package product pkg/product/csv.go β catalog CSV loading.
304package product
305
306import (
307 "bufio"
308 "bytes"
309 "embed"
310 "fmt"
311 "log"
312 "strings"
313
314 "github.com/bitfield/script"
315)
316
317//go:embed *.go
318var Source embed.FS
319
320func readproductscsv(csvFile string) (data []byte) {
321 data, err := script.File(csvFile).Bytes() //nolint
322 if err != nil {
323 log.Printf(`Error reading %s file %v`, csvFile, err)
324 }
325 return data
326}
327
328const csvMinFields = 51 // f[0] through f[50]
329
330// ReadCSV reads the catalog from a file. The parsing is in ParseCSV so that
331// it can be tested without one.
332func ReadCSV(csvFile string) Products {
333 return ParseCSV(readproductscsv(csvFile))
334}
335
336// ParseCSV turns the catalog bytes into products, skipping rows that are not
337// enabled and rows too short to fill one.
338func ParseCSV(data []byte) (prods Products) {
339 scanner := bufio.NewScanner(bytes.NewReader(data))
340 lineNum := 0
341 for scanner.Scan() {
342 lineNum++
343 line := scanner.Text()
344 f := strings.Split(line, ",")
345 if len(f) < 4 {
346 continue
347 }
348 if f[3] != "TRUE" {
349 continue
350 }
351 if len(f) < csvMinFields {
352 log.Printf("csv line %d: expected %d fields, got %d β skipping", lineNum, csvMinFields, len(f))
353 continue
354 }
355 q := Product{
356 Image1: f[0],
357 Partno: f[1],
358 Name: f[2],
359 Enable: f[3],
360 Price: f[4],
361 Quantity: f[5],
362 Shippable: f[6],
363 Minorder: f[7],
364 Maxorder: f[8],
365 Defaultquantity: f[9],
366 Stepquantity: f[10],
367 Mfgpartno: f[11],
368 Mfgname: f[12],
369 Category: f[13],
370 Subcategory: f[14],
371 Location: f[15],
372 Msrp: f[16],
373 Cost: f[17],
374 Typ: f[18],
375 Packagetype: f[19],
376 Technology: f[20],
377 Materials: f[21],
378 Value: f[22],
379 ValUnit: f[23],
380 Resistance: f[24],
381 ResUnit: f[25],
382 Tolerance: f[26],
383 VoltsRating: f[27],
384 AmpsRating: f[28],
385 WattsRating: f[29],
386 TempRating: f[30],
387 TempUnit: f[31],
388 Description1: f[32],
389 Description2: f[33],
390 Color1: f[34],
391 Color2: f[35],
392 Sourceinfo: f[36],
393 Datasheet: f[37],
394 Docs: f[38],
395 Reference: f[39],
396 Attributes: f[40],
397 Year: f[41],
398 Condition: f[42],
399 Note: f[43],
400 Warning: f[44],
401 CableLengthInches: f[45],
402 LengthInches: f[46],
403 WidthInches: f[47],
404 HeightInches: f[48],
405 WeightLb: f[49],
406 WeightOz: f[50],
407 }
408 prods = append(prods, q)
409 }
410 return prods
411}
412
413// ValidateCSV scans products for patterns that could cause issues in HTML/JS rendering.
414// Returns a list of warnings. Call after ReadCSV to check data integrity.
415func ValidateCSV(prods Products) []string {
416 var warnings []string
417 for i, pr := range prods {
418 check := func(field, value string) {
419 if strings.ContainsAny(value, "<>\"'&") {
420 warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains HTML-unsafe characters: %q", i, pr.Partno, field, value))
421 }
422 if strings.Contains(value, "|") {
423 warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains pipe character: %q", i, pr.Partno, field, value))
424 }
425 }
426 check("Partno", pr.Partno)
427 check("Name", pr.Name)
428 check("Description1", pr.Description1)
429 check("Description2", pr.Description2)
430 check("Note", pr.Note)
431 check("Warning", pr.Warning)
432 check("Category", pr.Category)
433 check("Subcategory", pr.Subcategory)
434 check("Mfgname", pr.Mfgname)
435 check("Mfgpartno", pr.Mfgpartno)
436 }
437 return warnings
438}
439
440
441// ===== pkg/product/csv_test.go =====
442package product
443
444import (
445 "strings"
446 "testing"
447)
448
449// row builds a catalog line with the fields the parser needs, so a test can
450// set the few it cares about without writing fifty-one commas.
451func row(set map[int]string) string {
452 f := make([]string, csvMinFields)
453 f[0] = "img.jpg"
454 f[1] = "PN-1"
455 f[2] = "A Part"
456 f[3] = "TRUE"
457 f[4] = "1.50"
458 f[5] = "10"
459 for i, v := range set {
460 f[i] = v
461 }
462 return strings.Join(f, ",")
463}
464
465func TestParseCSVReadsAProduct(t *testing.T) {
466 prods := ParseCSV([]byte(row(nil)))
467 if len(prods) != 1 {
468 t.Fatalf("got %d products, want 1", len(prods))
469 }
470 got := prods[0]
471 if got.Partno != "PN-1" || got.Name != "A Part" || got.Price != "1.50" || got.Quantity != "10" {
472 t.Errorf("fields did not land where they should: %+v", got)
473 }
474}
475
476// The last field is the fiftieth, and off-by-one at the end of a fifty-one
477// column row is invisible in a spreadsheet.
478func TestParseCSVReadsTheLastField(t *testing.T) {
479 prods := ParseCSV([]byte(row(map[int]string{50: "3.25", 49: "1"})))
480 if len(prods) != 1 {
481 t.Fatalf("got %d products, want 1", len(prods))
482 }
483 if prods[0].WeightOz != "3.25" {
484 t.Errorf("WeightOz = %q, want the last column", prods[0].WeightOz)
485 }
486 if prods[0].WeightLb != "1" {
487 t.Errorf("WeightLb = %q, want the second to last column", prods[0].WeightLb)
488 }
489}
490
491// Only enabled rows are sold. A row that says anything but TRUE is a product
492// deliberately withdrawn, and showing it anyway sells something not in stock.
493func TestParseCSVSkipsRowsThatAreNotEnabled(t *testing.T) {
494 for _, enable := range []string{"FALSE", "false", "true", "", "1", "yes"} {
495 prods := ParseCSV([]byte(row(map[int]string{3: enable})))
496 if len(prods) != 0 {
497 t.Errorf("Enable=%q produced a product; only TRUE should", enable)
498 }
499 }
500}
501
502// A short row cannot fill a product, and reading one anyway would panic on a
503// live catalog rather than skip a line.
504func TestParseCSVSkipsShortRows(t *testing.T) {
505 short := strings.Join([]string{"img.jpg", "PN-1", "A Part", "TRUE", "1.50"}, ",")
506 if prods := ParseCSV([]byte(short)); len(prods) != 0 {
507 t.Errorf("a row with 5 fields produced %d products", len(prods))
508 }
509 // Exactly one short of the minimum is the boundary worth pinning.
510 f := make([]string, csvMinFields-1)
511 for i := range f {
512 f[i] = "x"
513 }
514 f[3] = "TRUE"
515 if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 0 {
516 t.Errorf("a row one field short produced %d products", len(prods))
517 }
518}
519
520func TestParseCSVSkipsVeryShortAndBlankRows(t *testing.T) {
521 for _, line := range []string{"", ",", "a,b,c", "\n\n"} {
522 if prods := ParseCSV([]byte(line)); len(prods) != 0 {
523 t.Errorf("%q produced %d products", line, len(prods))
524 }
525 }
526}
527
528func TestParseCSVReadsSeveralRows(t *testing.T) {
529 data := strings.Join([]string{
530 row(map[int]string{1: "AAA"}),
531 row(map[int]string{3: "FALSE", 1: "SKIPPED"}),
532 row(map[int]string{1: "BBB"}),
533 }, "\n")
534 prods := ParseCSV([]byte(data))
535 if len(prods) != 2 {
536 t.Fatalf("got %d products, want 2", len(prods))
537 }
538 if prods[0].Partno != "AAA" || prods[1].Partno != "BBB" {
539 t.Errorf("got %q and %q, want AAA and BBB", prods[0].Partno, prods[1].Partno)
540 }
541}
542
543// The catalog is split on commas rather than parsed as CSV, so a quoted
544// field containing one shifts every column after it β including Enable, which
545// is column 3. The row then does not say TRUE where the parser looks, and the
546// product is dropped from the store without a word.
547//
548// A spreadsheet writes that quoting itself the moment a name contains a comma,
549// so this is reachable from ordinary editing rather than from bad data.
550//
551// This test records what the parser does today. If it is ever changed to use
552// encoding/csv, this test is the one that should fail.
553func TestParseCSVSilentlyDropsRowsWithAQuotedComma(t *testing.T) {
554 f := make([]string, csvMinFields)
555 for i := range f {
556 f[i] = "x"
557 }
558 f[1] = "PN-1"
559 f[2] = `"Resistor, 10k"` // one field in a spreadsheet, two after a split
560 f[3] = "TRUE"
561 f[4] = "1.50"
562 line := strings.Join(f, ",")
563
564 // The row is long enough β it is the shift that loses it, not the length.
565 if n := len(strings.Split(line, ",")); n <= csvMinFields {
566 t.Fatalf("the fixture has %d fields; it needs more than %d to isolate the shift", n, csvMinFields)
567 }
568
569 prods := ParseCSV([]byte(line))
570 if len(prods) != 0 {
571 t.Fatalf("the parser now keeps rows with a quoted comma (%d products); "+
572 "replace this test with one asserting the fields are correct", len(prods))
573 }
574
575 // Without the comma the same row is read, which is what shows the comma is
576 // the cause rather than anything else in the fixture.
577 f[2] = "Resistor 10k"
578 if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 1 {
579 t.Errorf("the same row without the comma gave %d products, want 1", len(prods))
580 }
581}
582
583func TestValidateCSVAcceptsCleanData(t *testing.T) {
584 prods := ParseCSV([]byte(row(nil)))
585 if w := ValidateCSV(prods); len(w) != 0 {
586 t.Errorf("clean data produced warnings: %v", w)
587 }
588}
589
590// These characters are what turn a product name into markup on the page.
591func TestValidateCSVFlagsCharactersThatBreakThePage(t *testing.T) {
592 for _, bad := range []string{`<script>`, `a"b`, "a'b", "a&b", "a>b"} {
593 prods := ParseCSV([]byte(row(map[int]string{2: bad})))
594 if len(prods) != 1 {
595 t.Fatalf("%q: got %d products", bad, len(prods))
596 }
597 w := ValidateCSV(prods)
598 if len(w) == 0 {
599 t.Errorf("%q in a product name was not flagged", bad)
600 continue
601 }
602 if !strings.Contains(w[0], "Name") {
603 t.Errorf("%q: the warning does not name the field: %s", bad, w[0])
604 }
605 }
606}
607
608// The pipe is the field separator in the cart's stored format, so a pipe in a
609// product name corrupts the cart rather than the page.
610func TestValidateCSVFlagsPipes(t *testing.T) {
611 prods := ParseCSV([]byte(row(map[int]string{2: "A|B"})))
612 w := ValidateCSV(prods)
613 if len(w) == 0 {
614 t.Fatal("a pipe in a product name was not flagged")
615 }
616 if !strings.Contains(w[0], "pipe") {
617 t.Errorf("the warning does not mention the pipe: %s", w[0])
618 }
619}
620
621// Every field that reaches the page is checked, not just the name.
622func TestValidateCSVChecksEveryRenderedField(t *testing.T) {
623 fields := map[int]string{
624 1: "PN<", // Partno
625 2: "N<", // Name
626 12: "MFG<", // Mfgname
627 11: "MP<", // Mfgpartno
628 13: "CAT<", // Category
629 14: "SUB<", // Subcategory
630 32: "D1<", // Description1
631 33: "D2<", // Description2
632 43: "NT<", // Note
633 44: "WN<", // Warning
634 }
635 for col, val := range fields {
636 prods := ParseCSV([]byte(row(map[int]string{3: "TRUE", col: val})))
637 if len(prods) != 1 {
638 t.Fatalf("column %d: got %d products", col, len(prods))
639 }
640 if w := ValidateCSV(prods); len(w) == 0 {
641 t.Errorf("an unsafe character in column %d was not flagged", col)
642 }
643 }
644}
645
646func TestValidateCSVOnNoProducts(t *testing.T) {
647 if w := ValidateCSV(nil); len(w) != 0 {
648 t.Errorf("an empty catalog produced warnings: %v", w)
649 }
650}
651
652// The warning names the row so it can be found in a spreadsheet of thousands.
653func TestValidateCSVNamesTheProduct(t *testing.T) {
654 prods := ParseCSV([]byte(row(map[int]string{1: "PN-SPECIAL", 2: "bad<"})))
655 w := ValidateCSV(prods)
656 if len(w) == 0 {
657 t.Fatal("no warning")
658 }
659 if !strings.Contains(w[0], "PN-SPECIAL") {
660 t.Errorf("the warning does not name the part: %s", w[0])
661 }
662}
663
664
665// ===== pkg/product/product.go =====
666// Package product pkg/product/product.go
667package product
668
669type Product struct {
670 Enable string
671 Partno string
672 Name string
673 Image1 string
674 Price string
675 Quantity string
676 Shippable string
677 Minorder string
678 Maxorder string
679 Defaultquantity string
680 Stepquantity string
681 Mfgpartno string
682 Mfgname string
683 Category string
684 Subcategory string
685 Location string
686 Msrp string
687 Cost string
688 Typ string
689 Packagetype string
690 Technology string
691 Materials string
692 Value string
693 ValUnit string
694 Resistance string
695 ResUnit string
696 Tolerance string
697 VoltsRating string
698 AmpsRating string
699 WattsRating string
700 TempRating string
701 TempUnit string
702 Description1 string
703 Description2 string
704 Color1 string
705 Color2 string
706 Sourceinfo string
707 Datasheet string
708 Docs string
709 Reference string
710 Attributes string
711 Year string
712 Condition string
713 Note string
714 Warning string
715 CableLengthInches string
716 LengthInches string
717 WidthInches string
718 HeightInches string
719 WeightLb string
720 WeightOz string
721}
722
723// Products is an array of Product
724type Products []Product
725
726
727// ===== pkg/tui/border.go =====
728// Package tui pkg/tui/border.go β the pisano frame around the product
729// table.
730//
731// The same drawing the website puts around its category tables, from the
732// same library: an integer sequence reduced modulo m, walked as a turtle
733// and rendered in box-drawing characters. On the page that is the wasm/deco
734// drop-in decorating the DOM after the fact; here there is no DOM to
735// decorate, so the frame is composed into the cells the table is drawn in.
736//
737// The figures are wasm/deco's, deliberately β one frame seen in two places
738// is the point, and a terminal that invented its own would just be a second
739// drawing to keep in step. What differs is the fit. The page knows the table
740// and grows a frame around it (FitAround); a terminal knows the area and has
741// to put both inside it (FitBox), because there is nowhere for a border to
742// overflow to.
743package tui
744
745import (
746 "strconv"
747 "sync"
748
749 "github.com/gdamore/tcell/v3"
750
751 "github.com/0magnet/pisano/pkg/border"
752 "github.com/0magnet/pisano/pkg/pisano"
753)
754
755// borderArt is a composed frame: the glyphs, the color of each, and the
756// clear middle the table goes in.
757type borderArt struct {
758 g border.Grid
759 tint border.Tint
760 in border.Rect
761}
762
763// recipe is one frame's three figures: the run along the top and bottom,
764// the run down the sides, and the closed figure at the corners.
765type recipe struct{ across, down, corner border.Figure }
766
767var (
768 recipesOnce sync.Once
769 borderSet []recipe
770)
771
772// recipes are wasm/deco's, and the reasoning is its: modulus 13 is both
773// runs β it travels +0,-4, which makes a side, and a quarter turn makes a
774// top square to it by construction rather than by search. Modulus 31 at
775// four passes is the corner, closed and with the full symmetry of the
776// square; an open figure would have loose ends of its own just where a run
777// is trying to stop. Modulus 8 is the second rung, for an area that cannot
778// spare the first's reach β a smaller figure beats no figure.
779//
780// The order is the preference, fullest corner first, and fitBorder keeps
781// it as long as the content can afford it.
782func recipes() []recipe {
783 recipesOnce.Do(func() {
784 side, ok := border.OfPasses(13, 1)
785 if !ok {
786 return
787 }
788 top := side.RotateCW()
789 for _, c := range [][2]int{{31, 4}, {8, 2}} {
790 corner, ok := border.OfPasses(c[0], c[1])
791 if !ok || !corner.Closed {
792 continue
793 }
794 borderSet = append(borderSet, recipe{across: top, down: side, corner: corner})
795 }
796 })
797 return borderSet
798}
799
800// borderPalette is pisano's six pass colors, which is what its own turtle
801// tints with β so the frame here and the frame on the page are the same
802// drawing in the same colors.
803var borderPalette = func() []tcell.Style {
804 hex := pisano.PassHex()
805 out := make([]tcell.Style, len(hex))
806 for i, h := range hex {
807 out[i] = styleText.Foreground(tcell.GetColor(h))
808 }
809 return out
810}()
811
812// frameCache keeps one frame per area size.
813//
814// Composing one is not free β it lays every copy of both runs and all four
815// corners onto a grid, cuts the runs where their own wave crosses, then
816// walks the result to check it is a single closed line. A draw happens on
817// every event the UI handles, pointer motion included, so composing per
818// draw would be the most expensive thing the program does. A size that
819// fits nothing is cached as a miss, so it is not retried either.
820var (
821 frameMu sync.Mutex
822 frameCache = map[string]*borderArt{}
823)
824
825// minFramed is the smallest clear middle worth taking an area down to. A
826// frame that fits but leaves room for a header and one row has spent the
827// table to draw a picture of one.
828const minFramed = 6
829
830// roomyRows and roomyCols are what a middle has to keep for the fuller
831// corner to be worth its longer reach: a table still worth reading β its
832// header and a dozen-odd products, at a width that does not start cutting
833// names β rather than merely a table.
834//
835// Below them the corner is competing with the content for the same cells
836// and the content wins, which is the only reason to draw a smaller figure
837// at all.
838const (
839 roomyRows = 16
840 roomyCols = 40
841)
842
843// fitBorder composes the largest frame that fits in a cols by rows area,
844// or reports none. The area is what the caller has; the frame is drawn
845// inside it and the table goes in what is left.
846func fitBorder(cols, rows int) *borderArt {
847 if cols < 8 || rows < 8 {
848 return nil
849 }
850 key := strconv.Itoa(cols) + "x" + strconv.Itoa(rows)
851 frameMu.Lock()
852 defer frameMu.Unlock()
853 if hit, seen := frameCache[key]; seen {
854 return hit
855 }
856 // The fullest corner that still leaves a table worth reading, and the
857 // roomiest otherwise.
858 //
859 // wasm/deco takes the first that fits, largest corner first, and can:
860 // it grows a frame AROUND its table, so a bigger corner only makes a
861 // bigger frame and costs the table nothing. Here the frame is cut OUT
862 // of a fixed area and every cell of corner is a row of products not
863 // shown, so the order alone is not an answer β but neither is simply
864 // taking the roomiest, which spends the good corner to buy four rows
865 // on a screen that had forty to spare.
866 //
867 // So: the same order deco uses, and the same preference, held only as
868 // long as the content is not the thing running out. Under that, the
869 // figure gives way rather than the table.
870 var got, roomiest *borderArt
871 var bestRoom int
872 for _, r := range recipes() {
873 l, g, ok := border.FitBox(r.across, r.down, r.corner, cols, rows)
874 if !ok {
875 continue
876 }
877 in := g.Inside()
878 w, h := in.X1-in.X0, in.Y1-in.Y0
879 if w < minFramed || h < minFramed {
880 continue
881 }
882 art := &borderArt{g: g, tint: l.TintByCopy(g, len(borderPalette)), in: in}
883 if h >= roomyRows && w >= roomyCols {
884 // Room to spare, and this is the fullest corner left to try.
885 // Nothing after it is better, and composing it would be work
886 // for a frame this one has already beaten.
887 got = art
888 break
889 }
890 // Rows before columns: a terminal runs out of height first, and a
891 // row lost is a product off the screen where a column lost is a
892 // name a little shorter.
893 if score := h<<16 | w; score > bestRoom {
894 bestRoom, roomiest = score, art
895 }
896 }
897 if got == nil {
898 got = roomiest
899 }
900 frameCache[key] = got
901 return got
902}
903
904// place centers the frame in the area it was fitted to and reports the
905// rectangle its clear middle occupies on screen β where the table goes.
906//
907// Centered because a frame is quantised by its figures and is never
908// exactly the size asked for; hanging it from the top left would leave the
909// slack in one corner.
910func (b *borderArt) place(area rect) (origin, inner rect) {
911 ox := area.x + (area.w-b.g.W())/2
912 oy := area.y + (area.h-b.g.H())/2
913 origin = rect{ox, oy, b.g.W(), b.g.H()}
914 inner = rect{ox + b.in.X0, oy + b.in.Y0, b.in.X1 - b.in.X0, b.in.Y1 - b.in.Y0}
915 return origin, inner
916}
917
918// draw paints the frame. Blanks are skipped rather than drawn as spaces,
919// so the frame never erases anything it overlaps β it is a decoration, and
920// the page's is drawn under its table for the same reason.
921func (b *borderArt) draw(sc tcell.Screen, origin rect) {
922 for y, row := range b.g {
923 for x, ch := range row {
924 if ch == border.Blank {
925 continue
926 }
927 st := styleText
928 if y < len(b.tint) && x < len(b.tint[y]) {
929 if i := b.tint[y][x]; i >= 0 {
930 st = borderPalette[i%len(borderPalette)]
931 }
932 }
933 sc.SetContent(origin.x+x, origin.y+y, ch, nil, st)
934 }
935 }
936}
937
938// framed splits an area into a frame and the cells inside it. An area that
939// fits no frame is handed back whole, which is what the page does too: a
940// box with no border is the answer, not a box with a broken one.
941func framed(area rect) (inner rect, b *borderArt, origin rect) {
942 b = fitBorder(area.w, area.h)
943 if b == nil {
944 return area, nil, rect{}
945 }
946 origin, inner = b.place(area)
947 return inner, b, origin
948}
949
950
951// ===== pkg/tui/border_test.go =====
952package tui
953
954import (
955 "testing"
956
957 "github.com/0magnet/pisano/pkg/border"
958)
959
960// A frame has to fit in the area it was asked for. It is cut out of the
961// table's own space, so one cell over means a border drawn across the
962// heading or the footer.
963func TestBorderFitsTheAreaItIsGiven(t *testing.T) {
964 for _, sz := range [][2]int{{160, 60}, {120, 40}, {80, 30}, {60, 24}} {
965 b := fitBorder(sz[0], sz[1])
966 if b == nil {
967 continue // nothing fits here, which is an answer
968 }
969 if b.g.W() > sz[0] || b.g.H() > sz[1] {
970 t.Errorf("%dx%d: frame is %dx%d, larger than its area", sz[0], sz[1], b.g.W(), b.g.H())
971 }
972 w, h := b.in.X1-b.in.X0, b.in.Y1-b.in.Y0
973 if w < minFramed || h < minFramed {
974 t.Errorf("%dx%d: middle is %dx%d, under the minimum", sz[0], sz[1], w, h)
975 }
976 }
977}
978
979// The clear middle is where the table is drawn, so nothing may be inked
980// inside it β a glyph there would be a box character among the products.
981func TestBorderMiddleIsClear(t *testing.T) {
982 b := fitBorder(160, 60)
983 if b == nil {
984 t.Fatal("no frame at 160x60, which is the size this page runs at")
985 }
986 for y := b.in.Y0; y < b.in.Y1; y++ {
987 for x := b.in.X0; x < b.in.X1; x++ {
988 if ch := b.g[y][x]; ch != border.Blank {
989 t.Fatalf("%q at %d,%d is inside the clear middle", ch, x, y)
990 }
991 }
992 }
993}
994
995// place puts the middle on screen where the table will be drawn, and the
996// frame around it β both inside the area.
997func TestBorderPlacesItsMiddleInsideTheArea(t *testing.T) {
998 area := rect{x: 3, y: 9, w: 160, h: 60}
999 b := fitBorder(area.w, area.h)
1000 if b == nil {
1001 t.Fatal("no frame")
1002 }
1003 origin, inner := b.place(area)
1004 if origin.x < area.x || origin.y < area.y ||
1005 origin.x+origin.w > area.x+area.w || origin.y+origin.h > area.y+area.h {
1006 t.Errorf("frame at %+v escapes the area %+v", origin, area)
1007 }
1008 if inner.x < origin.x || inner.y < origin.y ||
1009 inner.x+inner.w > origin.x+origin.w || inner.y+inner.h > origin.y+origin.h {
1010 t.Errorf("middle at %+v escapes the frame %+v", inner, origin)
1011 }
1012 if inner.w >= area.w || inner.h >= area.h {
1013 t.Errorf("middle %dx%d is not smaller than the area %dx%d", inner.w, inner.h, area.w, area.h)
1014 }
1015}
1016
1017// cornerReach is how far a frame's corner comes in from its own edge,
1018// which is what tells the two figures apart: the fuller corner reaches
1019// further, and that reach is its whole cost.
1020func cornerReach(b *borderArt) int { return b.in.Y0 }
1021
1022// With room to spare the frame uses the fuller corner, as the page does.
1023func TestBorderKeepsTheFullCornerWhenThereIsRoom(t *testing.T) {
1024 roomy := fitBorder(160, 60)
1025 tight := fitBorder(160, 30)
1026 if roomy == nil || tight == nil {
1027 t.Fatal("expected a frame at both sizes")
1028 }
1029 if cornerReach(roomy) <= cornerReach(tight) {
1030 t.Errorf("the roomy area's corner reaches %d, no further than the tight one's %d",
1031 cornerReach(roomy), cornerReach(tight))
1032 }
1033 if h := roomy.in.Y1 - roomy.in.Y0; h < roomyRows {
1034 t.Errorf("the full corner was kept on a middle of only %d rows", h)
1035 }
1036}
1037
1038// When it is the content running out, the figure gives way rather than
1039// the table: the frame chosen leaves more rows than the fuller corner
1040// would have.
1041func TestBorderGivesUpTheCornerRatherThanTheTable(t *testing.T) {
1042 const cols, rows = 160, 30
1043 got := fitBorder(cols, rows)
1044 if got == nil {
1045 t.Fatal("no frame at 160x30")
1046 }
1047 full := recipes()[0]
1048 _, g, ok := border.FitBox(full.across, full.down, full.corner, cols, rows)
1049 if !ok {
1050 t.Skip("the fuller corner does not fit here at all, which is a different path")
1051 }
1052 in := g.Inside()
1053 if want := in.Y1 - in.Y0; got.in.Y1-got.in.Y0 <= want {
1054 t.Errorf("gave up the corner for %d rows, no better than the %d it would have left",
1055 got.in.Y1-got.in.Y0, want)
1056 }
1057}
1058
1059// An area too small for any figure gets no frame, rather than a broken
1060// one or a table squeezed to nothing.
1061func TestBorderDeclinesASmallArea(t *testing.T) {
1062 for _, sz := range [][2]int{{4, 4}, {20, 6}, {7, 30}} {
1063 if b := fitBorder(sz[0], sz[1]); b != nil {
1064 t.Errorf("%dx%d got a frame with a %dx%d middle, want none",
1065 sz[0], sz[1], b.in.X1-b.in.X0, b.in.Y1-b.in.Y0)
1066 }
1067 }
1068}
1069
1070// Composing is the expensive thing here and a draw happens on every
1071// event, so the same size must come back from the cache rather than be
1072// composed again.
1073func TestBorderCachesBySize(t *testing.T) {
1074 a := fitBorder(132, 44)
1075 b := fitBorder(132, 44)
1076 if a != b {
1077 t.Error("the same size composed twice instead of coming from the cache")
1078 }
1079}
1080
1081
1082// ===== pkg/tui/catalog.go =====
1083// Package tui pkg/tui/catalog.go β the site's structures, derived the same
1084// way the templates derive them: category ordering from getcategories, the
1085// navigation tree from htmpl/catsubcats.html, the product page from
1086// htmpl/product.html. Where the website renders HTML, this renders the same
1087// data to terminal text.
1088package tui
1089
1090import (
1091 "fmt"
1092 "sort"
1093 "strconv"
1094 "strings"
1095
1096 "github.com/0magnet/tallytree"
1097
1098 "github.com/0magnet/m2/pkg/product"
1099 "github.com/0magnet/m2/pkg/storepage"
1100)
1101
1102// catalogInfo mirrors what pageMeta hands the templates: categories sorted
1103// by product count, subcategories likewise, with counts.
1104type catalogInfo struct {
1105 prods product.Products
1106 cats []string
1107 catCounts map[string]int
1108 subcatCounts map[string]map[string]int
1109 subcatsByCat map[string][]string
1110}
1111
1112func newCatalogInfo(prods product.Products) *catalogInfo {
1113 c := &catalogInfo{
1114 prods: prods,
1115 catCounts: make(map[string]int),
1116 subcatCounts: make(map[string]map[string]int),
1117 subcatsByCat: make(map[string][]string),
1118 }
1119 for _, prod := range prods {
1120 if prod.Category == "" {
1121 continue
1122 }
1123 c.catCounts[prod.Category]++
1124 if prod.Subcategory != "" {
1125 if c.subcatCounts[prod.Category] == nil {
1126 c.subcatCounts[prod.Category] = make(map[string]int)
1127 }
1128 c.subcatCounts[prod.Category][prod.Subcategory]++
1129 }
1130 }
1131 for cat := range c.catCounts {
1132 c.cats = append(c.cats, cat)
1133 }
1134 sort.Slice(c.cats, func(i, j int) bool {
1135 if c.catCounts[c.cats[i]] != c.catCounts[c.cats[j]] {
1136 return c.catCounts[c.cats[i]] > c.catCounts[c.cats[j]]
1137 }
1138 return c.cats[i] < c.cats[j]
1139 })
1140 for cat, subs := range c.subcatCounts {
1141 var names []string
1142 for sub := range subs {
1143 names = append(names, sub)
1144 }
1145 sort.Slice(names, func(i, j int) bool {
1146 if subs[names[i]] != subs[names[j]] {
1147 return subs[names[i]] > subs[names[j]]
1148 }
1149 return names[i] < names[j]
1150 })
1151 c.subcatsByCat[cat] = names
1152 }
1153 return c
1154}
1155
1156// products returns the rows the matching category/subcategory table
1157// lists, as /cat/<cat>[/<subcat>] does: every product in the category,
1158// subcategorized or not, and a subcategory page narrowing that to one.
1159//
1160// It used to hold back the products that have a subcategory, which is
1161// front.html's rule for its "Other Products in X" table and not this
1162// page's. Where every product in a category is subcategorized β which
1163// is most of them β that left the category page with nothing on it. The
1164// site had the same bug in its homepage sections and fixed it there
1165// (catTargetCSS in pkg/web); this is that fix, for that reason.
1166func (c *catalogInfo) products(cat, subcat string) product.Products {
1167 var out product.Products
1168 for _, p := range c.prods {
1169 switch {
1170 case cat == "":
1171 out = append(out, p)
1172 case subcat == "":
1173 if p.Category == cat {
1174 out = append(out, p)
1175 }
1176 default:
1177 if p.Category == cat && p.Subcategory == subcat {
1178 out = append(out, p)
1179 }
1180 }
1181 }
1182 return out
1183}
1184
1185// resolve reads a "<cat>" or "<cat>/<subcat>" path as a place in this
1186// catalog, matching the way a URL does: case-insensitively, and only
1187// where the catalog holds that name. It reports whether it found one.
1188func (c *catalogInfo) resolve(path string) (navTarget, bool) {
1189 parts := strings.SplitN(strings.Trim(path, "/"), "/", 2)
1190 if parts[0] == "" {
1191 return navTarget{}, true // /cat/ is all products
1192 }
1193 cat := ""
1194 for name := range c.catCounts {
1195 if strings.EqualFold(name, parts[0]) {
1196 cat = name
1197 break
1198 }
1199 }
1200 if cat == "" {
1201 return navTarget{}, false
1202 }
1203 if len(parts) == 1 {
1204 return navTarget{cat: cat}, true
1205 }
1206 // Either spelling: the name as the catalog holds it, or the name as
1207 // a URL spells it. The site writes "ΒΌ watt 5%" into a path as
1208 // quarter-watt-5-pct, and while the escaping lived only in pkg/web
1209 // this could not match those at all β every subcategory link with a
1210 // fraction or a percent in it left the store rather than opening the
1211 // listing right here.
1212 for _, sub := range c.subcatsByCat[cat] {
1213 if strings.EqualFold(sub, parts[1]) || strings.EqualFold(storepage.EscapeSubcat(sub), parts[1]) {
1214 return navTarget{cat: cat, subcat: sub}, true
1215 }
1216 }
1217 return navTarget{}, false
1218}
1219
1220func (c *catalogInfo) find(partno string) *product.Product {
1221 for i := range c.prods {
1222 if strings.EqualFold(c.prods[i].Partno, partno) {
1223 return &c.prods[i]
1224 }
1225 }
1226 return nil
1227}
1228
1229// navTarget is a place the categories tree can go: all products, a
1230// category, or a subcategory.
1231type navTarget struct{ cat, subcat string }
1232
1233type navItem struct {
1234 label string
1235 target navTarget
1236 hasSubs bool
1237}
1238
1239// buildTree lays the category menu out: counts right-aligned by β padding,
1240// β/β branches, β¬ where a category opens into subcategories.
1241//
1242// The drawing is tallytree's, not this file's. It used to be written out here
1243// and again in htmpl/catsubcats.html, two implementations of one picture kept
1244// in agreement by hand; both now call the library, so the alignment arithmetic
1245// exists once and has tests.
1246//
1247// Depth is pinned to two because the menu always COULD have subcategories, so
1248// it reserves room for them even in a catalog where nothing does β otherwise
1249// a flat catalog would draw a narrower tree than the same shop with one
1250// subcategory added.
1251func (c *catalogInfo) buildTree(expanded map[string]bool) []navItem {
1252 forest := []tallytree.Node{{Label: "All Products", Count: len(c.prods)}}
1253 for _, cat := range c.cats {
1254 n := tallytree.Node{Label: cat, Count: c.catCounts[cat]}
1255 for _, sub := range c.subcatsByCat[cat] {
1256 n.Children = append(n.Children, tallytree.Node{Label: sub, Count: c.subcatCounts[cat][sub]})
1257 }
1258 forest = append(forest, n)
1259 }
1260 rows := tallytree.Rows(forest, tallytree.Options{
1261 Depth: 2,
1262 Open: func(n tallytree.Node, d int) bool { return d != 0 || expanded == nil || expanded[n.Label] },
1263 })
1264 items := make([]navItem, 0, len(rows))
1265 cat := ""
1266 for _, r := range rows {
1267 t := navTarget{}
1268 switch {
1269 case r.Depth == 0 && r.Label != "All Products":
1270 cat = r.Label
1271 t = navTarget{cat: r.Label}
1272 case r.Depth > 0:
1273 t = navTarget{cat: cat, subcat: r.Label}
1274 }
1275 items = append(items, navItem{r.Line(tallytree.Options{}), t, r.HasChildren})
1276 }
1277 return items
1278}
1279
1280// checkerboard styles alternate letters inverse-video, as the site's
1281// checkerBoard wraps every other letter of the domain in an .nv span.
1282func checkerboard(s string) string {
1283 var out strings.Builder
1284 for i, ch := range s {
1285 if i%2 == 0 {
1286 fmt.Fprintf(&out, "[black:white]%c[white:black]", ch)
1287 } else {
1288 out.WriteRune(ch)
1289 }
1290 }
1291 out.WriteString("[-:-]")
1292 return out.String()
1293}
1294
1295// set reports a field the way product.html's conditions do: present unless
1296// empty or a bare zero.
1297func set(v string) bool {
1298 return v != "" && v != "0" && v != "0.0"
1299}
1300
1301// productLines renders htmpl/product.html: the same fields, labels, and
1302// order, as terminal text.
1303//
1304// anchor wraps the template's clickable things β its Add to cart button
1305// and its category, subcategory and datasheet links β in whatever makes
1306// them clickable on the page doing the drawing. A nil anchor draws them
1307// as plain text, which is what a page with nowhere to send a click gets.
1308func productLines(u *ui, p *product.Product, anchor func(text string, act func()) string) string {
1309 if anchor == nil {
1310 anchor = func(text string, _ func()) string { return text }
1311 }
1312 var b strings.Builder
1313 line := func(format string, args ...interface{}) {
1314 fmt.Fprintf(&b, format+"\n", args...)
1315 }
1316 line("[::b]%s[-:-:-]", esc(p.Name))
1317 line("Price: [white]$%s[-]", esc(p.Price))
1318 line("In stock: %s", esc(p.Quantity))
1319 if p.Quantity != "0" {
1320 // The template's <button onclick='addToCart(...)'>, in the place
1321 // it sits on the page: under the stock line, above the part no.
1322 line("%s", anchor("[black:#005f87] "+storepage.AddToCart+" [-:-]", func() { u.addToCart(p) }))
1323 line("partno: %s", esc(p.Partno))
1324 }
1325 if !strings.EqualFold(strings.Join(strings.Fields(p.Description1), ""), strings.Join(strings.Fields(p.Name), "")) && p.Description1 != "" {
1326 line("\n%s\n", esc(p.Description1))
1327 }
1328 if p.Mfgname != "" {
1329 line("Brand: %s", esc(p.Mfgname))
1330 }
1331 if p.Mfgpartno != "" {
1332 line("MPN: %s", esc(p.Mfgpartno))
1333 }
1334 cat, sub := p.Category, p.Subcategory
1335 line("Category: %s", anchor("[aqua]"+esc(cat)+"[-]",
1336 func() { u.show(listPageID(navTarget{cat: cat})) }))
1337 if sub != "" {
1338 line("Subcategory: %s", anchor("[aqua]"+esc(sub)+"[-]",
1339 func() { u.show(listPageID(navTarget{cat: cat, subcat: sub})) }))
1340 }
1341 if set(p.VoltsRating) {
1342 line("Voltage: %s", esc(p.VoltsRating))
1343 }
1344 if set(p.Value) {
1345 line("Value: %s%s", esc(p.Value), esc(p.ValUnit))
1346 }
1347 if set(p.AmpsRating) {
1348 line("Amperage: %s", esc(p.AmpsRating))
1349 }
1350 if p.Tolerance != "0" && p.Tolerance != "" {
1351 if f, err := strconv.ParseFloat(p.Tolerance, 64); err == nil {
1352 line("Tolerance: %.2f%%", 100*f)
1353 }
1354 }
1355 if p.Typ != "" {
1356 line("Typ: %s", esc(p.Typ))
1357 }
1358 if p.Packagetype != "" {
1359 line("Package Type: %s", esc(p.Packagetype))
1360 }
1361 if p.Technology != "" {
1362 line("Technology: %s", esc(p.Technology))
1363 }
1364 if p.Materials != "" {
1365 line("Materials: %s", esc(p.Materials))
1366 }
1367 if set(p.WattsRating) {
1368 line("Watts Rating: %s", esc(p.WattsRating))
1369 }
1370 if p.Year != "0" && p.Year != "" {
1371 line("Year: %s", esc(p.Year))
1372 }
1373 if set(p.CableLengthInches) {
1374 line("Cable Length: %s inches", esc(p.CableLengthInches))
1375 }
1376 if p.WeightOz != "0" && p.WeightOz != "0.0" && p.WeightOz != "" {
1377 line("Weight: %s oz", esc(p.WeightOz))
1378 }
1379 if p.TempRating != "0" && p.TempRating != "0.0" && p.TempRating != "" {
1380 line("Temp rating: %s%s", esc(p.TempRating), esc(p.TempUnit))
1381 }
1382 if p.Condition != "" {
1383 line("Condition: %s", esc(p.Condition))
1384 }
1385 if p.Datasheet != "" {
1386 // The template links this at <imgsrc>/pdf/<name>; the terminal
1387 // cannot show a PDF, so following it hands over the URL.
1388 ds := p.Datasheet
1389 line("Datasheet: %s", anchor("[aqua]"+esc(ds)+"[-]",
1390 func() { u.openURL(datasheetURL(ds)) }))
1391 }
1392 if p.Docs != "" {
1393 line("Documentation: %s", esc(p.Docs))
1394 }
1395 if p.Note != "" {
1396 line("Note: [yellow]%s[-]", esc(p.Note))
1397 }
1398 if p.Warning != "" {
1399 line("Warning: [red]%s[-]", esc(p.Warning))
1400 }
1401 if p.Description2 != "" {
1402 line("Additional Description: %s", esc(p.Description2))
1403 }
1404 return b.String()
1405}
1406
1407
1408// ===== pkg/tui/catalog_test.go =====
1409package tui
1410
1411import (
1412 "strings"
1413 "testing"
1414
1415 "github.com/0magnet/m2/pkg/product"
1416)
1417
1418// sampleCatalog is a catalog small enough to write the expected drawing out
1419// by hand, and shaped to exercise the alignment: counts of one, two, three and
1420// four digits, a category with subcategories and one without, and a last
1421// sibling on both levels.
1422func sampleCatalog() *catalogInfo {
1423 return &catalogInfo{
1424 prods: make(product.Products, 1247),
1425 cats: []string{"resistor", "inductor"},
1426 catCounts: map[string]int{"resistor": 111, "inductor": 34},
1427 subcatsByCat: map[string][]string{"resistor": {"quarter watt 5%", "ceramic"}},
1428 subcatCounts: map[string]map[string]int{"resistor": {"quarter watt 5%": 67, "ceramic": 1}},
1429 }
1430}
1431
1432// The menu's counts are right-aligned by padding the branch rule itself, so
1433// every count ends in the same column and the tree still reads as one line.
1434// These are the exact rows the site drew before the layout moved to tallytree.
1435func TestBuildTreeDrawing(t *testing.T) {
1436 want := []string{
1437 "βββ1247 All Products",
1438 "βββ¬β111 resistor",
1439 "β βββ67 quarter watt 5%",
1440 "β ββββ1 ceramic",
1441 "βββββ34 inductor",
1442 }
1443 got := sampleCatalog().buildTree(map[string]bool{"resistor": true})
1444 if len(got) != len(want) {
1445 t.Fatalf("%d rows, want %d", len(got), len(want))
1446 }
1447 for i := range want {
1448 if got[i].label != want[i] {
1449 t.Errorf("row %d:\n got %q\nwant %q", i, got[i].label, want[i])
1450 }
1451 }
1452}
1453
1454// A collapsed category keeps the β¬ that says it has subcategories, and the
1455// columns do not move when it closes.
1456func TestBuildTreeCollapsed(t *testing.T) {
1457 got := sampleCatalog().buildTree(map[string]bool{})
1458 if len(got) != 3 {
1459 t.Fatalf("%d rows with everything collapsed, want 3", len(got))
1460 }
1461 if !strings.Contains(got[1].label, "β¬") {
1462 t.Errorf("collapsed category lost its β¬: %q", got[1].label)
1463 }
1464 open := sampleCatalog().buildTree(map[string]bool{"resistor": true})
1465 if a, b := strings.Index(got[1].label, " "), strings.Index(open[1].label, " "); a != b {
1466 t.Errorf("collapsing moved the count column: %d vs %d", a, b)
1467 }
1468}
1469
1470// A category page lists everything in the category, as /cat/<cat> does
1471// β not only the products with no subcategory, which is front.html's
1472// rule for its "Other Products in X" table. Getting that wrong left
1473// every category page in this catalog empty.
1474func TestCategoryPageListsTheWholeCategory(t *testing.T) {
1475 c := newCatalogInfo(product.Products{
1476 {Partno: "a", Category: "diode", Subcategory: "1N47XX"},
1477 {Partno: "b", Category: "diode", Subcategory: "1N47XX"},
1478 {Partno: "c", Category: "diode", Subcategory: "bridge"},
1479 {Partno: "d", Category: "diode"},
1480 {Partno: "e", Category: "resistor"},
1481 })
1482 names := func(ps product.Products) string {
1483 var out []string
1484 for _, p := range ps {
1485 out = append(out, p.Partno)
1486 }
1487 return strings.Join(out, ",")
1488 }
1489 if got := names(c.products("diode", "")); got != "a,b,c,d" {
1490 t.Errorf("/cat/diode lists %q, want %q", got, "a,b,c,d")
1491 }
1492 if got := names(c.products("diode", "1N47XX")); got != "a,b" {
1493 t.Errorf("/cat/diode/1N47XX lists %q, want %q", got, "a,b")
1494 }
1495 if got := names(c.products("", "")); got != "a,b,c,d,e" {
1496 t.Errorf("all products lists %q, want %q", got, "a,b,c,d,e")
1497 }
1498}
1499
1500// Targets have to survive the layout: the rows carry where each entry goes.
1501func TestBuildTreeTargets(t *testing.T) {
1502 got := sampleCatalog().buildTree(map[string]bool{"resistor": true})
1503 want := []navTarget{{}, {cat: "resistor"}, {cat: "resistor", subcat: "quarter watt 5%"},
1504 {cat: "resistor", subcat: "ceramic"}, {cat: "inductor"}}
1505 for i := range want {
1506 if got[i].target != want[i] {
1507 t.Errorf("row %d target %+v, want %+v", i, got[i].target, want[i])
1508 }
1509 }
1510 if !got[1].hasSubs || got[4].hasSubs {
1511 t.Error("hasSubs is wrong: resistor has subcategories, inductor does not")
1512 }
1513}
1514
1515
1516// ===== pkg/tui/checkout.go =====
1517// Package tui pkg/tui/checkout.go β the cart, the shipping form, and
1518// checkout, mirroring the website's footer commerce (footer.html + the
1519// cart wasm). On the website the cart and the shipping form are
1520// <details> dropdowns rising from the fixed footer and checkout is a
1521// <dialog>; here they are overlays above the footer bar, outside the
1522// navigation history β v toggles the cart, Esc closes.
1523//
1524// The TUI is a client of the same store server the browser cart talks
1525// to: it builds the identical items payload ("partno X qty" lines plus
1526// a "shipping-to|..." line) and POSTs the same /create-payment-intent.
1527// The card-entry step lives behind payWithElements (pay_native.go / a
1528// future pay_js.go): when the TUI runs in the browser as wasm, that
1529// seam mounts the Stripe Payment Element over the terminal exactly as
1530// the cart wasm does today; a native terminal cannot take card details,
1531// and says so.
1532package tui
1533
1534import (
1535 "bytes"
1536 "encoding/json"
1537 "fmt"
1538 "io"
1539 "net/http"
1540 "strings"
1541 "time"
1542
1543 "github.com/gdamore/tcell/v3"
1544
1545 "github.com/0magnet/m2/pkg/storepage"
1546)
1547
1548// The shipping line, the form that fills it and the state list all live in
1549// pkg/storepage now, so the page, this and the cart wasm cannot drift apart.
1550// See pkg/storepage/shipping.go.
1551
1552// commerceRect anchors an overlay just above the footer summary it
1553// expands from β the cart (and checkout) above View Cart, the form
1554// above Add Shipping Info.
1555func (u *ui) commerceRect() rect {
1556 w, h := u.commerce.size()
1557 sw, sh := u.screen.Size()
1558 if w > sw-2 {
1559 w = sw - 2
1560 }
1561 x := 1
1562 region := 0
1563 if _, ok := u.commerce.(*shippingOverlay); ok {
1564 region = 1
1565 }
1566 if region < len(u.footRegions) {
1567 x = u.footRegions[region].x1 - 1
1568 }
1569 if x+w > sw-1 {
1570 x = sw - 1 - w
1571 }
1572 if x < 0 {
1573 x = 0
1574 }
1575 y := sh - 2 - h
1576 if y < 1 {
1577 y = 1
1578 h = sh - 3
1579 }
1580 return rect{x, y, w, h}
1581}
1582
1583func (u *ui) closeCommerce() {
1584 u.commerce = nil
1585}
1586
1587func (u *ui) toggleCart() {
1588 if u.commerce != nil {
1589 u.closeCommerce()
1590 return
1591 }
1592 u.openCart()
1593}
1594
1595// ---- the cart overlay ----
1596
1597type cartOverlay struct {
1598 ta *textArea
1599 lines int
1600}
1601
1602func (u *ui) openCart() {
1603 var b strings.Builder
1604 total := 0
1605 for _, partno := range u.cartOrder {
1606 qty := u.cartQty[partno]
1607 if qty == 0 {
1608 continue
1609 }
1610 p := u.cat.find(partno)
1611 if p == nil {
1612 continue
1613 }
1614 cents := priceCents(p.Price) * qty
1615 total += cents
1616 fmt.Fprintf(&b, "%3d Γ [aqua]%s[-] $%d.%02d\n", qty, esc(p.Name), cents/100, cents%100)
1617 }
1618 if u.shipping != nil {
1619 total += u.shipping.Cents
1620 to := strings.TrimSpace(u.shipping.Name + " " + u.shipping.City + " " + u.shipping.State)
1621 fmt.Fprintf(&b, " shipping[gray] to %s[-] $%d.%02d\n",
1622 esc(to), u.shipping.Cents/100, u.shipping.Cents%100)
1623 }
1624 if total == 0 {
1625 b.WriteString("[gray]your cart is empty[-]\n")
1626 }
1627 fmt.Fprintf(&b, "\nTotal: $%d.%02d\n", total/100, total%100)
1628 b.WriteString("\n[#5fd7ff]s[-] shipping Β· [#5fd7ff]p[-] card Β· [#5fd7ff]k[-] coin Β· [#5fd7ff]x[-] empty Β· [#5fd7ff]v[-] close\n")
1629 fmt.Fprintf(&b, "[gray]checkout talks to the store server at %s[-]", esc(ordersURL()))
1630 content := b.String()
1631 u.commerce = &cartOverlay{ta: newTextArea(content, false), lines: strings.Count(content, "\n") + 1}
1632}
1633
1634func (c *cartOverlay) title() string { return "View Cart" }
1635func (c *cartOverlay) size() (int, int) {
1636 return 70, c.lines + 2
1637}
1638
1639func (c *cartOverlay) draw(u *ui, sc tcell.Screen, r rect) {
1640 c.ta.draw(sc, r)
1641}
1642
1643func (c *cartOverlay) key(u *ui, ev *tcell.EventKey) bool {
1644 switch keyRune(ev) {
1645 case 'x':
1646 u.cartQty = map[string]int{}
1647 u.cartOrder = nil
1648 u.shipping = nil
1649 u.openCart()
1650 return true
1651 case 's':
1652 u.openShipping()
1653 return true
1654 case 'p':
1655 u.startCheckout()
1656 return true
1657 case 'k':
1658 // The checkout a terminal can finish by itself; see crypto.go.
1659 u.startCoinCheckout()
1660 return true
1661 }
1662 return false
1663}
1664
1665// ---- the shipping form, from footer.html's Add Shipping Info ----
1666
1667type shippingOverlay struct {
1668 frm *form
1669 h int
1670}
1671
1672func (s *shippingOverlay) title() string { return "Add Shipping Info" }
1673func (s *shippingOverlay) size() (int, int) { return 62, s.h }
1674func (s *shippingOverlay) draw(u *ui, sc tcell.Screen, r rect) { s.frm.draw(sc, r) }
1675func (s *shippingOverlay) key(u *ui, ev *tcell.EventKey) bool { return s.frm.key(ev) }
1676
1677func (u *ui) openShipping() {
1678 prev := u.shipping
1679 if prev == nil {
1680 prev = &storepage.Shipping{Cents: storepage.MinShippingCents, Country: storepage.DefaultCountry}
1681 }
1682 // Built by walking the shared field list rather than naming each input,
1683 // so a field added to the form appears here and on the page together.
1684 // read[name] returns what that control holds, in the spelling the cart
1685 // line wants β a state picker shows "Texas" and yields "TX".
1686 read := map[string]func() string{}
1687 items := make([]formItem, 0, len(storepage.ShippingFields))
1688 for _, f := range storepage.ShippingFields {
1689 switch f.Kind {
1690 case storepage.FieldState:
1691 opts := storepage.StateNames()
1692 dd := &fdropdown{lbl: f.TUILabel, opts: opts}
1693 for i, n := range opts {
1694 if n == storepage.StateName(prev.State) {
1695 dd.sel = i
1696 }
1697 }
1698 items = append(items, dd)
1699 read[f.Name] = func() string { return storepage.StateCode(dd.value()) }
1700 case storepage.FieldCountry:
1701 dd := &fdropdown{lbl: f.TUILabel, opts: storepage.Countries}
1702 items = append(items, dd)
1703 read[f.Name] = dd.value
1704 default:
1705 val := prev.Get(f.Name)
1706 if f.Kind == storepage.FieldMoney {
1707 val = fmt.Sprintf("%d.%02d", prev.Cents/100, prev.Cents%100)
1708 }
1709 in := &finput{lbl: f.TUILabel, text: []rune(val), width: f.Width}
1710 in.cur = len(in.text)
1711 items = append(items, in)
1712 read[f.Name] = in.value
1713 }
1714 }
1715
1716 // Declared separately on purpose: the buttons below close over frm, so it
1717 // has to exist before newForm is called.
1718 var frm *form //nolint:staticcheck
1719 frm = newForm(items, []fbutton{
1720 {"Add Shipping to Cart", func() {
1721 sh := storepage.Shipping{Cents: priceCents(read["shipping-price"]())}
1722 for _, f := range storepage.ShippingFields {
1723 if f.Kind != storepage.FieldMoney {
1724 sh.Set(f.Name, read[f.Name]())
1725 }
1726 }
1727 if msg := sh.Validate(); msg != "" {
1728 u.notice = msg
1729 return
1730 }
1731 u.shipping = &sh
1732 u.openCart()
1733 }},
1734 {"Cancel", func() { u.openCart() }},
1735 }, func() { u.openCart() })
1736 u.commerce = &shippingOverlay{frm: frm, h: len(items)*2 + 3}
1737}
1738
1739// ---- checkout: the cart wasm's flow, against the same server ----
1740
1741type checkoutOverlay struct {
1742 ta *textArea
1743}
1744
1745func (c *checkoutOverlay) title() string { return "Checkout" }
1746func (c *checkoutOverlay) size() (int, int) { return 78, 14 }
1747func (c *checkoutOverlay) draw(u *ui, sc tcell.Screen, r rect) { c.ta.draw(sc, r) }
1748func (c *checkoutOverlay) key(u *ui, ev *tcell.EventKey) bool { return c.ta.key(ev) }
1749
1750// ordersURL is where checkout is served from: the store being browsed
1751// in client mode, else SITEORDERSURL when the config names one (as the
1752// website's cart uses it), else the local store server.
1753func ordersURL() string {
1754 if s := storeURL(); s != "" {
1755 return s
1756 }
1757 if f.Siteordersurl != "" {
1758 return f.Siteordersurl
1759 }
1760 port := f.WebPort
1761 if port == 0 {
1762 port = 9883
1763 }
1764 return fmt.Sprintf("http://127.0.0.1:%d", port)
1765}
1766
1767// cartItem matches the JSON the cart wasm POSTs to /create-payment-intent.
1768type cartItem struct {
1769 ID string `json:"ID"`
1770 Amount int64 `json:"Amount"`
1771}
1772
1773func (u *ui) startCheckout() {
1774 if len(u.cartOrder) == 0 {
1775 u.notice = "the cart is empty"
1776 return
1777 }
1778 if u.shipping == nil {
1779 u.notice = "add shipping info first (s in the cart)"
1780 return
1781 }
1782
1783 // The same payload the cart wasm sends, and the same one the coin
1784 // checkout sends: see cartItems in crypto.go.
1785 items := u.cartItems()
1786
1787 co := &checkoutOverlay{ta: newTextArea("\n contacting "+esc(ordersURL())+" β¦", true)}
1788 u.commerce = co
1789
1790 go func() {
1791 clientSecret, err := createPaymentIntent(items)
1792 u.post(func() {
1793 if u.commerce != co {
1794 return // the overlay was closed meanwhile
1795 }
1796 if err != nil {
1797 co.ta.setContent(fmt.Sprintf(
1798 "\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",
1799 esc(err.Error()), esc(ordersURL())))
1800 return
1801 }
1802 u.payWithElements(clientSecret, co.ta.setContent)
1803 })
1804 }()
1805}
1806
1807// createPaymentIntent POSTs the cart to the store server, which
1808// validates every line against the catalog and answers with the payment
1809// intent's client secret β identical to the cart wasm's fetch.
1810func createPaymentIntent(items []cartItem) (string, error) {
1811 body, err := json.Marshal(map[string]interface{}{"items": items})
1812 if err != nil {
1813 return "", err
1814 }
1815 client := &http.Client{Timeout: 15 * time.Second}
1816 resp, err := client.Post(ordersURL()+"/create-payment-intent", "application/json", bytes.NewReader(body))
1817 if err != nil {
1818 return "", err
1819 }
1820 defer resp.Body.Close() //nolint:errcheck // read-side close
1821 data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
1822 if err != nil {
1823 return "", err
1824 }
1825 var out struct {
1826 ClientSecret string `json:"clientSecret"`
1827 Error string `json:"error"`
1828 }
1829 if err := json.Unmarshal(data, &out); err != nil {
1830 return "", fmt.Errorf("bad response (%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
1831 }
1832 if out.Error != "" {
1833 return "", fmt.Errorf("%s", out.Error)
1834 }
1835 if out.ClientSecret == "" {
1836 return "", fmt.Errorf("no client secret in response (%d)", resp.StatusCode)
1837 }
1838 return out.ClientSecret, nil
1839}
1840
1841
1842// ===== pkg/tui/crypto.go =====
1843// Package tui pkg/tui/crypto.go β paying in coin from the terminal.
1844//
1845// This is the checkout a terminal can actually complete. Stripe's card
1846// entry is a browser widget, which is why payWithElements tells a native
1847// terminal it cannot take card details; a payment address is text, and text
1848// is what a terminal has. The same /create-invoice endpoint the page uses
1849// answers here, so the terminal is not a second implementation of anything
1850// β it renders what the server already decided.
1851package tui
1852
1853import (
1854 "bytes"
1855 "encoding/json"
1856 "fmt"
1857 "io"
1858 "net/http"
1859 "net/url"
1860 "strings"
1861 "time"
1862
1863 "github.com/gdamore/tcell/v3"
1864)
1865
1866// invoiceView is the subset of an invoice the server hands a client.
1867type invoiceView struct {
1868 ID string `json:"id"`
1869 Coin string `json:"coin"`
1870 Address string `json:"address"`
1871 Amount string `json:"amount"`
1872 URI string `json:"uri"`
1873 FiatCents int64 `json:"fiat_cents"`
1874 Status string `json:"status"`
1875 Paid string `json:"paid"`
1876 Outstanding string `json:"outstanding"`
1877 Expires string `json:"expires"`
1878 TxID string `json:"txid"`
1879 Error string `json:"error"`
1880}
1881
1882type invoiceOverlay struct {
1883 ta *textArea
1884 inv *invoiceView
1885}
1886
1887func (o *invoiceOverlay) title() string { return "Pay in coin" }
1888func (o *invoiceOverlay) size() (int, int) { return 74, 16 }
1889func (o *invoiceOverlay) draw(u *ui, sc tcell.Screen, r rect) { o.ta.draw(sc, r) }
1890
1891func (o *invoiceOverlay) key(u *ui, ev *tcell.EventKey) bool {
1892 if keyRune(ev) == 'r' && o.inv != nil {
1893 u.refreshInvoice(o)
1894 return true
1895 }
1896 return o.ta.key(ev)
1897}
1898
1899// startCoinCheckout asks the store for an invoice and shows it.
1900func (u *ui) startCoinCheckout() {
1901 if len(u.cartOrder) == 0 {
1902 u.notice = "the cart is empty"
1903 return
1904 }
1905 if u.shipping == nil {
1906 u.notice = "add shipping info first (s in the cart)"
1907 return
1908 }
1909 items := u.cartItems()
1910
1911 ov := &invoiceOverlay{ta: newTextArea("\n asking "+esc(ordersURL())+" for an address β¦", true)}
1912 u.commerce = ov
1913
1914 go func() {
1915 inv, err := createInvoice(items)
1916 u.post(func() {
1917 if u.commerce != ov {
1918 return // closed meanwhile
1919 }
1920 if err != nil {
1921 ov.ta.setContent(fmt.Sprintf(
1922 "\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",
1923 esc(err.Error())))
1924 return
1925 }
1926 ov.inv = inv
1927 ov.ta.setContent(renderInvoice(inv))
1928 })
1929 }()
1930}
1931
1932// refreshInvoice re-reads one invoice; the server polls the chain when
1933// asked, so this is what turns "pending" into "paid" on screen.
1934func (u *ui) refreshInvoice(ov *invoiceOverlay) {
1935 id := ov.inv.ID
1936 go func() {
1937 inv, err := getInvoice(id)
1938 u.post(func() {
1939 if u.commerce != ov {
1940 return
1941 }
1942 if err != nil {
1943 u.notice = "could not refresh: " + esc(err.Error())
1944 return
1945 }
1946 ov.inv = inv
1947 ov.ta.setContent(renderInvoice(inv))
1948 })
1949 }()
1950}
1951
1952// cartItems builds the payload both checkouts send.
1953func (u *ui) cartItems() []cartItem {
1954 var items []cartItem
1955 for _, partno := range u.cartOrder {
1956 qty := u.cartQty[partno]
1957 p := u.cat.find(partno)
1958 if qty == 0 || p == nil {
1959 continue
1960 }
1961 items = append(items, cartItem{
1962 ID: fmt.Sprintf("%s X %d", partno, qty),
1963 Amount: int64(priceCents(p.Price) * qty),
1964 })
1965 }
1966 if u.shipping != nil {
1967 items = append(items, cartItem{ID: u.shipping.ID(), Amount: int64(u.shipping.Cents)})
1968 }
1969 return items
1970}
1971
1972func renderInvoice(inv *invoiceView) string {
1973 var b strings.Builder
1974 fmt.Fprintf(&b, "\n send [::b]%s %s[-:-:-]\n", esc(inv.Amount), esc(inv.Coin))
1975 fmt.Fprintf(&b, " to [aqua]%s[-]\n\n", esc(inv.Address))
1976 fmt.Fprintf(&b, " [gray]uri[-] %s\n", esc(inv.URI))
1977 fmt.Fprintf(&b, " [gray]order[-] $%d.%02d\n", inv.FiatCents/100, inv.FiatCents%100)
1978 if t, err := time.Parse(time.RFC3339, inv.Expires); err == nil {
1979 fmt.Fprintf(&b, " [gray]quote holds until[-] %s\n", esc(t.Local().Format("15:04:05")))
1980 }
1981 b.WriteString("\n")
1982 switch inv.Status {
1983 case "paid":
1984 b.WriteString(" [green]paid β thank you. the order is printing.[-]\n")
1985 if inv.TxID != "" {
1986 fmt.Fprintf(&b, " [gray]txid %s[-]\n", esc(inv.TxID))
1987 }
1988 case "underpaid":
1989 fmt.Fprintf(&b, " [yellow]part paid:[-] %s received, [::b]%s[-:-:-] still owed\n",
1990 esc(inv.Paid), esc(inv.Outstanding))
1991 case "expired":
1992 b.WriteString(" [red]the quote expired[-] β close and start again for a fresh price\n")
1993 default:
1994 b.WriteString(" [gray]waiting for the payment to confirmβ¦[-]\n")
1995 }
1996 b.WriteString("\n [#5fd7ff]r[-] check again Β· [#5fd7ff]esc[-] close\n")
1997 return b.String()
1998}
1999
2000func createInvoice(items []cartItem) (*invoiceView, error) {
2001 body, err := json.Marshal(map[string]any{"coin": "SKY", "items": items})
2002 if err != nil {
2003 return nil, err
2004 }
2005 client := &http.Client{Timeout: 30 * time.Second}
2006 resp, err := client.Post(ordersURL()+"/create-invoice", "application/json", bytes.NewReader(body))
2007 if err != nil {
2008 return nil, err
2009 }
2010 return readInvoice(resp)
2011}
2012
2013func getInvoice(id string) (*invoiceView, error) {
2014 client := &http.Client{Timeout: 30 * time.Second}
2015 resp, err := client.Get(ordersURL() + "/invoice/" + url.PathEscape(id))
2016 if err != nil {
2017 return nil, err
2018 }
2019 return readInvoice(resp)
2020}
2021
2022func readInvoice(resp *http.Response) (*invoiceView, error) {
2023 defer resp.Body.Close() //nolint:errcheck // read-side close
2024 data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
2025 if err != nil {
2026 return nil, err
2027 }
2028 var inv invoiceView
2029 if err := json.Unmarshal(data, &inv); err != nil {
2030 return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
2031 }
2032 if inv.Error != "" {
2033 return nil, fmt.Errorf("%s", inv.Error)
2034 }
2035 if resp.StatusCode != http.StatusOK {
2036 return nil, fmt.Errorf("%s", resp.Status)
2037 }
2038 return &inv, nil
2039}
2040
2041
2042// ===== pkg/tui/frame_dump_test.go =====
2043package tui
2044
2045import (
2046 "image/png"
2047 "os"
2048 "testing"
2049)
2050
2051func TestDumpFrame(t *testing.T) {
2052 out := os.Getenv("FRAMEOUT")
2053 if out == "" {
2054 t.Skip("set FRAMEOUT to dump a frame")
2055 }
2056 logo, err := loadImage(os.Getenv("FRAMELOGO"))
2057 if err != nil {
2058 t.Fatal(err)
2059 }
2060 bd := makeBackdrop(logo, 200, 100)
2061 fr := globeFrame(bd, 0.9, 2.3, -0.6)
2062 f, err := os.Create(out) //nolint:gosec
2063 if err != nil {
2064 t.Fatalf("create %s: %v", out, err)
2065 }
2066 defer f.Close() //nolint:errcheck,gosec
2067 if err := png.Encode(f, fr); err != nil {
2068 t.Fatal(err)
2069 }
2070}
2071
2072
2073// ===== pkg/tui/htmltable.go =====
2074// Package tui pkg/tui/htmltable.go β tables in a content page.
2075//
2076// htmlToText used to flatten a table: </td> became two spaces and </tr> a
2077// newline, so the Links page β which is a three-column table of supplier,
2078// location and notes β arrived as ragged prose with the columns running into
2079// each other. The site draws it as a table and so should this.
2080//
2081// The work is laid out here rather than in render.go because it needs its own
2082// wrapper: a cell's text carries the [aqua] and [link=N] tags the anchor pass
2083// already wrote into it, and those are zero width. Wrapping on len() would
2084// count them and wrapping without them would drop a link's styling β and its
2085// clickable region β at the break.
2086package tui
2087
2088import (
2089 "regexp"
2090 "strings"
2091
2092 xhtml "html"
2093)
2094
2095var (
2096 tagTable = regexp.MustCompile(`(?is)<table[^>]*>(.*?)</table>`)
2097 tagRow = regexp.MustCompile(`(?is)<tr[^>]*>(.*?)</tr>`)
2098 tagCellFull = regexp.MustCompile(`(?is)<(t[dh])[^>]*>(.*?)</t[dh]>`)
2099 runOfSpaces = regexp.MustCompile(`[ \t\n]+`)
2100)
2101
2102// colGap is the space between columns, in cells.
2103const colGap = 2
2104
2105// minCol is how narrow shrinking may make a column before it gives up and
2106// lets the table overflow instead. A column squeezed below this is unreadable,
2107// and an honestly too-wide table is easier to deal with than a shredded one.
2108const minCol = 6
2109
2110// cellText reduces one cell's HTML to its printed text, keeping the markup
2111// tags the anchor pass wrote and dropping everything else.
2112func cellText(s string) string {
2113 s = tagBreak.ReplaceAllString(s, " ")
2114 s = tagAny.ReplaceAllString(s, "")
2115 s = xhtml.UnescapeString(s)
2116 return strings.TrimSpace(runOfSpaces.ReplaceAllString(s, " "))
2117}
2118
2119// isResetTag reports whether a color tag turns styling off rather than on:
2120// "[-]", "[-:-:-]" and the like are built only from dashes and colons.
2121func isResetTag(body string) bool {
2122 if body == "" {
2123 return false
2124 }
2125 return strings.Trim(body, "-:") == ""
2126}
2127
2128// mtok is one piece of a tagged string: a zero-width tag, or one visible rune.
2129type mtok struct {
2130 tag string
2131 txt string
2132}
2133
2134// markupTokens splits a tagged string the way parseMarkup reads it, so the
2135// two agree on what is a tag and what is text. "[[" is a literal bracket.
2136func markupTokens(s string) []mtok {
2137 var out []mtok
2138 r := []rune(s)
2139 for i := 0; i < len(r); i++ {
2140 if r[i] != '[' {
2141 out = append(out, mtok{txt: string(r[i])})
2142 continue
2143 }
2144 if i+1 < len(r) && r[i+1] == '[' {
2145 out = append(out, mtok{txt: "["})
2146 i++
2147 continue
2148 }
2149 end := -1
2150 for j := i + 1; j < len(r) && j < i+24; j++ {
2151 if r[j] == ']' {
2152 end = j
2153 break
2154 }
2155 }
2156 if end < 0 {
2157 out = append(out, mtok{txt: "["})
2158 continue
2159 }
2160 body := string(r[i+1 : end])
2161 if _, ok := linkBody(body); ok || tagBody(body) {
2162 out = append(out, mtok{tag: "[" + body + "]"})
2163 i = end
2164 continue
2165 }
2166 out = append(out, mtok{txt: "["})
2167 }
2168 return out
2169}
2170
2171// wrapMarkup breaks a tagged string into lines of at most w printed columns.
2172//
2173// The color and link tags still open at a break are repeated at the start of
2174// the next line: each line is drawn on its own, so a wrapped link that did not
2175// carry its [link=N] across would stop being clickable half way through.
2176func wrapMarkup(s string, w int) []string {
2177 if w < 1 {
2178 w = 1
2179 }
2180 var (
2181 lines []string
2182 line strings.Builder
2183 lineW int
2184 color, link string
2185 word []mtok
2186 wordW int
2187 )
2188 startLine := func() {
2189 line.Reset()
2190 lineW = 0
2191 line.WriteString(color)
2192 line.WriteString(link)
2193 }
2194 pushLine := func() {
2195 lines = append(lines, line.String())
2196 startLine()
2197 }
2198 emitWord := func() {
2199 if len(word) == 0 {
2200 return
2201 }
2202 switch {
2203 case lineW > 0 && lineW+1+wordW > w:
2204 pushLine()
2205 case lineW > 0:
2206 line.WriteString(" ")
2207 lineW++
2208 }
2209 for _, t := range word {
2210 if t.tag != "" {
2211 body := t.tag[1 : len(t.tag)-1]
2212 if _, ok := linkBody(body); ok {
2213 if body == "/link" {
2214 link = ""
2215 } else {
2216 link = t.tag
2217 }
2218 } else if isResetTag(body) {
2219 color = ""
2220 } else {
2221 color = t.tag
2222 }
2223 line.WriteString(t.tag)
2224 continue
2225 }
2226 if lineW >= w { // a single word longer than the column
2227 pushLine()
2228 }
2229 if t.txt == "[" {
2230 line.WriteString("[[")
2231 } else {
2232 line.WriteString(t.txt)
2233 }
2234 lineW++
2235 }
2236 word = word[:0]
2237 wordW = 0
2238 }
2239
2240 for _, t := range markupTokens(s) {
2241 if t.tag == "" && t.txt == " " {
2242 emitWord()
2243 continue
2244 }
2245 word = append(word, t)
2246 if t.tag == "" {
2247 wordW++
2248 }
2249 }
2250 emitWord()
2251 if lineW > 0 || len(lines) == 0 {
2252 lines = append(lines, line.String())
2253 }
2254 return lines
2255}
2256
2257// fitCols shrinks the natural column widths until the row fits, always taking
2258// from the widest column so the notes column gives way before the two narrow
2259// ones do.
2260func fitCols(nat []int, width int) []int {
2261 cols := append([]int(nil), nat...)
2262 n := len(cols)
2263 if n == 0 {
2264 return cols
2265 }
2266 avail := width - colGap*(n-1)
2267 if avail < n {
2268 avail = n
2269 }
2270 total := 0
2271 for _, c := range cols {
2272 total += c
2273 }
2274 for total > avail {
2275 wi, wv := 0, cols[0]
2276 for i, c := range cols {
2277 if c > wv {
2278 wi, wv = i, c
2279 }
2280 }
2281 if wv <= minCol {
2282 break
2283 }
2284 cols[wi]--
2285 total--
2286 }
2287 return cols
2288}
2289
2290// renderTable draws one table's inner HTML as aligned columns.
2291func renderTable(inner string, width int) string {
2292 var (
2293 rows [][]string
2294 heads []bool
2295 )
2296 for _, r := range tagRow.FindAllStringSubmatch(inner, -1) {
2297 cells := tagCellFull.FindAllStringSubmatch(r[1], -1)
2298 if len(cells) == 0 {
2299 continue
2300 }
2301 row := make([]string, 0, len(cells))
2302 head := true
2303 for _, c := range cells {
2304 if !strings.EqualFold(c[1], "th") {
2305 head = false
2306 }
2307 row = append(row, cellText(c[2]))
2308 }
2309 rows = append(rows, row)
2310 heads = append(heads, head)
2311 }
2312 if len(rows) == 0 {
2313 return ""
2314 }
2315 n := 0
2316 for _, r := range rows {
2317 if len(r) > n {
2318 n = len(r)
2319 }
2320 }
2321 nat := make([]int, n)
2322 for _, r := range rows {
2323 for i, c := range r {
2324 if w := markupWidth(c); w > nat[i] {
2325 nat[i] = w
2326 }
2327 }
2328 }
2329 cols := fitCols(nat, width)
2330
2331 var b strings.Builder
2332 for ri, row := range rows {
2333 cellLines := make([][]string, n)
2334 h := 1
2335 for i := 0; i < n; i++ {
2336 txt := ""
2337 if i < len(row) {
2338 txt = row[i]
2339 }
2340 if heads[ri] && txt != "" {
2341 txt = "[::b]" + txt + "[-:-:-]"
2342 }
2343 cellLines[i] = wrapMarkup(txt, cols[i])
2344 if len(cellLines[i]) > h {
2345 h = len(cellLines[i])
2346 }
2347 }
2348 for li := 0; li < h; li++ {
2349 var line strings.Builder
2350 for i := 0; i < n; i++ {
2351 cell := ""
2352 if li < len(cellLines[i]) {
2353 cell = cellLines[i][li]
2354 }
2355 line.WriteString(cell)
2356 if i < n-1 {
2357 pad := cols[i] - markupWidth(cell) + colGap
2358 if pad < 1 {
2359 pad = 1
2360 }
2361 line.WriteString(strings.Repeat(" ", pad))
2362 }
2363 }
2364 b.WriteString(strings.TrimRight(line.String(), " "))
2365 b.WriteByte('\n')
2366 }
2367 if heads[ri] {
2368 var rule strings.Builder
2369 for i := 0; i < n; i++ {
2370 rule.WriteString(strings.Repeat("β", cols[i]))
2371 if i < n-1 {
2372 rule.WriteString(strings.Repeat(" ", colGap))
2373 }
2374 }
2375 b.WriteString("[gray]" + rule.String() + "[-]\n")
2376 }
2377 }
2378 return b.String()
2379}
2380
2381
2382// ===== pkg/tui/htmltable_test.go =====
2383package tui
2384
2385import (
2386 "strings"
2387 "testing"
2388)
2389
2390// A table is the Links page's whole content. Flattened to runs of spaces it
2391// stopped being a table at all, so what matters is that the columns line up
2392// and that a cell too wide for its column wraps inside it rather than
2393// pushing everything after it out of true.
2394func TestTableAlignsColumnsAndWraps(t *testing.T) {
2395 const in = `<table>
2396<thead><tr><th>Supplier</th><th>Where</th><th>Notes</th></tr></thead>
2397<tbody>
2398<tr><td>Alpha</td><td>Dallas, TX</td><td>one two three four five six seven</td></tr>
2399<tr><td>Beta</td><td>Lima, OH</td><td>short</td></tr>
2400</tbody></table>`
2401 text, _ := htmlToText(in, "2026", 46)
2402 lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
2403 for _, l := range lines {
2404 if markupWidth(l) > 46 {
2405 t.Errorf("line wider than the pane (%d): %q", markupWidth(l), l)
2406 }
2407 }
2408 // Every row starts its second column at the same place.
2409 if a, b := columnOf(lines, "Dallas, TX"), columnOf(lines, "Lima, OH"); a != b || a <= 0 {
2410 t.Errorf("second column not aligned: %d vs %d\n%s", a, b, text)
2411 }
2412 // The long notes cell wrapped instead of running off.
2413 if !strings.Contains(text, "seven") {
2414 t.Errorf("wrapped text lost:\n%s", text)
2415 }
2416 if columnOf(lines, "one") != columnOf(lines, "six") {
2417 t.Errorf("continuation not in the notes column:\n%s", text)
2418 }
2419}
2420
2421// A link that wraps has to stay a link on the second line: each line is
2422// drawn on its own, so the [link=N] tag must be repeated at the break.
2423func TestWrappedLinkKeepsItsTag(t *testing.T) {
2424 const in = `<table><tr><td><a href='https://example.com/'>a very long supplier name here</a></td></tr></table>`
2425 text, hrefs := htmlToText(in, "2026", 12)
2426 if len(hrefs) != 1 || hrefs[0] != "https://example.com/" {
2427 t.Fatalf("hrefs = %v", hrefs)
2428 }
2429 for i, l := range strings.Split(strings.TrimSpace(text), "\n") {
2430 if strings.TrimSpace(stripTagsLine(l)) == "" {
2431 continue
2432 }
2433 if !strings.Contains(l, "[link=1]") {
2434 t.Errorf("line %d dropped the link tag: %q", i, l)
2435 }
2436 }
2437}
2438
2439// Anchors inside a table are numbered with the rest of the page, in
2440// document order: the table is set aside and drawn last, which must not
2441// renumber anything.
2442func TestTableAnchorsKeepDocumentOrder(t *testing.T) {
2443 const in = `<p><a href='/one'>one</a></p>
2444<table><tr><td><a href='/two'>two</a></td><td><a href='/three'>three</a></td></tr></table>
2445<p><a href='/four'>four</a></p>`
2446 _, hrefs := htmlToText(in, "2026", 60)
2447 want := []string{"/one", "/two", "/three", "/four"}
2448 if len(hrefs) != len(want) {
2449 t.Fatalf("hrefs = %v", hrefs)
2450 }
2451 for i := range want {
2452 if hrefs[i] != want[i] {
2453 t.Errorf("hrefs[%d] = %q, want %q", i, hrefs[i], want[i])
2454 }
2455 }
2456}
2457
2458func stripTagsLine(s string) string {
2459 out := ""
2460 for _, sg := range parseMarkup(s, styleText) {
2461 out += string(sg.txt)
2462 }
2463 return out
2464}
2465
2466// columnOf reports the printed column a word starts at, on the first line
2467// that holds it.
2468func columnOf(lines []string, word string) int {
2469 for _, l := range lines {
2470 plain := stripTagsLine(l)
2471 if i := strings.Index(plain, word); i >= 0 {
2472 return i
2473 }
2474 }
2475 return -1
2476}
2477
2478
2479// ===== pkg/tui/kit.go =====
2480// Package tui pkg/tui/kit.go β drawing primitives on tcell: a small
2481// color-tag markup ([fg:bg:flags], as tview popularized), line printing
2482// with alignment and wrapping, and bordered boxes. The TUI is written
2483// straight on tcell v3 so it runs wherever the 0magnet ecosystem's
2484// terminal stack does β natively today, in the browser via
2485// tuiwasm/xtcell tomorrow.
2486package tui
2487
2488import (
2489 "fmt"
2490 "strconv"
2491 "strings"
2492
2493 "github.com/gdamore/tcell/v3"
2494)
2495
2496type rect struct{ x, y, w, h int }
2497
2498// esc quotes user data so it never parses as a color tag: "[" doubles,
2499// and the parser reads "[[" back as one literal bracket.
2500func esc(s string) string {
2501 return strings.ReplaceAll(s, "[", "[[")
2502}
2503
2504// seg is a run of text in one style, optionally inside a link.
2505type seg struct {
2506 txt []rune
2507 st tcell.Style
2508 // link is the 1-based index of the anchor this run belongs to, or 0
2509 // for ordinary text. What it indexes is the drawing code's business:
2510 // each page keeps its own table of what its links do. See linkTag.
2511 link int
2512}
2513
2514// namedColors are the palette names the markup uses; anything else goes
2515// to tcell.GetColor (which handles #rrggbb and the W3C names).
2516var namedColors = map[string]tcell.Color{
2517 "aqua": tcell.ColorAqua,
2518 "white": tcell.ColorWhite,
2519 "black": tcell.ColorBlack,
2520 "gray": tcell.GetColor("#808080"),
2521 "grey": tcell.GetColor("#808080"),
2522 "yellow": tcell.ColorYellow,
2523 "red": tcell.ColorRed,
2524 "green": tcell.ColorGreen,
2525 "orange": tcell.GetColor("#ffa500"),
2526}
2527
2528func markupColor(name string) (tcell.Color, bool) {
2529 if c, ok := namedColors[name]; ok {
2530 return c, true
2531 }
2532 c := tcell.GetColor(name)
2533 if c == tcell.ColorDefault && name != "default" {
2534 return c, false
2535 }
2536 return c, true
2537}
2538
2539// linkTag wraps text in an anchor: everything between [link=N] and
2540// [/link] carries link index N, which the drawing code turns into a
2541// clickable region. N is 1-based so that a zero index means "no link".
2542//
2543// The page's own markup is the only thing that may produce these β esc
2544// doubles the bracket in anything that came from the catalog or from a
2545// content file, so user data cannot open an anchor.
2546func linkTag(n int, s string) string {
2547 return fmt.Sprintf("[link=%d]%s[/link]", n, s)
2548}
2549
2550// linkBody reads a [link=N] / [/link] tag, returning the index it opens
2551// (0 for the closing tag) and whether the body was one of them at all.
2552func linkBody(s string) (int, bool) {
2553 if s == "/link" {
2554 return 0, true
2555 }
2556 rest, ok := strings.CutPrefix(s, "link=")
2557 if !ok {
2558 return 0, false
2559 }
2560 n, err := strconv.Atoi(rest)
2561 if err != nil || n < 1 {
2562 return 0, false
2563 }
2564 return n, true
2565}
2566
2567// tagBody reports whether the text between brackets looks like a color
2568// tag: colors and flags only, at most two colons.
2569func tagBody(s string) bool {
2570 if s == "" {
2571 return false
2572 }
2573 colons := 0
2574 for _, r := range s {
2575 switch {
2576 case r == ':':
2577 colons++
2578 case r == '#' || r == '-':
2579 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
2580 default:
2581 return false
2582 }
2583 }
2584 return colons <= 2
2585}
2586
2587// applyTag folds one [fg:bg:flags] spec into a style. Empty parts keep,
2588// "-" resets to the base.
2589func applyTag(cur, base tcell.Style, body string) tcell.Style {
2590 parts := strings.SplitN(body, ":", 3)
2591 if len(parts) > 0 && parts[0] != "" {
2592 if parts[0] == "-" {
2593 cur = cur.Foreground(base.GetForeground())
2594 } else if c, ok := markupColor(parts[0]); ok {
2595 cur = cur.Foreground(c)
2596 }
2597 }
2598 if len(parts) > 1 && parts[1] != "" {
2599 if parts[1] == "-" {
2600 cur = cur.Background(base.GetBackground())
2601 } else if c, ok := markupColor(parts[1]); ok {
2602 cur = cur.Background(c)
2603 }
2604 }
2605 if len(parts) > 2 {
2606 if parts[2] == "-" || parts[2] == "" {
2607 cur = cur.Attributes(base.GetAttributes())
2608 } else {
2609 for _, f := range parts[2] {
2610 switch f {
2611 case 'b':
2612 cur = cur.Bold(true)
2613 case 'u':
2614 cur = cur.Underline(true)
2615 }
2616 }
2617 }
2618 }
2619 return cur
2620}
2621
2622// parseMarkup splits a tagged string into styled segments.
2623func parseMarkup(s string, base tcell.Style) []seg {
2624 var out []seg
2625 cur := base
2626 link := 0
2627 var run []rune
2628 flush := func() {
2629 if len(run) > 0 {
2630 out = append(out, seg{run, cur, link})
2631 run = nil
2632 }
2633 }
2634 r := []rune(s)
2635 for i := 0; i < len(r); i++ {
2636 if r[i] != '[' {
2637 run = append(run, r[i])
2638 continue
2639 }
2640 if i+1 < len(r) && r[i+1] == '[' { // escaped literal bracket
2641 run = append(run, '[')
2642 i++
2643 continue
2644 }
2645 end := -1
2646 for j := i + 1; j < len(r) && j < i+24; j++ {
2647 if r[j] == ']' {
2648 end = j
2649 break
2650 }
2651 }
2652 if end < 0 {
2653 run = append(run, r[i])
2654 continue
2655 }
2656 body := string(r[i+1 : end])
2657 if n, ok := linkBody(body); ok {
2658 flush()
2659 link = n
2660 i = end
2661 continue
2662 }
2663 if !tagBody(body) {
2664 run = append(run, r[i])
2665 continue
2666 }
2667 flush()
2668 cur = applyTag(cur, base, body)
2669 i = end
2670 }
2671 flush()
2672 return out
2673}
2674
2675// markupWidth is the printed width of a tagged string.
2676func markupWidth(s string) int {
2677 n := 0
2678 for _, sg := range parseMarkup(s, tcell.StyleDefault) {
2679 n += len(sg.txt)
2680 }
2681 return n
2682}
2683
2684// printSegs draws segments on one row, clipped to maxW; returns cells drawn.
2685func printSegs(sc tcell.Screen, x, y, maxW int, segs []seg) int {
2686 return printSegsLinks(sc, x, y, maxW, segs, nil)
2687}
2688
2689// printSegsLinks is printSegs, reporting each anchor it drew: onLink is
2690// called once per run of link cells with the run's position, width and
2691// link index, which is what turns markup into something clickable.
2692//
2693// Per run of cells and not per segment: an anchor split by a color
2694// change inside it is still one link, and a link clipped away at maxW
2695// must not leave a region covering cells that were never drawn.
2696func printSegsLinks(sc tcell.Screen, x, y, maxW int, segs []seg, onLink func(x, y, w, link int)) int {
2697 n := 0
2698 run, runStart := 0, 0
2699 flush := func() {
2700 if run != 0 && onLink != nil {
2701 onLink(x+runStart, y, n-runStart, run)
2702 }
2703 run = 0
2704 }
2705 for _, sg := range segs {
2706 if sg.link != run {
2707 flush()
2708 run, runStart = sg.link, n
2709 }
2710 for _, r := range sg.txt {
2711 if n >= maxW {
2712 flush()
2713 return n
2714 }
2715 sc.SetContent(x+n, y, r, nil, sg.st)
2716 n++
2717 }
2718 }
2719 flush()
2720 return n
2721}
2722
2723// printMarkup draws one tagged line, clipped to maxW.
2724// The width it returns mirrors printSegs, which callers of that do use.
2725func printMarkup(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style) int { //nolint:unparam
2726 return printSegs(sc, x, y, maxW, parseMarkup(s, base))
2727}
2728
2729// printMarkupLinks draws one tagged line and reports its anchors.
2730func printMarkupLinks(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style, onLink func(x, y, w, link int)) {
2731 printSegsLinks(sc, x, y, maxW, parseMarkup(s, base), onLink)
2732}
2733
2734// printMarkupCenter draws one tagged line centered in w columns.
2735func printMarkupCenter(sc tcell.Screen, x, y, w int, s string, base tcell.Style) {
2736 pad := (w - markupWidth(s)) / 2
2737 if pad < 0 {
2738 pad = 0
2739 }
2740 printMarkup(sc, x+pad, y, w-pad, s, base)
2741}
2742
2743// wrapSegs word-wraps parsed lines to width w.
2744func wrapSegs(s string, w int, base tcell.Style, wrap bool) [][]seg {
2745 if w < 1 {
2746 w = 1
2747 }
2748 var out [][]seg
2749 for _, line := range strings.Split(s, "\n") {
2750 segs := parseMarkup(line, base)
2751 if !wrap {
2752 out = append(out, segs)
2753 continue
2754 }
2755 // Flatten to styled runes, then break into rows at spaces.
2756 var flat []styledRune
2757 for _, sg := range segs {
2758 for _, r := range sg.txt {
2759 flat = append(flat, styledRune{r, sg.st, sg.link})
2760 }
2761 }
2762 for len(flat) > w {
2763 cut := -1
2764 for i := w; i > 0; i-- {
2765 if flat[i].r == ' ' {
2766 cut = i
2767 break
2768 }
2769 }
2770 if cut <= 0 {
2771 cut = w
2772 }
2773 out = append(out, packSegs(flat[:cut]))
2774 for cut < len(flat) && flat[cut].r == ' ' {
2775 cut++
2776 }
2777 flat = flat[cut:]
2778 }
2779 out = append(out, packSegs(flat))
2780 }
2781 return out
2782}
2783
2784type styledRune struct {
2785 r rune
2786 st tcell.Style
2787 link int
2788}
2789
2790// keyRune reads the rune of a KeyRune event (v3 carries it as Str).
2791func keyRune(ev *tcell.EventKey) rune {
2792 if ev.Key() != tcell.KeyRune {
2793 return 0
2794 }
2795 rs := []rune(ev.Str())
2796 if len(rs) == 0 {
2797 return 0
2798 }
2799 return rs[0]
2800}
2801
2802func packSegs(flat []styledRune) []seg {
2803 var segs []seg
2804 for _, c := range flat {
2805 if n := len(segs); n > 0 && segs[n-1].st == c.st && segs[n-1].link == c.link {
2806 segs[n-1].txt = append(segs[n-1].txt, c.r)
2807 } else {
2808 segs = append(segs, seg{[]rune{c.r}, c.st, c.link})
2809 }
2810 }
2811 return segs
2812}
2813
2814// fillRect paints a rectangle with spaces in the given style.
2815func fillRect(sc tcell.Screen, r rect, st tcell.Style) {
2816 for y := r.y; y < r.y+r.h; y++ {
2817 for x := r.x; x < r.x+r.w; x++ {
2818 sc.SetContent(x, y, ' ', nil, st)
2819 }
2820 }
2821}
2822
2823// drawRule draws a horizontal rule, the terminal stand-in for the 1px
2824// table borders that close the page's header and footer.
2825func drawRule(sc tcell.Screen, x, y, w int, st tcell.Style) {
2826 for i := 0; i < w; i++ {
2827 sc.SetContent(x+i, y, 'β', nil, st)
2828 }
2829}
2830
2831// drawBox fills and frames a rectangle, with an optional title in the
2832// top border.
2833func drawBox(sc tcell.Screen, r rect, title string, border, fill tcell.Style) {
2834 if r.w < 2 || r.h < 2 {
2835 return
2836 }
2837 fillRect(sc, r, fill)
2838 for x := r.x + 1; x < r.x+r.w-1; x++ {
2839 sc.SetContent(x, r.y, 'β', nil, border)
2840 sc.SetContent(x, r.y+r.h-1, 'β', nil, border)
2841 }
2842 for y := r.y + 1; y < r.y+r.h-1; y++ {
2843 sc.SetContent(r.x, y, 'β', nil, border)
2844 sc.SetContent(r.x+r.w-1, y, 'β', nil, border)
2845 }
2846 sc.SetContent(r.x, r.y, 'β', nil, border)
2847 sc.SetContent(r.x+r.w-1, r.y, 'β', nil, border)
2848 sc.SetContent(r.x, r.y+r.h-1, 'β', nil, border)
2849 sc.SetContent(r.x+r.w-1, r.y+r.h-1, 'β', nil, border)
2850 if title != "" {
2851 printMarkup(sc, r.x+2, r.y, r.w-4, title, fill)
2852 }
2853}
2854
2855// inner is the area inside a box's border.
2856func (r rect) inner() rect {
2857 return rect{r.x + 1, r.y + 1, r.w - 2, r.h - 2}
2858}
2859
2860func (r rect) contains(x, y int) bool {
2861 return x >= r.x && y >= r.y && x < r.x+r.w && y < r.y+r.h
2862}
2863
2864
2865// ===== pkg/tui/links_test.go =====
2866package tui
2867
2868import (
2869 "strings"
2870 "testing"
2871
2872 "github.com/gdamore/tcell/v3"
2873
2874 "github.com/0magnet/m2/pkg/product"
2875 "github.com/0magnet/m2/pkg/storepage"
2876)
2877
2878// nullScreen is somewhere for the printers to put cells. Only SetContent
2879// is implemented: tcell v3 ships no simulation screen, and the anchor
2880// bookkeeping under test needs nothing else. The embedded nil interface
2881// supplies the rest of the method set, so a test that reached for one
2882// would panic rather than quietly pass.
2883type nullScreen struct{ tcell.Screen }
2884
2885func (nullScreen) SetContent(int, int, rune, []rune, tcell.Style) {}
2886
2887// span is a drawn anchor, as printSegsLinks reports one.
2888type span struct{ x, y, w, link int }
2889
2890// recorder collects the anchors a print reports, which is the whole of
2891// what makes a link clickable.
2892type recorder struct{ got []span }
2893
2894func (r *recorder) sink(x, y, w, link int) {
2895 r.got = append(r.got, span{x, y, w, link})
2896}
2897
2898// A link tag marks its text and nothing else, and the tag itself takes
2899// up no room on the line.
2900func TestLinkTagMarksOnlyItsText(t *testing.T) {
2901 s := "Category: " + linkTag(1, "diode") + " end"
2902 if w := markupWidth(s); w != len("Category: diode end") {
2903 t.Errorf("width %d, want %d", w, len("Category: diode end"))
2904 }
2905 var linked string
2906 for _, sg := range parseMarkup(s, tcell.StyleDefault) {
2907 if sg.link == 1 {
2908 linked += string(sg.txt)
2909 }
2910 }
2911 if linked != "diode" {
2912 t.Errorf("link 1 covers %q, want %q", linked, "diode")
2913 }
2914}
2915
2916// Color changes inside an anchor do not split it: the region has to
2917// cover the whole of the link or half of it stops being clickable.
2918func TestLinkSurvivesAColorChangeInside(t *testing.T) {
2919 var r recorder
2920 printMarkupLinks(nullScreen{}, 3, 7, 80, linkTag(1, "[aqua]dio[white]de[-]"), styleText, r.sink)
2921 if len(r.got) != 1 {
2922 t.Fatalf("%d regions, want 1: %+v", len(r.got), r.got)
2923 }
2924 if want := (span{x: 3, y: 7, w: 5, link: 1}); r.got[0] != want {
2925 t.Errorf("region %+v, want %+v", r.got[0], want)
2926 }
2927}
2928
2929// A link clipped off the end of the line must not leave a region over
2930// cells that were never drawn.
2931func TestLinkRegionStopsAtTheClip(t *testing.T) {
2932 var r recorder
2933 printMarkupLinks(nullScreen{}, 0, 0, 6, "abc"+linkTag(1, "defghij"), styleText, r.sink)
2934 if len(r.got) != 1 {
2935 t.Fatalf("%d regions, want 1: %+v", len(r.got), r.got)
2936 }
2937 if got := r.got[0].x + r.got[0].w; got != 6 {
2938 t.Errorf("region ends at %d, want 6 (the clip)", got)
2939 }
2940}
2941
2942// Two anchors on one line are two regions, in the order they were
2943// numbered β which is what binds each to the right action.
2944func TestTwoLinksOnOneLine(t *testing.T) {
2945 var idx []int
2946 printMarkupLinks(nullScreen{}, 0, 0, 80, linkTag(1, "one")+", "+linkTag(2, "two"), styleText,
2947 func(_, _, _, link int) { idx = append(idx, link) })
2948 if len(idx) != 2 || idx[0] != 1 || idx[1] != 2 {
2949 t.Errorf("link indices %v, want [1 2]", idx)
2950 }
2951}
2952
2953// Catalog text is escaped before it reaches the parser, so a product
2954// named with a bracket cannot open an anchor of its own.
2955func TestEscapedTextCannotForgeALink(t *testing.T) {
2956 for _, sg := range parseMarkup(esc("[link=1]nope[/link]"), tcell.StyleDefault) {
2957 if sg.link != 0 {
2958 t.Fatalf("escaped text produced link %d", sg.link)
2959 }
2960 }
2961}
2962
2963// The stock pages are mostly anchors; htmlToText has to hand back where
2964// each one goes, in the order its tags are numbered.
2965func TestHTMLToTextReturnsHrefs(t *testing.T) {
2966 in := `<p>see <a title='x' href="https://example.com/a">A</a> and ` +
2967 `<a href='/cat/diode'>diodes</a> and <a>nowhere</a></p>`
2968 text, hrefs := htmlToText(in, "2026", 80)
2969 want := []string{"https://example.com/a", "/cat/diode", ""}
2970 if len(hrefs) != len(want) {
2971 t.Fatalf("%d hrefs, want %d: %q", len(hrefs), len(want), hrefs)
2972 }
2973 for i := range want {
2974 if hrefs[i] != want[i] {
2975 t.Errorf("href %d = %q, want %q", i, hrefs[i], want[i])
2976 }
2977 }
2978 if !strings.Contains(text, "[link=2]") {
2979 t.Errorf("text carries no anchor tags: %q", text)
2980 }
2981 if markupWidth(text) != len("see A and diodes and nowhere\n") {
2982 t.Errorf("anchor tags left width %d in %q", markupWidth(text), text)
2983 }
2984}
2985
2986// An href is routed the way the site routes it: a path this store serves
2987// is a page the TUI has, and anything else is a link out.
2988func TestFollowRoutesSitePaths(t *testing.T) {
2989 u := &ui{panels: map[string]panel{}, cat: newCatalogInfo(product.Products{
2990 {Partno: "1N4001", Category: "diode", Subcategory: "1N40XX"},
2991 {Partno: "1N4729", Category: "diode", Subcategory: "1N47XX"},
2992 })}
2993 if u.follow("") != nil {
2994 t.Error("an empty href is not a link")
2995 }
2996 u.current = "content:about"
2997 u.follow("/")()
2998 if u.current != "home" {
2999 t.Errorf("/ went to %q, want home", u.current)
3000 }
3001 u.follow("/cat/diode/1N47XX")()
3002 if want := "list:diode|1N47XX"; u.current != want {
3003 t.Errorf("subcategory path went to %q, want %q", u.current, want)
3004 }
3005 u.follow("/p/1N4001")()
3006 if want := "prod:1N4001"; u.current != want {
3007 t.Errorf("product path went to %q, want %q", u.current, want)
3008 }
3009 // A link out does not navigate; it hands over the address.
3010 before := u.current
3011 u.follow("https://example.com")()
3012 if u.current != before {
3013 t.Errorf("an external link navigated to %q", u.current)
3014 }
3015 if !strings.Contains(u.notice, "example.com") {
3016 t.Errorf("external link gave no address: %q", u.notice)
3017 }
3018}
3019
3020// The pointer lights up whichever of the page's links it is over: the
3021// header nav on the top row, the footer's cells on the bottom one, and
3022// the panel's own anchors in between.
3023func TestHoveredFindsEachKindOfLink(t *testing.T) {
3024 const h = 20
3025 u := &ui{hoverX: -1, hoverY: -1}
3026 u.navRegions = append(u.navRegions, struct {
3027 x1, x2 int
3028 act string
3029 }{4, 14, "cats"})
3030 u.footRegions = append(u.footRegions, struct {
3031 x1, x2 int
3032 act string
3033 }{2, 9, "cart"})
3034 u.links = []linkRegion{{x: 6, y: 7, w: 5, act: func() {}}}
3035
3036 for _, c := range []struct {
3037 name string
3038 x, y int
3039 wantX1, wantX2 int
3040 want bool
3041 }{
3042 {"nothing seen yet", -1, -1, 0, 0, false},
3043 {"header entry", 5, 0, 4, 14, true},
3044 {"header, past the entry", 30, 0, 0, 0, false},
3045 {"footer cell", 3, h - 1, 2, 9, true},
3046 {"panel anchor", 8, 7, 6, 11, true},
3047 {"one cell past the anchor", 11, 7, 0, 0, false},
3048 {"the row below it", 8, 8, 0, 0, false},
3049 } {
3050 u.hoverX, u.hoverY = c.x, c.y
3051 x1, x2, _, ok := u.hovered(h)
3052 if ok != c.want {
3053 t.Errorf("%s: hovered=%v, want %v", c.name, ok, c.want)
3054 continue
3055 }
3056 if ok && (x1 != c.wantX1 || x2 != c.wantX2) {
3057 t.Errorf("%s: span %d..%d, want %d..%d", c.name, x1, x2, c.wantX1, c.wantX2)
3058 }
3059 }
3060}
3061
3062// sampleTable is a two-column table whose first column is the row's link.
3063func sampleTable() *table {
3064 tbl := &table{cols: []tcol{
3065 {title: "Name", link: true},
3066 {title: "Price", width: 9, alignRight: true},
3067 }}
3068 tbl.setRows([]trow{
3069 {cells: []tcell_{{text: "one"}, {text: "$1"}}},
3070 {cells: []tcell_{{text: "two"}, {text: "$2"}}},
3071 })
3072 return tbl
3073}
3074
3075// Every drawn row offers its name as an anchor, covering the name and
3076// not the blank column beside it β that span is both what one click
3077// follows and what the pointer lights up.
3078func TestTableReportsEachRowsLinkCell(t *testing.T) {
3079 tbl := sampleTable()
3080 var got []span
3081 tbl.onLink = func(x, y, w, row, _ int) { got = append(got, span{x, y, w, row}) }
3082 tbl.draw(nullScreen{}, rect{x: 2, y: 4, w: 40, h: 10})
3083
3084 want := []span{{x: 2, y: 5, w: 3, link: 0}, {x: 2, y: 6, w: 3, link: 1}}
3085 if len(got) != len(want) {
3086 t.Fatalf("%d anchors, want %d: %+v", len(got), len(want), got)
3087 }
3088 for i := range want {
3089 if got[i] != want[i] {
3090 t.Errorf("anchor %d = %+v, want %+v", i, got[i], want[i])
3091 }
3092 }
3093}
3094
3095// A column that is not the link column offers nothing, and neither does
3096// the header row.
3097func TestTableReportsOnlyTheLinkColumn(t *testing.T) {
3098 tbl := sampleTable()
3099 tbl.cols[0].link = false
3100 n := 0
3101 tbl.onLink = func(int, int, int, int, int) { n++ }
3102 tbl.draw(nullScreen{}, rect{x: 0, y: 0, w: 40, h: 10})
3103 if n != 0 {
3104 t.Errorf("%d anchors from a table with no link column, want 0", n)
3105 }
3106}
3107
3108// A row has an anchor per link cell, and an empty one offers none β
3109// which is how a product with no stock has nothing to add to a cart,
3110// the page omitting its button for the same reason.
3111func TestTableSkipsAnEmptyLinkCell(t *testing.T) {
3112 tbl := &table{cols: []tcol{
3113 {title: "Name", link: true},
3114 {title: "Buy", width: 11, link: true},
3115 }}
3116 tbl.setRows([]trow{
3117 {cells: []tcell_{{text: "in stock"}, {text: storepage.AddToCart}}},
3118 {cells: []tcell_{{text: "sold out"}, {text: ""}}},
3119 })
3120 byCol := map[int]int{}
3121 tbl.onLink = func(_, _, _, _, col int) { byCol[col]++ }
3122 tbl.draw(nullScreen{}, rect{x: 0, y: 0, w: 40, h: 10})
3123
3124 if byCol[0] != 2 {
3125 t.Errorf("%d name anchors, want one per row", byCol[0])
3126 }
3127 if byCol[1] != 1 {
3128 t.Errorf("%d buy anchors, want one β the sold-out row has no button", byCol[1])
3129 }
3130}
3131
3132// The link cell never reaches the table's own mouse handling β the page
3133// follows the anchor first. What is left is the rest of the row: it
3134// selects, and a second click on the selected row opens it.
3135func TestTableClickSelectsThenOpens(t *testing.T) {
3136 tbl := sampleTable()
3137 opened := -1
3138 tbl.onActivate = func(i int) { opened = i }
3139 click := tcell.NewEventMouse(0, 0, tcell.Button1, 0)
3140
3141 tbl.mouse(2, click) // row 1, which is not selected
3142 if opened != -1 {
3143 t.Errorf("the first click opened %d, want nothing", opened)
3144 }
3145 if tbl.sel != 1 {
3146 t.Errorf("the first click selected row %d, want 1", tbl.sel)
3147 }
3148 tbl.mouse(2, click)
3149 if opened != 1 {
3150 t.Errorf("the second click opened %d, want 1", opened)
3151 }
3152}
3153
3154
3155// ===== pkg/tui/open_js.go =====
3156//go:build js && wasm
3157
3158// Package tui pkg/tui/open_js.go β following a link in the browser
3159// build. The TUI is a page, so a link that leaves the store can be
3160// opened the way the page's own would be: in another tab.
3161package tui
3162
3163import "syscall/js"
3164
3165// openURL opens a link the store cannot render itself β an external
3166// site, or the PDF behind a datasheet.
3167func (u *ui) openURL(href string) {
3168 if href == "" {
3169 return
3170 }
3171 w := js.Global().Get("window")
3172 if !w.Truthy() {
3173 u.notice = "[aqua]" + esc(href) + "[-]"
3174 return
3175 }
3176 // noopener, or the opened page gets a handle on this one.
3177 if opened := w.Call("open", href, "_blank", "noopener"); !opened.Truthy() {
3178 // A blocked popup is silent otherwise; show the address instead
3179 // so the link is still followable by hand.
3180 u.notice = "[aqua]" + esc(href) + "[-]"
3181 }
3182}
3183
3184
3185// ===== pkg/tui/open_native.go =====
3186//go:build !js || !wasm
3187
3188// Package tui pkg/tui/open_native.go β following a link in a native
3189// terminal, where there is no browser to hand it to.
3190package tui
3191
3192// openURL shows a link the store cannot render itself β an external
3193// site, or the PDF behind a datasheet. A terminal has nowhere to open
3194// one, so it displays the address, which is what a terminal can do with
3195// a link that a person can then act on.
3196func (u *ui) openURL(href string) {
3197 if href == "" {
3198 return
3199 }
3200 u.notice = "[aqua]" + esc(href) + "[-]"
3201}
3202
3203
3204// ===== pkg/tui/pay_js.go =====
3205//go:build js && wasm
3206
3207// Package tui pkg/tui/pay_js.go β the browser half of the payment seam.
3208// This is where the Stripe Payment Element mounts over the terminal,
3209// exactly as the website's cart wasm mounts it over the page
3210// (wasm/cart/checkout.go holds the DOM/Stripe.js plumbing to relocate
3211// here). Until that lands, the browser build reports the gap instead of
3212// failing to compile.
3213package tui
3214
3215func (u *ui) payWithElements(clientSecret string, set func(string)) {
3216 _ = clientSecret
3217 set("\n [green]the store server accepted the cart[-] and created the\n payment intent.\n\n" +
3218 " [yellow]the Stripe Payment Element mount is not wired up in this build\n" +
3219 " yet[-] β complete this order at [aqua]https://" + esc(u.sitedomain) + "[-]\n")
3220}
3221
3222
3223// ===== pkg/tui/pay_native.go =====
3224//go:build !js || !wasm
3225
3226// Package tui pkg/tui/pay_native.go β the native half of the payment
3227// seam. Card details can never be typed into a terminal (they would
3228// pass through this program, which PCI forbids); the browser build of
3229// this TUI supplies the other half in pay_js.go, mounting the Stripe
3230// Payment Element over the terminal exactly as the website's cart wasm
3231// mounts it over the page. Until then the native flow proves the whole
3232// pipeline β cart, shipping, server validation, payment intent β and
3233// says where the last step lives.
3234package tui
3235
3236// payWithElements takes over the checkout dialog once the store server
3237// has created the payment intent for this cart.
3238func (u *ui) payWithElements(clientSecret string, set func(string)) {
3239 preview := clientSecret
3240 if len(preview) > 12 {
3241 preview = preview[:12] + "β¦"
3242 }
3243 set("\n [green]the store server accepted the cart[-] and created the payment\n intent (" +
3244 esc(preview) + ").\n\n" +
3245 " [yellow]card entry needs the Stripe Payment Element[-], which a terminal\n" +
3246 " cannot host. In the browser build of this TUI it opens right here,\n" +
3247 " over the terminal β in a native terminal, complete this order at\n" +
3248 " [aqua]https://" + esc(u.sitedomain) + "[-]\n\n" +
3249 " [gray]Esc closes β the cart is kept[-]\n")
3250}
3251
3252
3253// ===== pkg/tui/remote.go =====
3254// Package tui pkg/tui/remote.go β the TUI as a store client. With
3255// STOREURL set (m2 tui --storeurl https://magnetosphere.net) nothing is
3256// read from disk: the catalog comes from /api/products, images from /i,
3257// the logo from /logo.jpg, and checkout from the same origin β exactly
3258// what the browser build of this TUI will do from the page the store
3259// serves it on. Without it, the TUI reads the deployment's own files as
3260// before.
3261package tui
3262
3263import (
3264 "encoding/json"
3265 "fmt"
3266 "io"
3267 "net/http"
3268 "net/url"
3269 "os"
3270 "strings"
3271 "sync"
3272 "time"
3273
3274 "github.com/0magnet/m2/pkg/product"
3275)
3276
3277// storeURL is the remote store, normalized; empty means local files.
3278func storeURL() string {
3279 return strings.TrimRight(f.Storeurl, "/")
3280}
3281
3282// FetchCatalog loads the product catalog from a store's /api/products.
3283func FetchCatalog(store string) (product.Products, error) {
3284 client := &http.Client{Timeout: 30 * time.Second}
3285 resp, err := client.Get(strings.TrimRight(store, "/") + "/api/products")
3286 if err != nil {
3287 return nil, err
3288 }
3289 defer resp.Body.Close() //nolint:errcheck // read-side close
3290 if resp.StatusCode != http.StatusOK {
3291 return nil, fmt.Errorf("%s from %s/api/products", resp.Status, store)
3292 }
3293 var prods product.Products
3294 if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&prods); err != nil {
3295 return nil, err
3296 }
3297 return prods, nil
3298}
3299
3300// FetchSite fills the site identity (name, tagline, telegram links) from
3301// a store's /api/site β the browser build has no MENV file to source.
3302func FetchSite(store string) error {
3303 client := &http.Client{Timeout: 15 * time.Second}
3304 resp, err := client.Get(strings.TrimRight(store, "/") + "/api/site")
3305 if err != nil {
3306 return err
3307 }
3308 defer resp.Body.Close() //nolint:errcheck // read-side close
3309 if resp.StatusCode != http.StatusOK {
3310 return fmt.Errorf("%s from %s/api/site", resp.Status, store)
3311 }
3312 var site struct {
3313 Sitename, Siteext, Sitelongname, Sitetagline string
3314 Tgcontact, Tgchannel string
3315 Teststripekey bool
3316 Sitemeta, Sitedomain string
3317 Siteprettyname, Siteprettynamecap string
3318 Siteprettynamecaps, SiteASCIILogo string
3319 Stripepk string
3320 NavLinks []navLink
3321 }
3322 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&site); err != nil {
3323 return err
3324 }
3325 f.Sitename, f.Siteext = site.Sitename, site.Siteext
3326 f.Sitelongname, f.Sitetagline = site.Sitelongname, site.Sitetagline
3327 f.Tgcontact, f.Tgchannel = site.Tgcontact, site.Tgchannel
3328 f.Teststripekey = site.Teststripekey
3329 f.Sitemeta, f.Sitedomain = site.Sitemeta, site.Sitedomain
3330 f.Siteprettyname, f.Siteprettynamecap = site.Siteprettyname, site.Siteprettynamecap
3331 f.Siteprettynamecaps, f.SiteASCIILogo = site.Siteprettynamecaps, site.SiteASCIILogo
3332 if site.Stripepk != "" {
3333 f.StripePK = site.Stripepk
3334 }
3335 navLinks = site.NavLinks
3336 return nil
3337}
3338
3339// navLink is an entry a server-side drop-in added to the site navigation β
3340// pkg/web's NavLink, as /api/site serves it.
3341//
3342// The Etc menu was written out twice, once in htmpl/header.html and once in
3343// openEtc, and only the template knew about these: the store's Software page
3344// was in the page's menu and missing from the terminal's. A store client
3345// cannot see pkg/web's registry, so the server hands it over instead.
3346type navLink struct {
3347 Title, Href, Desc string
3348}
3349
3350// navLinks is what /api/site last reported. Empty in a deployment with no
3351// drop-ins, and empty in a local TUI with no store to ask.
3352var navLinks []navLink
3353
3354// fetchContent reads a stock page (about/policy/links): from the store's
3355// /api/content in client mode, from the deployment's content/ otherwise.
3356func fetchContent(name string) string {
3357 s := storeURL()
3358 if s == "" {
3359 return contentFile("content/" + name + ".html")
3360 }
3361 client := &http.Client{Timeout: 15 * time.Second}
3362 resp, err := client.Get(s + "/api/content/" + url.PathEscape(name))
3363 if err != nil {
3364 return ""
3365 }
3366 defer resp.Body.Close() //nolint:errcheck // read-side close
3367 if resp.StatusCode != http.StatusOK {
3368 return ""
3369 }
3370 data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
3371 if err != nil {
3372 return ""
3373 }
3374 return string(data)
3375}
3376
3377// imageCache holds fetched remote images; the render worker re-reads an
3378// image at every pane size, and the network should pay only once.
3379var (
3380 imageCacheMu sync.Mutex
3381 imageCache = map[string][]byte{}
3382)
3383
3384// getImageData reads an image by path or URL.
3385func getImageData(path string) ([]byte, error) {
3386 if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
3387 return os.ReadFile(path) //nolint:gosec // paths come from the catalog, not the user
3388 }
3389 imageCacheMu.Lock()
3390 data, ok := imageCache[path]
3391 imageCacheMu.Unlock()
3392 if ok {
3393 return data, nil
3394 }
3395 client := &http.Client{Timeout: 20 * time.Second}
3396 // Fetching the URL it was asked for is the point of this function.
3397 resp, err := client.Get(path) //nolint:gosec
3398 if err != nil {
3399 return nil, err
3400 }
3401 defer resp.Body.Close() //nolint:errcheck // read-side close
3402 if resp.StatusCode != http.StatusOK {
3403 return nil, fmt.Errorf("%s", resp.Status)
3404 }
3405 data, err = io.ReadAll(io.LimitReader(resp.Body, 16<<20))
3406 if err != nil {
3407 return nil, err
3408 }
3409 imageCacheMu.Lock()
3410 if len(imageCache) > 64 {
3411 imageCache = map[string][]byte{}
3412 }
3413 imageCache[path] = data
3414 imageCacheMu.Unlock()
3415 return data, nil
3416}
3417
3418
3419// ===== pkg/tui/render.go =====
3420// Package tui pkg/tui/render.go β images, the globe, and text conversion.
3421// Everything renders to cells: product photos become pixel buffers
3422// painted as β half-blocks (or libcaca canvases blitted glyph by glyph),
3423// and tcell diffs frames so only changed cells reach the terminal.
3424package tui
3425
3426import (
3427 "bytes"
3428 "fmt"
3429 "image"
3430 "image/color"
3431 _ "image/gif"
3432 _ "image/jpeg"
3433 _ "image/png"
3434 "net/url"
3435 "os"
3436 "path/filepath"
3437 "regexp"
3438 "strconv"
3439 "strings"
3440
3441 xhtml "html"
3442
3443 "github.com/gdamore/tcell/v3"
3444 "golang.org/x/image/draw"
3445
3446 "github.com/0magnet/chaosrack/pkg/geom"
3447 "github.com/0magnet/chaosrack/pkg/rasterview"
3448 "github.com/0magnet/img2txt-go/caca"
3449
3450 "github.com/0magnet/m2/pkg/product"
3451)
3452
3453// imgMode selects how product photos are painted.
3454type imgMode int
3455
3456const (
3457 modeHalfBlock imgMode = iota // truecolor, two pixels per cell via β
3458 modeCaca // libcaca ANSI art, as /logo renders it
3459)
3460
3461// artwork is a rendered product photo: a pixel buffer (half-block mode),
3462// a cell canvas (caca mode), or a message when there is nothing to show.
3463type artwork struct {
3464 rgba *image.RGBA
3465 cells *cellArt
3466 msg string
3467}
3468
3469// cellArt is a libcaca canvas as terminal cells.
3470type cellArt struct {
3471 w, h int
3472 ch []rune
3473 st []tcell.Style
3474}
3475
3476// imagePath resolves a product photo the way the templates do:
3477// img/<category>/<image1> (from the store's /i in client mode), falling
3478// back locally to img/<image1>. A nil product (nothing selected yet β
3479// an empty table's preview) has no image.
3480func imagePath(p *product.Product) string {
3481 if p == nil || p.Image1 == "" {
3482 return ""
3483 }
3484 if s := storeURL(); s != "" {
3485 return s + "/i/" + url.PathEscape(p.Category) + "/" + url.PathEscape(p.Image1)
3486 }
3487 for _, path := range []string{
3488 filepath.Join("img", p.Category, p.Image1),
3489 filepath.Join("img", p.Image1),
3490 } {
3491 if _, err := os.Stat(path); err == nil {
3492 return path
3493 }
3494 }
3495 return ""
3496}
3497
3498// datasheetURL resolves a product datasheet the way product.html does:
3499// <ImgSRC>/pdf/<name>, which is /i/pdf/<name> on the store's own origin.
3500// Without a store there is no origin to build on, so the local path the
3501// file would be served from is what a click has to offer instead.
3502func datasheetURL(name string) string {
3503 if name == "" {
3504 return ""
3505 }
3506 if s := storeURL(); s != "" {
3507 return s + "/i/pdf/" + url.PathEscape(name)
3508 }
3509 return filepath.Join("img", "pdf", name)
3510}
3511
3512func loadImage(path string) (image.Image, error) {
3513 data, err := getImageData(path)
3514 if err != nil {
3515 return nil, err
3516 }
3517 img, _, err := image.Decode(bytes.NewReader(data))
3518 return img, err
3519}
3520
3521// scaleToFit scales img into a pixel buffer no larger than maxW x maxH,
3522// preserving aspect. Half-block pixels are roughly square.
3523func scaleToFit(img image.Image, maxW, maxH int) *image.RGBA {
3524 b := img.Bounds()
3525 pw, ph := b.Dx(), b.Dy()
3526 if pw*maxH > ph*maxW {
3527 ph = ph * maxW / pw
3528 pw = maxW
3529 } else {
3530 pw = pw * maxH / ph
3531 ph = maxH
3532 }
3533 if pw < 1 {
3534 pw = 1
3535 }
3536 if ph < 1 {
3537 ph = 1
3538 }
3539 dst := image.NewRGBA(image.Rect(0, 0, pw, ph))
3540 draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
3541 return dst
3542}
3543
3544// renderArt produces a product photo sized to fit cols x rows cells.
3545func renderArt(path string, cols, rows int, mode imgMode) artwork {
3546 if path == "" {
3547 return artwork{msg: "no image"}
3548 }
3549 if cols < 2 || rows < 2 {
3550 return artwork{msg: "pane too small"}
3551 }
3552 if mode == modeCaca {
3553 cells, err := renderCaca(path, cols, rows)
3554 if err != nil {
3555 return artwork{msg: err.Error()}
3556 }
3557 return artwork{cells: cells}
3558 }
3559 img, err := loadImage(path)
3560 if err != nil {
3561 return artwork{msg: err.Error()}
3562 }
3563 if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
3564 return artwork{msg: "empty image"}
3565 }
3566 return artwork{rgba: scaleToFit(img, cols, rows*2)}
3567}
3568
3569// renderCaca draws an image as libcaca ANSI art β the same renderer
3570// behind the /logo endpoint, via the pure-Go img2txt port β and reads
3571// the canvas cells straight into terminal cells.
3572func renderCaca(path string, cols, rows int) (*cellArt, error) {
3573 data, err := getImageData(path)
3574 if err != nil {
3575 return nil, err
3576 }
3577 im, err := caca.DecodeImage(bytes.NewReader(data))
3578 if err != nil {
3579 return nil, err
3580 }
3581 if im.W == 0 || im.H == 0 {
3582 return nil, fmt.Errorf("bad dimensions")
3583 }
3584 // img2txt's 6x10 font-cell aspect, constrained by both axes.
3585 lines := cols * im.H * 6 / im.W / 10
3586 if lines > rows {
3587 lines = rows
3588 cols = lines * im.W * 10 / im.H / 6
3589 }
3590 if lines < 1 || cols < 1 {
3591 return nil, fmt.Errorf("pane too small")
3592 }
3593 cv := caca.NewCanvas(cols, lines)
3594 cv.SetColorANSI(caca.Default, caca.Transparent)
3595 cv.Clear()
3596 im.Dither.SetAlgorithm("fstein")
3597 cv.DitherBitmap(0, 0, cols, lines, im.Dither, im.Pixels)
3598
3599 art := &cellArt{w: cv.Width, h: cv.Height,
3600 ch: make([]rune, cv.Width*cv.Height), st: make([]tcell.Style, cv.Width*cv.Height)}
3601 for i, ch := range cv.Chars {
3602 art.ch[i] = ch
3603 art.st[i] = tcell.StyleDefault.
3604 Foreground(rgb12(caca.AttrToRGB12Fg(cv.Attrs[i]))).
3605 Background(rgb12(caca.AttrToRGB12Bg(cv.Attrs[i])))
3606 }
3607 return art, nil
3608}
3609
3610// rgb12 expands libcaca's 12-bit 0xRGB into a tcell color.
3611func rgb12(v uint16) tcell.Color {
3612 return tcell.NewRGBColor(
3613 int32((v>>8&0xF)*17), int32((v>>4&0xF)*17), int32((v&0xF)*17))
3614}
3615
3616// drawArtwork paints a rendered photo centered in r.
3617func drawArtwork(sc tcell.Screen, r rect, art *artwork) {
3618 switch {
3619 case art == nil:
3620 case art.rgba != nil:
3621 rows := (art.rgba.Bounds().Dy() + 1) / 2
3622 yoff := (r.h - rows) / 2
3623 if yoff < 0 {
3624 yoff = 0
3625 }
3626 drawRGBA(sc, r.x, r.y+yoff, r.w, r.h-yoff, art.rgba)
3627 case art.cells != nil:
3628 xoff := (r.w - art.cells.w) / 2
3629 yoff := (r.h - art.cells.h) / 2
3630 if xoff < 0 {
3631 xoff = 0
3632 }
3633 if yoff < 0 {
3634 yoff = 0
3635 }
3636 for y := 0; y < art.cells.h && y < r.h; y++ {
3637 for x := 0; x < art.cells.w && x < r.w; x++ {
3638 i := y*art.cells.w + x
3639 sc.SetContent(r.x+xoff+x, r.y+yoff+y, art.cells.ch[i], nil, art.cells.st[i])
3640 }
3641 }
3642 case art.msg != "":
3643 printMarkupCenter(sc, r.x, r.y+r.h/2, r.w, "[gray]"+esc(art.msg)+"[-]", styleText)
3644 }
3645}
3646
3647// The home panel: a wireframe globe turning over the site logo, as the
3648// website's landing view renders its wasm globe over the svg logo.
3649
3650// makeBackdrop scales the logo to one panel size.
3651func makeBackdrop(logo image.Image, cols, rows int) *image.RGBA {
3652 if logo == nil || cols <= 0 || rows <= 0 {
3653 if cols < 1 {
3654 cols = 1
3655 }
3656 if rows < 1 {
3657 rows = 1
3658 }
3659 return image.NewRGBA(image.Rect(0, 0, cols, rows*2))
3660 }
3661 // Full brightness, as the website shows it: the svg behind the canvas
3662 // is not dimmed there, so its white stays white here too. The globe
3663 // reads on top by its own saturation, the way it does on the page.
3664 return scaleToFit(logo, cols, rows*2)
3665}
3666
3667// globeLines is the globe the website's attractor engine uploads to
3668// WebGL β same geometry, from the same package.
3669var globeLines = geom.Globe(18, 36, 60)
3670
3671// globeFrame composites one animation frame: the dimmed logo with the
3672// chaosrack globe drawn over it by chaosrack's own software renderer, in
3673// its default look β random pose, redβblue gradient along model z (see
3674// rasterview.DefaultGradient). BackDim is the one departure from the
3675// WebGL look: an unantialiased raster needs the depth cue.
3676func globeFrame(backdrop *image.RGBA, ax, ay, az float64) *image.RGBA {
3677 frame := image.NewRGBA(backdrop.Bounds())
3678 copy(frame.Pix, backdrop.Pix)
3679 view := rasterview.View{AngleX: ax, AngleY: ay, AngleZ: az, Dist: 3.2, BackDim: 0.5}
3680 view.Render(frame, globeLines.Vertices, globeLines.Indices, rasterview.DefaultGradient())
3681 return frame
3682}
3683
3684// drawRGBA paints a pixel buffer into terminal cells at x0,y0, two pixels
3685// per cell via β, centered in cols columns. Cells unchanged since the
3686// last frame cost nothing: tcell diffs them away.
3687func drawRGBA(sc tcell.Screen, x0, y0, cols, maxRows int, img *image.RGBA) {
3688 pw, ph := img.Bounds().Dx(), img.Bounds().Dy()
3689 xoff := 0
3690 if cols > pw {
3691 xoff = (cols - pw) / 2
3692 }
3693 for y := 0; y < ph; y += 2 {
3694 if y/2 >= maxRows {
3695 break
3696 }
3697 for x := 0; x < pw; x++ {
3698 if xoff+x >= cols {
3699 break
3700 }
3701 top := img.RGBAAt(x, y)
3702 var bot color.RGBA
3703 if y+1 < ph {
3704 bot = img.RGBAAt(x, y+1)
3705 }
3706 st := tcell.StyleDefault.
3707 Foreground(tcell.NewRGBColor(step16(top.R), step16(top.G), step16(top.B))).
3708 Background(tcell.NewRGBColor(step16(bot.R), step16(bot.G), step16(bot.B)))
3709 sc.SetContent(x0+xoff+x, y0+y/2, 'β', nil, st)
3710 }
3711 }
3712}
3713
3714var (
3715 tmplAction = regexp.MustCompile(`\{\{[^}]*\}\}`)
3716 tagAnchor = regexp.MustCompile(`(?is)<a\b([^>]*)>(.*?)</a>`)
3717 attrHref = regexp.MustCompile(`(?is)\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
3718 tagBreak = regexp.MustCompile(`(?i)<br\s*/?>`)
3719 tagBlock = regexp.MustCompile(`(?i)</(p|h1|h2|h3|div|tr|li|ul|table|pre)>`)
3720 tagHeading = regexp.MustCompile(`(?is)<h[1-3][^>]*>(.*?)</h[1-3]>`)
3721 tagLi = regexp.MustCompile(`(?i)<li[^>]*>`)
3722 tagCell = regexp.MustCompile(`(?i)</t[dh]>`)
3723 tagAny = regexp.MustCompile(`(?s)<[^>]*>`)
3724 manyBlank = regexp.MustCompile(`\n{3,}`)
3725)
3726
3727// htmlToText renders a stock-page fragment (about/policy/links) as
3728// terminal text: links cyan like the site, headings bold, tags dropped.
3729//
3730// It also returns each anchor's href, in the order the [link=N] tags it
3731// wrote number them, so the page can make them followable. The Links
3732// page is a page of nothing but links; cyan text that does nothing is
3733// not a rendering of it.
3734//
3735// width is the column count the caller will draw into; tables are laid out
3736// to fit it. See htmltable.go.
3737func htmlToText(s, year string, width int) (string, []string) {
3738 s = strings.ReplaceAll(s, "{{.Page.Year}}", year)
3739 s = tmplAction.ReplaceAllString(s, "")
3740 s = strings.ReplaceAll(s, "[", "[[") // user HTML must not become tags
3741 s = tagHeading.ReplaceAllString(s, "\n[::b]$1[-:-:-]\n")
3742 var hrefs []string
3743 s = tagAnchor.ReplaceAllStringFunc(s, func(m string) string {
3744 g := tagAnchor.FindStringSubmatch(m)
3745 href := ""
3746 if a := attrHref.FindStringSubmatch(g[1]); a != nil {
3747 href = xhtml.UnescapeString(a[1] + a[2] + a[3]) // only one group matches
3748 }
3749 hrefs = append(hrefs, href)
3750 return linkTag(len(hrefs), "[aqua]"+g[2]+"[-]")
3751 })
3752 // Tables come out whole and are drawn last. The flattening below would
3753 // otherwise reduce their cells to runs of spaces, and drawing them first
3754 // would feed the finished columns straight back into it.
3755 var tables []string
3756 s = tagTable.ReplaceAllStringFunc(s, func(m string) string {
3757 tables = append(tables, tagTable.FindStringSubmatch(m)[1])
3758 return "\x00T" + strconv.Itoa(len(tables)-1) + "\x00"
3759 })
3760 s = tagBreak.ReplaceAllString(s, "\n")
3761 s = tagLi.ReplaceAllString(s, " * ")
3762 s = tagCell.ReplaceAllString(s, " ")
3763 s = tagBlock.ReplaceAllString(s, "\n")
3764 s = tagAny.ReplaceAllString(s, "")
3765 s = xhtml.UnescapeString(s)
3766 s = manyBlank.ReplaceAllString(s, "\n\n")
3767 for i, inner := range tables {
3768 s = strings.Replace(s, "\x00T"+strconv.Itoa(i)+"\x00", "\n"+renderTable(inner, width), 1)
3769 }
3770 return strings.TrimSpace(s) + "\n", hrefs
3771}
3772
3773// step16 snaps a channel to 16 levels, spanning the full range: 0x00 stays
3774// 0x00 and 0xFF stays 0xFF, so the logo's black and white are untouched and
3775// only the interpolated greys move, by at most 1/32 of the range.
3776//
3777// This is a rendering cost, not an aesthetic choice. The WebGL renderer
3778// caches rasterised glyphs under a key that includes both colors, so a
3779// truecolor half-block is a distinct glyph per color pair. A full-screen
3780// gradient at ~11k cells hands it ~11k keys a frame against an atlas of
3781// ~18k slots; it fills, clears, and re-rasterises everything, every frame.
3782// Quantising collapses the key space enough for the cache to hold, which
3783// measured out at roughly half the CPU and less than half the worst-case
3784// frame time.
3785func step16(v uint8) int32 { return int32(v&0xF0 | v>>4) }
3786
3787
3788// ===== pkg/tui/tui.go =====
3789// Package tui pkg/tui/tui.go β a terminal view of the web store, derived
3790// from the website itself: the same masthead, the catsubcats navigation
3791// tree, one panel at a time like the front page's :target panels, the
3792// category tables, product pages in template order, the stock content
3793// pages, and the footer bar. The landing view is the site's landing
3794// view: a wireframe globe turning over the logo.
3795//
3796// Written directly on tcell v3 with the small widget kit in widgets.go β
3797// the same terminal stack as the rest of the 0magnet ecosystem, so the
3798// browser build can ride tuiwasm/xtcell.
3799package tui
3800
3801import (
3802 "context"
3803 "embed"
3804 "fmt"
3805 "image"
3806 "math"
3807 "math/rand"
3808 "os"
3809 "strconv"
3810 "strings"
3811 "sync"
3812 "time"
3813
3814 "github.com/gdamore/tcell/v3"
3815
3816 "github.com/0magnet/calvin"
3817
3818 "github.com/0magnet/m2/pkg/config"
3819 "github.com/0magnet/m2/pkg/product"
3820 "github.com/0magnet/m2/pkg/storepage"
3821)
3822
3823//go:embed *.go
3824var Source embed.FS
3825
3826// f aliases the shared configuration, as in pkg/web.
3827var f = &config.F
3828
3829// site styles, from content/style.css
3830var (
3831 styleText = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack)
3832 styleLink = styleText.Foreground(tcell.ColorAqua) // a{color:cyan}
3833 colorHover = tcell.ColorGreen // a:hover{color:green}
3834 styleSel = tcell.StyleDefault.Foreground(tcell.GetColor("#ffa500")).Background(tcell.ColorWhite) // a.cur
3835 // .β thead{background-color:#6a1b9a;color:white} β a filled band, not
3836 // underlined text. Underlined purple read as a link, which those
3837 // column titles are not.
3838 styleThead = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.GetColor("#6a1b9a")).Bold(true)
3839 styleBorder = styleText.Foreground(tcell.ColorBlue)
3840 styleField = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.GetColor("#000087"))
3841 styleButton = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.GetColor("#005f87"))
3842
3843 // The header and footer tables are closed by a 1px blue border on the
3844 // page; their cell edges are white. Terminal rules stand in for both.
3845 ruleBlue = styleText.Foreground(tcell.GetColor("#0000ff"))
3846 ruleWhite = styleText.Foreground(tcell.ColorWhite)
3847)
3848
3849// panel is one of the site's :target panels.
3850type panel interface {
3851 draw(u *ui, sc tcell.Screen, r rect)
3852 key(u *ui, ev *tcell.EventKey) bool
3853 mouse(u *ui, r rect, ev *tcell.EventMouse) bool
3854}
3855
3856// overlayW is a commerce overlay (cart / shipping / checkout).
3857type overlayW interface {
3858 draw(u *ui, sc tcell.Screen, r rect)
3859 key(u *ui, ev *tcell.EventKey) bool
3860 size() (w, h int)
3861 title() string
3862}
3863
3864// linkRegion is one anchor on the drawn page: the cells it occupies and
3865// what following it does.
3866type linkRegion struct {
3867 x, y, w int
3868 act func()
3869}
3870
3871// dropState is an open header dropdown.
3872type dropState struct {
3873 kind string // "cats" | "etc"
3874 lst *list
3875 targets []navTarget // cats only
3876 width int
3877}
3878
3879// artHolder carries one asynchronously rendered product photo.
3880type artHolder struct {
3881 key string // what art holds
3882 want string // what was last requested
3883 art *artwork
3884}
3885
3886type artReq struct {
3887 key string
3888 path string
3889 w, h int
3890 mode imgMode
3891 holder *artHolder
3892}
3893
3894type ui struct {
3895 screen tcell.Screen
3896 cat *catalogInfo
3897 quit bool
3898
3899 panels map[string]panel
3900 history []string
3901 fwd []string
3902
3903 drop *dropState
3904 commerce overlayW
3905 notice string
3906
3907 // postMu gates post against RunOn's shutdown closing the queue.
3908 postMu sync.Mutex
3909 finished bool
3910
3911 // header nav and footer summary click regions, rebuilt each draw
3912 navRegions []struct {
3913 x1, x2 int
3914 act string
3915 }
3916 footRegions []struct {
3917 x1, x2 int
3918 act string
3919 }
3920
3921 // links are the anchors the visible page drew, rebuilt every draw and
3922 // consulted before the panel sees a click. On the website every one of
3923 // these is an <a>; here it is a run of cells with something to do.
3924 links []linkRegion
3925
3926 // hoverX, hoverY is the cell the pointer was last seen over, which is
3927 // what a:hover needs. -1 means the pointer has not been anywhere yet.
3928 hoverX, hoverY int
3929
3930 // bare drops the pisano frame around the product table, giving its
3931 // rows back to the table. Toggled with d.
3932 bare bool
3933
3934 // image rendering
3935 mode imgMode
3936 reqCh chan artReq
3937
3938 // expanded holds which categories' branches are open in the
3939 // Categories dropdown, like the template's <details open>.
3940 expanded map[string]bool
3941
3942 // home animation. The ticker goroutine computes frame (a pixel
3943 // buffer) and the home panel paints it; both sides go through mu.
3944 // current names the visible panel; the goroutine reads it too.
3945 mu sync.Mutex
3946 current string
3947 logo image.Image
3948 homeW int
3949 homeH int
3950 frame *image.RGBA
3951 backdrop *image.RGBA
3952 bdW, bdH int
3953 // pose randomized at startup like chaosrack's randomizeOrientation,
3954 // then tumbled by a random rate on every axis.
3955 poseX, poseY, poseZ float64
3956 rateX, rateY, rateZ float64
3957 done chan struct{}
3958
3959 asciiLines []string
3960 caps string
3961
3962 // cart, as the footer's View Cart
3963 cartOrder []string
3964 cartQty map[string]int
3965 shipping *storepage.Shipping
3966
3967 sitedomain string
3968 masthead [3]string
3969}
3970
3971// screenKey carries an alternative way of getting a screen.
3972type screenKey struct{}
3973
3974// WithScreen tells Run how to obtain its screen, for a host where
3975// tcell's own answer is the wrong one.
3976//
3977// This is what lets `m2 tui` be the same command in both places. In a
3978// terminal it opens a tcell screen on the tty and there is nothing to
3979// say; in a browser the screen has to be an xtcell one bound to the
3980// particular terminal and element the command was typed into, which
3981// only the page knows. Rather than a second command for the browser β
3982// which is what there used to be, and which promptly drifted β the page
3983// puts the answer here and runs the real one.
3984//
3985// A function and not a screen: most commands never need one, and
3986// building it takes the terminal over.
3987func WithScreen(ctx context.Context, newScreen func() (tcell.Screen, error)) context.Context {
3988 return context.WithValue(ctx, screenKey{}, newScreen)
3989}
3990
3991// screenFrom returns the screen ctx asks for, or nil for tcell's own.
3992func screenFrom(ctx context.Context) func() (tcell.Screen, error) {
3993 if ctx == nil {
3994 return nil
3995 }
3996 fn, _ := ctx.Value(screenKey{}).(func() (tcell.Screen, error))
3997 return fn
3998}
3999
4000// Run browses the store in a full-screen terminal UI until the user
4001// quits, on the screen ctx names or on a new tcell one.
4002func Run(ctx context.Context, prods product.Products) error {
4003 if newScreen := screenFrom(ctx); newScreen != nil {
4004 sc, err := newScreen()
4005 if err != nil {
4006 return err
4007 }
4008 return RunOn(sc, prods)
4009 }
4010 sc, err := tcell.NewScreen()
4011 if err != nil {
4012 return err
4013 }
4014 if err := sc.Init(); err != nil {
4015 return err
4016 }
4017 return RunOn(sc, prods)
4018}
4019
4020// RunOn drives the store UI on an initialized screen until the user
4021// quits, then finalizes it.
4022func RunOn(sc tcell.Screen, prods product.Products) error {
4023 u := &ui{
4024 cat: newCatalogInfo(prods),
4025 panels: map[string]panel{},
4026 mode: modeHalfBlock,
4027 reqCh: make(chan artReq, 1),
4028 expanded: map[string]bool{},
4029 done: make(chan struct{}),
4030 cartQty: map[string]int{},
4031 hoverX: -1,
4032 hoverY: -1,
4033 }
4034 u.sitedomain = f.Sitename + f.Siteext
4035 if u.sitedomain == "" {
4036 u.sitedomain = "m2"
4037 }
4038 u.asciiLines = strings.Split(strings.TrimRight(calvin.AsciiFont(u.sitedomain), "\n"), "\n")
4039 // The page sets this line in blackboard bold, and the terminal cannot:
4040 // the double-struck capitals live outside the BMP and no monospace font
4041 // here carries them, so every one of them falls back to a face whose
4042 // glyph is nearly twice the cell it is given and collides with its
4043 // neighbor. Plain capitals are the same words, drawn on the grid.
4044 u.caps = strings.ToUpper(u.sitedomain)
4045 long := f.Sitelongname
4046 if long == "" {
4047 long = u.sitedomain
4048 }
4049 tag := ""
4050 if f.Sitetagline != "" {
4051 tag = "- " + esc(f.Sitetagline) + " -"
4052 }
4053 u.masthead = [3]string{"[::b]" + esc(long) + "[-:-:-]", "[gray]" + tag + "[-]", checkerboard(u.sitedomain)}
4054
4055 // A fresh random starting pose each run, as the website gives each
4056 // page load β and a random spin rate per axis, each with a floor so
4057 // all three visibly contribute, the vector normalized so the overall
4058 // speed matches auto-rotate's.
4059 u.poseX = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
4060 u.poseY = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
4061 u.poseZ = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
4062 randRate := func() float64 {
4063 r := 0.35 + 0.65*rand.Float64() //nolint:gosec
4064 if rand.Intn(2) == 0 { //nolint:gosec
4065 return -r
4066 }
4067 return r
4068 }
4069 u.rateX, u.rateY, u.rateZ = randRate(), randRate(), randRate()
4070 norm := math.Sqrt(u.rateX*u.rateX + u.rateY*u.rateY + u.rateZ*u.rateZ)
4071 const pace = 0.042 // auto-rotate's 0.3 rad/s at the 140ms ticker
4072 u.rateX, u.rateY, u.rateZ = u.rateX/norm*pace, u.rateY/norm*pace, u.rateZ/norm*pace
4073
4074 logos := []string{"logo.jpg", "logo.png"}
4075 if s := storeURL(); s != "" {
4076 logos = []string{s + "/logo.jpg", s + "/logo.png"}
4077 }
4078 for _, p := range logos {
4079 if img, err := loadImage(p); err == nil {
4080 u.logo = img
4081 break
4082 }
4083 }
4084
4085 u.screen = sc
4086 sc.SetStyle(styleText)
4087 sc.EnableMouse()
4088
4089 u.panels["home"] = &homePanel{}
4090 u.current = "home"
4091
4092 go u.renderWorker()
4093 go u.animateHome()
4094
4095 defer func() {
4096 // Stop accepting posts before the screen closes the event queue
4097 // under them β a ticker mid-post must not race the close.
4098 u.postMu.Lock()
4099 u.finished = true
4100 u.postMu.Unlock()
4101 sc.Fini()
4102 close(u.done)
4103 }()
4104 events := sc.EventQ()
4105 u.draw()
4106 for !u.quit {
4107 ev, ok := <-events
4108 if !ok {
4109 break
4110 }
4111 switch e := ev.(type) {
4112 case *tcell.EventInterrupt:
4113 if fn, ok := e.Data().(func()); ok && fn != nil {
4114 fn()
4115 }
4116 case *tcell.EventResize:
4117 sc.Sync()
4118 case *tcell.EventKey:
4119 // v3 reports key releases too where the terminal can; a
4120 // shortcut must fire once, on the press.
4121 if e.Pressed() {
4122 u.handleKey(e)
4123 }
4124 case *tcell.EventMouse:
4125 u.handleMouse(e)
4126 }
4127 u.draw()
4128 }
4129 return nil
4130}
4131
4132// post runs fn on the event loop and redraws β the worker and the
4133// animation ticker use it. Non-blocking and refused after shutdown, so
4134// a producer can never race the queue's close nor wedge against a loop
4135// that has already exited.
4136func (u *ui) post(fn func()) {
4137 u.postMu.Lock()
4138 defer u.postMu.Unlock()
4139 if u.finished {
4140 return
4141 }
4142 select {
4143 case u.screen.EventQ() <- tcell.NewEventInterrupt(fn):
4144 default:
4145 }
4146}
4147
4148// ---- drawing ----
4149
4150// linkSink returns the callback the markup printers report anchors to,
4151// binding each link index to the page's own table of what its links do.
4152// A page builds that table as it builds its markup; see linkTag.
4153func (u *ui) linkSink(acts []func()) func(x, y, w, link int) {
4154 return func(x, y, w, link int) {
4155 if w <= 0 || link < 1 || link > len(acts) || acts[link-1] == nil {
4156 return
4157 }
4158 u.links = append(u.links, linkRegion{x, y, w, acts[link-1]})
4159 }
4160}
4161
4162// hovered is the run of cells the pointer is over, if it is over one of
4163// the page's links: the header nav, the footer's two cells, or an anchor
4164// the visible panel drew.
4165func (u *ui) hovered(h int) (x1, x2, y int, ok bool) {
4166 switch {
4167 case u.hoverY < 0:
4168 case u.hoverY == 0:
4169 for _, reg := range u.navRegions {
4170 if u.hoverX >= reg.x1 && u.hoverX < reg.x2 {
4171 return reg.x1, reg.x2, 0, true
4172 }
4173 }
4174 case u.hoverY == h-1:
4175 for _, reg := range u.footRegions {
4176 if u.hoverX >= reg.x1 && u.hoverX < reg.x2 {
4177 return reg.x1, reg.x2, h - 1, true
4178 }
4179 }
4180 default:
4181 for _, l := range u.links {
4182 if u.hoverY == l.y && u.hoverX >= l.x && u.hoverX < l.x+l.w {
4183 return l.x, l.x + l.w, l.y, true
4184 }
4185 }
4186 }
4187 return 0, 0, 0, false
4188}
4189
4190// paintHover recolors the link under the pointer, as a:hover{color:green}
4191// does on the page.
4192//
4193// It repaints cells the page has already drawn rather than styling them
4194// as they go, because which cells belong to a link is only known once it
4195// is drawn β and this way no drawing code has to know where the pointer
4196// is. It runs before the overlays, so an open dropdown covers it rather
4197// than being recolored by it.
4198func (u *ui) paintHover(sc tcell.Screen, h int) {
4199 x1, x2, y, ok := u.hovered(h)
4200 if !ok {
4201 return
4202 }
4203 for x := x1; x < x2; x++ {
4204 str, st, _ := sc.Get(x, y)
4205 sc.Put(x, y, str, st.Foreground(colorHover))
4206 }
4207}
4208
4209func (u *ui) draw() {
4210 sc := u.screen
4211 w, h := sc.Size()
4212 if w < 4 || h < 8 {
4213 sc.Show()
4214 return
4215 }
4216 sc.HideCursor()
4217 u.links = u.links[:0]
4218 fillRect(sc, rect{0, 0, w, h}, styleText)
4219 u.drawHeader(sc, w)
4220 // Row 1 is the header table's bottom border, so the masthead starts
4221 // below it, as the page's title block sits under the nav rule.
4222 for i, line := range u.masthead {
4223 printMarkupCenter(sc, 0, 2+i, w, line, styleText)
4224 }
4225 content := rect{0, 5, w, h - 8}
4226 u.mu.Lock()
4227 cur := u.current
4228 u.mu.Unlock()
4229 if p := u.panels[cur]; p != nil {
4230 p.draw(u, sc, content)
4231 }
4232 u.drawFooter(sc, w, h)
4233 u.paintHover(sc, h)
4234 if u.commerce != nil {
4235 r := u.commerceRect()
4236 drawBox(sc, r, "[white] "+u.commerce.title()+" [-]", styleText.Foreground(tcell.ColorWhite), styleText)
4237 u.commerce.draw(u, sc, r.inner())
4238 }
4239 if u.drop != nil {
4240 r := u.dropRect()
4241 drawBox(sc, r, "[white] "+map[string]string{"cats": "Categories", "etc": "Etc..."}[u.drop.kind]+" [-]", styleBorder, styleText)
4242 u.drop.lst.draw(sc, r.inner())
4243 }
4244 if u.notice != "" {
4245 nw := markupWidth(u.notice) + 6
4246 if nw > w-4 {
4247 nw = w - 4
4248 }
4249 r := rect{(w - nw) / 2, h/2 - 2, nw, 5}
4250 drawBox(sc, r, "", styleText.Foreground(tcell.ColorWhite), styleText)
4251 printMarkupCenter(sc, r.x+1, r.y+2, r.w-2, u.notice, styleText)
4252 }
4253 sc.Show()
4254}
4255
4256// drawHeader lays the nav out as the website's header table does: three
4257// equal columns β Categories, Home, Etc... β each label centered in its
4258// third, with its dropdown opening beneath it. The current section is
4259// cyan and underlined and the rest are white, as the page renders its
4260// links, and the row is closed by the header table's blue bottom border.
4261func (u *ui) drawHeader(sc tcell.Screen, w int) {
4262 entries := []struct{ label, act string }{
4263 {"Categories", "cats"}, {"Home", "home"}, {"Etc...", "etc"},
4264 }
4265 active := u.navActive()
4266 u.navRegions = u.navRegions[:0]
4267 third := w / 3
4268 for i, e := range entries {
4269 x := i*third + (third-len(e.label))/2
4270 if x < 0 {
4271 x = 0
4272 }
4273 tag := "[white]"
4274 if e.act == active {
4275 tag = "[aqua::u]"
4276 }
4277 printMarkup(sc, x, 0, w-x, tag+e.label+"[-:-:-]", styleText)
4278 u.navRegions = append(u.navRegions, struct {
4279 x1, x2 int
4280 act string
4281 }{x, x + len(e.label), e.act})
4282 }
4283 drawRule(sc, 0, 1, w, ruleBlue)
4284}
4285
4286// navActive names the header entry for the page on screen, the way the
4287// site marks the section you are in: the catalog pages belong to
4288// Categories, the stock pages to Etc..., everything else to Home.
4289func (u *ui) navActive() string {
4290 u.mu.Lock()
4291 cur := u.current
4292 u.mu.Unlock()
4293 switch {
4294 case strings.HasPrefix(cur, "list:"), strings.HasPrefix(cur, "prod:"):
4295 return "cats"
4296 case strings.HasPrefix(cur, "content:"):
4297 return "etc"
4298 }
4299 return "home"
4300}
4301
4302// drawFooter is the website's fixed footer: the status flash, address
4303// and key hints on one row, then the footer table itself β View Cart on
4304// the left half and Add Shipping Info on the right, each centered in its
4305// cell, ruled above and divided down the middle as the page's is.
4306func (u *ui) drawFooter(sc tcell.Screen, w, h int) {
4307 status := "[#FF10F0:#009EFF] Open For Business [-:-]"
4308 if f.Teststripekey {
4309 status = "[yellow:red] Test Mode Active - No Orders Processed [-:-]"
4310 }
4311 arrows := ""
4312 if len(u.history) > 0 {
4313 arrows += "β"
4314 }
4315 if len(u.fwd) > 0 {
4316 arrows += "β"
4317 }
4318 if arrows != "" {
4319 arrows = "[gray]" + arrows + "[-] "
4320 }
4321 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 Β· q quit[-]",
4322 status, arrows, u.pagePath())
4323 printMarkup(sc, 0, h-3, w, line, styleText)
4324
4325 // The footer table: a white rule for its top edge, then two equal
4326 // cells split by the divider the page draws between them.
4327 drawRule(sc, 0, h-2, w, ruleWhite)
4328 total, count := u.cartTotal()
4329 cart := fmt.Sprintf("β΄ View Cart Total: $%d.%02d", total/100, total%100)
4330 if count > 0 {
4331 cart = fmt.Sprintf("β΄ View Cart (%d) Total: $%d.%02d", count, total/100, total%100)
4332 }
4333 ship := "β΄ Add Shipping Info"
4334 u.footRegions = u.footRegions[:0]
4335 half := w / 2
4336 for i, e := range []struct{ label, act string }{{cart, "cart"}, {ship, "shipping"}} {
4337 x := i*half + (half-len([]rune(e.label)))/2
4338 if x < 0 {
4339 x = 0
4340 }
4341 printMarkup(sc, x, h-1, w-x, "[aqua]"+esc(e.label)+"[-]", styleText)
4342 u.footRegions = append(u.footRegions, struct {
4343 x1, x2 int
4344 act string
4345 }{x, x + len([]rune(e.label)), e.act})
4346 }
4347 sc.SetContent(half, h-1, 'β', nil, ruleWhite)
4348}
4349
4350func (u *ui) cartTotal() (cents, count int) {
4351 for partno, qty := range u.cartQty {
4352 if p := u.cat.find(partno); p != nil {
4353 cents += priceCents(p.Price) * qty
4354 count += qty
4355 }
4356 }
4357 if u.shipping != nil {
4358 cents += u.shipping.Cents
4359 }
4360 return cents, count
4361}
4362
4363func priceCents(s string) int {
4364 s = strings.TrimPrefix(s, "$")
4365 fl, err := strconv.ParseFloat(s, 64)
4366 if err != nil {
4367 return 0
4368 }
4369 return int(fl*100 + 0.5)
4370}
4371
4372// ---- input routing ----
4373
4374func (u *ui) handleKey(ev *tcell.EventKey) {
4375 if ev.Key() == tcell.KeyCtrlC {
4376 u.quit = true
4377 return
4378 }
4379 if u.notice != "" {
4380 u.notice = ""
4381 return
4382 }
4383 if u.drop != nil {
4384 if u.drop.lst.key(ev) {
4385 return
4386 }
4387 r := keyRune(ev)
4388 if ev.Key() == tcell.KeyEscape ||
4389 (r == 'c' && u.drop.kind == "cats") || (r == 'e' && u.drop.kind == "etc") {
4390 u.drop = nil
4391 return
4392 }
4393 }
4394 if u.commerce != nil {
4395 if u.commerce.key(u, ev) {
4396 return
4397 }
4398 if ev.Key() == tcell.KeyEscape {
4399 u.closeCommerce()
4400 return
4401 }
4402 }
4403 switch ev.Key() {
4404 case tcell.KeyEscape:
4405 u.back()
4406 return
4407 case tcell.KeyBackspace, tcell.KeyBackspace2:
4408 u.back()
4409 return
4410 }
4411 if ev.Key() == tcell.KeyRune {
4412 switch keyRune(ev) {
4413 case 'q':
4414 u.quit = true
4415 return
4416 case 'c':
4417 u.openCategories()
4418 return
4419 case 'h':
4420 u.show("home")
4421 return
4422 case 'e':
4423 u.openEtc()
4424 return
4425 case 'f':
4426 u.forward()
4427 return
4428 case 'v':
4429 u.toggleCart()
4430 return
4431 case 'i':
4432 if u.mode == modeHalfBlock {
4433 u.mode = modeCaca
4434 } else {
4435 u.mode = modeHalfBlock
4436 }
4437 return // the changed mode re-keys art requests at next draw
4438 case 'd':
4439 // The frame is drawn out of the table's own area, so it
4440 // costs rows of products. Worth having and worth being able
4441 // to put away, which the page never needs to offer.
4442 u.bare = !u.bare
4443 return
4444 }
4445 }
4446 if u.drop != nil || u.commerce != nil {
4447 return
4448 }
4449 u.mu.Lock()
4450 cur := u.current
4451 u.mu.Unlock()
4452 if p := u.panels[cur]; p != nil {
4453 p.key(u, ev)
4454 }
4455}
4456
4457func (u *ui) handleMouse(ev *tcell.EventMouse) {
4458 x, y := ev.Position()
4459 w, h := u.screen.Size()
4460 click := ev.Buttons()&tcell.Button1 != 0
4461 // Where the pointer is, for a:hover. Every event carries it, so bare
4462 // motion is not the only thing that keeps it current.
4463 u.hoverX, u.hoverY = x, y
4464 if u.notice != "" {
4465 if click {
4466 u.notice = ""
4467 }
4468 return
4469 }
4470 wasOpen := ""
4471 if u.drop != nil {
4472 r := u.dropRect().inner()
4473 if r.contains(x, y) {
4474 u.drop.lst.mouse(y-r.y, ev)
4475 return
4476 }
4477 if !click {
4478 return
4479 }
4480 // Close, and let the click land: clicking the other header label
4481 // switches menus in one click, as details dropdowns do β but a
4482 // click on this menu's own label must collapse it, not reopen it.
4483 wasOpen = u.drop.kind
4484 u.drop = nil
4485 }
4486 if click && y == 0 {
4487 for _, reg := range u.navRegions {
4488 if x >= reg.x1 && x < reg.x2 {
4489 switch reg.act {
4490 case "cats":
4491 if wasOpen != "cats" {
4492 u.openCategories()
4493 }
4494 case "home":
4495 u.show("home")
4496 case "etc":
4497 if wasOpen != "etc" {
4498 u.openEtc()
4499 }
4500 }
4501 return
4502 }
4503 }
4504 return
4505 }
4506 if click && y == h-1 {
4507 for _, reg := range u.footRegions {
4508 if x >= reg.x1 && x < reg.x2 {
4509 switch reg.act {
4510 case "cart":
4511 u.toggleCart()
4512 case "shipping":
4513 if _, open := u.commerce.(*shippingOverlay); open {
4514 u.closeCommerce()
4515 } else {
4516 u.openShipping()
4517 }
4518 }
4519 return
4520 }
4521 }
4522 return
4523 }
4524 if u.commerce != nil {
4525 r := u.commerceRect()
4526 if !r.contains(x, y) && click {
4527 u.closeCommerce()
4528 }
4529 return
4530 }
4531 // An anchor first: on the page these are <a>, and a click on one
4532 // follows it rather than reaching the widget it was drawn over.
4533 if click {
4534 for _, l := range u.links {
4535 if y == l.y && x >= l.x && x < l.x+l.w {
4536 l.act()
4537 return
4538 }
4539 }
4540 }
4541 content := rect{0, 5, w, h - 8}
4542 if content.contains(x, y) {
4543 u.mu.Lock()
4544 cur := u.current
4545 u.mu.Unlock()
4546 if p := u.panels[cur]; p != nil {
4547 p.mouse(u, content, ev)
4548 }
4549 }
4550}
4551
4552// ---- navigation ----
4553
4554func (u *ui) show(page string) {
4555 u.drop = nil
4556 u.closeCommerce()
4557 u.mu.Lock()
4558 if page == u.current {
4559 u.mu.Unlock()
4560 return
4561 }
4562 u.history = append(u.history, u.current)
4563 u.current = page
4564 u.mu.Unlock()
4565 u.fwd = nil
4566 u.ensurePanel(page)
4567}
4568
4569func (u *ui) back() {
4570 u.mu.Lock()
4571 if len(u.history) == 0 {
4572 u.mu.Unlock()
4573 return
4574 }
4575 page := u.history[len(u.history)-1]
4576 u.history = u.history[:len(u.history)-1]
4577 u.fwd = append(u.fwd, u.current)
4578 u.current = page
4579 u.mu.Unlock()
4580}
4581
4582func (u *ui) forward() {
4583 u.mu.Lock()
4584 if len(u.fwd) == 0 {
4585 u.mu.Unlock()
4586 return
4587 }
4588 page := u.fwd[len(u.fwd)-1]
4589 u.fwd = u.fwd[:len(u.fwd)-1]
4590 u.history = append(u.history, u.current)
4591 u.current = page
4592 u.mu.Unlock()
4593 u.ensurePanel(page)
4594}
4595
4596func (u *ui) ensurePanel(page string) {
4597 if _, ok := u.panels[page]; ok {
4598 return
4599 }
4600 switch {
4601 case strings.HasPrefix(page, "list:"):
4602 spec := strings.TrimPrefix(page, "list:")
4603 parts := strings.SplitN(spec, "|", 2)
4604 cat, sub := parts[0], ""
4605 if len(parts) == 2 {
4606 sub = parts[1]
4607 }
4608 u.panels[page] = newListPanel(u, page, cat, sub)
4609 case strings.HasPrefix(page, "prod:"):
4610 u.panels[page] = newProductPanel(u, strings.TrimPrefix(page, "prod:"))
4611 case strings.HasPrefix(page, "content:"):
4612 u.panels[page] = newContentPanel(u, strings.TrimPrefix(page, "content:"))
4613 }
4614}
4615
4616func listPageID(t navTarget) string {
4617 if t.cat == "" {
4618 return "list:"
4619 }
4620 if t.subcat == "" {
4621 return "list:" + t.cat
4622 }
4623 return "list:" + t.cat + "|" + t.subcat
4624}
4625
4626// pagePath renders the current panel as the URL path the website would
4627// give it β the footer shows it like an address bar.
4628func (u *ui) pagePath() string {
4629 u.mu.Lock()
4630 cur := u.current
4631 u.mu.Unlock()
4632 switch {
4633 case cur == "home":
4634 return "/"
4635 case strings.HasPrefix(cur, "content:"):
4636 return "/#" + strings.TrimPrefix(cur, "content:")
4637 case strings.HasPrefix(cur, "prod:"):
4638 return "/p/" + strings.TrimPrefix(cur, "prod:")
4639 case strings.HasPrefix(cur, "list:"):
4640 spec := strings.TrimPrefix(cur, "list:")
4641 if spec == "" {
4642 return "/cat"
4643 }
4644 return "/cat/" + strings.ReplaceAll(spec, "|", "/")
4645 }
4646 return "/"
4647}
4648
4649// ---- header dropdowns ----
4650
4651// dropRect puts an open header menu against the edge its entry is
4652// nearest: Categories at the left, Etc at the right.
4653//
4654// The page does this as a consequence of how its header is built β each
4655// menu is absolutely positioned inside its own cell of a full-width
4656// table, so the first lands at the far left and the last at the far
4657// right β and the result reads as deliberate: a pair of menus bracketing
4658// the header rather than two labels with boxes hanging under them. The
4659// terminal now does it on purpose.
4660func (u *ui) dropRect() rect {
4661 sw, sh := u.screen.Size()
4662 x := 0
4663 if u.drop.kind == "etc" {
4664 x = sw - u.drop.width
4665 }
4666 if x < 0 {
4667 x = 0
4668 }
4669 h := len(u.drop.lst.items) + 2
4670 if max := sh - 4; h > max {
4671 h = max
4672 }
4673 return rect{x, 2, u.drop.width, h}
4674}
4675
4676// openCategories drops down the catsubcats tree. Like the template's
4677// nested <details>, categories open and close: the current category
4678// starts open, Enter on a closed category opens it (Enter again
4679// navigates), β/space toggle, β closes.
4680func (u *ui) openCategories() {
4681 if u.drop != nil && u.drop.kind == "cats" {
4682 u.drop = nil
4683 return
4684 }
4685 if cat, _ := u.currentTarget(); cat != "" {
4686 u.expanded[cat] = true
4687 }
4688 // Width from the fully expanded tree, so the box doesn't resize as
4689 // branches open and close.
4690 width := 20
4691 for _, it := range u.cat.buildTree(nil) {
4692 if w := len([]rune(it.label)) + 4; w > width {
4693 width = w
4694 }
4695 }
4696 d := &dropState{kind: "cats", lst: &list{}, width: width}
4697 u.drop = d
4698 rebuild := func(selCat string) {
4699 items := u.cat.buildTree(u.expanded)
4700 d.lst.items = d.lst.items[:0]
4701 d.targets = d.targets[:0]
4702 sel := 0
4703 for i, it := range items {
4704 d.lst.items = append(d.lst.items, esc(it.label))
4705 d.targets = append(d.targets, it.target)
4706 if it.target.cat == selCat && it.target.subcat == "" {
4707 sel = i
4708 }
4709 }
4710 d.lst.sel = sel
4711 }
4712 rebuild("")
4713 itemAt := func(i int) (navTarget, bool) {
4714 if i < 0 || i >= len(d.targets) {
4715 return navTarget{}, false
4716 }
4717 return d.targets[i], true
4718 }
4719 hasSubs := func(t navTarget) bool {
4720 return t.cat != "" && t.subcat == "" && len(u.cat.subcatsByCat[t.cat]) > 0
4721 }
4722 d.lst.onPick = func(i int) {
4723 t, ok := itemAt(i)
4724 if !ok {
4725 return
4726 }
4727 if hasSubs(t) && !u.expanded[t.cat] {
4728 u.expanded[t.cat] = true
4729 rebuild(t.cat)
4730 return
4731 }
4732 u.show(listPageID(t))
4733 }
4734 d.lst.onKey = func(ev *tcell.EventKey, sel int) bool {
4735 t, ok := itemAt(sel)
4736 if !ok {
4737 return false
4738 }
4739 switch {
4740 case ev.Key() == tcell.KeyRight:
4741 if hasSubs(t) && !u.expanded[t.cat] {
4742 u.expanded[t.cat] = true
4743 rebuild(t.cat)
4744 }
4745 return true
4746 case ev.Key() == tcell.KeyLeft:
4747 if t.cat != "" && (u.expanded[t.cat] || t.subcat != "") {
4748 u.expanded[t.cat] = false
4749 rebuild(t.cat)
4750 }
4751 return true
4752 case keyRune(ev) == ' ':
4753 if t.cat == "" {
4754 return true
4755 }
4756 if t.subcat != "" {
4757 u.expanded[t.cat] = false
4758 } else if hasSubs(t) {
4759 u.expanded[t.cat] = !u.expanded[t.cat]
4760 }
4761 rebuild(t.cat)
4762 return true
4763 }
4764 return false
4765 }
4766}
4767
4768// currentTarget reports which category/subcategory panel is showing.
4769func (u *ui) currentTarget() (string, string) {
4770 u.mu.Lock()
4771 cur := u.current
4772 u.mu.Unlock()
4773 if !strings.HasPrefix(cur, "list:") {
4774 return "", ""
4775 }
4776 parts := strings.SplitN(strings.TrimPrefix(cur, "list:"), "|", 2)
4777 if len(parts) == 2 {
4778 return parts[0], parts[1]
4779 }
4780 return parts[0], ""
4781}
4782
4783// openEtc drops down the Etc... menu: About, Policy, Telegram, Contact, Links.
4784func (u *ui) openEtc() {
4785 if u.drop != nil && u.drop.kind == "etc" {
4786 u.drop = nil
4787 return
4788 }
4789 type entry struct {
4790 label string
4791 fn func()
4792 }
4793 var entries []entry
4794 add := func(label string, fn func()) { entries = append(entries, entry{label, fn}) }
4795 add("About", func() { u.show("content:about") })
4796 add("Policy", func() { u.show("content:policy") })
4797 // The Telegram entries are links out, and now follow like any other:
4798 // the browser build opens the chat, and in a terminal openURL shows
4799 // the address, which is what this did in both places before.
4800 if f.Tgchannel != "" {
4801 href := "https://t.me/" + f.Tgchannel
4802 add("Telegram", func() { u.drop = nil; u.openURL(href) })
4803 }
4804 if f.Tgcontact != "" {
4805 href := "https://t.me/" + f.Tgcontact
4806 add("Contact", func() { u.drop = nil; u.openURL(href) })
4807 }
4808 add("Links", func() { u.show("content:links") })
4809 // Whatever the deployment's drop-ins added to the page's menu, in the
4810 // same place and the same order the header puts them: after Links.
4811 // follow decides what each one means here β a page the TUI has of its
4812 // own, or a link out.
4813 for _, nl := range navLinks {
4814 act := u.follow(nl.Href)
4815 if act == nil {
4816 continue
4817 }
4818 add(nl.Title, func() { u.drop = nil; act() })
4819 }
4820 lst := &list{}
4821 for _, e := range entries {
4822 lst.items = append(lst.items, esc(e.label))
4823 }
4824 lst.onPick = func(i int) { entries[i].fn() }
4825 u.drop = &dropState{kind: "etc", lst: lst, width: 30}
4826}
4827
4828// ---- the render worker ----
4829
4830func (u *ui) requestArt(h *artHolder, path string, w, ht int) {
4831 key := fmt.Sprintf("%s|%d|%d|%d", path, w, ht, u.mode)
4832 if h.key == key || h.want == key {
4833 return
4834 }
4835 h.want = key
4836 req := artReq{key: key, path: path, w: w, h: ht, mode: u.mode, holder: h}
4837 select {
4838 case u.reqCh <- req:
4839 default:
4840 select { // replace the stale pending request
4841 case <-u.reqCh:
4842 default:
4843 }
4844 u.reqCh <- req
4845 }
4846}
4847
4848func (u *ui) renderWorker() {
4849 cache := map[string]artwork{}
4850 for req := range u.reqCh {
4851 art, ok := cache[req.key]
4852 if !ok {
4853 art = renderArt(req.path, req.w, req.h, req.mode)
4854 if len(cache) > 128 {
4855 cache = map[string]artwork{}
4856 }
4857 cache[req.key] = art
4858 }
4859 r := req
4860 a := art
4861 u.post(func() {
4862 r.holder.key = r.key
4863 r.holder.art = &a
4864 })
4865 }
4866}
4867
4868// ---- the home panel ----
4869
4870type homePanel struct{}
4871
4872func (hp *homePanel) draw(u *ui, sc tcell.Screen, r rect) {
4873 u.mu.Lock()
4874 u.homeW, u.homeH = r.w, r.h
4875 frame := u.frame
4876 u.mu.Unlock()
4877 if frame == nil {
4878 return
4879 }
4880 rows := (frame.Bounds().Dy() + 1) / 2
4881 drawRGBA(sc, r.x, r.y, r.w, r.h, frame)
4882 if rows < r.h {
4883 ty := r.y + rows
4884 for i, l := range u.asciiLines {
4885 if ty+i >= r.y+r.h {
4886 break
4887 }
4888 printMarkupCenter(sc, r.x, ty+i, r.w, esc(l), styleText)
4889 }
4890 if ty+len(u.asciiLines)+1 < r.y+r.h {
4891 printMarkupCenter(sc, r.x, ty+len(u.asciiLines)+1, r.w, esc(u.caps), styleText)
4892 }
4893 }
4894}
4895
4896func (hp *homePanel) key(*ui, *tcell.EventKey) bool { return false }
4897func (hp *homePanel) mouse(*ui, rect, *tcell.EventMouse) bool { return false }
4898
4899// animateHome turns the globe over the logo while the home panel is
4900// shown, like the website's landing animation over the svg.
4901func (u *ui) animateHome() {
4902 ticker := time.NewTicker(140 * time.Millisecond)
4903 defer ticker.Stop()
4904 for {
4905 select {
4906 case <-u.done:
4907 return
4908 case <-ticker.C:
4909 }
4910 u.mu.Lock()
4911 cur, w, h := u.current, u.homeW, u.homeH
4912 u.mu.Unlock()
4913 if cur != "home" || w <= 0 || h <= 0 {
4914 continue
4915 }
4916 globeRows := h - len(u.asciiLines) - 2
4917 if globeRows < 4 {
4918 globeRows = h
4919 }
4920 if u.backdrop == nil || u.bdW != w || u.bdH != globeRows {
4921 u.backdrop = makeBackdrop(u.logo, w, globeRows)
4922 u.bdW, u.bdH = w, globeRows
4923 }
4924 u.poseX += u.rateX
4925 u.poseY += u.rateY
4926 u.poseZ += u.rateZ
4927 frame := globeFrame(u.backdrop, u.poseX, u.poseY, u.poseZ)
4928 u.mu.Lock()
4929 u.frame = frame
4930 u.mu.Unlock()
4931 u.post(nil) // just redraw
4932 }
4933}
4934
4935// ---- category / subcategory listings ----
4936
4937// listPanel renders a category page: category.html's h1, the sidebar's
4938// list of subcategories, and the product table, with the selected row's
4939// image beside it (the terminal's stand-in for the table's image column).
4940type listPanel struct {
4941 page, cat, sub string
4942 heading []string
4943 links []func() // what this page's anchors do; see linkTag
4944 cols []storepage.Col // the shared column set, image included
4945 shown []storepage.Col // the ones this view draws, in table order
4946 prods []*product.Product // panel's full set
4947 rows []*product.Product // filtered
4948 tbl *table
4949 art artHolder
4950 filterOn bool
4951 filter finput
4952}
4953
4954// link registers an action for this page and returns the markup that
4955// wraps s in it.
4956func (lp *listPanel) link(s string, act func()) string {
4957 lp.links = append(lp.links, act)
4958 return linkTag(len(lp.links), s)
4959}
4960
4961func newListPanel(u *ui, page, cat, sub string) *listPanel {
4962 lp := &listPanel{page: page, cat: cat, sub: sub}
4963 all := u.cat.products(cat, sub)
4964 for i := range all {
4965 lp.prods = append(lp.prods, &all[i])
4966 }
4967 // category.html's h1, carrying the same links: the category name goes
4968 // to the category β which is how a subcategory page climbs back β and
4969 // each name links to its own page as the template's do.
4970 catLink := func(t navTarget, label string) string {
4971 return lp.link("[aqua]"+esc(label)+"[-]", func() { u.show(listPageID(t)) })
4972 }
4973 title := "All Products"
4974 switch {
4975 case sub != "":
4976 title = "Category: " + catLink(navTarget{cat: cat}, cat) +
4977 " | Subcategory: " + catLink(navTarget{cat: cat, subcat: sub}, sub)
4978 case cat != "":
4979 title = "Category: " + catLink(navTarget{cat: cat}, cat)
4980 }
4981 lp.heading = []string{"[::b]" + title + "[-:-:-]"}
4982 // The page's aside lists the category's subcategories under a
4983 // "Subcategories:" heading; the Categories dropdown is the terminal's
4984 // aside, so the list goes on the page where it can be reached.
4985 if cat != "" && sub == "" {
4986 if subs := u.cat.subcatsByCat[cat]; len(subs) > 0 {
4987 var parts []string
4988 for _, s := range subs {
4989 target := navTarget{cat: cat, subcat: s}
4990 parts = append(parts, fmt.Sprintf("%s (%d)",
4991 lp.link("[aqua]"+esc(s)+"[-]", func() { u.show(listPageID(target)) }),
4992 u.cat.subcatCounts[cat][s]))
4993 }
4994 lp.heading = append(lp.heading, "Subcategories: "+strings.Join(parts, ", "))
4995 }
4996 }
4997 // The columns are storepage's, so the page and the terminal list the
4998 // same things in the same order. Image is the one column a terminal
4999 // answers differently β it has one picture to spend, beside the
5000 // table, not a thumbnail per row β so it is dropped here and the art
5001 // panel stands in for it. Subcategory only where the rows can differ
5002 // in it, which is what storepage is being asked.
5003 lp.cols = storepage.Columns(sub == "")
5004 var cols []tcol
5005 for _, c := range lp.cols {
5006 if c.Kind == storepage.ColImage {
5007 continue
5008 }
5009 cols = append(cols, tcol{
5010 title: c.Title,
5011 width: c.Width,
5012 alignRight: c.Align == storepage.AlignRight,
5013 link: c.Kind == storepage.ColName || c.Kind == storepage.ColSubcategory || c.Kind == storepage.ColBuy,
5014 })
5015 lp.shown = append(lp.shown, c)
5016 }
5017 lp.tbl = &table{cols: cols}
5018 lp.tbl.onActivate = func(i int) {
5019 if i >= 0 && i < len(lp.rows) {
5020 u.show("prod:" + lp.rows[i].Partno)
5021 }
5022 }
5023 lp.fill("")
5024 return lp
5025}
5026
5027func (lp *listPanel) fill(query string) {
5028 lp.rows = lp.rows[:0]
5029 var rows []trow
5030 query = strings.ToLower(query)
5031 for _, p := range lp.prods {
5032 if query != "" && !strings.Contains(strings.ToLower(
5033 p.Partno+" "+p.Name+" "+p.Subcategory+" "+p.Description1), query) {
5034 continue
5035 }
5036 row := trow{}
5037 for _, c := range lp.shown {
5038 cell := storepage.CellFor(c, p)
5039 row.cells = append(row.cells, tcell_{cell.Text, cellStyle(c, p)})
5040 }
5041 rows = append(rows, row)
5042 lp.rows = append(lp.rows, p)
5043 }
5044 lp.tbl.setRows(rows)
5045}
5046
5047// cellStyle dresses a shared cell in the site's colors: links cyan, the
5048// buy button a button, and an out-of-stock name grey β which is the page
5049// greying a row it will not sell.
5050func cellStyle(c storepage.Col, p *product.Product) tcell.Style {
5051 switch c.Kind {
5052 case storepage.ColName:
5053 if !storepage.InStock(p) {
5054 return styleText.Foreground(tcell.GetColor("#808080"))
5055 }
5056 return styleLink
5057 case storepage.ColSubcategory:
5058 return styleLink
5059 case storepage.ColBuy:
5060 return styleButton
5061 }
5062 return styleText
5063}
5064
5065func (lp *listPanel) selected() *product.Product {
5066 if lp.tbl.sel >= 0 && lp.tbl.sel < len(lp.rows) {
5067 return lp.rows[lp.tbl.sel]
5068 }
5069 return nil
5070}
5071
5072// layout places the panel's parts. tblR is where the table's cells go,
5073// which is inside the frame when there is room for one β brd and brdR
5074// carry that frame and where it is drawn. Both draw and mouse take the
5075// table's rectangle from here, or a click would land a row or two off
5076// whatever the frame pushed the table to.
5077func (lp *listPanel) layout(u *ui, r rect) (head, tblR, artR, filtR, brdR rect, brd *borderArt) {
5078 hh := len(lp.heading)
5079 head = rect{r.x + 1, r.y, r.w - 2, hh}
5080 body := rect{r.x, r.y + hh + 1, r.w, r.h - hh - 1}
5081 fh := 0
5082 if lp.filterOn {
5083 fh = 1
5084 }
5085 tw := body.w * 3 / 5
5086 tblR = rect{body.x + 1, body.y, tw - 2, body.h - fh}
5087 artR = rect{body.x + tw, body.y, body.w - tw, body.h - fh}
5088 filtR = rect{body.x + 1, body.y + body.h - 1, body.w - 2, 1}
5089 if !u.bare {
5090 tblR, brd, brdR = framed(tblR)
5091 }
5092 return
5093}
5094
5095func (lp *listPanel) draw(u *ui, sc tcell.Screen, r rect) {
5096 head, tblR, artR, filtR, brdR, brd := lp.layout(u, r)
5097 if brd != nil {
5098 brd.draw(sc, brdR)
5099 }
5100 sink := u.linkSink(lp.links)
5101 for i, line := range lp.heading {
5102 printMarkupLinks(sc, head.x, head.y+i, head.w, line, styleText, sink)
5103 }
5104 // A row has the two the page's row has: the name opens the product,
5105 // as the product image does there, and Buy adds it to the cart.
5106 // Registering them as anchors is what makes one click do it and the
5107 // pointer light them up. Both select the row first, so the photo
5108 // beside the table shows what was just acted on.
5109 lp.tbl.onLink = func(x, y, w, row, col int) {
5110 if col >= len(lp.shown) {
5111 return
5112 }
5113 kind := lp.shown[col].Kind
5114 u.links = append(u.links, linkRegion{x, y, w, func() {
5115 lp.tbl.sel = row
5116 p := lp.selected()
5117 if p == nil {
5118 return
5119 }
5120 // What the cell says it is, rather than what column it
5121 // landed in: the shared cell already carries where it goes
5122 // and whether it is the row's buy.
5123 cell := storepage.CellFor(storepage.Col{Kind: kind}, p)
5124 switch {
5125 case cell.Buy:
5126 u.addToCart(p)
5127 case cell.Href != "":
5128 if act := u.follow(cell.Href); act != nil {
5129 act()
5130 }
5131 }
5132 }})
5133 }
5134 lp.tbl.draw(sc, tblR)
5135 if p := lp.selected(); p != nil {
5136 u.requestArt(&lp.art, imagePath(p), artR.w, artR.h)
5137 }
5138 drawArtwork(sc, artR, lp.art.art)
5139 if lp.filterOn {
5140 printMarkup(sc, filtR.x, filtR.y, 3, "[aqua]/ [-]", styleText)
5141 curX, _ := lp.filter.drawValue(sc, filtR.x+2, filtR.y, filtR.w-2, true)
5142 sc.ShowCursor(curX, filtR.y)
5143 }
5144}
5145
5146func (lp *listPanel) key(u *ui, ev *tcell.EventKey) bool {
5147 if lp.filterOn {
5148 switch ev.Key() {
5149 case tcell.KeyEscape:
5150 lp.filterOn = false
5151 lp.filter.text = nil
5152 lp.filter.cur = 0
5153 lp.fill("")
5154 return true
5155 case tcell.KeyEnter:
5156 lp.filterOn = false
5157 return true
5158 }
5159 if lp.filter.key(ev) {
5160 lp.fill(string(lp.filter.text))
5161 return true
5162 }
5163 return ev.Key() == tcell.KeyRune
5164 }
5165 if ev.Key() == tcell.KeyRune {
5166 switch keyRune(ev) {
5167 case '/':
5168 lp.filterOn = true
5169 lp.filter = finput{width: 40}
5170 return true
5171 case 'b':
5172 if p := lp.selected(); p != nil {
5173 u.addToCart(p)
5174 }
5175 return true
5176 }
5177 }
5178 return lp.tbl.key(ev)
5179}
5180
5181func (lp *listPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
5182 _, tblR, _, _, _, _ := lp.layout(u, r)
5183 x, y := ev.Position()
5184 if ev.Buttons()&(tcell.WheelUp|tcell.WheelDown) != 0 {
5185 // the wheel drives the table wherever it is rolled, the picture
5186 // side included
5187 return lp.tbl.mouse(1, ev)
5188 }
5189 if tblR.contains(x, y) {
5190 return lp.tbl.mouse(y-tblR.y, ev)
5191 }
5192 return false
5193}
5194
5195// ---- the product panel ----
5196
5197type productPanel struct {
5198 prod *product.Product
5199 ta *textArea
5200 art artHolder
5201 links []func() // what this page's anchors do; see linkTag
5202}
5203
5204func newProductPanel(u *ui, partno string) panel {
5205 p := u.cat.find(partno)
5206 if p == nil {
5207 return newContentPanel(u, "") // empty
5208 }
5209 pp := &productPanel{prod: p}
5210 pp.ta = newTextArea(productLines(u, p, func(text string, act func()) string {
5211 pp.links = append(pp.links, act)
5212 return linkTag(len(pp.links), text)
5213 }), true)
5214 return pp
5215}
5216
5217func (pp *productPanel) draw(u *ui, sc tcell.Screen, r rect) {
5218 ih := r.h * 3 / 5
5219 imgR := rect{r.x, r.y, r.w, ih}
5220 txtR := rect{r.x + 1, r.y + ih + 1, r.w - 2, r.h - ih - 1}
5221 u.requestArt(&pp.art, imagePath(pp.prod), imgR.w, imgR.h)
5222 drawArtwork(sc, imgR, pp.art.art)
5223 pp.ta.onLink = u.linkSink(pp.links)
5224 pp.ta.draw(sc, txtR)
5225}
5226
5227func (pp *productPanel) key(u *ui, ev *tcell.EventKey) bool {
5228 if ev.Key() == tcell.KeyEnter || keyRune(ev) == 'b' {
5229 u.addToCart(pp.prod)
5230 return true
5231 }
5232 return pp.ta.key(ev)
5233}
5234
5235func (pp *productPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
5236 switch {
5237 case ev.Buttons()&tcell.WheelUp != 0:
5238 pp.ta.wheel(true)
5239 case ev.Buttons()&tcell.WheelDown != 0:
5240 pp.ta.wheel(false)
5241 default:
5242 return false
5243 }
5244 return true
5245}
5246
5247func (u *ui) addToCart(p *product.Product) {
5248 if p.Quantity == "0" {
5249 u.notice = esc(p.Name) + " is out of stock"
5250 return
5251 }
5252 if _, ok := u.cartQty[p.Partno]; !ok {
5253 u.cartOrder = append(u.cartOrder, p.Partno)
5254 }
5255 u.cartQty[p.Partno]++
5256}
5257
5258// ---- content pages: about, policy, links ----
5259
5260type contentPanel struct {
5261 ta *textArea
5262 links []func() // what this page's anchors do; see linkTag
5263 // The page is re-rendered when the pane changes width, because the
5264 // tables in it are laid out to a column count. raw is kept so that
5265 // costs a re-render and not a re-fetch.
5266 u *ui
5267 raw string
5268 year string
5269 lastW int
5270}
5271
5272// contentFile mirrors pkg/web: the deployment-local file when present,
5273// falling back to the committed .example.
5274func contentFile(path string) string {
5275 if data, err := os.ReadFile(path); err == nil { //nolint
5276 return string(data)
5277 }
5278 data, err := os.ReadFile(path + ".example") //nolint
5279 if err != nil {
5280 return ""
5281 }
5282 return string(data)
5283}
5284
5285func newContentPanel(u *ui, name string) *contentPanel {
5286 cp := &contentPanel{
5287 u: u,
5288 raw: fetchContent(name),
5289 year: fmt.Sprintf("%d", time.Now().Year()),
5290 ta: newTextArea("[gray]no content/"+esc(name)+".html[-]", true),
5291 }
5292 return cp
5293}
5294
5295// render lays the page out for a pane w columns wide, keeping the reader
5296// where they were: setContent rewinds to the top, which is the right thing
5297// on a new page and the wrong thing on a resize.
5298func (cp *contentPanel) render(w int) {
5299 cp.lastW = w
5300 if cp.raw == "" {
5301 return
5302 }
5303 text, hrefs := htmlToText(cp.raw, cp.year, w)
5304 at := cp.ta.scroll
5305 cp.ta.setContent(text)
5306 cp.ta.scroll = at
5307 cp.links = cp.links[:0]
5308 for _, href := range hrefs {
5309 cp.links = append(cp.links, cp.u.follow(href))
5310 }
5311}
5312
5313// follow is what clicking an href does: a path this store serves is a
5314// page the TUI has of its own, and anything else is a link out.
5315func (u *ui) follow(href string) func() {
5316 switch {
5317 case href == "", href == "#":
5318 return nil
5319 case href == "/", href == "/#", href == "#home", href == "/#home":
5320 return func() { u.show("home") }
5321 // The site's own menu spells these "/#about" so that they work from a
5322 // page other than the front one, where a bare "#about" would only
5323 // append a fragment to the path already there. Both forms are the
5324 // same page here.
5325 case href == "#about", href == "#policy", href == "#links",
5326 href == "/#about", href == "/#policy", href == "/#links":
5327 page := "content:" + strings.TrimPrefix(strings.TrimPrefix(href, "/"), "#")
5328 return func() { u.show(page) }
5329 case strings.HasPrefix(href, "/p/"):
5330 if p := u.cat.find(strings.TrimPrefix(href, "/p/")); p != nil {
5331 partno := p.Partno
5332 return func() { u.show("prod:" + partno) }
5333 }
5334 abs := storeURL() + href
5335 return func() { u.openURL(abs) }
5336 case strings.HasPrefix(href, "/cat/"):
5337 // The site spells a subcategory in a URL through escapesubcat
5338 // ("ΒΌ watt 5%" is quarter-watt-5-pct), so a path only names a
5339 // page here when the catalog has those names as written. When it
5340 // does not, the store's own URL resolves it and the link goes
5341 // out rather than landing on an empty table.
5342 if t, ok := u.cat.resolve(strings.TrimPrefix(href, "/cat/")); ok {
5343 return func() { u.show(listPageID(t)) }
5344 }
5345 abs := storeURL() + href
5346 return func() { u.openURL(abs) }
5347 case strings.HasPrefix(href, "/"):
5348 // Some other page of this site β /logo, /sitemap.xml. The TUI has
5349 // no view of it, so hand over the address on the store's origin.
5350 abs := storeURL() + href
5351 return func() { u.openURL(abs) }
5352 }
5353 return func() { u.openURL(href) }
5354}
5355
5356func (cp *contentPanel) draw(u *ui, sc tcell.Screen, r rect) {
5357 inner := rect{r.x + 1, r.y, r.w - 2, r.h}
5358 if inner.w > 0 && inner.w != cp.lastW {
5359 cp.render(inner.w)
5360 }
5361 cp.ta.onLink = u.linkSink(cp.links)
5362 cp.ta.draw(sc, inner)
5363}
5364
5365func (cp *contentPanel) key(u *ui, ev *tcell.EventKey) bool {
5366 return cp.ta.key(ev)
5367}
5368
5369func (cp *contentPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
5370 switch {
5371 case ev.Buttons()&tcell.WheelUp != 0:
5372 cp.ta.wheel(true)
5373 case ev.Buttons()&tcell.WheelDown != 0:
5374 cp.ta.wheel(false)
5375 default:
5376 return false
5377 }
5378 return true
5379}
5380
5381
5382// ===== pkg/tui/widgets.go =====
5383// Package tui pkg/tui/widgets.go β the widget kit: a table, a list, a
5384// scrollable text area, and a form, written directly on tcell. Small on
5385// purpose: these are the site's shapes (the category table, the
5386// catsubcats dropdown, product pages, Add Shipping Info), not a general
5387// toolkit.
5388package tui
5389
5390import (
5391 "strings"
5392
5393 "github.com/gdamore/tcell/v3"
5394)
5395
5396// ---- textArea: scrollable, optionally word-wrapped markup text ----
5397
5398type textArea struct {
5399 content string
5400 base tcell.Style
5401 wrap bool
5402 scroll int
5403 lines [][]seg
5404 lastW int
5405 // onLink, when set, is told where each anchor in the text landed on
5406 // the screen, so the page can make it clickable. Set before every
5407 // draw: what is on screen changes with the scroll.
5408 onLink func(x, y, w, link int)
5409}
5410
5411func newTextArea(content string, wrap bool) *textArea {
5412 return &textArea{content: content, base: styleText, wrap: wrap}
5413}
5414
5415func (t *textArea) setContent(s string) {
5416 t.content = s
5417 t.lastW = 0
5418 t.scroll = 0
5419}
5420
5421func (t *textArea) draw(sc tcell.Screen, r rect) {
5422 if r.w != t.lastW || t.lines == nil {
5423 t.lines = wrapSegs(t.content, r.w, t.base, t.wrap)
5424 t.lastW = r.w
5425 }
5426 if max := len(t.lines) - r.h; t.scroll > max {
5427 t.scroll = max
5428 }
5429 if t.scroll < 0 {
5430 t.scroll = 0
5431 }
5432 for i := 0; i < r.h; i++ {
5433 li := t.scroll + i
5434 if li >= len(t.lines) {
5435 break
5436 }
5437 printSegsLinks(sc, r.x, r.y+i, r.w, t.lines[li], t.onLink)
5438 }
5439}
5440
5441func (t *textArea) key(ev *tcell.EventKey) bool {
5442 switch ev.Key() {
5443 case tcell.KeyUp:
5444 t.scroll--
5445 case tcell.KeyDown:
5446 t.scroll++
5447 case tcell.KeyPgUp:
5448 t.scroll -= 10
5449 case tcell.KeyPgDn:
5450 t.scroll += 10
5451 case tcell.KeyHome:
5452 t.scroll = 0
5453 case tcell.KeyEnd:
5454 t.scroll = len(t.lines)
5455 default:
5456 return false
5457 }
5458 return true
5459}
5460
5461func (t *textArea) wheel(up bool) {
5462 if up {
5463 t.scroll -= 3
5464 } else {
5465 t.scroll += 3
5466 }
5467}
5468
5469// ---- table: the site's product table ----
5470
5471type tcol struct {
5472 title string
5473 width int // 0 = expands
5474 alignRight bool
5475 // link marks the column the row's <a> lives in. Clicking a cell there
5476 // opens the row, as clicking the link on the page does; clicking any
5477 // other column selects the row, which is what shows its picture.
5478 link bool
5479}
5480
5481type tcell_ struct {
5482 text string
5483 style tcell.Style
5484}
5485
5486type trow struct {
5487 cells []tcell_
5488}
5489
5490type table struct {
5491 cols []tcol
5492 rows []trow
5493 sel int
5494 offset int
5495 onSelect func(int)
5496 onActivate func(int)
5497 // onLink, when set, is told where each drawn link cell landed and
5498 // which row and column it belongs to, so the page can register it as
5499 // an anchor β which is what makes one click act on it and the pointer
5500 // light it up. A row has more than one: its name opens the product
5501 // and its Buy cell adds it to the cart, as the page's row does. Set
5502 // before every draw, since the rows move as the table scrolls.
5503 onLink func(x, y, w, row, col int)
5504}
5505
5506func (t *table) setRows(rows []trow) {
5507 t.rows = rows
5508 t.offset = 0
5509 if len(rows) == 0 {
5510 t.sel = -1
5511 return
5512 }
5513 t.sel = 0
5514 if t.onSelect != nil {
5515 t.onSelect(0)
5516 }
5517}
5518
5519// colWidths distributes r.w across the columns; width-0 columns share
5520// the remainder.
5521func (t *table) colWidths(w int) []int {
5522 ws := make([]int, len(t.cols))
5523 fixed, flex := 0, 0
5524 for i, c := range t.cols {
5525 ws[i] = c.width
5526 if c.width == 0 {
5527 flex++
5528 } else {
5529 fixed += c.width + 1
5530 }
5531 }
5532 if flex > 0 {
5533 share := (w - fixed - flex) / flex
5534 if share < 4 {
5535 share = 4
5536 }
5537 for i, c := range t.cols {
5538 if c.width == 0 {
5539 ws[i] = share
5540 }
5541 }
5542 }
5543 return ws
5544}
5545
5546func (t *table) draw(sc tcell.Screen, r rect) {
5547 if r.h < 2 {
5548 return
5549 }
5550 ws := t.colWidths(r.w)
5551 // The page's thead is a filled purple band, so the band is the header
5552 // rather than only the words in it.
5553 fillRect(sc, rect{r.x, r.y, r.w, 1}, styleThead)
5554 x := r.x
5555 for i, c := range t.cols {
5556 st := styleThead
5557 txt := c.title
5558 if len(txt) > ws[i] {
5559 txt = txt[:ws[i]]
5560 }
5561 pad := ws[i] - len(txt)
5562 if c.alignRight {
5563 printMarkup(sc, x+pad, r.y, ws[i], esc(txt), st)
5564 } else {
5565 printMarkup(sc, x, r.y, ws[i], esc(txt), st)
5566 }
5567 x += ws[i] + 1
5568 }
5569 visible := r.h - 1
5570 if t.sel >= 0 {
5571 if t.sel < t.offset {
5572 t.offset = t.sel
5573 }
5574 if t.sel >= t.offset+visible {
5575 t.offset = t.sel - visible + 1
5576 }
5577 }
5578 for row := 0; row < visible; row++ {
5579 ri := t.offset + row
5580 if ri >= len(t.rows) {
5581 break
5582 }
5583 y := r.y + 1 + row
5584 selected := ri == t.sel
5585 if selected {
5586 fillRect(sc, rect{r.x, y, r.w, 1}, styleSel)
5587 }
5588 x = r.x
5589 for i, c := range t.rows[ri].cells {
5590 if i >= len(ws) {
5591 break
5592 }
5593 st := c.style
5594 if selected {
5595 st = styleSel
5596 }
5597 txt := c.text
5598 if len(txt) > ws[i] {
5599 txt = txt[:ws[i]]
5600 }
5601 pad := 0
5602 if t.cols[i].alignRight {
5603 pad = ws[i] - len(txt)
5604 }
5605 n := printMarkup(sc, x+pad, y, ws[i]-pad, esc(txt), st)
5606 // The anchor covers the text, not the empty column beside
5607 // it: a link that lights up out to the price would say the
5608 // blank cells were clickable too. An empty cell offers
5609 // nothing, which is how an out-of-stock row has no Buy.
5610 if t.cols[i].link && t.onLink != nil && n > 0 {
5611 t.onLink(x+pad, y, n, ri, i)
5612 }
5613 x += ws[i] + 1
5614 }
5615 }
5616}
5617
5618func (t *table) move(d int) {
5619 if len(t.rows) == 0 {
5620 return
5621 }
5622 t.sel += d
5623 if t.sel < 0 {
5624 t.sel = 0
5625 }
5626 if t.sel >= len(t.rows) {
5627 t.sel = len(t.rows) - 1
5628 }
5629 if t.onSelect != nil {
5630 t.onSelect(t.sel)
5631 }
5632}
5633
5634func (t *table) key(ev *tcell.EventKey) bool {
5635 switch ev.Key() {
5636 case tcell.KeyUp:
5637 t.move(-1)
5638 case tcell.KeyDown:
5639 t.move(1)
5640 case tcell.KeyPgUp:
5641 t.move(-10)
5642 case tcell.KeyPgDn:
5643 t.move(10)
5644 case tcell.KeyHome:
5645 t.move(-len(t.rows))
5646 case tcell.KeyEnd:
5647 t.move(len(t.rows))
5648 case tcell.KeyEnter:
5649 if t.sel >= 0 && t.onActivate != nil {
5650 t.onActivate(t.sel)
5651 }
5652 default:
5653 return false
5654 }
5655 return true
5656}
5657
5658// mouse handles a click or wheel at a position local to the table rect.
5659//
5660// A click on the row's link cell never gets here β that cell is an anchor
5661// and the page follows it first. What is left is the rest of the row:
5662// clicking it selects, which is what puts the row's photo beside the
5663// table, and clicking the selected row again opens it.
5664func (t *table) mouse(localY int, ev *tcell.EventMouse) bool {
5665 btn := ev.Buttons()
5666 switch {
5667 // One product per notch. Three is the usual step for scrolling TEXT
5668 // and wrong here, because the wheel moves the SELECTION: the row
5669 // picked is the one whose photo is shown and whose name a second
5670 // click opens, so three at a time skips two products rather than
5671 // covering ground faster. The dropdown has always moved by one.
5672 case btn&tcell.WheelUp != 0:
5673 t.move(-1)
5674 case btn&tcell.WheelDown != 0:
5675 t.move(1)
5676 case btn&tcell.Button1 != 0:
5677 ri := t.offset + localY - 1 // row 0 is the header
5678 if localY < 1 || ri < 0 || ri >= len(t.rows) {
5679 return false
5680 }
5681 if ri == t.sel {
5682 if t.onActivate != nil {
5683 t.onActivate(ri)
5684 }
5685 break
5686 }
5687 t.sel = ri
5688 if t.onSelect != nil {
5689 t.onSelect(ri)
5690 }
5691 default:
5692 return false
5693 }
5694 return true
5695}
5696
5697// ---- list: the dropdown menus ----
5698
5699type list struct {
5700 items []string // markup labels
5701 sel int
5702 offset int
5703 onPick func(int)
5704 // onKey sees keys first β the categories tree uses it for its
5705 // expand/collapse keys.
5706 onKey func(ev *tcell.EventKey, sel int) bool
5707}
5708
5709func (l *list) draw(sc tcell.Screen, r rect) {
5710 if l.sel >= 0 {
5711 if l.sel < l.offset {
5712 l.offset = l.sel
5713 }
5714 if l.sel >= l.offset+r.h {
5715 l.offset = l.sel - r.h + 1
5716 }
5717 }
5718 for i := 0; i < r.h; i++ {
5719 li := l.offset + i
5720 if li >= len(l.items) {
5721 break
5722 }
5723 st := styleLink
5724 if li == l.sel {
5725 st = styleSel
5726 fillRect(sc, rect{r.x, r.y + i, r.w, 1}, styleSel)
5727 }
5728 printMarkup(sc, r.x, r.y+i, r.w, l.items[li], st)
5729 }
5730}
5731
5732func (l *list) key(ev *tcell.EventKey) bool {
5733 if l.onKey != nil && l.onKey(ev, l.sel) {
5734 return true
5735 }
5736 switch ev.Key() {
5737 case tcell.KeyUp:
5738 if l.sel > 0 {
5739 l.sel--
5740 }
5741 case tcell.KeyDown:
5742 if l.sel < len(l.items)-1 {
5743 l.sel++
5744 }
5745 case tcell.KeyHome:
5746 l.sel = 0
5747 case tcell.KeyEnd:
5748 l.sel = len(l.items) - 1
5749 case tcell.KeyEnter:
5750 if l.onPick != nil && l.sel >= 0 {
5751 l.onPick(l.sel)
5752 }
5753 default:
5754 return false
5755 }
5756 return true
5757}
5758
5759func (l *list) mouse(localY int, ev *tcell.EventMouse) bool {
5760 btn := ev.Buttons()
5761 switch {
5762 case btn&tcell.WheelUp != 0:
5763 if l.sel > 0 {
5764 l.sel--
5765 }
5766 case btn&tcell.WheelDown != 0:
5767 if l.sel < len(l.items)-1 {
5768 l.sel++
5769 }
5770 case btn&tcell.Button1 != 0:
5771 li := l.offset + localY
5772 if li < 0 || li >= len(l.items) {
5773 return false
5774 }
5775 l.sel = li
5776 if l.onPick != nil {
5777 l.onPick(li)
5778 }
5779 default:
5780 return false
5781 }
5782 return true
5783}
5784
5785// ---- form: Add Shipping Info ----
5786
5787type formItem interface {
5788 label() string
5789 // drawValue paints the value cell; the form places the cursor.
5790 drawValue(sc tcell.Screen, x, y, w int, focused bool) (curX int, showCur bool)
5791 key(ev *tcell.EventKey) bool
5792 value() string
5793}
5794
5795type finput struct {
5796 lbl string
5797 text []rune
5798 cur int
5799 width int
5800}
5801
5802func (f *finput) label() string { return f.lbl }
5803func (f *finput) value() string { return strings.TrimSpace(string(f.text)) }
5804
5805func (f *finput) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
5806 if f.width < w {
5807 w = f.width
5808 }
5809 st := styleField
5810 fillRect(sc, rect{x, y, w, 1}, st)
5811 start := 0
5812 if f.cur >= w {
5813 start = f.cur - w + 1
5814 }
5815 for i := 0; i < w && start+i < len(f.text); i++ {
5816 sc.SetContent(x+i, y, f.text[start+i], nil, st)
5817 }
5818 return x + f.cur - start, focused
5819}
5820
5821func (f *finput) key(ev *tcell.EventKey) bool {
5822 switch ev.Key() {
5823 case tcell.KeyRune:
5824 ins := []rune(ev.Str())
5825 f.text = append(f.text[:f.cur], append(ins, f.text[f.cur:]...)...)
5826 f.cur += len(ins)
5827 case tcell.KeyBackspace, tcell.KeyBackspace2:
5828 if f.cur > 0 {
5829 f.text = append(f.text[:f.cur-1], f.text[f.cur:]...)
5830 f.cur--
5831 }
5832 case tcell.KeyDelete:
5833 if f.cur < len(f.text) {
5834 f.text = append(f.text[:f.cur], f.text[f.cur+1:]...)
5835 }
5836 case tcell.KeyLeft:
5837 if f.cur > 0 {
5838 f.cur--
5839 }
5840 case tcell.KeyRight:
5841 if f.cur < len(f.text) {
5842 f.cur++
5843 }
5844 case tcell.KeyHome:
5845 f.cur = 0
5846 case tcell.KeyEnd:
5847 f.cur = len(f.text)
5848 default:
5849 return false
5850 }
5851 return true
5852}
5853
5854// fdropdown selects among options with β/β, or by typing a prefix (so
5855// "t","x" lands on TX).
5856type fdropdown struct {
5857 lbl string
5858 opts []string
5859 sel int
5860}
5861
5862func (f *fdropdown) label() string { return f.lbl }
5863func (f *fdropdown) value() string { return f.opts[f.sel] }
5864
5865func (f *fdropdown) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
5866 st := styleField
5867 label := f.opts[f.sel]
5868 if label == "" {
5869 label = "β"
5870 }
5871 txt := "β " + label + " βΈ"
5872 if len(txt) > w {
5873 txt = txt[:w]
5874 }
5875 fillRect(sc, rect{x, y, w, 1}, st)
5876 printMarkup(sc, x, y, w, esc(txt), st)
5877 return x, false
5878}
5879
5880func (f *fdropdown) key(ev *tcell.EventKey) bool {
5881 switch {
5882 case ev.Key() == tcell.KeyLeft:
5883 if f.sel > 0 {
5884 f.sel--
5885 }
5886 case ev.Key() == tcell.KeyRight:
5887 if f.sel < len(f.opts)-1 {
5888 f.sel++
5889 }
5890 case ev.Key() == tcell.KeyRune:
5891 want := strings.ToUpper(ev.Str())
5892 for i := 1; i <= len(f.opts); i++ {
5893 o := f.opts[(f.sel+i)%len(f.opts)]
5894 if strings.HasPrefix(strings.ToUpper(o), want) {
5895 f.sel = (f.sel + i) % len(f.opts)
5896 break
5897 }
5898 }
5899 default:
5900 return false
5901 }
5902 return true
5903}
5904
5905type fbutton struct {
5906 lbl string
5907 fn func()
5908}
5909
5910// form lays items out one per row with a button row below, inside a
5911// bordered box drawn by its owner.
5912type form struct {
5913 items []formItem
5914 buttons []fbutton
5915 focus int // 0..len(items)-1, then buttons
5916 cancel func()
5917 labelCol int
5918}
5919
5920func newForm(items []formItem, buttons []fbutton, cancel func()) *form {
5921 f := &form{items: items, buttons: buttons, cancel: cancel}
5922 for _, it := range items {
5923 if n := len(it.label()); n > f.labelCol {
5924 f.labelCol = n
5925 }
5926 }
5927 return f
5928}
5929
5930func (f *form) draw(sc tcell.Screen, r rect) {
5931 sc.HideCursor()
5932 for i, it := range f.items {
5933 y := r.y + i*2
5934 if y >= r.y+r.h-1 {
5935 break
5936 }
5937 printMarkup(sc, r.x, y, r.w, esc(it.label()), styleLink)
5938 curX, show := it.drawValue(sc, r.x+f.labelCol+1, y, r.w-f.labelCol-1, f.focus == i)
5939 if show && f.focus == i {
5940 sc.ShowCursor(curX, y)
5941 }
5942 }
5943 by := r.y + len(f.items)*2
5944 x := r.x + 2
5945 for i, b := range f.buttons {
5946 st := styleButton
5947 if f.focus == len(f.items)+i {
5948 st = styleSel
5949 }
5950 lbl := " " + b.lbl + " "
5951 printMarkup(sc, x, by, r.w, esc(lbl), st)
5952 x += len(lbl) + 2
5953 }
5954}
5955
5956func (f *form) key(ev *tcell.EventKey) bool {
5957 switch ev.Key() {
5958 case tcell.KeyEscape:
5959 if f.cancel != nil {
5960 f.cancel()
5961 }
5962 return true
5963 case tcell.KeyTab, tcell.KeyDown:
5964 f.focus = (f.focus + 1) % (len(f.items) + len(f.buttons))
5965 return true
5966 case tcell.KeyBacktab, tcell.KeyUp:
5967 f.focus--
5968 if f.focus < 0 {
5969 f.focus = len(f.items) + len(f.buttons) - 1
5970 }
5971 return true
5972 case tcell.KeyEnter:
5973 if f.focus >= len(f.items) {
5974 f.buttons[f.focus-len(f.items)].fn()
5975 } else {
5976 f.focus++
5977 }
5978 return true
5979 }
5980 if f.focus < len(f.items) {
5981 if f.items[f.focus].key(ev) {
5982 return true
5983 }
5984 }
5985 // Swallow stray runes so shortcuts never fire while a form is up.
5986 return ev.Key() == tcell.KeyRune
5987}
5988
5989
5990// ===== pkg/web/app.go =====
5991// Package web pkg/web/app.go β the fiber application, buildable for two hosts.
5992//
5993// Serve (native) and the in-tab site server (js/wasm β pkg/storepane's
5994// `serve` command, listening on the bottle vnet loopback) construct the same
5995// application from the same templates and handlers. AppOpts carries the only
5996// differences: where the request log goes, and the origin to lean on for
5997// everything a browser tab must not or cannot hold β the Stripe secret key,
5998// files on disk, and the toolchain that compiles wasm drop-ins.
5999package web
6000
6001import (
6002 "context"
6003 "errors"
6004 "fmt"
6005 "io"
6006 "net/http"
6007 "os"
6008 "strings"
6009 "time"
6010
6011 "github.com/gofiber/fiber/v3"
6012 "github.com/gofiber/fiber/v3/middleware/static"
6013
6014 "github.com/0magnet/bottle"
6015)
6016
6017// AppOpts selects the host the app is being built for.
6018type AppOpts struct {
6019 // LogOutput receives the request log; nil keeps the native default
6020 // (stdout). The in-tab server points this at its shell's terminal, so
6021 // browsing the vnet site scrolls an access log like any server.
6022 LogOutput io.Writer
6023
6024 // ProxyOrigin, when non-empty, marks the in-tab role. Routes that need
6025 // the host machine (wasm compilation, source tarballs, images and fonts
6026 // on disk, the logo pipeline, CUPS printing) are not registered β a GET
6027 // that matches nothing is fetched from this origin instead β and the
6028 // payment endpoints forward there, so the tab renders the store but
6029 // never holds a secret key.
6030 ProxyOrigin string
6031}
6032
6033// NewApp builds the store's fiber application. It does not listen; Serve
6034// (native) and the in-tab server each own their listener.
6035func NewApp(o AppOpts) *fiber.App {
6036 initTMPL()
6037 logOut := o.LogOutput
6038 if logOut == nil {
6039 logOut = os.Stdout
6040 }
6041 inTab := o.ProxyOrigin != ""
6042 // In the tab the drop-ins are named from the origin, not from a disk
6043 // that carries none of them.
6044 if inTab {
6045 serveFromOrigin(o.ProxyOrigin)
6046 }
6047
6048 r := fiber.New(fiber.Config{
6049 ErrorHandler: func(c fiber.Ctx, err error) error {
6050 code := fiber.StatusInternalServerError
6051 var e *fiber.Error
6052 if errors.As(err, &e) {
6053 code = e.Code
6054 }
6055 c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
6056 return c.Status(code).SendString(err.Error())
6057 },
6058 })
6059
6060 r.Use(func(c fiber.Ctx) error {
6061 start := time.Now()
6062 err := c.Next()
6063 status := c.Response().StatusCode()
6064 // Unmatched routes and handler errors are turned into their real
6065 // status by the app ErrorHandler after this middleware returns, so
6066 // c.Response() still reads 200 here. Recover the true code from the
6067 // error so 404s (and 5xx) are logged accurately rather than as 200.
6068 if err != nil {
6069 var fe *fiber.Error
6070 if errors.As(err, &fe) {
6071 status = fe.Code
6072 } else {
6073 status = fiber.StatusInternalServerError
6074 }
6075 }
6076 lat := time.Since(start)
6077 colors := c.App().Config().ColorScheme
6078 ip := fmt.Sprintf("%*s", 15, c.IP())
6079 ipsStr := strings.Join(c.IPs(), ", ")
6080 ips := fmt.Sprintf("%*s", 15, ipsStr)
6081 method := fmt.Sprintf("%-*s", 6, c.Method())
6082 statCol := statusColor(status, colors) + fmt.Sprintf("%3d", status) + colors.Reset
6083 methCol := methodColor(c.Method(), colors) + method + colors.Reset
6084 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
6085 return err
6086 })
6087
6088 if !inTab {
6089 // The desk page's OS layer and browser engine, served ahead of any
6090 // wasm module: jsfs/vnet must exist before Go captures globalThis.fs.
6091 jsAsset := func(body []byte) fiber.Handler {
6092 return func(c fiber.Ctx) error {
6093 c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
6094 c.Set(fiber.HeaderCacheControl, "public, max-age=3600")
6095 return c.Send(body)
6096 }
6097 }
6098 r.Get("/bottle/jsfs.js", jsAsset(bottle.JSFS()))
6099 r.Get("/bottle/vnet.js", jsAsset(bottle.VNetJS()))
6100 serveSourceCode(r)
6101 serveWASM(r)
6102 serveBrowseResponder(r)
6103 r.Get("/logo", logo)
6104 r.Get("/logo/:width", logo)
6105 r.Get("/logo/:width/:height", logo)
6106 // The ports that draw: a product image as ANSI art turned into HTML, and
6107 // a heading as a FIGlet banner. Nothing on the site links to either β
6108 // they are here for the decorator on /test to call.
6109 r.Get("/art/:cat/:name", art)
6110 r.Get("/banner", banner)
6111 r.Get("/logo.png", sendFile)
6112 r.Get("/logo.html", sendFile)
6113 r.Get("/mobilelogo.html", sendFile)
6114 r.Get("/logolarge.html", sendFile)
6115 r.Get("/favicon.ico", sendImage)
6116 if f.Siteimagesrc == "" {
6117 r.Use("/i", static.New("./img"))
6118 r.Use("/img", static.New("./img"))
6119 }
6120 r.Use("/font", static.New("./font"))
6121 r.Get("/stl/:filename", func(c fiber.Ctx) error {
6122 name := c.Params("filename")
6123 if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
6124 return c.SendStatus(fiber.StatusBadRequest)
6125 }
6126 return c.SendFile("./img/stl/" + name)
6127 })
6128 r.Get("/stl/base64/:filename", stlbase64)
6129 r.Get("/tui", tuipage)
6130 r.Get("/desk", deskpage)
6131 }
6132
6133 r.Get("/robots.txt", robots)
6134 r.Get("/site.webmanifest", func(c fiber.Ctx) error {
6135 return c.JSON(fiber.Map{
6136 "name": f.Sitelongname,
6137 "short_name": f.Sitename,
6138 "icons": []fiber.Map{
6139 {"src": f.Siteimagesrc + "/i/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"},
6140 {"src": f.Siteimagesrc + "/i/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"},
6141 },
6142 "theme_color": "#ffffff",
6143 "background_color": "#ffffff",
6144 "display": "standalone",
6145 })
6146 })
6147 r.Get("/api/products", apiproducts)
6148 r.Get("/api/site", apisite)
6149 r.Get("/api/content/:name", apicontent)
6150 r.Get("/sitemap", sitemap)
6151 r.Get("/sitemap.xml", sitemap)
6152 r.Get("/", homepage)
6153 r.Get("/p/:partno", productpage)
6154 r.Get("/post/:partno", handlecat)
6155 r.Get("/p", handlecat)
6156 r.Get("/cat", handlecat)
6157 r.Get("/cat/:cat", handlecat)
6158 r.Get("/cat/:cat/:subcat", handlecat)
6159 r.Get("/style.css", style)
6160 r.Get("/font.css", fontcss)
6161
6162 if !inTab {
6163 for _, register := range extraRoutes {
6164 register(r)
6165 }
6166 handleOrder(r)
6167 // Crypto checkout, if a wallet is configured; the poller that prints
6168 // a receipt when a payment confirms starts with it.
6169 handleInvoice(r)
6170 go WatchInvoices(context.Background())
6171 } else {
6172 // The drop-ins this binary carries, ahead of the catch-all so they are
6173 // answered from the embed rather than fetched back off the origin.
6174 serveCarriedWASM(r)
6175 // Money crosses back to the real server: the tab renders the cart,
6176 // the origin holds the Stripe key and the order book.
6177 r.Post("/create-payment-intent", proxyToOrigin(o.ProxyOrigin))
6178 r.Post("/submit-order", proxyToOrigin(o.ProxyOrigin))
6179 // Everything the tab does not carry β images, fonts, STL models,
6180 // the logo pipeline, /complete's Stripe lookup β reads through to
6181 // the origin. Registered last, so it is the 404 path.
6182 r.Use(proxyToOrigin(o.ProxyOrigin))
6183 }
6184 return r
6185}
6186
6187// proxyToOrigin relays the request to the real origin and copies the answer
6188// back. Under js/wasm net/http rides the browser's fetch, so a same-origin
6189// relay needs no credentials or CORS ceremony.
6190func proxyToOrigin(origin string) fiber.Handler {
6191 return func(c fiber.Ctx) error {
6192 method := c.Method()
6193 if method != fiber.MethodGet && method != fiber.MethodPost {
6194 return fiber.ErrNotFound
6195 }
6196 var body io.Reader
6197 if method == fiber.MethodPost {
6198 body = strings.NewReader(string(c.Body()))
6199 }
6200 req, err := http.NewRequest(method, origin+c.OriginalURL(), body)
6201 if err != nil {
6202 return fiber.ErrBadGateway
6203 }
6204 if ct := c.Get(fiber.HeaderContentType); ct != "" {
6205 req.Header.Set(fiber.HeaderContentType, ct)
6206 }
6207 resp, err := http.DefaultClient.Do(req)
6208 if err != nil {
6209 return fiber.ErrBadGateway
6210 }
6211 defer resp.Body.Close() //nolint:errcheck,gosec
6212 payload, err := io.ReadAll(resp.Body)
6213 if err != nil {
6214 return fiber.ErrBadGateway
6215 }
6216 if ct := resp.Header.Get("Content-Type"); ct != "" {
6217 c.Set(fiber.HeaderContentType, ct)
6218 }
6219 return c.Status(resp.StatusCode).Send(payload)
6220 }
6221}
6222
6223
6224// ===== pkg/web/art.go =====
6225package web
6226
6227// Two endpoints that draw with the ports: a product image as ANSI art turned
6228// into HTML, and a heading as a FIGlet banner.
6229//
6230// Server side rather than in the wasm decorator, for two different reasons.
6231// The image art needs a JPEG decode and a dither per product, which is real
6232// work to do fourteen times in a browser and is the same answer every time β
6233// so it is done once here and cached. The banner needs the FIGlet fonts, and
6234// there are eight hundred kilobytes of them; embedding that in a decorator to
6235// draw a dozen headings is the wrong trade, while here they are already in the
6236// binary.
6237//
6238// Nothing on the site links to either. They exist for the decorator on /test to
6239// call, so the served HTML is unchanged and a browser that does not run the
6240// wasm sees the page it always saw.
6241
6242import (
6243 "bytes"
6244 "fmt"
6245 "io"
6246 "net/http"
6247 "os"
6248 "path/filepath"
6249 "strconv"
6250 "strings"
6251 "sync"
6252 "time"
6253
6254 "github.com/gofiber/fiber/v3"
6255
6256 "github.com/0magnet/ansifilter-go/ansifilter"
6257 "github.com/0magnet/img2txt-go/caca"
6258 "github.com/0magnet/toilet-go/figlet"
6259 "github.com/0magnet/toilet-go/fonts"
6260)
6261
6262// artCache keys rendered art by everything that changes it. Both renders are
6263// pure functions of their inputs and neither is cheap, so nothing is drawn
6264// twice.
6265var (
6266 artCache = map[string]string{}
6267 artMu sync.Mutex
6268)
6269
6270func cached(key string, make func() (string, error)) (string, error) {
6271 artMu.Lock()
6272 hit, ok := artCache[key]
6273 artMu.Unlock()
6274 if ok {
6275 return hit, nil
6276 }
6277 out, err := make()
6278 if err != nil {
6279 return "", err
6280 }
6281 artMu.Lock()
6282 artCache[key] = out
6283 artMu.Unlock()
6284 return out, nil
6285}
6286
6287// artWidth keeps a request from asking for a canvas big enough to be a denial
6288// of service. Eighty is a terminal; two hundred is already wider than the
6289// column the page gives it.
6290const (
6291 artMin = 8
6292 artMax = 200
6293)
6294
6295// art renders one product image as ANSI art and exports it as HTML.
6296//
6297// The chain is the one the store already had most of: img2txt-go decodes and
6298// dithers the image into a libcaca canvas, which exports ANSI β the same
6299// renderer the terminal UI draws products with β and ansifilter-go turns that
6300// ANSI into HTML. Going straight from the canvas to caca's own HTML export
6301// would skip the middle step and lose the point: the ANSI is what the terminal
6302// shows, so passing it through is what makes the two agree.
6303func art(c fiber.Ctx) error {
6304 cols := 60
6305 if v, err := strconv.Atoi(c.Query("cols")); err == nil {
6306 cols = v
6307 }
6308 cols = min(max(cols, artMin), artMax)
6309 cat, name := c.Params("cat"), c.Params("name")
6310 if !safeSegment(cat) || !safeSegment(name) {
6311 return c.Status(fiber.StatusBadRequest).SendString("bad path")
6312 }
6313 src := imgPath(cat, name)
6314 out, err := cached(fmt.Sprintf("art\x00%s\x00%d", src, cols), func() (string, error) {
6315 return renderArt(src, cols)
6316 })
6317 if err != nil {
6318 return c.Status(fiber.StatusNotFound).SendString(err.Error())
6319 }
6320 c.Set("Content-Type", "text/html;charset=utf-8")
6321 c.Set("Cache-Control", "public, max-age=86400")
6322 return c.SendString(out)
6323}
6324
6325func renderArt(src string, cols int) (string, error) {
6326 data, err := fetch(src)
6327 if err != nil {
6328 return "", err
6329 }
6330 im, err := caca.DecodeImage(bytes.NewReader(data))
6331 if err != nil {
6332 return "", err
6333 }
6334 if im.W == 0 || im.H == 0 {
6335 return "", fmt.Errorf("image has no size")
6336 }
6337 // img2txt's 6x10 font cell, so the art keeps the picture's proportions
6338 // instead of coming out squashed by the shape of a character.
6339 rows := cols * im.H * 6 / im.W / 10
6340 if rows < 1 {
6341 rows = 1
6342 }
6343 cv := caca.NewCanvas(cols, rows)
6344 cv.SetColorANSI(caca.Default, caca.Transparent)
6345 cv.Clear()
6346 im.Dither.SetAlgorithm("fstein")
6347 cv.DitherBitmap(0, 0, cols, rows, im.Dither, im.Pixels)
6348 ansi, ok := cv.Export("ansi")
6349 if !ok {
6350 return "", fmt.Errorf("canvas will not export ansi")
6351 }
6352 g := ansifilter.New(ansifilter.HTML)
6353 g.SetFragmentCode(true)
6354 return g.GenerateString(string(ansi)), nil
6355}
6356
6357// banner renders a line of text as a FIGlet banner.
6358func banner(c fiber.Ctx) error {
6359 // A query parameter, not a path segment. Fiber hands back path params still
6360 // percent-encoded, and rendering those gives a banner reading
6361 // DIODE%20%E2%86%92%201N40XX, which is exactly what went live.
6362 text := c.Query("text")
6363 if text == "" {
6364 return c.Status(fiber.StatusBadRequest).SendString("no text")
6365 }
6366 if len(text) > 64 {
6367 text = text[:64]
6368 }
6369 font := c.Query("font")
6370 if font == "" {
6371 font = "future"
6372 }
6373 width := 200
6374 if v, err := strconv.Atoi(c.Query("width")); err == nil && v > 0 {
6375 width = min(v, 400)
6376 }
6377 out, err := cached(fmt.Sprintf("banner\x00%s\x00%s\x00%d", text, font, width), func() (string, error) {
6378 return renderBanner(text, font, width)
6379 })
6380 if err != nil {
6381 return c.Status(fiber.StatusNotFound).SendString(err.Error())
6382 }
6383 c.Set("Content-Type", "text/plain;charset=utf-8")
6384 c.Set("Cache-Control", "public, max-age=86400")
6385 return c.SendString(out)
6386}
6387
6388func renderBanner(text, font string, width int) (string, error) {
6389 raw, ok := fonts.Get(font)
6390 if !ok {
6391 return "", fmt.Errorf("no font %q", font)
6392 }
6393 fnt, err := figlet.ParseFont(raw)
6394 if err != nil {
6395 return "", err
6396 }
6397 r := figlet.NewRenderer(fnt)
6398 r.SetWidth(width)
6399 r.SetMode(figlet.ParseMode("smush"))
6400 for _, ch := range text {
6401 r.PutChar(ch)
6402 }
6403 cv := r.Flush()
6404 if cv == nil {
6405 return "", fmt.Errorf("nothing rendered")
6406 }
6407 utf8, ok := cv.Export("utf8")
6408 if !ok {
6409 return "", fmt.Errorf("canvas will not export utf8")
6410 }
6411 return strings.TrimRight(string(utf8), "\n"), nil
6412}
6413
6414// safeSegment rejects anything that could climb out of the image directory.
6415// The two path parts come off the wire, so they are the one thing here a
6416// visitor controls.
6417func safeSegment(s string) bool {
6418 if s == "" || s == "." || s == ".." {
6419 return false
6420 }
6421 return !strings.ContainsAny(s, "/\\") && !strings.Contains(s, "..")
6422}
6423
6424// imgPath is where a product image lives: on disk under ./img when the site
6425// serves its own, and behind Siteimagesrc when it does not β the same two cases
6426// the template's ImgSRC covers.
6427func imgPath(cat, name string) string {
6428 if f.Siteimagesrc == "" {
6429 return filepath.Join("img", cat, name)
6430 }
6431 return f.Siteimagesrc + "/i/" + cat + "/" + name
6432}
6433
6434// fetch reads an image from wherever imgPath put it, on disk or over http.
6435func fetch(src string) ([]byte, error) {
6436 if !strings.HasPrefix(src, "http://") && !strings.HasPrefix(src, "https://") {
6437 return os.ReadFile(src) //nolint:gosec // safeSegment has already refused anything with a separator in it
6438 }
6439 client := &http.Client{Timeout: 20 * time.Second}
6440 resp, err := client.Get(src) //nolint:gosec // ImgSRC is the operator's own
6441 if err != nil {
6442 return nil, err
6443 }
6444 defer resp.Body.Close() //nolint:errcheck // read-side close
6445 if resp.StatusCode != http.StatusOK {
6446 return nil, fmt.Errorf("%s", resp.Status)
6447 }
6448 return io.ReadAll(io.LimitReader(resp.Body, 16<<20))
6449}
6450
6451
6452// ===== pkg/web/art_test.go =====
6453package web
6454
6455import (
6456 "os"
6457 "path/filepath"
6458 "strings"
6459 "testing"
6460)
6461
6462// The two renders have to work end to end, since nothing else exercises the
6463// chain: img2txt decodes and dithers, the canvas exports ANSI, ansifilter turns
6464// that into HTML.
6465func TestRenderArt(t *testing.T) {
6466 var sample string
6467 err := filepath.WalkDir("../../img", func(p string, d os.DirEntry, err error) error {
6468 if err != nil || d.IsDir() || sample != "" {
6469 return nil //nolint:nilerr // a missing image directory is not this test's business
6470 }
6471 switch strings.ToLower(filepath.Ext(p)) {
6472 case ".jpg", ".jpeg", ".png", ".gif":
6473 sample = p
6474 }
6475 return nil
6476 })
6477 if err != nil || sample == "" {
6478 t.Skip("no product images on disk to render")
6479 }
6480 out, err := renderArt(sample, 40)
6481 if err != nil {
6482 t.Fatalf("rendering %s: %v", sample, err)
6483 }
6484 if !strings.Contains(out, "<span") {
6485 t.Errorf("no colored spans in the output; ansifilter produced %.80q", out)
6486 }
6487 if n := strings.Count(out, "\n"); n < 4 {
6488 t.Errorf("only %d lines of art, expected the image to fill a few", n)
6489 }
6490}
6491
6492// A banner has to come back as several lines of art, not as the text itself.
6493func TestRenderBanner(t *testing.T) {
6494 out, err := renderBanner("resistor", "future", 200)
6495 if err != nil {
6496 t.Fatal(err)
6497 }
6498 lines := strings.Split(out, "\n")
6499 if len(lines) < 3 {
6500 t.Fatalf("a banner should be several rows tall, got %d: %q", len(lines), out)
6501 }
6502 if strings.Contains(out, "resistor") {
6503 t.Error("the banner is the literal text, so no font was applied")
6504 }
6505 t.Logf("banner is %d lines, widest %d", len(lines), widest(lines))
6506 for _, l := range lines {
6507 t.Log(l)
6508 }
6509}
6510
6511func widest(lines []string) int {
6512 w := 0
6513 for _, l := range lines {
6514 if n := len([]rune(l)); n > w {
6515 w = n
6516 }
6517 }
6518 return w
6519}
6520
6521// The path guard is the only place a visitor's input reaches the filesystem.
6522func TestSafeSegment(t *testing.T) {
6523 for _, bad := range []string{"", ".", "..", "../etc", "a/b", `a\b`, "x..y"} {
6524 if safeSegment(bad) {
6525 t.Errorf("%q was accepted", bad)
6526 }
6527 }
6528 for _, good := range []string{"diode", "1N4148.jpg", "a-b_c.png"} {
6529 if !safeSegment(good) {
6530 t.Errorf("%q was refused", good)
6531 }
6532 }
6533}
6534
6535
6536// ===== pkg/web/browse.go =====
6537package web
6538
6539// The real-origin browse substrate: giving the desk's nested browser a genuine
6540// origin to render the in-tab store at.
6541//
6542// netscrape renders a fetched page into a sandboxed srcdoc with an opaque
6543// origin, which cannot reach the tab's virtual loopback at all; its relay
6544// carries stylesheets and images and nothing else. That is enough for a page
6545// of text and pictures and is structurally unable to run a wasm drop-in: a
6546// <script src> is never fetched, and an in-page WebAssembly.instantiateStreaming
6547// could not reach vnet even if it were. So the store inside the desk had a
6548// cart that could not add and a logo the attractor never drew over.
6549//
6550// github.com/0magnet/realorigin fixes the class of problem rather than the
6551// symptom. The store gets its own browser origin B, distinct from the site's
6552// origin V, whose network layer is a service worker relaying every fetch back
6553// to a responder on V β which answers it from the in-tab server over vnet. The
6554// page is then an ordinary document: native subresource loading, real fetch,
6555// real wasm, cookies and storage of its own.
6556//
6557// Two things make it cheap here where it is expensive in general:
6558//
6559// - There is exactly ONE target, the tab's own store, so there is exactly one
6560// browse origin. realorigin content-addresses origins precisely so a
6561// wildcard can cover a whole address space; one fixed target hashes to one
6562// fixed hostname, which needs one ordinary certificate and no wildcard, no
6563// DNS-01 and no DNS provider credentials.
6564// - B must be a different REGISTRABLE domain from V, not a subdomain, so that
6565// untrusted content is fully cross-site from the site holding the keys.
6566// That is a deployment fact, and BrowseSuffix is where it is stated.
6567//
6568// Everything here is inert until BrowseSuffix and BrowseAddr are both set.
6569
6570import (
6571 "context"
6572 "github.com/gofiber/fiber/v3"
6573 "log"
6574 "net/url"
6575 "strconv"
6576 "strings"
6577
6578 "github.com/0magnet/realorigin"
6579
6580 "github.com/0magnet/m2/pkg/config"
6581)
6582
6583// browseResponderPath is where the app origin serves realorigin's responder β
6584// the trust boundary, which must run first-party on V. The desk page loads it
6585// before the wasm that configures it.
6586const browseResponderPath = "/browse-responder.js"
6587
6588// browseEnabled reports whether the deployment configured the substrate.
6589func browseEnabled() bool {
6590 return config.F.BrowseSuffix != "" && config.F.BrowseAddr != ""
6591}
6592
6593// browseScheme and browsePort describe how a browse origin is reached.
6594//
6595// Hosted, that is https on 443 and the port is not in the URL. The local
6596// model is the exception the platform itself makes: *.localhost resolves to
6597// the loopback without any DNS and counts as a secure context, so a service
6598// worker runs there over plain http. It is how realorigin and skywire are
6599// developed against (.mesh.localhost), and it is the only way to exercise the
6600// whole path without a wildcard certificate.
6601func browseScheme() string {
6602 if isLoopbackSuffix(config.F.BrowseSuffix) {
6603 return "http"
6604 }
6605 return "https"
6606}
6607
6608// browsePort is the port a browse origin is served on, empty when it is the
6609// scheme's default.
6610func browsePort() string {
6611 if !isLoopbackSuffix(config.F.BrowseSuffix) {
6612 return ""
6613 }
6614 if i := strings.LastIndex(config.F.BrowseAddr, ":"); i >= 0 {
6615 return config.F.BrowseAddr[i+1:]
6616 }
6617 return ""
6618}
6619
6620// isLoopbackSuffix reports whether the browse domain is the browser's own
6621// loopback namespace rather than a real one.
6622func isLoopbackSuffix(suffix string) bool {
6623 return strings.HasSuffix(strings.TrimSuffix(suffix, "."), ".localhost")
6624}
6625
6626// appOrigin is V: the origin the responder runs on, and the exact string the
6627// browse origin posts its handshake to. It has to be what the BROWSER sees,
6628// not what the site calls itself.
6629//
6630// Those are the same thing hosted, where SITEDOMAIN is both the brand and the
6631// address. They are not the same under the loopback model: SITEDOMAIN is
6632// assembled from SITENAME and SITEEXT for the masthead, so a development run
6633// still calls itself magnetosphere.net while the browser is on
6634// localhost:<WEBPORT>. Posting the handshake to the brand would silently drop
6635// it and hang the frame on its interstitial forever, which is a bad way to
6636// find out.
6637func appOrigin() string {
6638 if isLoopbackSuffix(config.F.BrowseSuffix) {
6639 return "http://localhost:" + strconv.Itoa(config.F.WebPort)
6640 }
6641 raw := config.F.Sitedomain
6642 if raw == "" {
6643 return ""
6644 }
6645 if !strings.Contains(raw, "://") {
6646 raw = "https://" + raw
6647 }
6648 u, err := url.Parse(raw)
6649 if err != nil || u.Host == "" {
6650 return ""
6651 }
6652 return u.Scheme + "://" + u.Host
6653}
6654
6655// serveBrowseResponder puts realorigin's responder β the trust boundary β on
6656// the app origin, where it has to run first-party. An earlier design in that
6657// project put it in a cross-origin helper instead and Storage Partitioning
6658// stranded it in another partition, which is worth not rediscovering.
6659func serveBrowseResponder(r *fiber.App) {
6660 if !browseEnabled() {
6661 return
6662 }
6663 body := realorigin.ResponderJS()
6664 r.Get(browseResponderPath, func(c fiber.Ctx) error {
6665 c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
6666 return c.Send(body)
6667 })
6668}
6669
6670// StartBrowseOrigin runs the browse-origin bootstrap listener.
6671//
6672// It serves two static files and never proxies anything: the service worker at
6673// its own path and the bootstrap shell at every other. All content travels the
6674// other way, through the visitor's own tab, so this listener sees none of it
6675// and nothing about it scales with use.
6676//
6677// Returns immediately when the substrate is not configured, which is the
6678// state of any deployment that has not set up the second domain.
6679func StartBrowseOrigin(ctx context.Context) {
6680 if !browseEnabled() {
6681 return
6682 }
6683 app := appOrigin()
6684 if app == "" {
6685 log.Printf("browse: SITEURL is not a URL; the browse origin needs one to name its app origin")
6686 return
6687 }
6688 cfg := realorigin.Config{
6689 Addr: config.F.BrowseAddr,
6690 Suffix: config.F.BrowseSuffix,
6691 AppOrigin: app,
6692 }
6693 if _, err := realorigin.Handler(cfg); err != nil {
6694 log.Printf("browse: %v", err)
6695 return
6696 }
6697 log.Printf("browse: serving %s on %s for %s β each target the tab reaches gets its own origin",
6698 config.F.BrowseSuffix, config.F.BrowseAddr, app)
6699 go func() {
6700 if err := cfg.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
6701 log.Printf("browse: %v", err)
6702 }
6703 }()
6704}
6705
6706
6707// ===== pkg/web/browse_test.go =====
6708package web
6709
6710import (
6711 "strings"
6712 "testing"
6713)
6714
6715// The substrate is off unless BOTH the domain and the listener are named. A
6716// deployment with neither must behave exactly as it did before.
6717func TestBrowseIsOffUntilConfigured(t *testing.T) {
6718 saved := *f
6719 t.Cleanup(func() { *f = saved })
6720
6721 for _, tc := range []struct {
6722 suffix, addr string
6723 want bool
6724 }{
6725 {"", "", false},
6726 {".haltingstate.net", "", false},
6727 {"", "127.0.0.1:7997", false},
6728 {".haltingstate.net", "127.0.0.1:7997", true},
6729 } {
6730 f.BrowseSuffix, f.BrowseAddr = tc.suffix, tc.addr
6731 if got := browseEnabled(); got != tc.want {
6732 t.Errorf("browseEnabled(%q,%q) = %v, want %v", tc.suffix, tc.addr, got, tc.want)
6733 }
6734 }
6735}
6736
6737// Hosted is https on the default port; the loopback model is plain http on
6738// the listener's own port, which is the only way to exercise the whole path
6739// without a wildcard certificate.
6740func TestBrowseSchemeAndPort(t *testing.T) {
6741 saved := *f
6742 t.Cleanup(func() { *f = saved })
6743 for _, tc := range []struct {
6744 suffix, addr string
6745 scheme, port string
6746 }{
6747 {".haltingstate.net", "127.0.0.1:7997", "https", ""},
6748 {".mesh.localhost", "127.0.0.1:7997", "http", "7997"},
6749 {".mesh.localhost", ":7997", "http", "7997"},
6750 } {
6751 f.BrowseSuffix, f.BrowseAddr = tc.suffix, tc.addr
6752 if got := browseScheme(); got != tc.scheme {
6753 t.Errorf("browseScheme(%q) = %q, want %q", tc.suffix, got, tc.scheme)
6754 }
6755 if got := browsePort(); got != tc.port {
6756 t.Errorf("browsePort(%q,%q) = %q, want %q", tc.suffix, tc.addr, got, tc.port)
6757 }
6758 }
6759}
6760
6761// B must be a different registrable domain from V, not a subdomain of it:
6762// different hosts are merely cross-origin, and only different registrable
6763// domains make untrusted content fully cross-site from the site holding the
6764// Stripe key.
6765func TestBrowseSuffixIsNotUnderTheSiteDomain(t *testing.T) {
6766 saved := *f
6767 t.Cleanup(func() { *f = saved })
6768 f.Sitedomain = "magnetosphere.net"
6769 f.BrowseSuffix, f.BrowseAddr = ".haltingstate.net", "127.0.0.1:7997"
6770
6771 if got := appOrigin(); got != "https://magnetosphere.net" {
6772 t.Errorf("appOrigin() = %q", got)
6773 }
6774 if strings.HasSuffix(f.BrowseSuffix, "."+f.Sitedomain) {
6775 t.Error("the browse domain is under the site's own; it must be a separate registrable domain")
6776 }
6777}
6778
6779func TestAppOriginFromSiteDomain(t *testing.T) {
6780 saved := *f
6781 t.Cleanup(func() { *f = saved })
6782 f.BrowseSuffix = ".haltingstate.net" // hosted: the brand IS the address
6783 for _, tc := range []struct{ in, want string }{
6784 {"magnetosphere.net", "https://magnetosphere.net"},
6785 {"https://magnetosphere.net", "https://magnetosphere.net"},
6786 {"http://localhost:8099", "http://localhost:8099"},
6787 {"", ""},
6788 } {
6789 f.Sitedomain = tc.in
6790 if got := appOrigin(); got != tc.want {
6791 t.Errorf("appOrigin(%q) = %q, want %q", tc.in, got, tc.want)
6792 }
6793 }
6794}
6795
6796// Under the loopback model V is wherever the browser actually has it, not
6797// what the masthead says. SITEDOMAIN is assembled from SITENAME and SITEEXT
6798// for branding, so a development run calls itself magnetosphere.net while
6799// being served from localhost β and a handshake posted to the brand is
6800// silently dropped.
6801func TestAppOriginIsTheServingOriginUnderLoopback(t *testing.T) {
6802 saved := *f
6803 t.Cleanup(func() { *f = saved })
6804 f.Sitedomain, f.WebPort = "magnetosphere.net", 8099
6805 f.BrowseSuffix, f.BrowseAddr = ".mesh.localhost", "127.0.0.1:7997"
6806
6807 if got, want := appOrigin(), "http://localhost:8099"; got != want {
6808 t.Errorf("appOrigin() = %q, want %q β the brand is not the address here", got, want)
6809 }
6810 f.BrowseSuffix = ".haltingstate.net"
6811 if got, want := appOrigin(), "https://magnetosphere.net"; got != want {
6812 t.Errorf("hosted appOrigin() = %q, want %q", got, want)
6813 }
6814}
6815
6816
6817// ===== pkg/web/catalog.go =====
6818// Package web pkg/web/catalog.go β the in-memory catalog the server sells from.
6819package web
6820
6821import (
6822 "log"
6823 "os"
6824 "sync"
6825 "time"
6826
6827 p "github.com/0magnet/m2/pkg/product"
6828)
6829
6830var (
6831 allproducts p.Products
6832 allproductsMu sync.RWMutex
6833)
6834
6835var lastModTime time.Time
6836
6837// LoadCatalog reads the products CSV named in the config into memory,
6838// logging any data-integrity warnings.
6839func LoadCatalog() error {
6840 fileInfo, err := os.Stat(f.ProductsCSV)
6841 if err != nil {
6842 return err
6843 }
6844 lastModTime = fileInfo.ModTime()
6845 prods := p.ReadCSV(f.ProductsCSV)
6846 if warnings := p.ValidateCSV(prods); len(warnings) > 0 {
6847 for _, w := range warnings {
6848 log.Println("CSV warning:", w)
6849 }
6850 }
6851 allproductsMu.Lock()
6852 allproducts = prods
6853 allproductsMu.Unlock()
6854 return nil
6855}
6856
6857// WatchCatalog polls the products CSV and reloads it when it changes.
6858// Run it in a goroutine; it never returns.
6859func WatchCatalog() {
6860 for {
6861 fileInfo, err := os.Stat(f.ProductsCSV)
6862 if err != nil {
6863 log.Println("Error getting file info:", err)
6864 time.Sleep(10 * time.Second)
6865 continue
6866 }
6867
6868 currentModTime := fileInfo.ModTime()
6869 if currentModTime != lastModTime {
6870 log.Println("CSV file has been modified!")
6871 if err := LoadCatalog(); err != nil {
6872 log.Println("Error reloading catalog:", err)
6873 }
6874 }
6875
6876 time.Sleep(10 * time.Second)
6877 }
6878}
6879
6880// SetCatalog replaces the in-memory catalog directly, for hosts that have no
6881// CSV on disk to load β the in-tab site server seeds this from /api/products,
6882// which is already the public projection (no cost, location or source info).
6883func SetCatalog(prods p.Products) {
6884 allproductsMu.Lock()
6885 allproducts = prods
6886 allproductsMu.Unlock()
6887}
6888
6889
6890// ===== pkg/web/catsubcats_test.go =====
6891package web
6892
6893import (
6894 "bytes"
6895 "flag"
6896 htmpl "html/template"
6897 "os"
6898 "strings"
6899 "testing"
6900
6901 p "github.com/0magnet/m2/pkg/product"
6902)
6903
6904// sampleProducts is a catalog small enough to write the expected menu out by
6905// hand, and shaped to exercise the alignment: counts of one, two and three
6906// digits, a category with subcategories and one without.
6907func sampleProducts() p.Products {
6908 var out p.Products
6909 add := func(cat, sub string, n int) {
6910 for i := 0; i < n; i++ {
6911 out = append(out, p.Product{Enable: "TRUE", Category: cat, Subcategory: sub,
6912 Partno: cat + sub + string(rune('a'+i%26))})
6913 }
6914 }
6915 add("resistor", "quarter watt 5%", 67)
6916 add("resistor", "ceramic", 1)
6917 add("inductor", "", 34)
6918 return out
6919}
6920
6921// renderMenu runs just the category menu, the way a page does.
6922func renderMenu(t *testing.T, prods p.Products) string {
6923 t.Helper()
6924 saved := allproducts
6925 allproducts = prods
6926 defer func() { allproducts = saved }()
6927 data := htmlTemplateData{}
6928 data.LenAllProducts = len(prods)
6929 data.CatsCounts, data.Cats, data.SubCatsCounts, data.SubCatsByCat = getcategories(prods)
6930 data.Page = "front"
6931 tmpl := htmpl.New("catsubcats").Funcs(funcs)
6932 // Read by path rather than through h: the accessors resolve relative to
6933 // the server's working directory, which is not a test's.
6934 src, err := os.ReadFile("../../htmpl/catsubcats.html")
6935 if err != nil {
6936 t.Fatalf("reading catsubcats: %v", err)
6937 }
6938 if _, err := tmpl.Parse(string(src)); err != nil {
6939 t.Fatalf("parsing catsubcats: %v", err)
6940 }
6941 var b bytes.Buffer
6942 if err := tmpl.Execute(&b, map[string]interface{}{"Page": data, "Category": "", "Subcategory": ""}); err != nil {
6943 t.Fatalf("executing catsubcats: %v", err)
6944 }
6945 return b.String()
6946}
6947
6948// The menu's counts are right-aligned by padding the branch rule itself, so
6949// every count ends in the same column. These glyph runs are what the site drew
6950// before the layout moved to tallytree.
6951func TestCatSubCatsDrawing(t *testing.T) {
6952 out := renderMenu(t, sampleProducts())
6953 for _, want := range []string{
6954 "βββ102 ", // All Products
6955 "βββ¬β68 ", // resistor, has subcategories
6956 "β ββ67 ", // quarter watt 5%
6957 "β βββ1 ", // ceramic, last subcategory, rule stretched two
6958 "ββββ34 ", // inductor, no subcategories, last category
6959 } {
6960 if !strings.Contains(out, want) {
6961 t.Errorf("menu is missing %q\n\n%s", want, out)
6962 }
6963 }
6964}
6965
6966// TestCatSubCatsGolden holds the menu's whole markup, not just its glyphs.
6967// The layout moved out to tallytree; the anchors, titles, classes and nesting
6968// around it must not have moved with it.
6969//
6970// Regenerate with: go test ./pkg/web -run CatSubCatsGolden -update
6971func TestCatSubCatsGolden(t *testing.T) {
6972 got := renderMenu(t, sampleProducts())
6973 const golden = "testdata/catsubcats.golden"
6974 if *update {
6975 if err := os.WriteFile(golden, []byte(got), 0o600); err != nil {
6976 t.Fatal(err)
6977 }
6978 t.Log("golden rewritten")
6979 return
6980 }
6981 want, err := os.ReadFile(golden)
6982 if err != nil {
6983 t.Fatalf("%v (run with -update to create it)", err)
6984 }
6985 if got != string(want) {
6986 t.Errorf("menu markup changed.\n got:\n%s\nwant:\n%s", got, want)
6987 }
6988}
6989
6990var update = flag.Bool("update", false, "rewrite the golden files")
6991
6992
6993// ===== pkg/web/dropinfs.go =====
6994package web
6995
6996// The drop-ins the binary carries, for a server whose disk has none.
6997//
6998// The store runs in three places and only two of them have the binaries on a
6999// filesystem. On a host, CompileWASM writes them next to the working directory
7000// and the routes read them from there. In a browser tab β pkg/storepane's
7001// `serve`, behind the desk's netscrape window β there is no toolchain to build
7002// them with and no disk that carries them, and the sources are not there
7003// either, so nothing will ever produce them locally.
7004//
7005// So the build stages them into dropins/ and they ride along inside the
7006// binary. A tab then serves the same cart, attractor and border decoration the
7007// origin serves, out of itself, with no round trip.
7008//
7009// The page binaries (term, and the separate tui/desk) are deliberately NOT
7010// staged. term.wasm is the binary doing the embedding, and a file cannot
7011// contain itself; the tab does not serve /tui or /desk anyway β those routes
7012// belong to the host role.
7013//
7014// An empty stage is fine and is what a fresh checkout builds: the directive
7015// matches dropins/README.md, nothing is carried, and the lookup below simply
7016// says no. The disk and then the origin answer instead.
7017
7018import (
7019 "embed"
7020 "io/fs"
7021 "path"
7022 "strings"
7023)
7024
7025//go:embed dropins
7026var dropinFS embed.FS
7027
7028// embeddedDropin returns the staged bytes of one drop-in, by the file name a
7029// page asks for ("stl2-tiny.wasm").
7030func embeddedDropin(file string) ([]byte, bool) {
7031 // Only the binaries, by a bare name. The directory also holds its own
7032 // README, which is not something a page may ask this for.
7033 if file == "" || !strings.HasSuffix(file, ".wasm") || strings.ContainsAny(file, `/\`) {
7034 return nil, false
7035 }
7036 b, err := dropinFS.ReadFile(path.Join("dropins", file))
7037 if err != nil {
7038 return nil, false
7039 }
7040 return b, true
7041}
7042
7043// embeddedDropinNames lists the staged binaries, so the routes can be
7044// registered for exactly what the build carried.
7045func embeddedDropinNames() []string {
7046 ents, err := fs.ReadDir(dropinFS, "dropins")
7047 if err != nil {
7048 return nil
7049 }
7050 var out []string
7051 for _, e := range ents {
7052 if !e.IsDir() && strings.HasSuffix(e.Name(), ".wasm") {
7053 out = append(out, e.Name())
7054 }
7055 }
7056 return out
7057}
7058
7059// lookupDropin is embeddedDropin behind a variable so a test can say what the
7060// build carried. Whether a binary carries a drop-in is fixed at compile time,
7061// which makes every test of the pick order depend on what happened to be
7062// staged in the tree it was compiled from; this makes that an input.
7063var lookupDropin = embeddedDropin
7064
7065
7066// ===== pkg/web/dropinfs_test.go =====
7067package web
7068
7069import (
7070 "os"
7071 "path/filepath"
7072 "testing"
7073)
7074
7075// A fresh checkout stages nothing, and that has to build and behave β the
7076// directive matches only the README, and every lookup says no.
7077func TestEmbeddedDropinOnAnEmptyStage(t *testing.T) {
7078 // Whatever this working tree has staged, README.md is never a drop-in.
7079 if _, ok := embeddedDropin("README.md"); ok {
7080 t.Error("README.md should not be reachable as a drop-in")
7081 }
7082 for _, bad := range []string{"", "../wasmpick.go", "sub/x.wasm", `a\b.wasm`} {
7083 if _, ok := embeddedDropin(bad); ok {
7084 t.Errorf("embeddedDropin(%q) should be refused", bad)
7085 }
7086 }
7087 for _, n := range embeddedDropinNames() {
7088 if filepath.Ext(n) != ".wasm" {
7089 t.Errorf("staged name %q is not a .wasm", n)
7090 }
7091 }
7092}
7093
7094// pageWasm is what keeps term.wasm from being staged into itself.
7095func TestPageBinariesAreNotStaged(t *testing.T) {
7096 for _, name := range []string{"term", "tui", "desk"} {
7097 if !pageWasm[name] {
7098 t.Errorf("%s must be treated as a page binary, not a drop-in", name)
7099 }
7100 }
7101 for _, name := range []string{"cart", "stl2", "deco"} {
7102 if pageWasm[name] {
7103 t.Errorf("%s is a drop-in and must be stageable", name)
7104 }
7105 }
7106}
7107
7108// stageDropins takes the smallest build of each drop-in and clears what a
7109// previous run left, so a source dropped from WasmSRC stops being carried.
7110func TestStageDropinsTakesTheSmallBuildAndClearsStale(t *testing.T) {
7111 t.Chdir(t.TempDir())
7112 dir := filepath.Join("pkg", "web", "dropins")
7113 if err := os.MkdirAll(dir, 0o750); err != nil {
7114 t.Fatal(err)
7115 }
7116 write := func(name, body string) {
7117 if err := os.WriteFile(name, []byte(body), 0o600); err != nil {
7118 t.Fatal(err)
7119 }
7120 }
7121 // Left over from a run when "gone" was still configured.
7122 write(filepath.Join(dir, "gone-tiny.wasm"), "stale")
7123
7124 write("cart.wasm", "cart-go")
7125 write("cart-tiny.wasm", "cart-tiny")
7126 write("deco.wasm", "deco-go") // no tinygo build of this one
7127
7128 stageDropins([]string{"wasm/cart", "wasm/deco", "wasm/never"})
7129
7130 got := map[string]string{}
7131 ents, err := os.ReadDir(dir)
7132 if err != nil {
7133 t.Fatal(err)
7134 }
7135 for _, e := range ents {
7136 b, rerr := os.ReadFile(filepath.Join(dir, e.Name())) //nolint:gosec // test tree
7137 if rerr != nil {
7138 t.Fatal(rerr)
7139 }
7140 got[e.Name()] = string(b)
7141 }
7142 want := map[string]string{
7143 "cart-tiny.wasm": "cart-tiny", // small build preferred
7144 "deco.wasm": "deco-go", // only build there is
7145 }
7146 if len(got) != len(want) {
7147 t.Fatalf("staged %v, want %v", got, want)
7148 }
7149 for k, v := range want {
7150 if got[k] != v {
7151 t.Errorf("staged %s = %q, want %q", k, got[k], v)
7152 }
7153 }
7154}
7155
7156
7157// ===== pkg/web/invoice.go =====
7158package web
7159
7160// Crypto checkout: the other button beside Stripe.
7161//
7162// The two paths meet where it matters. Both validate every line against the
7163// server's own catalog before believing a total, and both end by writing an
7164// order into orders/ and printing a receipt β so a paid order looks the same
7165// however it was paid, and the page in the tray keeps meaning what it means.
7166//
7167// Where they differ is when. A card authorizes while the reader waits; a
7168// chain payment arrives minutes later, from a page that may already be
7169// closed. So the receipt prints on CONFIRMATION, not on placement β
7170// otherwise every abandoned checkout would put paper in the tray and the
7171// glance-at-the-printer workflow would stop working.
7172
7173import (
7174 "context"
7175 "crypto/rand"
7176 "encoding/hex"
7177 "encoding/json"
7178 "fmt"
7179 "log"
7180 "os"
7181 "path/filepath"
7182 "time"
7183
7184 "github.com/gofiber/fiber/v3"
7185
7186 "github.com/0magnet/m2/pkg/pay"
7187 "github.com/0magnet/m2/pkg/pay/sky"
7188 "github.com/0magnet/m2/pkg/storepage"
7189)
7190
7191// cryptoPay is the configured crypto checkout, or nil when the deployment
7192// has no wallet set. Nil means the button is not offered at all, which is
7193// what a store with nothing configured should show rather than an error.
7194var cryptoPay *cryptoConfig
7195
7196type cryptoConfig struct {
7197 store *pay.Store
7198 rater *pay.Rater
7199 backend pay.Backend
7200 window time.Duration
7201 ordersIn string
7202}
7203
7204// initCrypto builds the crypto checkout from the configuration, and reports
7205// whether it is on.
7206func initCrypto(ordersDir string) *cryptoConfig {
7207 if f.SkyXpub == "" {
7208 return nil
7209 }
7210 store, err := pay.NewStore(ordersDir)
7211 if err != nil {
7212 log.Printf("crypto checkout off: %v", err)
7213 return nil
7214 }
7215
7216 // A fixed rate REPLACES the exchange rather than joining it: the
7217 // lowest-wins rule settles a disagreement between exchanges quoting one
7218 // market, and a manual figure mixed in just wins whenever it is lower.
7219 var sources []pay.RateSource
7220 switch {
7221 case f.SkyRateFix != "":
7222 sources = []pay.RateSource{pay.Fixed{Label: "operator", USD: f.SkyRateFix}}
7223 case f.SkyRatePair != "":
7224 sources = []pay.RateSource{pay.LBank{Pair: f.SkyRatePair}}
7225 default:
7226 log.Printf("crypto checkout off: set a rate pair or a fixed rate")
7227 return nil
7228 }
7229
7230 mins := f.InvoiceMins
7231 if mins <= 0 {
7232 mins = 15
7233 }
7234 return &cryptoConfig{
7235 store: store,
7236 rater: &pay.Rater{Sources: sources, Min: f.RateMin, Max: f.RateMax},
7237 backend: &sky.Backend{Xpub: f.SkyXpub, NodeURL: f.SkyNodeURL},
7238 window: time.Duration(mins) * time.Minute,
7239 ordersIn: ordersDir,
7240 }
7241}
7242
7243// handleInvoice registers the crypto endpoints, if crypto is configured.
7244func handleInvoice(r *fiber.App) {
7245 cryptoPay = initCrypto("./orders")
7246 if cryptoPay == nil {
7247 return
7248 }
7249 r.Post("/create-invoice", createInvoice)
7250 r.Get("/invoice/:id", invoiceStatus)
7251 log.Printf("crypto checkout on: skycoin, %s window", cryptoPay.window)
7252}
7253
7254// invoiceRequest is what the cart posts: the same items it sends Stripe.
7255type invoiceRequest struct {
7256 Coin string `json:"coin"`
7257 Items []pay.Item `json:"items"`
7258}
7259
7260// createInvoice prices an order in coin and hands back an address.
7261func createInvoice(c fiber.Ctx) error {
7262 var req invoiceRequest
7263 if err := json.Unmarshal(c.Body(), &req); err != nil {
7264 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid JSON"})
7265 }
7266 if len(req.Items) == 0 {
7267 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
7268 }
7269 if req.Coin != "" && pay.Coin(req.Coin) != cryptoPay.backend.Coin() {
7270 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Unsupported coin"})
7271 }
7272
7273 total, err := validateOrderItems(req.Items)
7274 if err != nil {
7275 log.Printf("invoice validation failed: %v", err)
7276 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
7277 }
7278 if total < 50 {
7279 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
7280 }
7281
7282 ctx, cancel := context.WithTimeout(c.Context(), 20*time.Second)
7283 defer cancel()
7284
7285 rate, source, err := cryptoPay.rater.Quote(ctx)
7286 if err != nil {
7287 // No usable price means no sale. Quoting anyway is the mistake the
7288 // rate layer exists to prevent.
7289 log.Printf("invoice refused, no rate: %v", err)
7290 return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "No price available right now"})
7291 }
7292 coin := cryptoPay.backend.Coin()
7293 atomic, err := pay.AtomicFor(total, rate, coin.Decimals())
7294 if err != nil {
7295 log.Printf("invoice pricing failed: %v", err)
7296 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to price the order"})
7297 }
7298
7299 now := time.Now()
7300 taken, err := cryptoPay.store.TakenAddresses(coin, now)
7301 if err != nil {
7302 log.Printf("invoice address reservation failed: %v", err)
7303 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to allocate an address"})
7304 }
7305 addr, err := cryptoPay.backend.NextAddress(ctx, taken)
7306 if err != nil {
7307 log.Printf("invoice address allocation failed: %v", err)
7308 return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Unable to allocate an address"})
7309 }
7310
7311 inv := &pay.Invoice{
7312 ID: newInvoiceID(), Coin: coin, Address: addr, Atomic: atomic,
7313 FiatCents: total, Rate: rate.FloatString(10), RateSource: source,
7314 Items: req.Items, Created: now, Expires: now.Add(cryptoPay.window),
7315 Status: pay.StatusPending,
7316 }
7317 if err := cryptoPay.store.Put(inv); err != nil {
7318 log.Printf("invoice save failed: %v", err)
7319 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save the invoice"})
7320 }
7321 return c.JSON(invoiceView(inv))
7322}
7323
7324// invoiceStatus reports one invoice, polling the chain first so a reader
7325// watching the page sees the payment as soon as it confirms.
7326func invoiceStatus(c fiber.Ctx) error {
7327 inv, err := cryptoPay.store.Get(c.Params("id"))
7328 if err != nil {
7329 return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "No such invoice"})
7330 }
7331 if inv.Status != pay.StatusPaid {
7332 ctx, cancel := context.WithTimeout(c.Context(), 15*time.Second)
7333 defer cancel()
7334 if err := refreshInvoice(ctx, inv); err != nil {
7335 log.Printf("invoice %s refresh failed: %v", inv.ID, err)
7336 }
7337 }
7338 return c.JSON(invoiceView(inv))
7339}
7340
7341// invoiceView is what a client is told: never the whole record, because the
7342// items and the rate source are the store's business, not the page's.
7343func invoiceView(inv *pay.Invoice) fiber.Map {
7344 return fiber.Map{
7345 "id": inv.ID,
7346 "coin": string(inv.Coin),
7347 "address": inv.Address,
7348 "amount": pay.AmountString(inv.Atomic, inv.Coin.Decimals()),
7349 "uri": inv.URI(),
7350 "fiat_cents": inv.FiatCents,
7351 "status": string(inv.Status),
7352 "paid": pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()),
7353 "outstanding": pay.AmountString(inv.Outstanding(), inv.Coin.Decimals()),
7354 "expires": inv.Expires.Format(time.RFC3339),
7355 "txid": inv.TxID,
7356 }
7357}
7358
7359// refreshInvoice asks the chain, records what it finds, and β the first time
7360// an invoice settles β writes the order and prints the receipt.
7361func refreshInvoice(ctx context.Context, inv *pay.Invoice) error {
7362 atomic, txid, confirmed, err := cryptoPay.backend.Received(ctx, inv.Address)
7363 if err != nil {
7364 return err
7365 }
7366 changed := inv.Apply(atomic, txid, confirmed, time.Now())
7367 settled := inv.Status == pay.StatusPaid && !inv.Printed
7368 if settled {
7369 if err := completeInvoice(inv); err != nil {
7370 // The money arrived; failing to print must not lose that.
7371 log.Printf("invoice %s settled but could not be completed: %v", inv.ID, err)
7372 } else {
7373 inv.Printed = true
7374 changed = true
7375 }
7376 }
7377 if changed {
7378 return cryptoPay.store.Put(inv)
7379 }
7380 return nil
7381}
7382
7383// completeInvoice writes the order beside the card orders and prints it.
7384func completeInvoice(inv *pay.Invoice) error {
7385 items := make([]map[string]any, 0, len(inv.Items))
7386 for _, it := range inv.Items {
7387 items = append(items, map[string]any{"id": it.ID, "amount": it.Amount, "quantity": 1})
7388 }
7389 local := map[string]any{"items": items}
7390 orderData := map[string]any{
7391 "clientData": local,
7392 "verifiedCents": inv.FiatCents,
7393 "currency": "usd",
7394 "cryptoStatus": string(inv.Status),
7395 "coin": string(inv.Coin),
7396 "coinAmount": pay.AmountString(inv.Atomic, inv.Coin.Decimals()),
7397 "coinPaid": pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()),
7398 "rateUSD": inv.Rate,
7399 "rateSource": inv.RateSource,
7400 "address": inv.Address,
7401 "txid": inv.TxID,
7402 "submittedAt": time.Now().Format(time.RFC3339),
7403 }
7404 data, err := json.MarshalIndent(orderData, "", " ")
7405 if err != nil {
7406 return err
7407 }
7408 id := string(inv.Coin) + "-" + inv.ID
7409 path := filepath.Join(cryptoPay.ordersIn, id+".json")
7410 if err := os.WriteFile(path, data, 0o600); err != nil {
7411 return err
7412 }
7413 receipt, err := buildReceipt(local, id)
7414 if err != nil {
7415 return err
7416 }
7417 if err := sendToCUPS(receipt, "Order "+id); err != nil {
7418 _ = os.WriteFile(filepath.Join(cryptoPay.ordersIn, id+".print_failed"), []byte(err.Error()), 0o600) //nolint:errcheck,gosec // best-effort marker; the failure is logged
7419 return err
7420 }
7421 return nil
7422}
7423
7424// WatchInvoices polls live invoices until ctx is done.
7425//
7426// The page polls too, but a reader who closes the tab still bought
7427// something: without this, an order paid after the last poll would sit in
7428// orders/ unprinted and unnoticed.
7429func WatchInvoices(ctx context.Context) {
7430 if cryptoPay == nil {
7431 return
7432 }
7433 t := time.NewTicker(time.Minute)
7434 defer t.Stop()
7435 for {
7436 select {
7437 case <-ctx.Done():
7438 return
7439 case <-t.C:
7440 live, err := cryptoPay.store.Live()
7441 if err != nil {
7442 log.Printf("invoice poll failed: %v", err)
7443 continue
7444 }
7445 for _, inv := range live {
7446 if err := refreshInvoice(ctx, inv); err != nil {
7447 log.Printf("invoice %s poll failed: %v", inv.ID, err)
7448 }
7449 }
7450 }
7451 }
7452}
7453
7454// validateOrderItems totals an order against the server's own catalog.
7455//
7456// Shared with the Stripe path: a client is never believed about a price in
7457// either. The shipping line is the one the client sets, so it is held to the
7458// same minimum the form enforces β otherwise a hand-written request could
7459// ship for a cent.
7460func validateOrderItems(items []pay.Item) (int64, error) {
7461 total := int64(0)
7462 for _, it := range items {
7463 if it.Amount <= 0 {
7464 return 0, fmt.Errorf("non-positive amount for %q", it.ID)
7465 }
7466 if storepage.IsShippingID(it.ID) {
7467 if it.Amount < int64(storepage.MinShippingCents) {
7468 return 0, fmt.Errorf("shipping below the minimum: %d cents", it.Amount)
7469 }
7470 total += it.Amount
7471 continue
7472 }
7473 expected, err := validateItemAmount(it.ID, it.Amount)
7474 if err != nil {
7475 return 0, err
7476 }
7477 total += expected
7478 }
7479 return total, nil
7480}
7481
7482// newInvoiceID is a random id: it names a file and appears in a URL, and
7483// must not be guessable from an order number.
7484func newInvoiceID() string {
7485 var b [12]byte
7486 if _, err := rand.Read(b[:]); err != nil {
7487 return fmt.Sprintf("%d", time.Now().UnixNano())
7488 }
7489 return hex.EncodeToString(b[:])
7490}
7491
7492
7493// ===== pkg/web/invoice_test.go =====
7494package web
7495
7496import (
7497 "testing"
7498
7499 "github.com/0magnet/m2/pkg/pay"
7500 "github.com/0magnet/m2/pkg/storepage"
7501)
7502
7503// Both payment paths total an order through this, so a client is never
7504// believed about a price in one and checked in the other.
7505func TestValidateOrderItemsHoldsShippingToTheMinimum(t *testing.T) {
7506 ship := storepage.Shipping{
7507 Cents: storepage.MinShippingCents, Name: "A", City: "Dallas",
7508 State: "TX", Country: storepage.DefaultCountry,
7509 }
7510
7511 // A shipping line at the minimum is fine.
7512 total, err := validateOrderItems([]pay.Item{
7513 {ID: ship.ID(), Amount: int64(storepage.MinShippingCents)},
7514 })
7515 if err != nil {
7516 t.Fatalf("the minimum was refused: %v", err)
7517 }
7518 if total != int64(storepage.MinShippingCents) {
7519 t.Errorf("total = %d, want %d", total, storepage.MinShippingCents)
7520 }
7521
7522 // A cent of shipping is not. The form enforces this in the browser;
7523 // a hand-written request does not go through the form.
7524 if _, err := validateOrderItems([]pay.Item{
7525 {ID: ship.ID(), Amount: 1},
7526 }); err == nil {
7527 t.Error("a one-cent shipping line was accepted")
7528 }
7529}
7530
7531func TestValidateOrderItemsRejectsNonPositive(t *testing.T) {
7532 if _, err := validateOrderItems([]pay.Item{{ID: "X-1 X 1", Amount: 0}}); err == nil {
7533 t.Error("a zero-amount line was accepted")
7534 }
7535 if _, err := validateOrderItems([]pay.Item{{ID: "X-1 X 1", Amount: -500}}); err == nil {
7536 t.Error("a negative line was accepted")
7537 }
7538}
7539
7540// An unknown part cannot be priced, so it cannot be sold.
7541func TestValidateOrderItemsRejectsUnknownPart(t *testing.T) {
7542 if _, err := validateOrderItems([]pay.Item{
7543 {ID: "NO-SUCH-PART-12345 X 1", Amount: 100},
7544 }); err == nil {
7545 t.Error("an unknown part was priced")
7546 }
7547}
7548
7549// Crypto is off unless a wallet is configured: a store with nothing set
7550// should show no button rather than one that errors.
7551func TestCryptoOffWithoutAnXpub(t *testing.T) {
7552 saved := f.SkyXpub
7553 f.SkyXpub = ""
7554 defer func() { f.SkyXpub = saved }()
7555 if got := initCrypto(t.TempDir()); got != nil {
7556 t.Error("crypto checkout came up with no xpub configured")
7557 }
7558}
7559
7560// ...and off when there is a wallet but no way to price it, rather than
7561// quoting from a guess.
7562func TestCryptoOffWithoutARate(t *testing.T) {
7563 savedX, savedP, savedF := f.SkyXpub, f.SkyRatePair, f.SkyRateFix
7564 f.SkyXpub = "xpub6DH2sNie8sDh7cUbZRXeMEHaJrU72g8UZ45Z8VS26oUc8NMZrfbgpWH1U1osnJDisro6sUDVNL6MbnsAmfDNXDLjC8UfRLuM2YSoPMeY4DS"
7565 f.SkyRatePair, f.SkyRateFix = "", ""
7566 defer func() { f.SkyXpub, f.SkyRatePair, f.SkyRateFix = savedX, savedP, savedF }()
7567 if got := initCrypto(t.TempDir()); got != nil {
7568 t.Error("crypto checkout came up with no rate source")
7569 }
7570}
7571
7572
7573// ===== pkg/web/navmenu.go =====
7574package web
7575
7576// The category menu's layout.
7577//
7578// The drawing β β/β branches, a β¬ where a category opens into subcategories,
7579// and the counts right-aligned by padding the rule itself β is tallytree's. It
7580// used to be written out twice: once here in htmpl/catsubcats.html with the
7581// repeat and sub template functions, and once again in pkg/tui for the terminal,
7582// whose comment read "reproduces htmpl/catsubcats.html". Two implementations of
7583// one picture, kept in agreement by hand and tested by neither.
7584//
7585// What stays here is the markup. The template asks for the rows and wraps each
7586// one in the disclosure elements and anchors it needs; it no longer works out
7587// where anything goes.
7588
7589import (
7590 htmpl "html/template"
7591
7592 "github.com/0magnet/tallytree"
7593)
7594
7595// navRow is one line of the menu: the glyphs that lead up to it, its count,
7596// and what it points at.
7597//
7598// Prefix is HTML because its blanks are non-breaking spaces. An ordinary space
7599// there would collapse and the tree would lose its columns.
7600type navRow struct {
7601 Prefix htmpl.HTML
7602 Count int
7603 Label string
7604 HasSubs bool
7605}
7606
7607// navCat is a category row together with the subcategory rows beneath it, which
7608// is the shape the markup needs: each category is a <details> holding its own
7609// subcategories.
7610type navCat struct {
7611 navRow
7612 Subs []navRow
7613}
7614
7615// navMenu is the whole menu: the "everything" row, then the categories.
7616type navMenu struct {
7617 All navRow
7618 Cats []navCat
7619}
7620
7621// navmenu lays the menu out for a page.
7622//
7623// Depth is pinned to two because the menu always COULD have subcategories, so
7624// it reserves room for them even in a catalog where nothing does β otherwise a
7625// flat catalog would draw a narrower tree than the same shop with one
7626// subcategory added, and the menu would change shape as stock came and went.
7627//
7628// Every branch is drawn open: the disclosure elements do the hiding, so the
7629// rows are always all there.
7630func navmenu(d htmlTemplateData) navMenu {
7631 forest := []tallytree.Node{{Label: "All Products", Count: d.LenAllProducts}}
7632 for _, cat := range d.Cats {
7633 n := tallytree.Node{Label: cat, Count: d.CatsCounts[cat]}
7634 for _, sub := range d.SubCatsByCat[cat] {
7635 n.Children = append(n.Children, tallytree.Node{Label: sub, Count: d.SubCatsCounts[cat][sub]})
7636 }
7637 forest = append(forest, n)
7638 }
7639 rows := tallytree.Rows(forest, tallytree.Options{
7640 Depth: 2,
7641 Glyphs: tallytree.GlyphSet{Blank: " "},
7642 })
7643 var m navMenu
7644 for i, r := range rows {
7645 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
7646 switch {
7647 case i == 0:
7648 m.All = row
7649 case r.Depth == 0:
7650 m.Cats = append(m.Cats, navCat{navRow: row})
7651 default:
7652 m.Cats[len(m.Cats)-1].Subs = append(m.Cats[len(m.Cats)-1].Subs, row)
7653 }
7654 }
7655 return m
7656}
7657
7658
7659// ===== pkg/web/order.go =====
7660// Package web pkg/web/order.go β checkout, order persistence, receipt printing.
7661package web
7662
7663import (
7664 "bytes"
7665 "encoding/json"
7666 "fmt"
7667 htmpl "html/template"
7668 "log"
7669 "os"
7670 "path/filepath"
7671 "regexp"
7672 "strconv"
7673 "strings"
7674 "time"
7675
7676 "github.com/bitfield/script"
7677 "github.com/gofiber/fiber/v3"
7678 "github.com/stripe/stripe-go/v81"
7679 "github.com/stripe/stripe-go/v81/paymentintent"
7680
7681 "github.com/0magnet/m2/pkg/pay"
7682)
7683
7684// validPIID matches Stripe PaymentIntent IDs: "pi_" followed by alphanumeric chars.
7685// Also allows plain alphanumeric+underscore+hyphen for test order IDs.
7686var validPIID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
7687
7688func handleOrder(r *fiber.App) {
7689 r.Get("/checkout.css", func(c fiber.Ctx) error {
7690 c.Set("Content-Type", "text/css;charset=utf-8")
7691 _, err := c.Status(fiber.StatusOK).Write([]byte(h.CheckoutCSS()))
7692 return err
7693 })
7694
7695 r.Get("/complete", func(c fiber.Ctx) error {
7696 // Complete template
7697 completetmpl := htmpl.New("index")
7698 if _, err := completetmpl.Parse(h.CompletePage()); err != nil {
7699 msg := fmt.Sprintf("Error parsing complete page template: %v", err)
7700 log.Println(msg)
7701 return c.Status(fiber.StatusInternalServerError).SendString(msg)
7702 }
7703 if _, err := completetmpl.New("wasm").Parse(h.Wasm()); err != nil {
7704 log.Println("Error parsing wasm template:", err)
7705 msg := fmt.Sprintf("Error parsing wasm template: %v", err)
7706 log.Println(msg)
7707 return c.Status(fiber.StatusInternalServerError).SendString(msg)
7708 }
7709 h1 := htmlPageTemplateData
7710 // The checkout completion page is what the cart wasm drives: it
7711 // fills in the payment status icon, text and intent id.
7712 h1.WasmBinary = wasmFor("cart")
7713 /*
7714 proto := "http"
7715 if c.Secure() {
7716 proto += "s"
7717 }
7718 */
7719 proto := "https"
7720 h1.Canonical = proto + `://` + c.Hostname() + c.OriginalURL()
7721 h1.BaseURL = proto + `://` + c.Hostname()
7722 h1.RequestHost = c.Hostname()
7723 h1.Protocol = proto
7724 h1.Time = time.Now().Format(time.RFC3339Nano)
7725 h1.Year = fmt.Sprintf("%v", time.Now().Year())
7726 tmplData := map[string]interface{}{
7727 "Page": h1,
7728 }
7729 var result bytes.Buffer
7730 err := completetmpl.Execute(&result, tmplData)
7731 if err != nil {
7732 msg := fmt.Sprintf("Could not execute html template %v", err)
7733 log.Println(msg)
7734 return c.Status(fiber.StatusInternalServerError).SendString(msg)
7735 }
7736 c.Set("Content-Type", "text/html;charset=utf-8")
7737 return c.Status(fiber.StatusOK).Send(result.Bytes())
7738 })
7739
7740 r.Get("/order/:piid", func(c fiber.Ctx) error {
7741 piid := c.Params("piid")
7742 if !validPIID.MatchString(piid) {
7743 return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
7744 }
7745 order, err := script.File("orders/" + piid + ".json").Bytes()
7746 if err != nil {
7747 return c.Status(fiber.StatusNotFound).SendString("Order not found")
7748 }
7749 return c.Status(fiber.StatusOK).Send(order)
7750 })
7751
7752 r.Get("/order/:piid/html", func(c fiber.Ctx) error {
7753 piid := c.Params("piid")
7754 if !validPIID.MatchString(piid) {
7755 return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
7756 }
7757 order, err := script.File("orders/" + piid + ".json").Bytes()
7758 if err != nil {
7759 return c.Status(fiber.StatusNotFound).SendString("Order not found")
7760 }
7761 var m map[string]interface{}
7762 if err := json.Unmarshal(order, &m); err != nil {
7763 return c.Status(500).SendString("failed to unmarshal order json: " + err.Error())
7764 }
7765 receipt, err := buildReceipt(m, piid)
7766 if err != nil {
7767 return c.Status(500).SendString("failed to build receipt: " + err.Error())
7768 }
7769 return c.Status(200).SendString(string(receipt))
7770 })
7771
7772 r.Post("/create-payment-intent", func(c fiber.Ctx) error {
7773 rawBody := c.Body()
7774 if rawBody == nil {
7775 log.Printf("Failed to read raw request body")
7776 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to read request body"})
7777 }
7778
7779 var req struct {
7780 Items []item `json:"items"`
7781 }
7782 if err := json.Unmarshal(rawBody, &req); err != nil {
7783 log.Printf("Failed to parse JSON: %v", err)
7784 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
7785 }
7786
7787 if len(req.Items) == 0 {
7788 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
7789 }
7790
7791 // Validate each item's amount against the server-side product
7792 // catalog. Shared with the crypto path (see invoice.go) so a client
7793 // is never believed about a price in one and checked in the other.
7794 items := make([]pay.Item, 0, len(req.Items))
7795 for _, it := range req.Items {
7796 items = append(items, pay.Item{ID: it.ID, Amount: it.Amount})
7797 }
7798 total, err := validateOrderItems(items)
7799 if err != nil {
7800 log.Printf("Item validation failed: %v", err)
7801 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
7802 }
7803
7804 if total < 50 {
7805 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
7806 }
7807
7808 params := &stripe.PaymentIntentParams{
7809 Amount: stripe.Int64(total),
7810 Currency: stripe.String(string(stripe.CurrencyUSD)),
7811 }
7812 pi, err := paymentintent.New(params)
7813 if err != nil {
7814 log.Printf("Failed to create PaymentIntent: %v", err)
7815 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
7816 }
7817
7818 log.Printf("Created PaymentIntent %s for %d cents", pi.ID, total)
7819 return c.Status(fiber.StatusOK).JSON(fiber.Map{
7820 "clientSecret": pi.ClientSecret,
7821 "dpmCheckerLink": fmt.Sprintf("https://dashboard.stripe.com/settings/payment_methods/review?transaction_id=%s", pi.ID),
7822 })
7823 })
7824
7825 r.Post("/submit-order", func(c fiber.Ctx) error {
7826 var requestData struct {
7827 LocalStorageData map[string]interface{} `json:"localStorageData"`
7828 PaymentIntentID string `json:"paymentIntentId"`
7829 }
7830
7831 if err := c.Bind().Body(&requestData); err != nil {
7832 log.Println(err)
7833 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request data"})
7834 }
7835
7836 if !validPIID.MatchString(requestData.PaymentIntentID) {
7837 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payment intent ID"})
7838 }
7839
7840 log.Printf("Received payment intent ID: %s\n", requestData.PaymentIntentID)
7841
7842 paymentIntent, err := paymentintent.Get(requestData.PaymentIntentID, nil)
7843 if err != nil {
7844 log.Printf("Error retrieving payment intent: %v", err)
7845 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to verify payment"})
7846 }
7847 if paymentIntent.Status != stripe.PaymentIntentStatusSucceeded {
7848 log.Printf("Payment was not successful, status: %s", paymentIntent.Status)
7849 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Payment not successful"})
7850 }
7851
7852 ordersDir := "./orders"
7853 if err := os.MkdirAll(ordersDir, 0o750); err != nil {
7854 log.Printf("Error creating orders directory: %v", err)
7855 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
7856 }
7857
7858 filePath := filepath.Join(ordersDir, fmt.Sprintf("%s.json", requestData.PaymentIntentID))
7859
7860 // Idempotency: if the order file already exists, don't overwrite or reprint
7861 if _, err := os.Stat(filePath); err == nil {
7862 log.Printf("Order %s already exists, skipping duplicate submission", requestData.PaymentIntentID)
7863 return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order already submitted"})
7864 }
7865
7866 // Include the verified Stripe amount alongside the client-supplied data
7867 orderData := map[string]interface{}{
7868 "clientData": requestData.LocalStorageData,
7869 "verifiedCents": paymentIntent.Amount,
7870 "currency": string(paymentIntent.Currency),
7871 "stripeStatus": string(paymentIntent.Status),
7872 "submittedAt": time.Now().Format(time.RFC3339),
7873 }
7874
7875 data, err := json.MarshalIndent(orderData, "", " ")
7876 if err != nil {
7877 log.Printf("Error marshaling data to json: %v", err)
7878 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
7879 }
7880 if err := os.WriteFile(filePath, data, 0o600); err != nil {
7881 log.Printf("Error writing data to file: %v", err)
7882 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
7883 }
7884
7885 // ---- Print receipt via CUPS (non-blocking so your response is snappy)
7886 go func(pid string, local map[string]interface{}) {
7887 receipt, err := buildReceipt(local, pid)
7888 if err != nil {
7889 log.Printf("build receipt failed: %v", err)
7890 return
7891 }
7892 if err := sendToCUPS(receipt, "Order "+pid); err != nil {
7893 log.Printf("print failed: %v", err)
7894 _ = 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
7895 }
7896 }(requestData.PaymentIntentID, requestData.LocalStorageData)
7897
7898 return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order submitted successfully"})
7899 })
7900
7901 /*
7902 r.Post("/reprint/:pid", func(c fiber.Ctx) error {
7903 pid := c.Params("pid")
7904 b, err := os.ReadFile(filepath.Join("./orders", pid+".json"))
7905 if err != nil { return c.Status(404).SendString("not found") }
7906 var m map[string]interface{}
7907 if err := json.Unmarshal(b, &m); err != nil { return c.Status(500).SendString(err.Error()) }
7908 receipt, err := buildReceipt(m, pid)
7909 if err != nil { return c.Status(500).SendString(err.Error()) }
7910 if err := sendToCUPS(receipt, "Order "+pid); err != nil {
7911 return c.Status(500).SendString(err.Error())
7912 }
7913 return c.SendStatus(204)
7914 })
7915 */
7916}
7917
7918func buildReceipt(local map[string]interface{}, paymentIntentID string) ([]byte, error) {
7919 // Pretty JSON body from what you already persisted
7920 body, err := json.MarshalIndent(local, "", " ")
7921 if err != nil {
7922 return nil, err
7923 }
7924 // Simple text receipt header
7925 ts := time.Now().Format("2006-01-02 15:04:05")
7926 hdr := fmt.Sprintf(
7927 "==================== ORDER ====================\n"+
7928 "PaymentIntent: %s\nTime: %s\n===============================================\n\n",
7929 paymentIntentID, ts,
7930 )
7931 // Footer (optional)
7932 ftr := "\n\n---------------------- END ---------------------\n"
7933 receipt := append([]byte(hdr), body...)
7934 receipt = append(receipt, []byte(ftr)...)
7935 return receipt, nil
7936}
7937
7938// serverPriceCents looks up a product's price from the server-side catalog by part number.
7939func serverPriceCents(partno string) (int64, error) {
7940 allproductsMu.RLock()
7941 prods := allproducts
7942 allproductsMu.RUnlock()
7943 for _, prod := range prods {
7944 if prod.Partno == partno {
7945 return parsePriceCents(prod.Price), nil
7946 }
7947 }
7948 return 0, fmt.Errorf("product %q not found in catalog", partno)
7949}
7950
7951// parsePriceCents converts a price string like "$1.23" or "1.23" to cents.
7952func parsePriceCents(s string) int64 {
7953 if s == "" {
7954 return 0
7955 }
7956 s = strings.TrimPrefix(s, "$")
7957 f, err := strconv.ParseFloat(s, 64)
7958 if err != nil {
7959 return 0
7960 }
7961 if f < 0 {
7962 return -int64(-f*100 + 0.5)
7963 }
7964 return int64(f*100 + 0.5)
7965}
7966
7967// validateItemAmount parses a client item ID ("partno X qty"), looks up the
7968// server-side price, computes the expected total, and returns it. If the
7969// client-supplied amount doesn't match, an error is returned.
7970func validateItemAmount(itemID string, clientAmount int64) (int64, error) {
7971 // Parse "partno X qty"
7972 parts := strings.SplitN(itemID, " X ", 2)
7973 if len(parts) != 2 {
7974 return 0, fmt.Errorf("unexpected item ID format: %q", itemID)
7975 }
7976 partno := parts[0]
7977 qty, err := strconv.Atoi(parts[1])
7978 if err != nil || qty <= 0 {
7979 return 0, fmt.Errorf("invalid quantity in item ID %q", itemID)
7980 }
7981
7982 unitCents, err := serverPriceCents(partno)
7983 if err != nil {
7984 return 0, err
7985 }
7986 expected := unitCents * int64(qty)
7987 if expected != clientAmount {
7988 return 0, fmt.Errorf("amount mismatch for %q: client sent %d cents, server expects %d cents", partno, clientAmount, expected)
7989 }
7990 return expected, nil
7991}
7992
7993// escape for inclusion inside *double quotes* in a bash command string
7994func bashEscapeDoubleQuoted(s string) string {
7995 s = strings.ReplaceAll(s, `\`, `\\`)
7996 s = strings.ReplaceAll(s, `"`, `\"`)
7997 s = strings.ReplaceAll(s, "$", `\$`)
7998 s = strings.ReplaceAll(s, "`", "\\`")
7999 return s
8000}
8001
8002func sendToCUPS(receipt []byte, title string) error {
8003 if title == "" {
8004 title = "Order"
8005 }
8006 var cmd strings.Builder
8007 cmd.WriteString("lp")
8008
8009 if f.PrinterName != "" {
8010 cmd.WriteString(` -d "`)
8011 cmd.WriteString(bashEscapeDoubleQuoted(f.PrinterName))
8012 cmd.WriteString(`"`)
8013 }
8014
8015 cmd.WriteString(` -t "`)
8016 cmd.WriteString(bashEscapeDoubleQuoted(title))
8017 cmd.WriteString(`"`)
8018
8019 if f.CupsOptions != "" {
8020 for _, opt := range strings.Split(f.CupsOptions, ",") {
8021 opt = strings.TrimSpace(opt)
8022 if opt == "" {
8023 continue
8024 }
8025 cmd.WriteString(` -o "`)
8026 cmd.WriteString(bashEscapeDoubleQuoted(opt))
8027 cmd.WriteString(`"`)
8028 }
8029 }
8030
8031 full := fmt.Sprintf(`bash -lc %q`, cmd.String())
8032
8033 _, err := script.Echo(string(receipt)).Exec(full).Stdout()
8034 if err != nil {
8035 return fmt.Errorf("lp failed: %v", err)
8036 }
8037 return nil
8038}
8039
8040
8041// ===== pkg/web/other.go =====
8042// Package web pkg/web/other.go β site-specific drop-in routes (not
8043// committed). Registers via the extraRoutes registry in server.go;
8044// deleting this file removes these routes with no other code changes.
8045// See other.go.example.
8046package web
8047
8048import (
8049 "bytes"
8050 "fmt"
8051 "log"
8052 "strconv"
8053 "strings"
8054
8055 "github.com/gofiber/fiber/v3"
8056)
8057
8058func init() {
8059 extraRoutes = append(extraRoutes, handleOthers)
8060 // The software page is the only one here worth crawling; see demospage.
8061 // /demos is kept as the older spelling of the same page.
8062 extraSitemapURLs = append(extraSitemapURLs, "/software")
8063 extraNavLinks = append(extraNavLinks, NavLink{
8064 Title: "Software",
8065 Href: "/software",
8066 Desc: "programs written here, running in your browser",
8067 })
8068}
8069
8070func handleOthers(r *fiber.App) {
8071 r.Get("/coffee", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusTeapot) })
8072 r.Get("/clock", clock)
8073 r.Get("/attractors", attractorspage)
8074 r.Get("/COVID", covidpage)
8075 r.Get("/test", testpage)
8076 r.Get("/software", demospage)
8077 r.Get("/software/", demospage)
8078 // The page was published as /demos first and is linked from the READMEs
8079 // of several repositories, so that spelling keeps working.
8080 r.Get("/demos", demospage)
8081 r.Get("/demos/", demospage)
8082}
8083
8084func clock(c fiber.Ctx) error {
8085 c.Set("Content-Type", "text/html;charset=utf-8")
8086 _, err := c.Status(fiber.StatusOK).Write([]byte(mustReadFileToString("content/clock.html")))
8087 return err
8088}
8089
8090func covidpage(c fiber.Ctx) error {
8091 tmpl, err := mainTmpl()
8092 if err != nil {
8093 msg := fmt.Sprintf("Error parse html template: %v", err)
8094 log.Println(msg)
8095 return c.Status(fiber.StatusInternalServerError).SendString(msg)
8096 }
8097 tmpl0, err := tmpl.Clone()
8098 if err != nil {
8099 msg := fmt.Sprintf("Error cloning template: %v", err)
8100 log.Println(msg)
8101 return c.Status(fiber.StatusInternalServerError).SendString(msg)
8102 }
8103 _, err = tmpl0.New("main").Parse(mustReadFileToString("content/mementomori.html"))
8104 if err != nil {
8105 msg := fmt.Sprintf("Error parsing main template: %v", err)
8106 log.Println(msg)
8107 return c.Status(fiber.StatusInternalServerError).SendString(msg)
8108 }
8109 tmpl = tmpl0
8110 log.Println(c.Get("User-Agent"))
8111 c.Set("Content-Type", "text/html;charset=utf-8")
8112 h1 := pageMeta(c, htmlPageTemplateData)
8113 h1.Page = "hidden"
8114 h1.MetaDesc = "The COVID ΜΆvΜΆaΜΆcΜΆcΜΆiΜΆnΜΆeΜΆ bioweapon injection genocide and the new dark age of humanity"
8115 // h1.Mobile = strings.Contains(strings.ToLower(c.Get("User-Agent")), "mobile")
8116 tmplData := map[string]interface{}{
8117 "Page": h1,
8118 "Prods": allproducts,
8119 }
8120 var result bytes.Buffer
8121 err = tmpl.Execute(&result, tmplData)
8122 if err != nil {
8123 msg := fmt.Sprintf("Error executing template: %v", err)
8124 log.Println(msg)
8125 return c.Status(fiber.StatusInternalServerError).SendString(msg)
8126 }
8127 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
8128 return err
8129}
8130
8131// attractorspage renders a chromeless fullscreen page for the
8132// strange-attractor visualizer (no header, footer, cart, or store
8133// nav). Reuses the existing stl2 wasm β its URL-path dispatcher
8134// sees "/attractors" and falls into the default branch which
8135// invokes attractor.Run(). Loads the TinyGo or stdlib wasm based
8136// on f.UseTinygo.
8137func attractorspage(c fiber.Ctx) error {
8138 // Newest build wins, with the shim that matches it β the same rule the
8139 // template pages use. Hardcoding "-tiny.wasm" here meant this page served
8140 // whatever the last tinygo run produced even when the Go build was newer,
8141 // and paired it with the site-global shim rather than its own.
8142 pick, ok := pickWasm("stl2")
8143 if !ok {
8144 return c.Status(fiber.StatusNotFound).SendString("no stl2 wasm build available")
8145 }
8146 wasmFile := pick.File
8147 html := fmt.Sprintf(`<!DOCTYPE html>
8148<html lang="en">
8149<head>
8150<meta charset="utf-8">
8151<meta name="viewport" content="width=device-width, initial-scale=1">
8152<title>Strange Attractors β %s</title>
8153<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.">
8154<meta name="robots" content="index, follow">
8155<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>
8156<script src="%s"></script>
8157<script>
8158if (!WebAssembly.instantiateStreaming) {
8159 WebAssembly.instantiateStreaming = async (resp, importObject) => {
8160 const source = await (await resp).arrayBuffer();
8161 return await WebAssembly.instantiate(source, importObject);
8162 };
8163}
8164const go = new Go();
8165WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject).then((result) => {
8166 go.run(result.instance);
8167}).catch((err) => { console.error("Failed to run WASM:", err); });
8168</script>
8169</head>
8170<body>
8171<canvas id="gocanvas"></canvas>
8172</body>
8173</html>`, f.Sitelongname, f.WasmExecPath, wasmFile)
8174 c.Set("Content-Type", "text/html; charset=utf-8")
8175 return c.Status(fiber.StatusOK).SendString(html)
8176}
8177
8178// ---------------------------------------------------------------------------
8179// /demos β the live demos hosted on magnetosphere.net subdomains.
8180//
8181// Every one of these projects publishes its demo from GitHub Pages under a
8182// CNAME of this domain (chaosrack.magnetosphere.net and so on), and until now
8183// nothing on the web linked to them. A page nobody links to is a page a
8184// crawler reaches only through the certificate transparency logs, if at all β
8185// so the subdomains existed but were effectively unfindable.
8186//
8187// This is the index that fixes that. It is a real page at a real URL rather
8188// than another anchor on the front page, for two reasons: an anchor section
8189// shares the front page's <title> and description, and this content is not
8190// about electronic surplus; and the front page hides every non-target section
8191// with CSS, so the text would be in the markup but never rendered to a reader.
8192//
8193// It lives in the drop-in rather than in the m2 repo because it is specific to
8194// this deployment β no other m2 site has these subdomains. The one piece that
8195// did go upstream is extraSitemapURLs, which is the generic half: a drop-in
8196// route that wants to be crawled needs to be in the sitemap, and there was no
8197// way to say so.
8198// ---------------------------------------------------------------------------
8199
8200// demoAlt is a second build of the same demo, linked beside the first. Several
8201// of these ship a TinyGo build and a standard-Go build of the same program;
8202// the difference is size against completeness, and both are worth reaching.
8203type demoAlt struct {
8204 Label string
8205 Path string
8206}
8207
8208// demoSite is one demo: where it runs, where its source is, and one sentence
8209// saying what it actually does. The sentence is the part that matters β it is
8210// what a search result shows and the only description of these subdomains that
8211// exists anywhere.
8212type demoSite struct {
8213 Name string
8214 Host string
8215 Repo string
8216 What string
8217 Alts []demoAlt
8218
8219 // Pending holds a demo back until its subdomain actually answers over
8220 // HTTPS. GitHub issues a Pages certificate some time after the DNS lands,
8221 // and until it does the link is a TLS error rather than a page.
8222 //
8223 // The entry stays here rather than being deleted and rewritten later,
8224 // because the description is the part that took the thought. Clearing this
8225 // flag is the whole of the work when the certificate arrives β and a page
8226 // whose entire purpose is to be crawled must not be the thing that teaches
8227 // a crawler these hostnames are broken.
8228 Pending bool
8229}
8230
8231// demoGroup is a heading over a run of demos.
8232type demoGroup struct {
8233 Title string
8234 Note string
8235 Sites []demoSite
8236}
8237
8238var demoGroups = []demoGroup{{
8239 Title: "Applications",
8240 Note: "Whole programs, running in the tab. Nothing is uploaded and nothing runs on a server.",
8241 Sites: []demoSite{
8242 {
8243 Name: "chaosrack",
8244 Host: "chaosrack.magnetosphere.net",
8245 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.",
8246 },
8247 {
8248 Name: "shipyard",
8249 Host: "shipyard.magnetosphere.net",
8250 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.",
8251 },
8252 {
8253 Name: "shipwright",
8254 Host: "shipwright.magnetosphere.net",
8255 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.",
8256 },
8257 {
8258 Name: "netscrape",
8259 Host: "netscrape.magnetosphere.net",
8260 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.",
8261 },
8262 {
8263 Name: "desk",
8264 Host: "desk.magnetosphere.net",
8265 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.",
8266 Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
8267 },
8268 {
8269 Name: "websh",
8270 Host: "websh.magnetosphere.net",
8271 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.",
8272 Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
8273 },
8274 {
8275 Name: "dict",
8276 Host: "dict.magnetosphere.net",
8277 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.",
8278 },
8279 {
8280 Name: "pisano",
8281 Host: "pisano.magnetosphere.net",
8282 What: "Designs made by reducing an integer sequence modulo m β the Pisano period made visible β with a shell to drive the renderer.",
8283 Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
8284 },
8285 },
8286}, {
8287 Title: "Libraries ported to Go and WebAssembly",
8288 Note: "JavaScript libraries reimplemented in Go, so a Go program in a tab can use them without a JavaScript dependency.",
8289 Sites: []demoSite{
8290 {
8291 Name: "xterm-go",
8292 Host: "xterm-go.magnetosphere.net",
8293 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.",
8294 },
8295 {
8296 Name: "winbox-go",
8297 Host: "winbox-go.magnetosphere.net",
8298 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.",
8299 },
8300 {
8301 Name: "cosmos-go",
8302 Host: "cosmos-go.magnetosphere.net",
8303 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.",
8304 },
8305 {
8306 Name: "tuiwasm",
8307 Host: "tuiwasm.magnetosphere.net",
8308 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.",
8309 Alts: []demoAlt{{Label: "TinyGo build", Path: "/tinygo/"}},
8310 },
8311 },
8312}, {
8313 Title: "Command-line tools ported to Go",
8314 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.",
8315 Sites: []demoSite{
8316 {
8317 Name: "plot-go",
8318 Host: "plot-go.magnetosphere.net",
8319 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.",
8320 },
8321 {
8322 Name: "img2txt-go",
8323 Host: "img2txt-go.magnetosphere.net",
8324 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.",
8325 },
8326 {
8327 Name: "ansifilter-go",
8328 Host: "ansifilter-go.magnetosphere.net",
8329 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.",
8330 },
8331 {
8332 Name: "calvin",
8333 Host: "calvin.magnetosphere.net",
8334 What: "Text converted to the Calvin S box-drawing font. Type in the box and watch the letters turn.",
8335 },
8336 },
8337}, {
8338 Title: "Interface libraries written here",
8339 Note: "Not ports of anything β original libraries, each with a demo that is the library doing its one job.",
8340 Sites: []demoSite{
8341 {
8342 Name: "rack-go",
8343 Host: "rack-go.magnetosphere.net",
8344 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.",
8345 },
8346 },
8347}, {
8348 // Not written here: these are upstream projects this site's author is a
8349 // contributor to. They are on the software page because the page is the
8350 // honest answer to "what does whoever runs this store work on", and
8351 // leaving them off made that answer look smaller than it is.
8352 //
8353 // Repo is set on both β demoRepo defaults to github.com/0magnet/<Name>,
8354 // which is wrong for anything upstream.
8355 Title: "Upstream projects contributed to",
8356 Note: "Not written here, and not WebAssembly. Listed because a good deal of the work behind this site went into them.",
8357 Sites: []demoSite{{
8358 Name: "skycoin",
8359 Host: "skycoin.magnetosphere.net",
8360 Repo: "https://github.com/skycoin/skycoin",
8361 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.",
8362 }, {
8363 Name: "skywire",
8364 Host: "theskywirenetwork.net",
8365 Repo: "https://github.com/skycoin/skywire",
8366 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.",
8367 }},
8368}}
8369
8370// demoRepo is the source URL for a demo. All of these live under the same
8371// GitHub account and the repository is named after the program.
8372func (d demoSite) demoRepo() string {
8373 if d.Repo != "" {
8374 return d.Repo
8375 }
8376 return "https://github.com/0magnet/" + d.Name
8377}
8378
8379// demosBody renders the page. It is plain markup in the vocabulary the rest of
8380// the site already uses β the α table and the β€ heading from the links page β
8381// so it inherits the stylesheet rather than carrying one of its own.
8382func demosBody() string {
8383 var b strings.Builder
8384 // A column of its own. main is full-bleed and centered, which is right for
8385 // the store front but runs prose to both edges of a wide window and puts the
8386 // last table column under the scrollbar.
8387 b.WriteString("<div style='max-width:1200px;margin:0 auto;padding:0 14px'>\n")
8388 b.WriteString("<h1>Software</h1>\n")
8389 b.WriteString("<p>Software written here, running in your browser. ")
8390 b.WriteString("Every one of these is Go compiled to WebAssembly, served from a subdomain of this site, ")
8391 b.WriteString("and open source. Nothing below needs an account, an install, or a round trip to a server.</p>\n")
8392
8393 for _, g := range demoGroups {
8394 // A group every one of whose demos is still waiting on a certificate
8395 // would render as a heading over nothing.
8396 if live(g.Sites) == 0 {
8397 continue
8398 }
8399 b.WriteString("<h2 class='β€'>" + g.Title + "</h2>\n")
8400 if g.Note != "" {
8401 b.WriteString("<p>" + g.Note + "</p>\n")
8402 }
8403 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")
8404 for _, d := range g.Sites {
8405 if d.Pending {
8406 continue
8407 }
8408 b.WriteString("<tr><td style='width:12%'><pre><a title='" + d.Host + "' href='https://" + d.Host + "/'>" + d.Name + "</a>")
8409 for _, a := range d.Alts {
8410 b.WriteString("<br><a title='" + d.Host + a.Path + "' href='https://" + d.Host + a.Path + "'>" + a.Label + "</a>")
8411 }
8412 b.WriteString("</pre></td>")
8413 b.WriteString("<td style='width:62%;padding-right:16px'><pre style='white-space:pre-wrap'>" + d.What + "</pre></td>")
8414 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")
8415 }
8416 b.WriteString("</tbody></table>\n")
8417 }
8418 b.WriteString(deepBody())
8419 b.WriteString("<p><a href='/'>Back to the store</a></p>\n")
8420 b.WriteString("</div>\n")
8421 return b.String()
8422}
8423
8424// demosLD is the structured data for the page: an ItemList of the demos, each
8425// a SoftwareApplication at its own URL.
8426//
8427// This is the half a crawler reads. The list gives every subdomain a name, a
8428// description and a URL in one document, which is the fastest way to get a set
8429// of never-before-linked hostnames into an index.
8430func demosLD(canonical string) string {
8431 var b strings.Builder
8432 b.WriteString("<script type='application/ld+json'>\n{\n")
8433 b.WriteString("\"@context\": \"https://schema.org\",\n")
8434 b.WriteString("\"@type\": \"ItemList\",\n")
8435 b.WriteString("\"name\": \"Demos\",\n")
8436 b.WriteString("\"url\": \"" + canonical + "\",\n")
8437 b.WriteString("\"itemListElement\": [\n")
8438 pos := 0
8439 for _, g := range demoGroups {
8440 for _, d := range g.Sites {
8441 if d.Pending {
8442 continue
8443 }
8444 if pos > 0 {
8445 b.WriteString(",\n")
8446 }
8447 pos++
8448 b.WriteString("{\"@type\": \"ListItem\", \"position\": " + strconv.Itoa(pos) + ", \"item\": {")
8449 b.WriteString("\"@type\": \"SoftwareApplication\", ")
8450 b.WriteString("\"name\": \"" + d.Name + "\", ")
8451 b.WriteString("\"url\": \"https://" + d.Host + "/\", ")
8452 b.WriteString("\"applicationCategory\": \"DeveloperApplication\", ")
8453 b.WriteString("\"operatingSystem\": \"Any (WebAssembly)\", ")
8454 b.WriteString("\"codeRepository\": \"" + d.demoRepo() + "\", ")
8455 b.WriteString("\"description\": \"" + jsonString(d.What) + "\"}}")
8456 }
8457 }
8458 b.WriteString("\n]\n}\n</script>\n")
8459 return b.String()
8460}
8461
8462// jsonString escapes the characters a description could plausibly contain and
8463// JSON cannot carry raw. The blurbs above are written by hand and hold none of
8464// them today; this is here so that editing one cannot silently produce invalid
8465// structured data, which a crawler drops without reporting.
8466func jsonString(s string) string {
8467 r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", " ", "\t", " ")
8468 return r.Replace(s)
8469}
8470
8471// demospage serves /demos.
8472//
8473// It borrows the site chrome β head, header, footer β and replaces the "schema"
8474// partial with the ItemList above, rather than the front page's Organization
8475// block.
8476//
8477// It states WasmBinary as empty. That is the default now β a page opts in
8478// with wasmFor β but saying so here keeps the intent readable: this page is
8479// text, and a crawler meant to read it quickly should not be handed the STL
8480// viewer. Emptying the list is how the templates ask for that; overriding the
8481// "wasm" partial with an empty body does NOT work, because Go templates treat
8482// a whitespace-only body as no definition at all and leave the existing one
8483// standing.
8484func demospage(c fiber.Ctx) error {
8485 tmpl, err := mainTmpl()
8486 if err != nil {
8487 msg := fmt.Sprintf("Error parsing html template: %v", err)
8488 log.Println(msg)
8489 return c.Status(fiber.StatusInternalServerError).SendString(msg)
8490 }
8491 tmpl0, err := tmpl.Clone()
8492 if err != nil {
8493 msg := fmt.Sprintf("Error cloning template: %v", err)
8494 log.Println(msg)
8495 return c.Status(fiber.StatusInternalServerError).SendString(msg)
8496 }
8497 h1 := pageMeta(c, htmlPageTemplateData)
8498 h1.Page = "software"
8499 // One canonical URL for two routes. /demos was the first spelling and is
8500 // linked from several repository READMEs, so it still answers β but it
8501 // must not compete with /software for the same content, which is exactly
8502 // what a self-referencing canonical on each would set up.
8503 h1.Canonical = h1.BaseURL + "/software"
8504 h1.Title = "Software β Go and WebAssembly in the browser | " + f.Sitelongname
8505 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."
8506 h1.KeyWords = "Go, WebAssembly, wasm, TinyGo, browser demo, xterm.js port, WinBox port, cosmos.gl port, in-browser shell, strange attractor, libcaca img2txt, ansifilter"
8507 h1.WasmBinary = nil
8508
8509 for _, part := range []struct{ name, body string }{
8510 {"main", demosBody()},
8511 {"schema", demosLD(h1.Canonical)},
8512 } {
8513 if _, err := tmpl0.New(part.name).Parse(part.body); err != nil {
8514 msg := fmt.Sprintf("Error parsing %s template: %v", part.name, err)
8515 log.Println(msg)
8516 return c.Status(fiber.StatusInternalServerError).SendString(msg)
8517 }
8518 }
8519
8520 c.Set("Content-Type", "text/html;charset=utf-8")
8521 tmplData := map[string]interface{}{
8522 "Page": h1,
8523 "Prods": allproducts,
8524 }
8525 var result bytes.Buffer
8526 if err := tmpl0.Execute(&result, tmplData); err != nil {
8527 msg := fmt.Sprintf("Error executing template: %v", err)
8528 log.Println(msg)
8529 return c.Status(fiber.StatusInternalServerError).SendString(msg)
8530 }
8531 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
8532 return err
8533}
8534
8535// ---------------------------------------------------------------------------
8536// Deep links.
8537//
8538// Three of the demos take a parameter, so a link can open on a particular
8539// thing rather than on the front of the app. tuiwasm is the striking case: one
8540// wasm binary holds 31 separate programs, and without this the page links to
8541// the binary and every program inside it is invisible.
8542//
8543// This is the cheapest indexable surface here by a distance. Each of these is
8544// a distinct URL that renders distinct content, and the link text is the
8545// demo's own name β which is what someone searching for "terminal matrix
8546// animation" or "Langton's ant in the browser" actually types.
8547// ---------------------------------------------------------------------------
8548
8549// deepLink is one parameterized entry point: what to call it, what it opens,
8550// and the query that gets there.
8551type deepLink struct {
8552 Label string
8553 Desc string
8554 Query string
8555}
8556
8557// deepGroup is a run of deep links into one demo.
8558type deepGroup struct {
8559 Host string
8560 Title string
8561 Note string
8562 Links []deepLink
8563}
8564
8565var deepGroups = []deepGroup{{
8566 Host: "tuiwasm.magnetosphere.net",
8567 Title: "tuiwasm β 43 terminal-UI programs, one per link",
8568 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.",
8569 Links: []deepLink{
8570 {"aquarium", "fish swimming past swaying seaweed", "demo=aquarium"},
8571 {"aurora", "curtains of light, folding, with the creases where they turn", "demo=aurora"},
8572 {"boids", "flocking by separation, alignment and cohesion", "demo=boids"},
8573 {"bonsai", "a bonsai tree growing branch by branch", "demo=bonsai"},
8574 {"bounce", "the screensaver logo, and the wait for it to hit a corner", "demo=bounce"},
8575 {"boxes", "tcell's own boxes demo β random boxes, timed", "demo=boxes"},
8576 {"charts", "asciigraph β a line plot in cells", "demo=charts"},
8577 {"clock", "an analog clock, after aclock", "demo=clock"},
8578 {"colors", "tcell's own colors demo β boxes cycling through the palette", "demo=colors"},
8579 {"cube", "a rotating wireframe solid, shaded by depth", "demo=cube"},
8580 {"donut", "a lit torus with a z-buffer", "demo=donut"},
8581 {"fire", "a heat grid seeded with noise and cooled upward", "demo=fire"},
8582 {"fireworks", "shells that rise, burst and droop into willows", "demo=fireworks"},
8583 {"flow", "particles carried through a divergence-free curl-noise field", "demo=flow"},
8584 {"frost", "a crystal growing by diffusion-limited aggregation", "demo=frost"},
8585 {"julia", "a Julia set morphing as its parameter walks the cardioid", "demo=julia"},
8586 {"langton", "Langton's ants: chaos, then the highway", "demo=langton"},
8587 {"lavalamp", "wax that heats, rises, cools and sinks", "demo=lavalamp"},
8588 {"life", "Conway's life, colored by how long a cell has lived", "demo=life"},
8589 {"lightning", "a branching discharge: leader, return stroke, afterglow", "demo=lightning"},
8590 {"matrix", "falling columns of glyphs", "demo=matrix"},
8591 {"maze", "a maze carved by backtracking, then solved", "demo=maze"},
8592 {"metaballs", "blobs that bulge and merge as they approach", "demo=metaballs"},
8593 {"moire", "two drifting ripples interfering", "demo=moire"},
8594 {"parrot", "the party parrot, rolling once around the hue wheel", "demo=parrot"},
8595 {"pendulum", "double pendulums released together, shearing apart", "demo=pendulum"},
8596 {"physarum", "slime mold laying trails and building a transport network", "demo=physarum"},
8597 {"pipes", "pipes growing and turning, with correct elbows", "demo=pipes"},
8598 {"plasma", "summed sine waves of position, offset by time", "demo=plasma"},
8599 {"proxima", "Escape from Proxima 5 β gdamore's tcell space shooter", "demo=proxima"},
8600 {"proxima2", "Escape from Proxima 5 on tcell v2 β the same game, one major back", "demo=proxima2"},
8601 {"rain", "drops with depth, slant, streaks and splashes", "demo=rain"},
8602 {"reaction", "Gray-Scott reaction-diffusion: spots that divide into a labyrinth", "demo=reaction"},
8603 {"ripple", "a ripple tank: rain falling, rings meeting and interfering", "demo=ripple"},
8604 {"sand", "grains heaping at their angle of repose", "demo=sand"},
8605 {"snow", "flakes that sway, settle and drift into banks", "demo=snow"},
8606 {"starfield", "stars streaming past the viewer", "demo=starfield"},
8607 {"styles", "lipgloss β borders, color, alignment", "demo=styles"},
8608 {"tables", "go-pretty β the wasm compatibility matrix", "demo=tables"},
8609 {"tunnel", "flying down a textured tube", "demo=tunnel"},
8610 {"unicode", "tcell's own unicode demo β wide, combining and emoji glyphs", "demo=unicode"},
8611 {"widgets", "tview β a flexbox of lists, tables and text views on tcell v2", "demo=widgets"},
8612 {"wolfram", "elementary cellular automata scrolling upward - 30, 90, 110", "demo=wolfram"},
8613 },
8614}, {
8615 Host: "plot-go.magnetosphere.net",
8616 Title: "plot-go β the same counters through different pipelines",
8617 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.",
8618 Links: []deepLink{
8619 {"avg:5", "a five-sample moving average β the default", "p=avg:5"},
8620 {"roc:5", "rate of change over five samples", "p=roc:5"},
8621 {"avg:5|roc:5", "smoothed first, then differentiated", "p=avg:5%7Croc:5"},
8622 {"cma", "a cumulative moving average over the whole run", "p=cma"},
8623 },
8624}, {
8625 Host: "dict.magnetosphere.net",
8626 Title: "dict β a word, looked up on arrival",
8627 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.",
8628 Links: []deepLink{
8629 {"serendipity", "Webster's 1913 on serendipity", "run=dict%20serendipity"},
8630 {"rain", "every sense of rain, across all four sources", "run=dict%20%3Arain"},
8631 {"recieve", "a transposition: the misspelling a subsequence matcher cannot find", "run=dict%20recieve"},
8632 {"aluminium", "the British spelling, folded to the American entry", "run=dict%20aluminium"},
8633 },
8634}}
8635
8636// deepBody renders the deep-link section: one paragraph of links per demo,
8637// rather than a table. A table of 31 one-word rows is mostly borders.
8638func deepBody() string {
8639 var b strings.Builder
8640 b.WriteString("<h2 class='β€'>Straight to one thing</h2>\n")
8641 b.WriteString("<p>Some of these take a parameter, so a link can open on a particular demo, ")
8642 b.WriteString("pipeline or word instead of the front of the app.</p>\n")
8643 for _, g := range deepGroups {
8644 b.WriteString("<h3 style='font-size:10pt'>" + g.Title + "</h3>\n")
8645 b.WriteString("<p style='max-width:70em;margin:0 auto'>" + g.Note + "</p>\n")
8646 b.WriteString("<p style='max-width:70em;margin:6px auto;line-height:1.9'>")
8647 for i, l := range g.Links {
8648 if i > 0 {
8649 b.WriteString(" · ")
8650 }
8651 b.WriteString("<a title=\"" + l.Desc + "\" href='https://" + g.Host + "/?" + l.Query + "'>" + l.Label + "</a>")
8652 }
8653 b.WriteString("</p>\n")
8654 }
8655 return b.String()
8656}
8657
8658// live counts the demos in a group that are ready to be linked.
8659func live(sites []demoSite) int {
8660 n := 0
8661 for _, d := range sites {
8662 if !d.Pending {
8663 n++
8664 }
8665 }
8666 return n
8667}
8668
8669// ---------------------------------------------------------------------------
8670// /test β the site with the decoration wasm loaded.
8671//
8672// Deliberately not a gallery. The thing to judge is the site, so this parses
8673// exactly what homepage parses and differs from it in one line: the wasm list.
8674// Diffing /test against / therefore shows the decoration and nothing else, and
8675// when a piece of it is worth keeping the change is to add it to homepage.
8676
8677// testpage serves /test.
8678func testpage(c fiber.Ctx) error {
8679 tmpl, err := mainTmpl()
8680 if err != nil {
8681 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("template: %v", err))
8682 }
8683 tmpl0, err := tmpl.Clone()
8684 if err != nil {
8685 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("clone: %v", err))
8686 }
8687 for _, part := range []struct{ name, body string }{
8688 {"main", h.FrontPage()},
8689 {"about", h.AboutPage()},
8690 {"policy", h.PolicyPage()},
8691 {"links", h.LinksPage()},
8692 } {
8693 if _, err := tmpl0.New(part.name).Parse(part.body); err != nil {
8694 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("%s: %v", part.name, err))
8695 }
8696 }
8697 h1 := pageMeta(c, htmlPageTemplateData)
8698 h1.Page = "front"
8699 h1.Canonical = h1.BaseURL + "/test"
8700 h1.Title = "sandbox β " + f.Sitelongname
8701 // The front page's own canvas, plus the decorator. This is the whole of
8702 // the difference from homepage.
8703 h1.WasmBinary = wasmFor("stl2", "deco")
8704 c.Set("Content-Type", "text/html;charset=utf-8")
8705 c.Set("Cache-Control", "no-store")
8706 var out bytes.Buffer
8707 if err := tmpl0.Execute(&out, map[string]interface{}{"Page": h1, "Prods": allproducts}); err != nil {
8708 return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("execute: %v", err))
8709 }
8710 body := collapseNewlines.ReplaceAll(out.Bytes(), []byte("\n"))
8711 // Injected rather than added to style.css: the rules are generated from the
8712 // live category list, which a static file cannot be. If this graduates it
8713 // wants a template hook rather than a string splice.
8714 body = bytes.Replace(body, []byte("</head>"), []byte(catTargetCSS()+"</head>"), 1)
8715 _, err = c.Status(fiber.StatusOK).Write(body)
8716 return err
8717}
8718
8719// catTargetCSS makes a targeted category show its subcategories' sections too.
8720//
8721// The problem it solves: sections are flat siblings under .β and only the one
8722// matching :target is shown, so #cat-resistor displays its heading, its list of
8723// subcategory links, and the "Other Products" table β which holds only the
8724// products that have no subcategory. Every resistor has one, so the category
8725// view is a page with no products on it.
8726//
8727// The fix is one rule per category, naming that category's subcategory
8728// sections by the prefix their ids already share:
8729//
8730// [id="cat-resistor"]:target ~ [id^="cat-resistor-"]{display:block}
8731//
8732// Nesting the sections and using :has() would also work and reads better, but
8733// :has() only reached Firefox in December 2023 while :target has worked since
8734// about 2010 β and a browser without :has() would find the nested section
8735// inside a display:none parent, which breaks navigation rather than degrading
8736// it. This uses :target, ~ and [attr^=] only, so it works wherever the
8737// existing navigation does, and needs no second copy of the markup.
8738func catTargetCSS() string {
8739 cats := htmlPageTemplateData.Cats
8740 var b strings.Builder
8741 b.WriteString("<style>/* a targeted category shows its subcategories too */\n")
8742 for _, cat := range cats {
8743 if len(htmlPageTemplateData.SubCatsByCat[cat]) == 0 {
8744 continue
8745 }
8746 // A category whose name plus a dash begins another category's name
8747 // would have its rule match that one's sections as well. Skip it
8748 // rather than emit a rule that reveals the wrong products.
8749 clash := false
8750 for _, other := range cats {
8751 if other != cat && strings.HasPrefix(other, cat+"-") {
8752 clash = true
8753 break
8754 }
8755 }
8756 if clash {
8757 b.WriteString("/* skipped " + cat + ": prefixes another category */\n")
8758 continue
8759 }
8760 id := "cat-" + cat
8761 b.WriteString(`[id="` + id + `"]:target ~ [id^="` + id + `-"]{display:block}` + "\n")
8762 }
8763 b.WriteString("</style>")
8764 return b.String()
8765}
8766
8767
8768// ===== pkg/web/server.go =====
8769// Package web pkg/web/server.go β the store server: routes and page handlers.
8770package web
8771
8772import (
8773 "bytes"
8774 "context"
8775 "encoding/base64"
8776 "fmt"
8777 "log"
8778 "os"
8779 "path/filepath"
8780 "regexp"
8781 "sort"
8782 "strconv"
8783 "strings"
8784 "sync"
8785
8786 "github.com/gofiber/fiber/v3"
8787
8788 "github.com/bitfield/script"
8789
8790 "github.com/0magnet/m2/pkg/config"
8791 p "github.com/0magnet/m2/pkg/product"
8792 "github.com/0magnet/m2/pkg/storepage"
8793)
8794
8795// f aliases the shared configuration; the name keeps the handler code
8796// identical to its pre-refactor form at the repo root, and drop-in
8797// other.go files keep working unchanged.
8798var f = &config.F
8799
8800var collapseNewlines = regexp.MustCompile(`\n{2,}`)
8801
8802func methodColor(method string, colors fiber.Colors) string {
8803 switch method {
8804 case fiber.MethodGet:
8805 return colors.Cyan
8806 case fiber.MethodPost:
8807 return colors.Green
8808 case fiber.MethodPut:
8809 return colors.Yellow
8810 case fiber.MethodDelete:
8811 return colors.Red
8812 case fiber.MethodPatch:
8813 return colors.White
8814 case fiber.MethodHead:
8815 return colors.Magenta
8816 case fiber.MethodOptions:
8817 return colors.Blue
8818 default:
8819 return colors.Reset
8820 }
8821}
8822
8823func statusColor(code int, colors fiber.Colors) string {
8824 switch {
8825 case code >= fiber.StatusOK && code < fiber.StatusMultipleChoices:
8826 return colors.Green
8827 case code >= fiber.StatusMultipleChoices && code < fiber.StatusBadRequest:
8828 return colors.Blue
8829 case code >= fiber.StatusBadRequest && code < fiber.StatusInternalServerError:
8830 return colors.Yellow
8831 default:
8832 return colors.Red
8833 }
8834}
8835
8836// Serve runs the web store: templates, routes, wasm compilation, and the
8837// http listener. It blocks for the life of the server.
8838func Serve() {
8839 wg := new(sync.WaitGroup)
8840 wg.Add(1)
8841 r := NewApp(AppOpts{})
8842 // The browse origin the desk's nested browser renders the store at. Its
8843 // own listener, because it is a different domain: a wholly static pair of
8844 // files that never sees any of the content it makes renderable.
8845 StartBrowseOrigin(context.Background())
8846 go func() {
8847 err := r.Listen(fmt.Sprintf(":%d", f.WebPort))
8848 if err != nil {
8849 log.Println("Error serving http: ", err)
8850 }
8851 wg.Done()
8852 }()
8853 CompileWASM()
8854 wg.Wait()
8855}
8856
8857func sitemap(c fiber.Ctx) error {
8858 c.Type("xml", "utf-8")
8859 return c.SendString(generateSitemapXML())
8860}
8861
8862// apisite serves the site identity for store clients β the browser tui
8863// has no MENV file to source its masthead from.
8864func apisite(c fiber.Ctx) error {
8865 return c.JSON(fiber.Map{
8866 "Sitename": f.Sitename,
8867 "Siteext": f.Siteext,
8868 "Sitelongname": f.Sitelongname,
8869 "Sitetagline": f.Sitetagline,
8870 "Tgcontact": f.Tgcontact,
8871 "Tgchannel": f.Tgchannel,
8872 "Teststripekey": f.Teststripekey,
8873 // The identity fields below appear verbatim in every rendered page,
8874 // so serving them here reveals nothing new; they let the in-tab site
8875 // server render the same masthead and metadata the origin does.
8876 "Sitemeta": f.Sitemeta,
8877 "Sitedomain": f.Sitedomain,
8878 "Siteprettyname": f.Siteprettyname,
8879 "Siteprettynamecap": f.Siteprettynamecap,
8880 "Siteprettynamecaps": f.Siteprettynamecaps,
8881 "SiteASCIILogo": f.SiteASCIILogo,
8882 "Stripepk": f.StripePK,
8883 // The drop-in navigation entries, so a client β the terminal build,
8884 // and the TUI served into a browser tab β can offer the same Etc menu
8885 // the page does instead of a shorter one missing every drop-in page.
8886 "NavLinks": extraNavLinks,
8887 })
8888}
8889
8890// apicontent serves the stock-page fragments (about/policy/links) raw,
8891// for the tui to render as text.
8892func apicontent(c fiber.Ctx) error {
8893 name := c.Params("name")
8894 switch name {
8895 case "about", "policy", "links":
8896 default:
8897 return c.SendStatus(fiber.StatusNotFound)
8898 }
8899 c.Set("Content-Type", "text/html;charset=utf-8")
8900 return c.SendString(contentFile("content/" + name + ".html"))
8901}
8902
8903// tuipage serves the terminal storefront: the same store TUI `m2 tui`
8904// runs natively, compiled to wasm (the wasm/tui drop-in, listed in
8905// WASMSRC) and drawn into an xterm-go terminal filling the page.
8906func tuipage(c fiber.Ctx) error {
8907 // The joint build where the deployment has one, else this page's own.
8908 // See pageBinary.
8909 binary, execPath := pageBinary("term", "tui")
8910 html := fmt.Sprintf(`<!DOCTYPE html>
8911<html lang="en">
8912<head>
8913<meta charset="utf-8">
8914<meta name="viewport" content="width=device-width, initial-scale=1">
8915<title>%s β terminal</title>
8916<meta name="description" content="%s as a terminal: browse the catalog, view products, and fill a cart in a TUI running in your browser.">
8917<meta name="robots" content="index, follow">
8918<link rel="stylesheet" href="/font.css">
8919<style>html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;}#terminal{position:fixed;inset:0;}</style>
8920<script src="%s"></script>
8921<script>
8922if (!WebAssembly.instantiateStreaming) {
8923 WebAssembly.instantiateStreaming = async (resp, importObject) => {
8924 const source = await (await resp).arrayBuffer();
8925 return await WebAssembly.instantiate(source, importObject);
8926 };
8927}
8928const go = new Go();
8929WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject).then((result) => {
8930 go.run(result.instance);
8931}).catch((err) => { console.error("Failed to run WASM:", err); });
8932</script>
8933</head>
8934<body>
8935<div id="terminal"></div>
8936</body>
8937</html>`, f.Sitedomain, f.Sitelongname, execPath, binary)
8938 c.Set("Content-Type", "text/html; charset=utf-8")
8939 return c.Status(fiber.StatusOK).SendString(html)
8940}
8941
8942// deskpage serves the site as a desktop: the website in a maximized window
8943// and the storefront terminal in another, both launchable from the panel.
8944// Same drop-in mechanism as tuipage, against the 'wasm/desk' source.
8945func deskpage(c fiber.Ctx) error {
8946 // The joint build where the deployment has one, else this page's own.
8947 binary, execPath := pageBinary("term", "desk")
8948 // ?bare / ?fsonly / ?nonet split the page's script layer for debugging:
8949 // bare = none of jsfs/vnet, fsonly = jsfs alone, nonet = no browser.
8950 //
8951 // netscrape is no longer among these. It used to be a JS engine served at
8952 // /netscrape.js; it is now a Go browser compiled INTO the desk wasm, so
8953 // there is no script to leave out β ?nonet is honored by the desk itself,
8954 // which reads it from location.search and does not open the window.
8955 scripts := `<script src="/bottle/jsfs.js"></script>
8956<script src="/bottle/vnet.js"></script>`
8957 // The browse substrate, when the deployment has a second domain for it.
8958 // The responder must be first-party here and must exist before the wasm
8959 // configures it, so it goes in with the rest of the page scripts; the
8960 // config object tells the desk which origin to render the store at.
8961 if browseEnabled() {
8962 scripts += fmt.Sprintf("\n<script>window.__M2_BROWSE__={suffix:%q,scheme:%q,port:%q};</script>"+
8963 "\n<script src=%q></script>", config.F.BrowseSuffix, browseScheme(), browsePort(), browseResponderPath)
8964 }
8965 switch {
8966 case c.Query("bare") != "":
8967 scripts = ""
8968 case c.Query("fsonly") != "":
8969 scripts = `<script src="/bottle/jsfs.js"></script>`
8970 }
8971 html := fmt.Sprintf(`<!DOCTYPE html>
8972<html lang="en">
8973<head>
8974<meta charset="utf-8">
8975<meta name="viewport" content="width=device-width, initial-scale=1">
8976<title>%s β desktop</title>
8977<meta name="description" content="%s as a desktop: the website and the storefront terminal, each in a window.">
8978<meta name="robots" content="index, follow">
8979<link rel="stylesheet" href="/font.css">
8980<style>
8981html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;
8982 font-family:mononokiregular,ui-monospace,monospace;color:#fff;}
8983/* Windows are positioned against this, so it is the element that fills the
8984 page rather than the body. */
8985#desktop{position:fixed;inset:0;}
8986#boot{padding:14px;color:#777;font-size:13px;}
8987</style>
8988%s
8989<script>
8990// Channel providers for the netscrape browser window the desk opens. The
8991// clearnet channel is the page's own fetch β enough for this origin and for
8992// anything CORS permits; there is no skysocks here. dmsg has no transport in
8993// this page at all, so it answers 502 rather than hanging.
8994globalThis.__m2Glue = {
8995 fetchClearnet: async function (exit, method, url, body, winId, headers) {
8996 try {
8997 const r = await fetch(url, { method: method || "GET", body: body || undefined, headers: headers || undefined });
8998 const buf = new Uint8Array(await r.arrayBuffer());
8999 const hs = {};
9000 r.headers.forEach(function (v, k) { hs[k] = v; });
9001 return { status: r.status, body: buf, headers: hs };
9002 } catch (e) {
9003 return { status: 502, body: new Uint8Array(), headers: {} };
9004 }
9005 },
9006 fetchDmsg: async function () { return { status: 502, body: new Uint8Array(), headers: {} }; },
9007};
9008
9009// netscrape asks globalThis.__netscrapeFetch(url) for every page and
9010// subresource, and expects a Response. Without one it falls back to a
9011// same-origin /fetch proxy this server does not have, so the transport is
9012// wired here rather than left to that default.
9013//
9014// Note the argument order: netscrape's own loader.js calls
9015// fetchClearnet(url, method, body), but __m2Glue predates it and takes
9016// (exit, method, url, ...). The glue is what the rest of this page uses, so
9017// the shim adapts to the glue rather than the other way round.
9018globalThis.__netscrapeFetch = function (url) {
9019 var u;
9020 try { u = new URL(url, location.href); } catch (e) { return fetch(url); }
9021 var path = (u.pathname || "/") + (u.search || "");
9022 var mesh = /\.(dmsg|skysocks|skynet)$/i.test(u.hostname) || /^[0-9a-f]{66}$/i.test(u.hostname);
9023 // vnet:<port> is the site running INSIDE this tab, on the page's virtual
9024 // loopback. It needs its own name: the desk's own origin is a real
9025 // 127.0.0.1:<port>, so addressing the in-tab server as 127.0.0.1 too sent
9026 // it to the page's ordinary fetch and out to the host, which answered
9027 // ERR_CONNECTION_REFUSED. Nothing outside this page can be reached at
9028 // "vnet", so the routing is unambiguous.
9029 var call;
9030 if (u.hostname === "vnet") {
9031 call = globalThis.vnet.httpFetch(parseInt(u.port || "80", 10), "GET", path, null);
9032 } else if (mesh) {
9033 call = globalThis.__m2Glue.fetchDmsg(u.hostname, "GET", path, null);
9034 } else {
9035 call = globalThis.__m2Glue.fetchClearnet(null, "GET", u.href, null, null, null);
9036 }
9037 return Promise.resolve(call).then(function (r) {
9038 var h = new Headers();
9039 if (r && r.headers) { for (var k in r.headers) { try { h.set(k, r.headers[k]); } catch (e) {} } }
9040 return new Response((r && r.body) || new Uint8Array(0), { status: (r && r.status) || 200, headers: h });
9041 });
9042};
9043</script>
9044<script src="%s"></script>
9045<script>
9046if (!WebAssembly.instantiateStreaming) {
9047 WebAssembly.instantiateStreaming = async (resp, importObject) => {
9048 const source = await (await resp).arrayBuffer();
9049 return await WebAssembly.instantiate(source, importObject);
9050 };
9051}
9052const go = new Go();
9053WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject).then((result) => {
9054 const b = document.getElementById("boot"); if (b) b.remove();
9055 go.run(result.instance);
9056}).catch((err) => { console.error("Failed to run WASM:", err); });
9057</script>
9058</head>
9059<body>
9060<div id="desktop"><div id="boot">loadingβ¦</div></div>
9061</body>
9062</html>`, f.Sitedomain, f.Sitelongname, scripts, execPath, binary)
9063 c.Set("Content-Type", "text/html; charset=utf-8")
9064 return c.Status(fiber.StatusOK).SendString(html)
9065}
9066
9067// apiproducts serves the catalog as JSON for store clients (the tui's
9068// --store mode, and the browser tui to come). The business-sensitive
9069// columns the HTML never renders stay private.
9070func apiproducts(c fiber.Ctx) error {
9071 allproductsMu.RLock()
9072 prods := make(p.Products, len(allproducts))
9073 copy(prods, allproducts)
9074 allproductsMu.RUnlock()
9075 for i := range prods {
9076 prods[i].Cost = ""
9077 prods[i].Location = ""
9078 prods[i].Sourceinfo = ""
9079 }
9080 return c.JSON(prods)
9081}
9082
9083// extraRoutes collects route registrars from optional drop-in files
9084// (see other.go.example). A drop-in appends its registrar from init();
9085// deleting the file removes its routes with no other code changes.
9086var extraRoutes []func(*fiber.App)
9087
9088// extraSitemapURLs collects site-relative paths that drop-in routes want
9089// listed in /sitemap.xml. A route that is not linked from anywhere and not
9090// in the sitemap is one a crawler has no way to reach, which for a page that
9091// exists to be found is the whole of the problem.
9092//
9093// Paths only, each beginning with "/": the sitemap is per-host and the base
9094// URL is the deployment's own, so a drop-in cannot and should not name it.
9095var extraSitemapURLs []string
9096
9097// NavLink is an entry a drop-in adds to the site navigation.
9098type NavLink struct {
9099 // Title is the link text, Href where it goes, and Desc the tooltip.
9100 Title, Href, Desc string
9101}
9102
9103// extraNavLinks collects navigation entries from drop-in files, the way
9104// extraRoutes collects their routes.
9105//
9106// A drop-in route that nothing links to is reachable only by someone who
9107// already knows the path, and the stock header cannot name it: the header is
9108// shared by every deployment, and a hardcoded entry would be a broken link
9109// everywhere the drop-in is absent. Registering it here keeps the link and the
9110// route in the same file, so deleting that file removes both.
9111var extraNavLinks []NavLink
9112
9113func logo(c fiber.Ctx) error {
9114 tmpl, err := auxTmpl()
9115 if err != nil {
9116 msg := fmt.Sprintf("Error parsing html template: %v", err)
9117 log.Println(msg)
9118 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9119 }
9120 tmpl0, err := tmpl.Clone()
9121 if err != nil {
9122 msg := fmt.Sprintf("Error cloning template: %v", err)
9123 log.Println(msg)
9124 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9125 }
9126 _, err = tmpl0.New("main").Parse(h.Logo())
9127 if err != nil {
9128 msg := fmt.Sprintf("Error parsing product page template: %v", err)
9129 log.Println(msg)
9130 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9131 }
9132 tmpl = tmpl0
9133 c.Set("Content-Type", "text/html;charset=utf-8")
9134
9135 img2txtFlags := ""
9136 if w, err := strconv.Atoi(c.Params("width")); err == nil {
9137 img2txtFlags = fmt.Sprintf("--width=%d ", w)
9138 }
9139 if h, err := strconv.Atoi(c.Params("height")); err == nil {
9140 img2txtFlags = fmt.Sprintf("--height=%d ", h)
9141 }
9142
9143 logoHTMLslice, err := script.Exec(fmt.Sprintf("bash -c 'img2txt %s logo.jpg | ansifilter -H'", img2txtFlags)).Slice()
9144 if err != nil {
9145 log.Println("error: ", err)
9146 _, err = c.Status(fiber.StatusInternalServerError).Write([]byte(err.Error() + "/n" + strings.Join(logoHTMLslice, "\n")))
9147 return err
9148 }
9149 if len(logoHTMLslice) > 2 {
9150 logoHTMLslice = logoHTMLslice[:len(logoHTMLslice)-3]
9151 }
9152 if len(logoHTMLslice) > 18 {
9153 logoHTMLslice = logoHTMLslice[19:]
9154 }
9155
9156 var result bytes.Buffer
9157 h1 := pageMeta(c, htmlTemplateData{})
9158 h1.Page = "logo"
9159 h1.Title = "logo"
9160 tmplData := map[string]interface{}{
9161 "Content": strings.Join(logoHTMLslice, "\n"),
9162 }
9163 err = tmpl.Execute(&result, tmplData)
9164 if err != nil {
9165 log.Println("error: ", err)
9166 _, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
9167 return err
9168 }
9169 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
9170 return err
9171}
9172
9173func robots(c fiber.Ctx) error {
9174 c.Set("Content-Type", "text/plain;charset=utf-8")
9175 // "User-agent: *" alone is a group with no rules in it. That is legal and
9176 // means allow everything, but it says so by omission β and a group that
9177 // states nothing is one whose meaning a later edit can change by accident.
9178 // Allow: / states it. The trailing newline is so the Sitemap line is a
9179 // line, which some parsers care about.
9180 _, err := c.Status(fiber.StatusOK).Write([]byte(fmt.Sprintf(
9181 "User-agent: *\nAllow: /\n\nSitemap: https://%s/sitemap.xml\n", c.Hostname())))
9182 return err
9183}
9184
9185func style(c fiber.Ctx) error {
9186 c.Set("Content-Type", "text/css;charset=utf-8")
9187 _, err := c.Status(fiber.StatusOK).Write([]byte(h.StyleCSS()))
9188 return err
9189}
9190
9191// fontcss serves just the @font-face block, so the terminal page can have
9192// the site's face without pulling in the whole stylesheet. The TUI measures
9193// its cell from this font when it opens, which is why it is a separate,
9194// cacheable route rather than something inlined per page load.
9195func fontcss(c fiber.Ctx) error {
9196 c.Set("Content-Type", "text/css;charset=utf-8")
9197 c.Set("Cache-Control", "public, max-age=86400")
9198 _, err := c.Status(fiber.StatusOK).Write([]byte(h.FontCSS()))
9199 return err
9200}
9201
9202func serveWASM(r *fiber.App) {
9203 if f.WasmExecPath != "" {
9204 _, err := script.File(f.WasmExecPath).Bytes()
9205 if err != nil {
9206 log.Printf("Error reading %s: %v\n", f.WasmExecPath, err)
9207 } else { //the wasm exec must be present or none of the webassembly stuff will work ; provided by the golang installaton
9208 r.Get(f.WasmExecPathTinyGo, func(c fiber.Ctx) error {
9209 wasmExecData, err := script.File(f.WasmExecPathTinyGo).Bytes()
9210 if err != nil {
9211 log.Printf("Error reading %s: %v\n", f.WasmExecPathTinyGo, err)
9212 return c.SendStatus(fiber.StatusNotFound)
9213 }
9214 c.Set("Content-Type", "application/js")
9215 _, err = c.Status(fiber.StatusOK).Write(wasmExecData)
9216 return err
9217 })
9218
9219 r.Get(f.WasmExecPathGo, func(c fiber.Ctx) error {
9220 wasmExecData, err := script.File(f.WasmExecPathGo).Bytes()
9221 if err != nil {
9222 log.Printf("Error reading %s: %v\n", f.WasmExecPathGo, err)
9223 return c.SendStatus(fiber.StatusNotFound)
9224 }
9225 c.Set("Content-Type", "application/js")
9226 _, err = c.Status(fiber.StatusOK).Write(wasmExecData)
9227 return err
9228 })
9229
9230 // Register both variants per source: a drop-in tinygo cannot
9231 // compile still serves its Go build, and pages pick whichever
9232 // binary exists.
9233 for _, wasmSRC := range f.WasmSRC {
9234 base := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
9235 for _, suffix := range []string{".wasm", "-tiny.wasm"} {
9236 outputFile := base + suffix
9237 r.Get("/"+outputFile, func(c fiber.Ctx) error {
9238 // A binary this size must revalidate, not cache
9239 // blindly: a browser holding yesterday's wasm
9240 // makes a deploy look like nothing changed.
9241 fi, err := os.Stat(outputFile)
9242 if err != nil {
9243 return c.SendStatus(fiber.StatusInternalServerError)
9244 }
9245 etag := fmt.Sprintf(`"%x-%x"`, fi.ModTime().Unix(), fi.Size())
9246 c.Set("Cache-Control", "no-cache")
9247 c.Set("ETag", etag)
9248 if c.Get("If-None-Match") == etag {
9249 return c.SendStatus(fiber.StatusNotModified)
9250 }
9251 data, err := script.File(outputFile).Bytes()
9252 if err != nil {
9253 script.File(outputFile).Stdout() //nolint
9254 return c.SendStatus(fiber.StatusInternalServerError)
9255 }
9256 c.Set("Content-Type", "application/wasm")
9257 return c.Status(fiber.StatusOK).Send(data)
9258 })
9259 }
9260 }
9261 }
9262 }
9263}
9264
9265func sendFile(c fiber.Ctx) error {
9266 return c.SendFile("." + c.Path())
9267}
9268func sendImage(c fiber.Ctx) error {
9269 c.Set("Content-Type", "image/jpeg")
9270 return c.SendFile("./img" + c.Path())
9271}
9272
9273func stlbase64(c fiber.Ctx) error {
9274 name := c.Params("filename")
9275 if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
9276 return c.SendStatus(fiber.StatusBadRequest)
9277 }
9278 stlfile, err := script.File("img/stl/" + name).Bytes()
9279 if err != nil {
9280 return c.SendStatus(fiber.StatusNotFound)
9281 }
9282 _, err = c.Status(fiber.StatusOK).Write([]byte("data:model/stl;base64," + base64.StdEncoding.EncodeToString(stlfile)))
9283 return err
9284}
9285
9286type item struct {
9287 ID string
9288 Amount int64
9289}
9290
9291func cathtmlfunc(c fiber.Ctx) error {
9292 tmpl, err := mainTmpl()
9293 if err != nil {
9294 msg := fmt.Sprintf("Error parsing html template: %v", err)
9295 log.Println(msg)
9296 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9297 }
9298 tmpl0, err := tmpl.Clone()
9299 if err != nil {
9300 msg := fmt.Sprintf("Error cloning html template: %v", err)
9301 log.Println(msg)
9302 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9303 }
9304 _, err = tmpl0.New("main").Parse(h.CategoryPage())
9305 if err != nil {
9306 msg := fmt.Sprintf("Error parsing Category page template: %v", err)
9307 log.Println(msg)
9308 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9309 }
9310 tmpl = tmpl0
9311 var tmplData map[string]interface{}
9312 var result bytes.Buffer
9313 var categoryproducts p.Products
9314 c.Set("Content-Type", "text/html;charset=utf-8")
9315 h1 := pageMeta(c, htmlPageTemplateData)
9316 h1.Title = fmt.Sprintf("%s | %s", func() string {
9317 var str string
9318 if c.Params("partno") != "" {
9319 return "No product matching partno.: " + c.Params("partno") + " | Showing All Products"
9320 }
9321 if c.Params("cat") == "" {
9322 return "All Products"
9323 }
9324 str = fmt.Sprintf("Category: %s", c.Params("cat"))
9325 if c.Params("subcat") != "" {
9326 str += fmt.Sprintf("; Subcategory: %s", c.Params("subcat"))
9327 }
9328 return str
9329 }(), h1.Title)
9330 h1.Page = "category"
9331 // The listing has an Add to cart on every row, and the cart is what
9332 // defines addToCart. Without it every one of those buttons was inert
9333 // β the page rendered them, the click found no such function, and
9334 // nothing reached the cart from a category page at all. The small
9335 // build, because this is carried by every listing view.
9336 h1.WasmBinary = wasmSmallFor("cart")
9337 if c.Params("cat") == "" && c.Params("subcat") == "" {
9338 tmplData = map[string]interface{}{
9339 "Products": allproducts,
9340 "Page": h1,
9341 "Category": c.Params("cat"),
9342 "Subcategory": c.Params("subcat"),
9343 "Prods": allproducts,
9344 "Product": c.Params("partno"),
9345 }
9346 } else {
9347
9348 for _, prod := range allproducts {
9349 if strings.EqualFold(prod.Category, c.Params("cat")) && (c.Params("subcat") == "" || strings.EqualFold(escapesubcat(prod.Subcategory), c.Params("subcat"))) {
9350 categoryproducts = append(categoryproducts, prod)
9351 }
9352 }
9353 tmplData = map[string]interface{}{
9354 "Products": categoryproducts,
9355 "Page": h1,
9356 "Category": c.Params("cat"),
9357 "Subcategory": c.Params("subcat"),
9358 "Prods": allproducts,
9359 }
9360 }
9361 err = tmpl.Execute(&result, tmplData)
9362 if err != nil {
9363 msg := fmt.Sprintf("Error execute html template: %v", err)
9364 log.Println(msg)
9365 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9366 }
9367 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
9368 return err
9369}
9370
9371func getcats() (cats []string) {
9372 var catsMap = make(map[string]int)
9373 for _, prod := range allproducts {
9374 catsMap[prod.Category]++
9375 }
9376 for cat := range catsMap {
9377 cats = append(cats, cat)
9378 }
9379 return cats
9380}
9381func contains(slice []string, str string) bool {
9382 for _, s := range slice {
9383 if s == str {
9384 return true
9385 }
9386 }
9387 return false
9388}
9389func getcategories(allproducts p.Products) (map[string]int, []string, map[string]map[string]int, map[string][]string) {
9390 categoryCounts := make(map[string]int)
9391 subcategoryCounts := make(map[string]map[string]int)
9392 subcategoriesByCategory := make(map[string][]string)
9393
9394 for _, prod := range allproducts {
9395 if prod.Category != "" {
9396 categoryCounts[prod.Category]++
9397 if prod.Subcategory != "" {
9398 if subcategoryCounts[prod.Category] == nil {
9399 subcategoryCounts[prod.Category] = make(map[string]int)
9400 }
9401 subcategoryCounts[prod.Category][prod.Subcategory]++
9402 if !contains(subcategoriesByCategory[prod.Category], prod.Subcategory) {
9403 subcategoriesByCategory[prod.Category] = append(subcategoriesByCategory[prod.Category], prod.Subcategory)
9404 }
9405 }
9406 }
9407 }
9408
9409 var sortableCategories []struct {
9410 Name string
9411 Count int
9412 }
9413 for cat, count := range categoryCounts {
9414 sortableCategories = append(sortableCategories, struct {
9415 Name string
9416 Count int
9417 }{Name: cat, Count: count})
9418 }
9419 sort.Slice(sortableCategories, func(i, j int) bool {
9420 return sortableCategories[i].Count > sortableCategories[j].Count
9421 })
9422 var sortedCategories []string
9423 for _, cat := range sortableCategories {
9424 sortedCategories = append(sortedCategories, cat.Name)
9425 var sortableSubcategories []struct {
9426 Name string
9427 Count int
9428 }
9429 for subcat, count := range subcategoryCounts[cat.Name] {
9430 sortableSubcategories = append(sortableSubcategories, struct {
9431 Name string
9432 Count int
9433 }{Name: subcat, Count: count})
9434 }
9435 sort.Slice(sortableSubcategories, func(i, j int) bool {
9436 return sortableSubcategories[i].Count > sortableSubcategories[j].Count
9437 })
9438 var sortedSubcategories []string
9439 for _, subcat := range sortableSubcategories {
9440 sortedSubcategories = append(sortedSubcategories, subcat.Name)
9441 }
9442 subcategoriesByCategory[cat.Name] = sortedSubcategories
9443 }
9444 return categoryCounts, sortedCategories, subcategoryCounts, subcategoriesByCategory
9445}
9446
9447func getsubcats(cat string) (subcats []string) {
9448 var subcatsMap = make(map[string]int)
9449 for _, prod := range allproducts {
9450 if cat == "" || strings.EqualFold(cat, prod.Category) {
9451 if prod.Subcategory != "" {
9452 subcatsMap[escapesubcat(prod.Subcategory)]++
9453 }
9454 }
9455 }
9456 for subcat := range subcatsMap {
9457 subcats = append(subcats, subcat)
9458 }
9459 return subcats
9460}
9461
9462// escapesubcat spells a subcategory the way it appears in a URL.
9463//
9464// The rules live in pkg/storepage now, because both views need them and
9465// only this one had them: the terminal could recognize no /cat/x/y path
9466// whose subcategory had been escaped, so it handed those links to the
9467// browser rather than opening the listing it has.
9468func escapesubcat(sc string) string { return storepage.EscapeSubcat(sc) }
9469
9470func handlecat(c fiber.Ctx) error {
9471 if c.Params("cat") == "" && c.Params("subcat") == "" {
9472 return cathtmlfunc(c)
9473 }
9474 var catexists bool
9475 var subcatexists bool
9476 catexists = false
9477 for _, cat := range getcats() {
9478 if strings.EqualFold(cat, c.Params("cat")) {
9479 catexists = true
9480 break
9481 }
9482 }
9483 subcatexists = false
9484 if c.Params("subcat") != "" {
9485 for _, subcat := range getsubcats("") {
9486 if strings.EqualFold(escapesubcat(subcat), c.Params("subcat")) {
9487 subcatexists = true
9488 break
9489 }
9490 }
9491 }
9492 if c.Params("subcat") != "" && !subcatexists {
9493 log.Printf("subcategory %s does not match any existing subcategory\n", c.Params("subcat"))
9494 return c.Redirect().To("/cat/" + c.Params("cat"))
9495 }
9496 if !catexists {
9497 log.Printf("category %s does not match any existing category\n", c.Params("cat"))
9498 return c.Redirect().To("/cat")
9499 }
9500 if catexists || (catexists && subcatexists) {
9501 return cathtmlfunc(c)
9502 }
9503 return c.SendStatus(fiber.StatusNotFound)
9504}
9505
9506func homepage(c fiber.Ctx) error {
9507 tmpl, err := mainTmpl()
9508 if err != nil {
9509 msg := fmt.Sprintf("Could not parsing html template: %v", err)
9510 log.Println(msg)
9511 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9512 }
9513 tmpl0, err := tmpl.Clone()
9514 if err != nil {
9515 msg := fmt.Sprintf("Error cloning template: %v", err)
9516 log.Println(msg)
9517 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9518 }
9519 _, err = tmpl0.New("main").Parse(h.FrontPage())
9520 if err != nil {
9521 msg := fmt.Sprintf("Error parsing Front Page template: %v", err)
9522 log.Println(msg)
9523 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9524 }
9525 _, err = tmpl0.New("about").Parse(h.AboutPage())
9526 if err != nil {
9527 msg := fmt.Sprintf("Error parsing About Page template: %v", err)
9528 log.Println(msg)
9529 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9530 }
9531 _, err = tmpl0.New("policy").Parse(h.PolicyPage())
9532 if err != nil {
9533 msg := fmt.Sprintf("Error parsing Policy Page template: %v", err)
9534 log.Println(msg)
9535 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9536 }
9537 _, err = tmpl0.New("links").Parse(h.LinksPage())
9538 if err != nil {
9539 msg := fmt.Sprintf("Error parsing Links Page template: %v", err)
9540 log.Println(msg)
9541 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9542 }
9543 tmpl = tmpl0
9544 log.Println(c.Get("User-Agent"))
9545 c.Set("Content-Type", "text/html;charset=utf-8")
9546 h1 := pageMeta(c, htmlPageTemplateData)
9547 // The front page has the canvas stl2 animates into, and deco, which draws
9548 // the borders round the product tables, the ASCII art behind the
9549 // photographs and the headings as FIGlet banners. Both are decorations: the
9550 // HTML is the same either way, and a browser that runs neither gets the
9551 // page it always got.
9552 // The small build of each: these ride on every page view, and which
9553 // build newest-wins picks is decided by whichever toolchain finished
9554 // last β a page was serving the 23MB stl2 rather than the 9MB one
9555 // because the Go build had just been rebuilt. Size on a page a
9556 // customer loads is not a thing to leave to build order.
9557 h1.WasmBinary = wasmSmallFor("cart", "stl2", "deco")
9558 tmplData := map[string]interface{}{
9559 "Page": h1,
9560 "Prods": allproducts,
9561 }
9562 var result bytes.Buffer
9563 err = tmpl.Execute(&result, tmplData)
9564 if err != nil {
9565 msg := fmt.Sprintf("Error executing template: %v", err)
9566 log.Println(msg)
9567 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9568 }
9569 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
9570 return err
9571}
9572
9573func productpage(c fiber.Ctx) error {
9574 tmpl, err := mainTmpl()
9575 if err != nil {
9576 msg := fmt.Sprintf("Error parsing html template: %v", err)
9577 log.Println(msg)
9578 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9579 }
9580 tmpl0, err := tmpl.Clone()
9581 if err != nil {
9582 msg := fmt.Sprintf("Error cloning template: %v", err)
9583 log.Println(msg)
9584 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9585 }
9586 _, err = tmpl0.New("main").Parse(h.ProductPage())
9587 if err != nil {
9588 msg := fmt.Sprintf("Error parsing product page template: %v", err)
9589 log.Println(msg)
9590 return c.Status(fiber.StatusInternalServerError).SendString(msg)
9591 }
9592 tmpl = tmpl0
9593 c.Set("Content-Type", "text/html;charset=utf-8")
9594 for _, prod := range allproducts {
9595 if strings.EqualFold(prod.Partno, c.Params("partno")) {
9596 var result bytes.Buffer
9597 h1 := pageMeta(c, htmlPageTemplateData)
9598 h1.Page = "product"
9599 // A product page has the canvas and the package-type element the
9600 // STL viewer reads to pick its model β and an Add to cart,
9601 // which needs the cart that defines addToCart.
9602 h1.WasmBinary = wasmSmallFor("cart", "stl2")
9603 h1.Title = fmt.Sprintf("%s | %s", prod.Name, h1.Title)
9604 tmplData := map[string]interface{}{
9605 "Prod": prod,
9606 "Page": h1,
9607 "Prods": allproducts,
9608 }
9609 err := tmpl.Execute(&result, tmplData)
9610 if err != nil {
9611 log.Println("error: ", err)
9612 _, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
9613 return err
9614 }
9615 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
9616 return err
9617 }
9618 }
9619 log.Printf("product %s does not match any existing product\n", c.Params("partno"))
9620 return c.Status(fiber.StatusNotFound).Redirect().To("/cat")
9621}
9622
9623
9624// ===== pkg/web/shipping.go =====
9625package web
9626
9627// The shipping form, as the page draws it.
9628//
9629// These are methods on the page data rather than FuncMap entries for the
9630// reason wasmpick.go's WasmExecs records: templates here are parsed in
9631// several places and only some of them call .Funcs(funcs), so a template
9632// function is a parse error waiting for whichever page was built bare.
9633// A method travels with the data and every parse site can call it.
9634
9635import (
9636 "github.com/0magnet/m2/pkg/storepage"
9637)
9638
9639// ShippingFields is the form, in order β the same list pkg/tui builds its
9640// overlay from and the same field names wasm/cart reads back.
9641func (h htmlTemplateData) ShippingFields() []storepage.Field {
9642 return storepage.ShippingFields
9643}
9644
9645// ShippingStates is the State select's options.
9646func (h htmlTemplateData) ShippingStates() []storepage.Jurisdiction {
9647 return storepage.States
9648}
9649
9650// ShippingCountries is the Country select's options.
9651func (h htmlTemplateData) ShippingCountries() []string { return storepage.Countries }
9652
9653// StatePrompt is the unselected entry at the top of the State select.
9654func (h htmlTemplateData) StatePrompt() string { return storepage.StatePrompt }
9655
9656// MinShipping is the least the store ships for, as the number a min
9657// attribute wants ("7").
9658func (h htmlTemplateData) MinShipping() string {
9659 return itoa(storepage.MinShippingCents / 100)
9660}
9661
9662// MinShippingValue is that same figure as a price ("7.00"), for the
9663// amount field's default.
9664func (h htmlTemplateData) MinShippingValue() string {
9665 c := storepage.MinShippingCents
9666 return itoa(c/100) + "." + pad2(c%100)
9667}
9668
9669func itoa(n int) string {
9670 if n == 0 {
9671 return "0"
9672 }
9673 var b []byte
9674 for n > 0 {
9675 b = append([]byte{byte('0' + n%10)}, b...)
9676 n /= 10
9677 }
9678 return string(b)
9679}
9680
9681func pad2(n int) string {
9682 s := itoa(n)
9683 if len(s) < 2 {
9684 return "0" + s
9685 }
9686 return s
9687}
9688
9689// CoinCheckout reports whether the deployment takes crypto, so the cart
9690// offers the button only where it will work. A method rather than a
9691// FuncMap entry, for the reason at the top of this file.
9692func (h htmlTemplateData) CoinCheckout() bool { return cryptoPay != nil }
9693
9694// CoinName is the coin the store takes, for the button's label.
9695func (h htmlTemplateData) CoinName() string {
9696 if cryptoPay == nil {
9697 return ""
9698 }
9699 return string(cryptoPay.backend.Coin())
9700}
9701
9702
9703// ===== pkg/web/shipping_test.go =====
9704package web
9705
9706import (
9707 "bytes"
9708 htmpl "html/template"
9709 "os"
9710 "strings"
9711 "testing"
9712
9713 "github.com/0magnet/m2/pkg/storepage"
9714)
9715
9716// renderFooter runs the footer partial the way a page does. It is parsed
9717// BARE β no .Funcs β on purpose: that is the state a template set built
9718// somewhere that forgot the FuncMap is in, and the shipping form must
9719// survive it. See pkg/web/shipping.go.
9720func renderFooter(t *testing.T) string {
9721 t.Helper()
9722 src, err := os.ReadFile("../../htmpl/footer.html")
9723 if err != nil {
9724 t.Fatal(err)
9725 }
9726 tmpl, err := htmpl.New("footer").Parse(string(src))
9727 if err != nil {
9728 t.Fatalf("footer does not parse without a FuncMap: %v", err)
9729 }
9730 var b bytes.Buffer
9731 if err := tmpl.Execute(&b, struct{ Page htmlTemplateData }{}); err != nil {
9732 t.Fatal(err)
9733 }
9734 return b.String()
9735}
9736
9737// The cart wasm reads each control by [name='...']; a renamed or dropped
9738// control silently produces an empty field in the shipping line, so the
9739// generated form must still carry every one of them.
9740func TestShippingFormKeepsItsControlNames(t *testing.T) {
9741 got := renderFooter(t)
9742 for _, f := range storepage.ShippingFields {
9743 if !strings.Contains(got, "name='"+f.Name+"'") {
9744 t.Errorf("no control named %q in the rendered form", f.Name)
9745 }
9746 if !strings.Contains(got, "id='"+f.Name+"'") {
9747 t.Errorf("no id %q in the rendered form", f.Name)
9748 }
9749 }
9750}
9751
9752// Every jurisdiction reaches the page, by code and by name.
9753func TestShippingFormListsEveryState(t *testing.T) {
9754 got := renderFooter(t)
9755 if n := strings.Count(got, "<option value='"); n != len(storepage.States)+len(storepage.Countries)+1 {
9756 t.Errorf("option count = %d, want %d states + %d countries + 1 prompt",
9757 n, len(storepage.States), len(storepage.Countries))
9758 }
9759 for _, s := range storepage.States {
9760 if !strings.Contains(got, "<option value='"+s.Code+"'>"+s.Name+"</option>") {
9761 t.Errorf("missing state option %s / %s", s.Code, s.Name)
9762 }
9763 }
9764}
9765
9766// The minimum is one number now; the form must show it rather than its own.
9767func TestShippingFormUsesTheSharedMinimum(t *testing.T) {
9768 got := renderFooter(t)
9769 if !strings.Contains(got, "min='7'") || !strings.Contains(got, "value='7.00'") {
9770 t.Errorf("amount field does not carry the shared minimum:\n%s", got)
9771 }
9772}
9773
9774// A pipe in any field shifts every field after it in the cart line.
9775func TestShippingRejectsAndScrubsTheSeparator(t *testing.T) {
9776 s := storepage.Shipping{
9777 Cents: 700, Name: "A|B", City: "Dallas", State: "TX", Country: "United States",
9778 }
9779 if msg := s.Validate(); msg == "" {
9780 t.Error("a pipe in Name was accepted")
9781 }
9782 if strings.Count(s.ID(), "|") != 7 {
9783 t.Errorf("ID() did not scrub the separator: %q", s.ID())
9784 }
9785 back, ok := storepage.ParseShipping(s.ID())
9786 if !ok || back.City != "Dallas" || back.State != "TX" {
9787 t.Errorf("round trip lost fields: %+v ok=%v", back, ok)
9788 }
9789}
9790
9791// Below the minimum is refused rather than silently charged.
9792func TestShippingEnforcesTheMinimum(t *testing.T) {
9793 if msg := (storepage.Shipping{Cents: 0}).Validate(); msg == "" {
9794 t.Error("zero shipping was accepted")
9795 }
9796 if msg := (storepage.Shipping{Cents: storepage.MinShippingCents}).Validate(); msg != "" {
9797 t.Errorf("the minimum itself was refused: %s", msg)
9798 }
9799}
9800
9801
9802// ===== pkg/web/sourcecode.go =====
9803// Package web pkg/web/sourcecode.go β the store serves its own source.
9804package web
9805
9806import (
9807 "bytes"
9808 "embed"
9809 "fmt"
9810 "io/fs"
9811 "os"
9812 "strings"
9813
9814 "github.com/alecthomas/chroma/v2"
9815 "github.com/alecthomas/chroma/v2/formatters/html"
9816 "github.com/alecthomas/chroma/v2/lexers"
9817 "github.com/alecthomas/chroma/v2/styles"
9818 "github.com/gofiber/fiber/v3"
9819
9820 "github.com/0magnet/m2/pkg/config"
9821 "github.com/0magnet/m2/pkg/product"
9822 "github.com/0magnet/m2/pkg/tui"
9823)
9824
9825//go:embed *.go
9826var srcWeb embed.FS
9827
9828type srcEntry struct {
9829 Prefix string
9830 FS fs.FS
9831}
9832
9833// goSources holds each package's embedded Go files for /sourcecode/go. A Go
9834// package cannot embed files outside its own directory, so since the code
9835// moved out of the repo root every package embeds its own *.go and is listed
9836// here. The web package's own embed picks up the other.go drop-in when a
9837// deployment builds with one.
9838var goSources = []srcEntry{
9839 {"pkg/config", config.Source},
9840 {"pkg/product", product.Source},
9841 {"pkg/tui", tui.Source},
9842 {"pkg/web", srcWeb},
9843}
9844
9845// RegisterSource adds a package's embedded sources to /sourcecode/go β used
9846// by the commands package, which web cannot import without a cycle.
9847func RegisterSource(prefix string, fsys fs.FS) {
9848 goSources = append(goSources, srcEntry{prefix, fsys})
9849}
9850
9851var sourcesWasm []fs.FS
9852var sourceCore = os.DirFS("ui")
9853var sourceHtml = os.DirFS("htmpl")
9854var sourceContent = os.DirFS("content")
9855
9856func serveSourceCode(r *fiber.App) {
9857 for _, wasmSRC := range f.WasmSRC {
9858 sourcesWasm = append(sourcesWasm, os.DirFS(wasmSRC))
9859 }
9860 r.Get("/sourcecode", func(c fiber.Ctx) error {
9861 ret := `<!doctype html>
9862<html lang='en'>
9863<head>
9864<link rel="stylesheet" href="/style.css" type="text/css">
9865</head>
9866<body class='grid-container' style='background-color:black;color:white;'>
9867<a href='/sourcecode/go'>GO</a><br><br>
9868
9869<a href='/sourcecode/html'>HTML</a><br><br>
9870
9871<a href='/sourcecode/content'>Content</a><br><br>
9872
9873<a href='/sourcecode/core'>C.O.R.E.</a><br><br>
9874
9875<a href='/sourcecode/wasm'>WASM</a><br><br>
9876
9877</body>
9878</html>
9879`
9880 c.Set("Content-Type", "text/html;charset=utf-8")
9881 _, err := c.Status(fiber.StatusOK).Write([]byte(ret))
9882 return err
9883 })
9884
9885 r.Get("/sourcecode/html", sourcecodehtml)
9886 r.Get("/sourcecode/content", sourcecodecontent)
9887 r.Get("/sourcecode/go", sourcecodego)
9888 r.Get("/sourcecode/core", sourcecodecore)
9889 r.Get("/sourcecode/wasm", func(c fiber.Ctx) error {
9890 ret := `<!doctype html>
9891<html lang='en'>
9892<head>
9893<link rel="stylesheet" href="/style.css" type="text/css">
9894</head>
9895<body class='grid-container' style='background-color:black;color:white;'>
9896`
9897 for _, wasmSRC := range f.WasmSRC {
9898 pathNameSlc := strings.Split(wasmSRC, "/")
9899 pathName := pathNameSlc[len(pathNameSlc)-1]
9900 ret += `<a href='/sourcecode/wasm/` + pathName + `'>` + pathName + `</a><br>
9901 `
9902 }
9903 ret += `</body></html>
9904 `
9905 c.Set("Content-Type", "text/html;charset=utf-8")
9906 _, err := c.Status(fiber.StatusOK).Write([]byte(ret))
9907 return err
9908 })
9909
9910 for i, wasmSRC := range f.WasmSRC {
9911 pathNameSlc := strings.Split(wasmSRC, "/")
9912 pathName := pathNameSlc[len(pathNameSlc)-1]
9913 r.Get("/sourcecode/wasm/"+pathName, func(c fiber.Ctx) error {
9914 return sourcecode(c, sourcesWasm[i], "dracula", "go")
9915 })
9916 }
9917}
9918
9919func sourcecodehtml(c fiber.Ctx) error {
9920 return sourcecode(c, sourceHtml, "monokai", "html")
9921}
9922func sourcecodecontent(c fiber.Ctx) error {
9923 return sourcecode(c, sourceContent, "monokai", "html")
9924}
9925
9926func sourcecodego(c fiber.Ctx) error {
9927 var builder strings.Builder
9928 for _, src := range goSources {
9929 if err := collectSource(&builder, src.FS, src.Prefix, "go"); err != nil {
9930 return err
9931 }
9932 }
9933 return renderSource(c, builder.String(), "monokai", "go")
9934}
9935
9936func sourcecodecore(c fiber.Ctx) error {
9937 return sourcecode(c, sourceCore, "solarized-dark256", "go")
9938}
9939
9940func sourcecode(c fiber.Ctx, fsys fs.FS, styleName string, lang string) error {
9941 var builder strings.Builder
9942 if err := collectSource(&builder, fsys, "", lang); err != nil {
9943 return err
9944 }
9945 return renderSource(c, builder.String(), styleName, lang)
9946}
9947
9948// collectSource concatenates every .<lang> file in fsys into builder, each
9949// under a banner naming it (with prefix, so files from different packages
9950// keep their repo paths).
9951func collectSource(builder *strings.Builder, fsys fs.FS, prefix string, lang string) error {
9952 return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
9953 if err != nil {
9954 return err
9955 }
9956 if !d.IsDir() && strings.HasSuffix(path, "."+lang) {
9957 content, err := fs.ReadFile(fsys, path)
9958 if err != nil {
9959 return err
9960 }
9961 name := path
9962 if prefix != "" {
9963 name = prefix + "/" + path
9964 }
9965 builder.WriteString(fmt.Sprintf("// ===== %s =====\n", name))
9966 builder.Write(content)
9967 builder.WriteString("\n\n")
9968 }
9969 return nil
9970 })
9971}
9972
9973func renderSource(c fiber.Ctx, source string, styleName string, lang string) error {
9974 c.Set("Content-Type", "text/html;charset=utf-8")
9975 var buf bytes.Buffer
9976
9977 // Pick lexer & style
9978 lexer := lexers.Get(lang)
9979 if lexer == nil {
9980 lexer = lexers.Fallback
9981 }
9982 lexer = chroma.Coalesce(lexer)
9983
9984 style := styles.Get(styleName)
9985 if style == nil {
9986 style = styles.Fallback
9987 }
9988
9989 // Formatter with line numbers & CSS classes
9990 formatter := html.New(
9991 html.WithLineNumbers(true),
9992 html.WithClasses(true),
9993 )
9994
9995 iterator, err := lexer.Tokenise(nil, source)
9996 if err != nil {
9997 return err
9998 }
9999
10000 // Optional: include CSS in output
10001 var css bytes.Buffer
10002 _ = formatter.WriteCSS(&css, style) //nolint:errcheck // the stylesheet is decoration; the highlighted source is still readable without it
10003 buf.WriteString("<style>")
10004 buf.Write(css.Bytes())
10005 buf.WriteString("</style>")
10006
10007 if err := formatter.Format(&buf, style, iterator); err != nil {
10008 return err
10009 }
10010
10011 _, err = c.Status(fiber.StatusOK).Write(buf.Bytes())
10012 return err
10013}
10014
10015
10016// ===== pkg/web/tmpl.go =====
10017// Package web pkg/web/tmpl.go β template plumbing.
10018package web
10019
10020import (
10021 "bytes"
10022 "fmt"
10023 htmpl "html/template"
10024 "log"
10025 "os"
10026 "path/filepath"
10027 "sort"
10028 "strconv"
10029 "strings"
10030 ttmpl "text/template"
10031 "time"
10032
10033 "github.com/gofiber/fiber/v3"
10034
10035 p "github.com/0magnet/m2/pkg/product"
10036)
10037
10038/*
10039//go:embed htmpl/*
10040var templatesFS embed.FS
10041
10042//go:embed content/*
10043var contentFS embed.FS
10044*/
10045/*
10046var (
10047 templatesFS = os.DirFS("htmpl")
10048 contentFS = os.DirFS("content")
10049)
10050*/
10051/*
10052func mustReadEmbeddedFileToString(path string, fs embed.FS) string {
10053 return string(mustReadEmbeddedFileToBytes(path, fs))
10054}
10055
10056func mustReadEmbeddedFileToBytes(path string, fs embed.FS) []byte {
10057 data, err := fs.ReadFile(path)
10058 if err != nil {
10059 panic(err)
10060 }
10061 return data
10062}
10063*/
10064
10065func mustReadFileToString(path string) string {
10066 return string(mustReadFileToBytes(path))
10067}
10068
10069// optionalReadFileToString returns "" when the file does not exist.
10070// Used for deployment-local assets (e.g. content/font.css) that are
10071// not part of the source repo.
10072func optionalReadFileToString(path string) string {
10073 data, err := os.ReadFile(path) //nolint
10074 if err != nil {
10075 return ""
10076 }
10077 return string(data)
10078}
10079
10080// contentFile returns the deployment-local file when present, falling
10081// back to the committed <path>.example. Lets site operators override
10082// stock pages (about, policy, links) without touching tracked files.
10083func contentFile(path string) string {
10084 if s := optionalReadFileToString(path); s != "" {
10085 return s
10086 }
10087 return mustReadFileToString(path + ".example")
10088}
10089
10090func mustReadFileToBytes(path string) []byte {
10091 data, err := os.ReadFile(path) //nolint
10092 if err != nil {
10093 panic(err)
10094 }
10095 return data
10096}
10097
10098type htmlTemplate struct {
10099 Empty func() string
10100 Head func() string
10101 Logo func() string
10102 Header func() string
10103 Categories func() string
10104 CatSubcats func() string
10105 Footer func() string
10106 MainPage func() string
10107 AuxPage func() string
10108 FrontPage func() string
10109 CategoryPage func() string
10110 CategoryPageMD func() string
10111 ProductPage func() string
10112 ProductPageMD func() string
10113 Schema func() string
10114 Cart func() string
10115 XMLSitemap func() string
10116 Wasm func() string
10117 Keys func() string
10118 AboutPage func() string
10119 PolicyPage func() string
10120 LinksPage func() string
10121 CheckoutPage func() string
10122 CompletePage func() string
10123 CheckoutCSS func() string
10124 StyleCSS func() string
10125 FontCSS func() string
10126}
10127
10128var h = htmlTemplate{
10129 Empty: func() string { return mustReadFileToString("htmpl/empty.html") },
10130 Head: func() string { return mustReadFileToString("htmpl/head.html") },
10131 Logo: func() string { return mustReadFileToString("htmpl/logo.html") },
10132 Header: func() string { return mustReadFileToString("htmpl/header.html") },
10133 Categories: func() string { return mustReadFileToString("htmpl/categories.html") },
10134 CatSubcats: func() string { return mustReadFileToString("htmpl/catsubcats.html") },
10135 Footer: func() string { return mustReadFileToString("htmpl/footer.html") },
10136 MainPage: func() string { return mustReadFileToString("htmpl/main.html") },
10137 AuxPage: func() string { return mustReadFileToString("htmpl/auxpage.html") },
10138 FrontPage: func() string { return mustReadFileToString("htmpl/front.html") },
10139 CategoryPage: func() string { return mustReadFileToString("htmpl/category.html") },
10140 CategoryPageMD: func() string { return mustReadFileToString("htmpl/category.md") },
10141 ProductPage: func() string { return mustReadFileToString("htmpl/product.html") },
10142 ProductPageMD: func() string { return mustReadFileToString("htmpl/product.md") },
10143 Schema: func() string { return mustReadFileToString("htmpl/schema.html") },
10144 Cart: func() string { return mustReadFileToString("htmpl/cart.html") },
10145 XMLSitemap: func() string { return mustReadFileToString("htmpl/sitemap.xml") },
10146 Wasm: func() string { return mustReadFileToString("htmpl/wasm.html") },
10147 Keys: func() string { return mustReadFileToString("htmpl/keys.html") },
10148 CompletePage: func() string { return mustReadFileToString("htmpl/complete.html") },
10149 AboutPage: func() string { return contentFile("content/about.html") },
10150 PolicyPage: func() string { return contentFile("content/policy.html") },
10151 LinksPage: func() string { return contentFile("content/links.html") },
10152 CheckoutPage: func() string { return mustReadFileToString("content/checkout.html") },
10153 CheckoutCSS: func() string { return mustReadFileToString("content/checkout.css") },
10154 StyleCSS: func() string {
10155 return fontCSS() + mustReadFileToString("content/style.css")
10156 },
10157 // The terminal page wants the face on its own, without the stylesheet.
10158 FontCSS: func() string { return fontCSS() },
10159}
10160
10161var htmlPageTemplateData htmlTemplateData
10162
10163var funcs = htmpl.FuncMap{
10164 "replace": replace, "mul": mul, "div": div, "safeHTML": safeHTML,
10165 "safeJS": safeJS, "stripProtocol": stripProtocol, "add": add, "sub": sub,
10166 "toFloat": toFloat, "equalsIgnoreCase": equalsIgnoreCase,
10167 "getsubcats": getsubcats, "escapesubcat": escapesubcat,
10168 "sortsubcats": sortsubcats, "repeat": repeat, "subcatlink": subcatlink,
10169 "navmenu": navmenu,
10170}
10171
10172func mainTmpl() (tmpl *htmpl.Template, err error) {
10173 tmpl = htmpl.New("index").Funcs(funcs)
10174 if _, err := tmpl.Parse(h.MainPage()); err != nil {
10175 log.Println("Error parsing index template:", err)
10176 return tmpl, err
10177 }
10178
10179 for _, p := range mainPartials() {
10180 if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
10181 log.Printf("Error parsing %s template: %v", p.Name, err)
10182 return tmpl, err
10183 }
10184 }
10185 return tmpl, err
10186}
10187
10188// mainPartialSrc is every template main.html may invoke, besides the
10189// "main" its caller supplies.
10190//
10191// One list of name and loader rather than a list of names and a map to
10192// look them up in: the names have to be readable without loading the
10193// bodies, because a test has no working directory to read them from, and
10194// two collections would let a name arrive with no body behind it.
10195//
10196// The test matters because a missing partial is not a parse error.
10197// html/template notices only at execute time, and then on every page of
10198// the site at once, which is a poor place to find out.
10199var mainPartialSrc = []struct {
10200 Name string
10201 Body func() string
10202}{
10203 {"head", h.Head},
10204 {"schema", h.Schema},
10205 {"header", h.Header},
10206 {"catsubcats", h.CatSubcats},
10207 {"categories", h.Categories},
10208 {"footer", h.Footer},
10209 {"cart", h.Cart},
10210 {"wasm", h.Wasm},
10211 {"keys", h.Keys},
10212}
10213
10214// mainPartials loads them.
10215func mainPartials() []struct{ Name, Content string } {
10216 out := make([]struct{ Name, Content string }, 0, len(mainPartialSrc))
10217 for _, p := range mainPartialSrc {
10218 out = append(out, struct{ Name, Content string }{p.Name, p.Body()})
10219 }
10220 return out
10221}
10222
10223func auxTmpl() (tmpl *htmpl.Template, err error) {
10224 tmpl = htmpl.New("index").Funcs(funcs)
10225 if _, err := tmpl.Parse(h.AuxPage()); err != nil {
10226 log.Println("Error parsing index template:", err)
10227 return tmpl, err
10228 }
10229
10230 partials := []struct {
10231 Name string
10232 Content string
10233 }{
10234 {"head", h.Head()},
10235 {"schema", h.Empty()},
10236 {"wasm", h.Empty()},
10237 }
10238
10239 for _, p := range partials {
10240 if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
10241 log.Printf("Error parsing %s template: %v", p.Name, err)
10242 return tmpl, err
10243 }
10244 }
10245 return tmpl, err
10246}
10247
10248func pageMeta(c fiber.Ctx, base htmlTemplateData) htmlTemplateData {
10249 h := base
10250 host := string(c.Request().Host())
10251 /*
10252 proto := "http"
10253 if c.Secure() {
10254 proto += "s"
10255 }
10256 */
10257 proto := "https"
10258 h.Canonical = proto + "://" + host + c.OriginalURL()
10259 h.BaseURL = proto + "://" + host
10260 h.RequestHost = host
10261 h.Protocol = proto
10262 h.CatsCounts, h.Cats, h.SubCatsCounts, h.SubCatsByCat = getcategories(allproducts)
10263 h.LenAllProducts = len(allproducts)
10264 h.Time = time.Now().Format(time.RFC3339Nano)
10265 h.Year = fmt.Sprintf("%v", time.Now().Year())
10266 h.MetaDesc = f.Sitemeta
10267 h.ImgBase = h.ImgSRC
10268 if strings.HasPrefix(h.ImgBase, "/") {
10269 h.ImgBase = h.BaseURL + h.ImgBase
10270 }
10271 h.KeyWords = strings.Replace(f.Sitelongname, " ", ", ", -1)
10272 return h
10273}
10274
10275func initTMPL() {
10276 htmlPageTemplateData = htmlTemplateData{
10277 TestMode: f.Teststripekey,
10278 Title: f.Sitelongname,
10279 StripePK: f.StripePK,
10280 SiteName: f.Sitedomain,
10281 SiteTagLine: f.Sitetagline,
10282 SiteName1: htmpl.HTML(checkerBoard(f.Sitedomain)), //nolint
10283 SiteLongName: f.Sitelongname,
10284 SiteASCIILogo: htmpl.HTML(f.SiteASCIILogo), //nolint
10285 SitePrettyName: f.Siteprettyname,
10286 SitePrettyNameCap: f.Siteprettynamecap,
10287 SitePrettyNameCaps: f.Siteprettynamecaps,
10288 TelegramContact: f.Tgcontact,
10289 TelegramChannel: f.Tgchannel,
10290 WasmExecPath: f.WasmExecPath,
10291 WasmExecRel: f.WasmExecPath,
10292 Cats: getcats(),
10293 LenAllProducts: len(allproducts),
10294 ImgSRC: func() (ret string) {
10295 ret = f.Siteimagesrc
10296 if ret == "" {
10297 ret = "/i"
10298 }
10299 return ret
10300 }(),
10301 Page: "front",
10302 Time: time.Now().Format(time.RFC3339Nano),
10303 Year: fmt.Sprintf("%v", time.Now().Year()),
10304 NavLinks: extraNavLinks,
10305 }
10306 htmlPageTemplateData.CatsCounts, htmlPageTemplateData.Cats, htmlPageTemplateData.SubCatsCounts, htmlPageTemplateData.SubCatsByCat = getcategories(allproducts)
10307 // No wasm by default; a page opts in with wasmFor. Defaulting to all of
10308 // them is what had every page in the site fetching stl2 (7.9MB) for a
10309 // canvas it does not have, and asking for the tui and desk binaries,
10310 // which have no TinyGo build and answer 500. Those two pages build their
10311 // own HTML and loader anyway, so they never read this list.
10312 htmlPageTemplateData.WasmBinary = nil
10313
10314}
10315
10316// wasmFor returns only the configured binaries named, so a page carries the
10317// wasm it can actually use and nothing else.
10318//
10319// Every page used to carry all of them, and each binary opens by looking for
10320// the element it drives and returning when that element is not there β so an
10321// ordinary category page fetched several megabytes, instantiated it, and got
10322// a handful of early returns. stl2 is the expensive one at 7.9MB, and only
10323// the front page and a product page have the canvas it draws into.
10324//
10325// It also stops the binaries that have no TinyGo build yet from being asked
10326// for on every page in the site: those fetches answer 500, which is two
10327// failed requests per page view for something the page could not use anyway.
10328//
10329// Names match on the part before the suffix, so a caller says "stl2" and gets
10330// whichever of stl2.wasm or stl2-tiny.wasm this build is configured for.
10331func wasmFor(names ...string) []wasmPick { return wasmPicks(pickWasm, names) }
10332
10333// wasmSmallFor is wasmFor for a binary a page carries on every view
10334// rather than when the reader asks for it: it takes the tinygo build
10335// where there is one. See pickWasmSmall.
10336func wasmSmallFor(names ...string) []wasmPick { return wasmPicks(pickWasmSmall, names) }
10337
10338func wasmPicks(pick func(string) (wasmPick, bool), names []string) []wasmPick {
10339 // WasmSRC lists the sources THIS process builds, and a page must not
10340 // name a drop-in the deployment never compiles. The tab compiles
10341 // nothing and its list is empty, so the same gate there would drop
10342 // every binary before it was looked for; the origin, which pick asks
10343 // directly, is the authority instead.
10344 inTab := servingInTab()
10345 configured := map[string]bool{}
10346 if !inTab {
10347 for _, src := range f.WasmSRC {
10348 configured[strings.TrimSuffix(filepath.Base(src), filepath.Ext(src))] = true
10349 }
10350 }
10351 ret := make([]wasmPick, 0, len(names))
10352 for _, want := range names {
10353 if !inTab && !configured[want] {
10354 continue
10355 }
10356 if p, ok := pick(want); ok {
10357 ret = append(ret, p)
10358 }
10359 }
10360 return ret
10361}
10362
10363type xmlTemplateData struct {
10364 BaseURL string
10365 Extra []string
10366 Cats []string
10367 SubCatsByCat map[string][]string
10368 Products p.Products
10369 Update string
10370}
10371
10372func generateSitemapXML() string {
10373 xmlSitemapTemplateData := xmlTemplateData{
10374 BaseURL: "https://" + f.Sitedomain,
10375 Products: allproducts,
10376 Extra: extraSitemapURLs,
10377 Update: time.Now().Format("2006-01-02"),
10378 }
10379 _, xmlSitemapTemplateData.Cats, _, xmlSitemapTemplateData.SubCatsByCat = getcategories(allproducts)
10380 var err1 error
10381 xtmpl, err1 := ttmpl.New("index").Funcs(ttmpl.FuncMap{"getsubcats": getsubcats}).Parse(h.XMLSitemap())
10382 if err1 != nil {
10383 log.Println("Error parsing index template:", err1)
10384 }
10385 var result bytes.Buffer
10386 err1 = xtmpl.Execute(&result, xmlSitemapTemplateData)
10387 if err1 != nil {
10388 log.Println("error: ", err1)
10389 }
10390 return result.String()
10391}
10392
10393func toFloat(s string) float64 {
10394 if s == "" {
10395 return 0.0
10396 }
10397 f, err := strconv.ParseFloat(s, 64)
10398 if err != nil {
10399 return 0.0
10400 }
10401 return f
10402}
10403
10404func checkerBoard(input string) string {
10405 var result strings.Builder
10406 for i, char := range input {
10407 // Wrap every other letter with the specified HTML
10408 if i%2 == 0 {
10409 result.WriteString(fmt.Sprintf("<span class='nv'>%c</span>", char))
10410 } else {
10411 result.WriteRune(char)
10412 }
10413 }
10414 return result.String()
10415}
10416
10417type htmlTemplateData struct {
10418 Title string
10419 MetaDesc string
10420 Canonical string
10421 BaseURL string
10422 ImgSRC string // url where images are hosted
10423 // ImgBase is ImgSRC made absolute. og:image and schema.org image must be
10424 // absolute URLs: a link unfurler has no page to resolve a relative one
10425 // against and most simply drop it, which is why every share of this site
10426 // showed no picture. ImgSRC may already name another host, so this cannot
10427 // be done by pasting BaseURL in front of it in the template.
10428 ImgBase string
10429 OrdersURL string // url where checkout is served from
10430 SiteName string
10431 SiteTagLine string
10432 SiteName1 htmpl.HTML //checkerboard - alternate swap text & bg color
10433 SiteLongName string
10434 SitePrettyName string //ππππππ₯π π€π‘πππ£π.πππ₯
10435 SitePrettyNameCap string //ππππππ₯π π€π‘πππ£π.πππ₯
10436 SitePrettyNameCaps string //ππΈπΎβπΌπππββπΌβπΌ.βπΌπ
10437 SiteASCIILogo htmpl.HTML
10438 TelegramContact string
10439 TelegramChannel string
10440 Protocol string
10441 RequestHost string
10442 KeyWords string
10443 Style htmpl.HTML
10444 Heading htmpl.HTML
10445 StripePK string
10446 Cats []string
10447 CatsCounts map[string]int
10448 SubCatsCounts map[string]map[string]int
10449 SubCatsByCat map[string][]string
10450 LenAllProducts int
10451 Mobile bool
10452 Gocanvas htmpl.HTML
10453 WasmBinary []wasmPick
10454 WasmExecPath string
10455 WasmExecRel string
10456 StyleFontFace htmpl.CSS
10457 Message htmpl.HTML
10458 Page string
10459 Year string
10460 Time string
10461 AboutHTML htmpl.HTML
10462 LinksHTML htmpl.HTML
10463 PolicyHTML htmpl.HTML
10464 TestMode bool
10465
10466 // NavLinks are the entries drop-in routes added to the header; see
10467 // extraNavLinks.
10468 NavLinks []NavLink
10469}
10470
10471func equalsIgnoreCase(a, b string) bool {
10472 return strings.EqualFold(strings.Join(strings.Fields(a), ""), strings.Join(strings.Fields(b), ""))
10473}
10474
10475func replace(s, o, n string) string {
10476 return strings.ReplaceAll(s, o, n)
10477}
10478func mul(a, b float64) float64 {
10479 return a * b
10480}
10481func div(a, b float64) float64 {
10482 return a / b
10483}
10484func add(a, b int) int {
10485 return a + b
10486}
10487func sub(a, b int) int {
10488 return a - b
10489}
10490func safeHTML(s string) htmpl.HTML {
10491 return htmpl.HTML(s) //nolint
10492}
10493func safeJS(s string) htmpl.JS {
10494 return htmpl.JS(s) //nolint
10495}
10496func stripProtocol(s string) string {
10497 return strings.Replace(strings.Replace(s, "https://", "", -1), "http://", "", -1)
10498}
10499func repeat(s string, count int) string {
10500 var result string
10501 for i := 0; i < count; i++ {
10502 result += s
10503 }
10504 return result
10505}
10506func sortsubcats(subcats []string, counts map[string]map[string]int) []string {
10507 sort.Slice(subcats, func(i, j int) bool {
10508 catI, catJ := subcats[i], subcats[j]
10509 countI, countJ := counts[catI]["count"], counts[catJ]["count"]
10510 return countI > countJ
10511 })
10512 return subcats
10513}
10514
10515func subcatlink(subcategory string) string {
10516 s := subcategory
10517 s = strings.ReplaceAll(s, "ΒΌ", "quarter-")
10518 s = strings.ReplaceAll(s, "Β½", "half-")
10519 s = strings.ReplaceAll(s, "1/16", "sixteenth-")
10520 s = strings.ReplaceAll(s, "%", "-pct")
10521 s = strings.ReplaceAll(s, " ", " ")
10522 s = strings.ReplaceAll(s, "watt1", "watt-1")
10523 s = strings.ReplaceAll(s, "watt5", "watt-5")
10524 s = strings.ReplaceAll(s, " ", "-")
10525 s = strings.ReplaceAll(s, "--", "-")
10526 return s
10527}
10528
10529// fontCSS is the deployment's @font-face block, read from disk. It is a
10530// plain function rather than a method on h because h's own initializer
10531// needs it, and a var cannot refer to itself.
10532func fontCSS() string { return optionalReadFileToString("content/font.css") }
10533
10534
10535// ===== pkg/web/tmpl_test.go =====
10536package web
10537
10538import (
10539 "os"
10540 "regexp"
10541 "testing"
10542)
10543
10544var tmplInvoke = regexp.MustCompile(`\{\{\s*template\s+"([^"]+)"`)
10545
10546// Every partial main.html asks for has to be registered with it.
10547//
10548// html/template does not notice a missing one when the page is parsed β
10549// only when it is executed, and then on every page of the site at once.
10550// That is a poor place to find out, and a new partial is exactly the
10551// change that forgets the registration.
10552func TestMainTemplateInvokesOnlyRegisteredPartials(t *testing.T) {
10553 src, err := os.ReadFile("../../htmpl/main.html")
10554 if err != nil {
10555 t.Fatalf("read main.html: %v", err)
10556 }
10557 known := map[string]bool{
10558 // Supplied by the handler for the page being rendered.
10559 "main": true,
10560 }
10561 for _, p := range mainPartialSrc {
10562 known[p.Name] = true
10563 }
10564 found := tmplInvoke.FindAllSubmatch(src, -1)
10565 if len(found) == 0 {
10566 t.Fatal("main.html invokes no templates at all, which cannot be right")
10567 }
10568 for _, m := range found {
10569 if name := string(m[1]); !known[name] {
10570 t.Errorf("main.html invokes %q, which mainTmpl does not register", name)
10571 }
10572 }
10573}
10574
10575// Every registration names a distinct template and carries a loader for
10576// it. The loaders read from the working directory, so this checks the
10577// shape rather than calling them.
10578func TestMainPartialsAreWellFormed(t *testing.T) {
10579 seen := map[string]bool{}
10580 for _, p := range mainPartialSrc {
10581 if p.Name == "" || p.Body == nil {
10582 t.Errorf("partial %+v has no name or no body", p.Name)
10583 }
10584 if seen[p.Name] {
10585 t.Errorf("%q is registered twice", p.Name)
10586 }
10587 seen[p.Name] = true
10588 }
10589}
10590
10591
10592// ===== pkg/web/toolchain_js.go =====
10593//go:build js && wasm
10594
10595package web
10596
10597// haveToolchain reports whether this build can shell out to a Go
10598// compiler. In a page it cannot: there is no Go, no tinygo, and nothing
10599// to exec them with.
10600//
10601// This exists because the whole m2 command tree now runs in the browser
10602// shell, so `m2 wasm` and `m2 run` are reachable there β and every
10603// failure path in wasmbuild.go is a log.Fatal, which is os.Exit, which
10604// in a page takes down the wasm instance and every terminal in it. A
10605// command that cannot do its job should say so and return non-zero, not
10606// kill the tab.
10607const haveToolchain = false
10608
10609
10610// ===== pkg/web/toolchain_native.go =====
10611//go:build !js || !wasm
10612
10613package web
10614
10615// haveToolchain reports whether this build can shell out to a Go
10616// compiler. Natively it can.
10617const haveToolchain = true
10618
10619
10620// ===== pkg/web/wasmbuild.go =====
10621// Package web pkg/web/wasmbuild.go β compiling the drop-in wasm apps.
10622package web
10623
10624import (
10625 "encoding/json"
10626 "fmt"
10627 "io"
10628 "io/fs"
10629 "log"
10630 "net/http"
10631 "os"
10632 "os/exec"
10633 "path/filepath"
10634 "regexp"
10635 "strconv"
10636
10637 "github.com/gofiber/fiber/v3"
10638 "strings"
10639 "time"
10640
10641 "github.com/bitfield/script"
10642 "github.com/briandowns/spinner"
10643
10644 "github.com/0magnet/m2/pkg/config"
10645)
10646
10647func CompileWASM() {
10648 if !haveToolchain {
10649 // Nothing here to build with; see toolchain_js.go.
10650 log.Println("wasm: no Go toolchain in this environment; nothing compiled")
10651 return
10652 }
10653 s := spinner.New(spinner.CharSets[14], 25*time.Millisecond)
10654 s.Suffix = " Compiling wasm..."
10655 // Each build runs on the system's default Go first; only if that fails does
10656 // it retry on a pinned Go toolchain. The pinned version is normally resolved
10657 // dynamically β tinygo trails the latest Go release, so on failure we read
10658 // the Go range tinygo supports from its own error and pin the newest matching
10659 // Go patch (see resolveCompatibleToolchain). That keeps the site on the newest
10660 // Go whenever tinygo supports it and self-heals across Go bumps with no
10661 // hardcoded version. GOTOOLCHAINFALLBACK in the MENV config is an OPTIONAL
10662 // offline backstop, used only if the dynamic lookup can't reach the release
10663 // list; leave it empty for fully dynamic behavior.
10664 offlineFallback := config.ScriptExecString("${GOTOOLCHAINFALLBACK}")
10665
10666 // Stage the drop-ins BEFORE building anything, from whatever the last run
10667 // left on disk, because a page binary embeds them (dropinfs.go) and
10668 // go:embed is read when it compiles.
10669 //
10670 // Staging after the builds instead would make the embed current within a
10671 // single run, and it is tempting for that reason. It also puts the tinygo
10672 // builds β stl2's is about twenty minutes β in front of every term.wasm
10673 // rebuild, so a one-line server edit would not reach the site for half an
10674 // hour. Staging first costs the opposite and much less: when a drop-in's
10675 // SOURCE changes, the copy inside the page binary is one restart behind.
10676 //
10677 // That is affordable because the embedded copy is a fallback and nothing
10678 // else. The host serves the drop-ins off disk, where they are current the
10679 // moment the build finishes, and a tab whose carried copy is stale is
10680 // still a tab serving a working cart. Nothing reads the embed unless the
10681 // disk has nothing.
10682 stageDropins(dropInSources())
10683
10684 for _, wasmSRC := range f.WasmSRC {
10685 ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
10686 outputFile := baseName(wasmSRC) + ".wasm"
10687 mk := func(tc string) string {
10688 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)
10689 }
10690 buildWasmWithFallback(mk, offlineFallback, s, true)
10691 }
10692 if !f.UseTinygo {
10693 return
10694 }
10695 for _, wasmSRC := range f.WasmSRC {
10696 ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
10697 outputFile := baseName(wasmSRC) + "-tiny.wasm"
10698 mk := func(tc string) string {
10699 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)
10700 }
10701 // The Go build above succeeded and serves; a drop-in that tinygo
10702 // cannot compile (e.g. one using net/http, which tinygo's shim
10703 // does not build) must not take the whole store down.
10704 buildWasmWithFallback(mk, offlineFallback, s, false)
10705 }
10706}
10707
10708// dropInSources is WasmSRC minus the page binaries β the sources whose builds
10709// a page loads alongside itself, and so the ones worth carrying.
10710func dropInSources() []string {
10711 var out []string
10712 for _, src := range f.WasmSRC {
10713 if !pageWasm[baseName(src)] {
10714 out = append(out, src)
10715 }
10716 }
10717 return out
10718}
10719
10720// pageWasm names the binaries that ARE a page rather than something a page
10721// loads alongside itself. They are not staged into the embed: the tab does not
10722// serve /tui or /desk, and term.wasm is the binary doing the embedding β a
10723// file cannot contain itself.
10724var pageWasm = map[string]bool{"term": true, "tui": true, "desk": true}
10725
10726func baseName(wasmSRC string) string {
10727 return strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
10728}
10729
10730// stageDropins copies the smallest build of each drop-in into the directory
10731// dropinfs.go embeds, so the page binaries compiled next carry them.
10732//
10733// Best effort throughout. A tree without the staging directory β a binary
10734// running somewhere other than its own source β simply stages nothing and the
10735// build carries nothing, which is the same as a fresh checkout.
10736func stageDropins(srcs []string) {
10737 dir := filepath.Join("pkg", "web", "dropins")
10738 // os.Root confines every name below to this directory. The names are
10739 // derived from WasmSRC, which is configuration rather than a request, but
10740 // "configuration cannot traverse" is a claim about the config file and not
10741 // about this code; the root makes it a property of this code.
10742 root, err := os.OpenRoot(dir)
10743 if err != nil {
10744 log.Printf("wasm: no %s to stage into; page binaries will carry no drop-ins", dir)
10745 return
10746 }
10747 defer root.Close() //nolint:errcheck // read-mostly; the writes below are closed individually
10748
10749 // Clear first, so a drop-in dropped from WasmSRC stops being carried.
10750 if ents, rerr := fs.ReadDir(root.FS(), "."); rerr == nil {
10751 for _, e := range ents {
10752 if !e.IsDir() && strings.HasSuffix(e.Name(), ".wasm") {
10753 _ = root.Remove(e.Name()) //nolint:errcheck // stale copy; the writes below are what matter
10754 }
10755 }
10756 }
10757
10758 for _, src := range srcs {
10759 name := baseName(src)
10760 // Smallest first, matching what a page is given to load.
10761 for _, cand := range []string{name + "-tiny.wasm", name + ".wasm"} {
10762 b, rerr := os.ReadFile(cand) //nolint:gosec // a path this process just built
10763 if rerr != nil {
10764 continue
10765 }
10766 if werr := writeInRoot(root, cand, b); werr != nil {
10767 log.Printf("wasm: staging %s: %v", cand, werr)
10768 continue
10769 }
10770 log.Printf("wasm: staged %s (%d bytes) for the in-tab server", cand, len(b))
10771 break
10772 }
10773 }
10774}
10775
10776// writeInRoot creates one file under root and writes it whole.
10777func writeInRoot(root *os.Root, name string, b []byte) error {
10778 fh, err := root.Create(name)
10779 if err != nil {
10780 return err
10781 }
10782 if _, err = fh.Write(b); err != nil {
10783 _ = fh.Close() //nolint:errcheck // the write error is the one to report
10784 return err
10785 }
10786 return fh.Close()
10787}
10788
10789// runBash runs an inner bash script and returns its combined stdout+stderr, so
10790// the caller can both log it and parse compiler errors out of it.
10791func runBash(inner string) (string, error) {
10792 // The command is built by this package, not taken from a request.
10793 out, err := exec.Command("bash", "-c", inner).CombinedOutput() //nolint:gosec
10794 return string(out), err
10795}
10796
10797// buildWasmWithFallback builds wasm on the system Go first (mkCmd("")). On
10798// failure it resolves a Go toolchain tinygo can use β dynamically from tinygo's
10799// own error output, or the offlineFallback if the release list is unreachable β
10800// and retries with GOTOOLCHAIN pinned to it. When required, a build that still
10801// fails is fatal; otherwise it is logged and skipped (the pages fall back to
10802// whichever binary exists). mkCmd receives the string to place before the
10803// build command: "" for the default, or "GOTOOLCHAIN=β¦ ".
10804func buildWasmWithFallback(mkCmd func(toolchainPrefix string) string, offlineFallback string, s *spinner.Spinner, required bool) {
10805 log.Println("Compiling wasm with:")
10806 log.Println(mkCmd(""))
10807 s.Start()
10808 out, err := runBash(mkCmd(""))
10809 s.Stop()
10810 log.Print(out)
10811 if err == nil {
10812 log.Println("Compiled wasm!")
10813 return
10814 }
10815 tc := resolveCompatibleToolchain(out)
10816 if tc == "" {
10817 tc = offlineFallback
10818 }
10819 if tc == "" {
10820 if required {
10821 log.Fatalf("wasm build failed and no compatible Go toolchain could be resolved: %v", err)
10822 }
10823 log.Printf("optional wasm build failed (no compatible toolchain): %v", err)
10824 return
10825 }
10826 log.Printf("wasm build failed on the default Go; retrying with GOTOOLCHAIN=%s", tc)
10827 s.Start()
10828 out, err = runBash(mkCmd("GOTOOLCHAIN=" + tc + " "))
10829 s.Stop()
10830 log.Print(out)
10831 if err != nil {
10832 if required {
10833 log.Fatal(err)
10834 }
10835 log.Printf("optional wasm build failed: %v", err)
10836 return
10837 }
10838 log.Printf("Compiled wasm (Go toolchain %s)!", tc)
10839}
10840
10841// resolveCompatibleToolchain reads tinygo's supported Go range from a failed
10842// build's output (e.g. "requires go version 1.19 through 1.26, got go1.27") and
10843// returns the newest released Go patch of that ceiling minor (e.g. "go1.26.7"),
10844// or "" if the range can't be parsed or the release list can't be fetched.
10845func resolveCompatibleToolchain(buildOutput string) string {
10846 m := regexp.MustCompile(`through (\d+)\.(\d+)`).FindStringSubmatch(buildOutput)
10847 if m == nil {
10848 return ""
10849 }
10850 minor := m[1] + "." + m[2]
10851
10852 client := &http.Client{Timeout: 20 * time.Second}
10853 resp, err := client.Get("https://go.dev/dl/?mode=json&include=all")
10854 if err != nil {
10855 return ""
10856 }
10857 defer func() { _ = resp.Body.Close() }() //nolint:errcheck,gosec
10858 body, err := io.ReadAll(resp.Body)
10859 if err != nil {
10860 return ""
10861 }
10862 var rels []struct {
10863 Version string `json:"version"`
10864 }
10865 if err := json.Unmarshal(body, &rels); err != nil {
10866 return ""
10867 }
10868
10869 // Newest patch of go<minor> (require a patch number: GOTOOLCHAIN rejects a
10870 // bare "go1.26" β it must be a full toolchain version like go1.26.7).
10871 re := regexp.MustCompile(`^go` + regexp.QuoteMeta(minor) + `\.(\d+)$`)
10872 best, bestPatch := "", -1
10873 for _, r := range rels {
10874 mm := re.FindStringSubmatch(r.Version)
10875 if mm == nil {
10876 continue
10877 }
10878 // A version that does not parse yields 0, which loses the comparison
10879 // below, so an unparsable tag is skipped rather than erroring.
10880 if p, _ := strconv.Atoi(mm[1]); p > bestPatch { //nolint:errcheck
10881 bestPatch, best = p, r.Version
10882 }
10883 }
10884 return best
10885}
10886
10887func ldflags(s string) (ss string) {
10888 checkFiles, err := script.FindFiles(s).Slice()
10889 if err != nil {
10890 log.Fatal(err)
10891 }
10892 if f.LDFlagsX != "" {
10893 for _, s := range checkFiles {
10894 res, err := script.File(s).Match(strings.Split(f.LDFlagsX, "=")[0]).String()
10895 if err != nil {
10896 log.Fatal(err)
10897 }
10898 if res != "" {
10899 ss += fmt.Sprintf(` -X 'main.%s' `, f.LDFlagsX)
10900 break
10901 }
10902 }
10903 }
10904 for _, s := range checkFiles {
10905 res, err := script.File(s).Match("wasmName").String()
10906 if err != nil {
10907 log.Fatal(err)
10908 }
10909 if res != "" {
10910 ss += fmt.Sprintf(` -X 'main.wasmName=%s' `, strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))+".wasm")
10911 break
10912 }
10913 }
10914 if ss != "" {
10915 ss = `-ldflags="` + ss + `"`
10916 }
10917 return ss
10918}
10919
10920// serveCarriedWASM registers a route for every drop-in the build staged into
10921// the binary, and for the runtime shims a page needs to start one.
10922//
10923// It is the tab's copy of serveWASM. serveWASM reads the working directory and
10924// is registered only in the host role, which is right there and wrong here: a
10925// tab has no working directory carrying binaries and never will. Everything
10926// unmatched falls through to the read-through proxy, so a drop-in the build did
10927// not stage still reaches the origin; this only means the common ones do not
10928// have to.
10929//
10930// Registered BEFORE the proxy catch-all, and only for files actually carried,
10931// so a route never shadows a path the origin could answer better.
10932func serveCarriedWASM(r *fiber.App) {
10933 for _, file := range embeddedDropinNames() {
10934 body, ok := embeddedDropin(file)
10935 if !ok {
10936 continue
10937 }
10938 r.Get("/"+file, func(c fiber.Ctx) error {
10939 c.Set(fiber.HeaderContentType, "application/wasm")
10940 // Immutable: this one is compiled into the binary serving it, so
10941 // it cannot change without the page that loaded it changing too.
10942 c.Set(fiber.HeaderCacheControl, "public, max-age=31536000, immutable")
10943 return c.Send(body)
10944 })
10945 }
10946}
10947
10948
10949// ===== pkg/web/wasmpick.go =====
10950package web
10951
10952// Which build of each wasm drop-in a page loads, and which runtime shim starts
10953// it.
10954//
10955// USETINYGO does not mean "tinygo only". wasmbuild.go builds the Go variant of
10956// every source FIRST and unconditionally (that build is `required`), and only
10957// then, if the flag is set, additionally builds the tinygo variants as
10958// best-effort. So the usual state on the server is that BOTH stl2.wasm and
10959// stl2-tiny.wasm exist.
10960//
10961// The serving side did not know that. The old wasmBinary() named "<x>-tiny.wasm" for
10962// every source whenever the flag was on, without looking at the disk, which had
10963// two consequences:
10964//
10965// - A fresh Go build was ignored in favor of a stale tinygo one. The Go build
10966// of stl2 takes about 4 seconds and the tinygo build about 22 minutes, so
10967// during that window the page served a binary from before the edit β a
10968// 22-minute feedback loop for a 4-second change.
10969// - A tinygo build that FAILED still had its name emitted. wasmbuild.go says
10970// a drop-in tinygo cannot compile "must not take the whole store down" and
10971// that "the pages fall back to whichever binary exists", but only tuipage
10972// and deskpage ever did that stat; template pages asked for a file that was
10973// not there.
10974//
10975// So the choice is made per binary, from the disk, newest wins.
10976
10977import (
10978 "net/http"
10979 "os"
10980 "strings"
10981 "sync"
10982)
10983
10984// wasmPick is one binary a page will load together with the shim that can start
10985// it. The two travel together because they must match: tinygo's wasm_exec.js
10986// cannot start a stdlib Go binary (it throws a LinkError on the gojs imports)
10987// and the stdlib shim cannot start a tinygo one.
10988type wasmPick struct {
10989 File string // "stl2.wasm" or "stl2-tiny.wasm"
10990 ExecPath string // the wasm_exec.js matching its toolchain
10991 GoGlobal string // the JS global holding that shim's Go constructor
10992}
10993
10994// The two shims both define globalThis.Go, so a page needing both must load
10995// them one after another and stash each constructor before the next overwrites
10996// it. These are the names it stashes them under.
10997const (
10998 goGlobalStd = "__GoStd"
10999 goGlobalTiny = "__GoTiny"
11000)
11001
11002// newerFile reports which of two paths has the later modification time.
11003// Missing files lose; if neither exists, ok is false.
11004func newerFile(a, b string) (path string, ok bool) {
11005 sa, ea := os.Stat(a)
11006 sb, eb := os.Stat(b)
11007 switch {
11008 case ea == nil && eb == nil:
11009 if sb.ModTime().After(sa.ModTime()) {
11010 return b, true
11011 }
11012 return a, true
11013 case ea == nil:
11014 return a, true
11015 case eb == nil:
11016 return b, true
11017 }
11018 return "", false
11019}
11020
11021// pickWasm chooses the build of one drop-in to serve.
11022//
11023// When the tinygo build is not wanted at all the Go build is the only candidate,
11024// so a site that never sets USETINYGO behaves exactly as before.
11025func pickWasm(name string) (wasmPick, bool) {
11026 if servingInTab() {
11027 if p, ok := pickCarried(name); ok {
11028 return p, true
11029 }
11030 return pickFromOrigin(name)
11031 }
11032 std := name + ".wasm"
11033 if !f.UseTinygo {
11034 if _, err := os.Stat(std); err != nil {
11035 return wasmPick{}, false
11036 }
11037 return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
11038 }
11039 which, ok := newerFile(std, name+"-tiny.wasm")
11040 if !ok {
11041 return wasmPick{}, false
11042 }
11043 if strings.HasSuffix(which, "-tiny.wasm") {
11044 return wasmPick{File: which, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
11045 }
11046 return wasmPick{File: which, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
11047}
11048
11049// pickWasmSmall chooses the SMALLER build of a drop-in: the tinygo one
11050// when it exists, whatever the modification times say.
11051//
11052// pickWasm's newest-wins rule is right for a binary a page loads because
11053// that binary is the point of the page β during the twenty-odd minutes a
11054// tinygo build takes, serving the four-second Go build is what keeps the
11055// edit visible. It is the wrong rule for one every page carries whether
11056// the reader uses it or not. The cart is that: 4.5MB as a Go build
11057// against 1MB as a tinygo one, fetched on every catalog page view to
11058// keep a list of part numbers in localStorage.
11059//
11060// Falls back to pickWasm, so a site with no tinygo β or one whose tinygo
11061// build of this source failed β gets the Go build rather than nothing.
11062func pickWasmSmall(name string) (wasmPick, bool) {
11063 if servingInTab() {
11064 if p, ok := pickCarried(name); ok {
11065 return p, true
11066 }
11067 return pickFromOrigin(name)
11068 }
11069 if f.UseTinygo {
11070 if tiny := name + "-tiny.wasm"; fileExists(tiny) {
11071 return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
11072 }
11073 }
11074 return pickWasm(name)
11075}
11076
11077func fileExists(path string) bool {
11078 _, err := os.Stat(path)
11079 return err == nil
11080}
11081
11082// pageBinary picks the binary a full-page app loads, taking the first of
11083// names the deployment has actually built, and the tinygo build of it
11084// where there is one.
11085//
11086// The names are given joint-first. /tui and /desk were two binaries of
11087// about fifty megabytes each that were all but the same fifty β the desk
11088// links pkg/storepane to put a store in a window, and the storefront page
11089// is pkg/storepane and nothing else β so wasm/term is the two together,
11090// eight kilobytes larger than the desk alone and serving both. A
11091// deployment that builds it gets it; one that still lists the separate
11092// drop-ins gets those, and nothing has to change at once.
11093//
11094// The shim travels with the pick because the two are not interchangeable:
11095// tinygo's wasm_exec.js throws a LinkError on a stdlib Go binary's gojs
11096// imports, and the stdlib one cannot start a tinygo binary.
11097func pageBinary(names ...string) (file, execPath string) {
11098 for _, n := range names {
11099 if f.UseTinygo && fileExists(n+"-tiny.wasm") {
11100 return n + "-tiny.wasm", f.WasmExecPathTinyGo
11101 }
11102 if fileExists(n + ".wasm") {
11103 return n + ".wasm", f.WasmExecPathGo
11104 }
11105 }
11106 // Nothing built yet. Name the app's own Go build, so a page served
11107 // during the first compile asks for the file that is about to exist
11108 // rather than for nothing at all.
11109 return names[len(names)-1] + ".wasm", f.WasmExecPathGo
11110}
11111
11112// wasmExec is one runtime shim a page must load.
11113type wasmExec struct {
11114 Path string
11115 GoGlobal string
11116}
11117
11118// wasmExecsFor returns the shims a set of picks needs, deduplicated and in a
11119// stable order. A page with a tinygo binary and a Go binary on it gets both.
11120func wasmExecsFor(picks []wasmPick) []wasmExec {
11121 var out []wasmExec
11122 seen := map[string]bool{}
11123 for _, p := range picks {
11124 if p.ExecPath == "" || seen[p.GoGlobal] {
11125 continue
11126 }
11127 seen[p.GoGlobal] = true
11128 out = append(out, wasmExec{Path: p.ExecPath, GoGlobal: p.GoGlobal})
11129 }
11130 return out
11131}
11132
11133// WasmExecs is the shim list for this page's binaries, as a METHOD rather than
11134// a template function.
11135//
11136// It was a FuncMap entry, and that broke the whole site: templates are parsed
11137// in several places here and only some of them call .Funcs(funcs), so a page
11138// whose set was built bare β order.go's, for one β died at parse time with
11139// `function "wasmexecs" not defined` and served a 500. A method travels with
11140// the data instead of with the template set, so every set can call it and no
11141// new parse site has to remember anything.
11142func (h htmlTemplateData) WasmExecs() []wasmExec { return wasmExecsFor(h.WasmBinary) }
11143
11144// The in-tab server's filesystem is not the authority on which drop-ins a
11145// page may load.
11146//
11147// When the store runs inside the page (pkg/storepane's `serve`, behind the
11148// desk's netscrape window) its jsfs carries the templates and content the
11149// render code reads and nothing else β images, fonts and models already
11150// read through to the origin on a miss, and so do the wasm binaries. But
11151// the picking above asks the local disk, which in the tab holds none of
11152// them, so every page rendered there came out with no drop-in referenced
11153// at all: no cart, no decoration, and no attractor over the logo. The
11154// binaries were reachable the whole time; nothing ever named them.
11155//
11156// So in the tab the origin is asked instead, once per name.
11157
11158// originSrc is the origin an in-tab server's misses read through to, and
11159// what it has learned about which binaries that origin serves. base is
11160// empty in the host process, where the disk is the authority and none of
11161// this runs.
11162var originSrc struct {
11163 mu sync.Mutex
11164 base string
11165 seen map[string]bool
11166}
11167
11168// serveFromOrigin marks this process the in-tab server. NewApp calls it
11169// with the same origin it gives the read-through proxy.
11170func serveFromOrigin(base string) {
11171 originSrc.mu.Lock()
11172 defer originSrc.mu.Unlock()
11173 originSrc.base, originSrc.seen = base, map[string]bool{}
11174}
11175
11176// servingInTab reports whether this is the in-page server.
11177func servingInTab() bool {
11178 originSrc.mu.Lock()
11179 defer originSrc.mu.Unlock()
11180 return originSrc.base != ""
11181}
11182
11183// originHas reports whether the origin serves a file, asking it at most
11184// once per name and remembering the answer. A binary the origin does not
11185// have goes unreferenced, which is the answer a missing file gives on a
11186// host.
11187func originHas(file string) bool {
11188 originSrc.mu.Lock()
11189 base, seen := originSrc.base, originSrc.seen
11190 if have, ok := seen[file]; ok {
11191 originSrc.mu.Unlock()
11192 return have
11193 }
11194 originSrc.mu.Unlock()
11195
11196 have := false
11197 if req, err := http.NewRequest(http.MethodHead, base+"/"+file, nil); err == nil {
11198 if resp, derr := http.DefaultClient.Do(req); derr == nil {
11199 _ = resp.Body.Close() //nolint:errcheck // nothing was read from it
11200 have = resp.StatusCode == http.StatusOK
11201 }
11202 }
11203
11204 originSrc.mu.Lock()
11205 seen[file] = have
11206 originSrc.mu.Unlock()
11207 return have
11208}
11209
11210// pickFromOrigin chooses a drop-in the way the tab must: smallest build
11211// the origin actually serves.
11212//
11213// It does not consult UseTinygo. That flag says whether THIS process
11214// should run tinygo over the sources, and the tab has no toolchain and
11215// built nothing; what matters is which builds exist at the other end. The
11216// tab also pays for every byte twice β once over the network into the
11217// page, once through the proxy into the frame β so the small build is
11218// wanted wherever there is one.
11219// pickCarried chooses a drop-in the binary itself carries. It runs before the
11220// origin is asked, so a tab with a staged build answers out of itself.
11221func pickCarried(name string) (wasmPick, bool) {
11222 if tiny := name + "-tiny.wasm"; carriesDropin(tiny) {
11223 return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
11224 }
11225 if std := name + ".wasm"; carriesDropin(std) {
11226 return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
11227 }
11228 return wasmPick{}, false
11229}
11230
11231// carriesDropin reports whether the build staged this file into the binary.
11232func carriesDropin(file string) bool {
11233 _, ok := lookupDropin(file)
11234 return ok
11235}
11236
11237func pickFromOrigin(name string) (wasmPick, bool) {
11238 if tiny := name + "-tiny.wasm"; originHas(tiny) {
11239 return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
11240 }
11241 if std := name + ".wasm"; originHas(std) {
11242 return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
11243 }
11244 return wasmPick{}, false
11245}
11246
11247
11248// ===== pkg/web/wasmpick_test.go =====
11249package web
11250
11251import (
11252 "bytes"
11253 htmpl "html/template"
11254 "net/http"
11255 "net/http/httptest"
11256 "os"
11257 "path/filepath"
11258 "strings"
11259 "testing"
11260 "time"
11261)
11262
11263// touch writes a file with an explicit mtime, so "newest wins" can be tested
11264// without sleeping between writes.
11265func touch(t *testing.T, path string, age time.Duration) {
11266 t.Helper()
11267 if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
11268 t.Fatal(err)
11269 }
11270 when := time.Now().Add(-age)
11271 if err := os.Chtimes(path, when, when); err != nil {
11272 t.Fatal(err)
11273 }
11274}
11275
11276func TestNewerFile(t *testing.T) {
11277 dir := t.TempDir()
11278 a := filepath.Join(dir, "a.wasm")
11279 b := filepath.Join(dir, "b.wasm")
11280
11281 if _, ok := newerFile(a, b); ok {
11282 t.Error("neither file exists, but newerFile reported a winner")
11283 }
11284
11285 touch(t, a, time.Hour)
11286 if got, ok := newerFile(a, b); !ok || got != a {
11287 t.Errorf("only a exists: got %q ok=%v, want %q true", got, ok, a)
11288 }
11289 if got, ok := newerFile(b, a); !ok || got != a {
11290 t.Errorf("only a exists (as second arg): got %q ok=%v, want %q true", got, ok, a)
11291 }
11292
11293 // b newer than a: b must win from either argument position, or the
11294 // selection would depend on the order the caller happened to pass them.
11295 touch(t, b, time.Minute)
11296 if got, _ := newerFile(a, b); got != b {
11297 t.Errorf("b is newer: got %q, want %q", got, b)
11298 }
11299 if got, _ := newerFile(b, a); got != b {
11300 t.Errorf("b is newer (arg order swapped): got %q, want %q", got, b)
11301 }
11302}
11303
11304// A tie must not flap between the two on successive page loads.
11305func TestNewerFileTieIsStable(t *testing.T) {
11306 dir := t.TempDir()
11307 a := filepath.Join(dir, "a.wasm")
11308 b := filepath.Join(dir, "b.wasm")
11309 touch(t, a, time.Hour)
11310 touch(t, b, time.Hour)
11311 first, _ := newerFile(a, b)
11312 for i := 0; i < 5; i++ {
11313 if got, _ := newerFile(a, b); got != first {
11314 t.Fatalf("tie is not stable: %q then %q", first, got)
11315 }
11316 }
11317}
11318
11319// The shim must match the build, and a page carrying both toolchains must get
11320// both shims under distinct globals β the whole point of the aliasing.
11321func TestWasmExecsForDedupesAndKeepsBoth(t *testing.T) {
11322 picks := []wasmPick{
11323 {File: "stl2-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
11324 {File: "deco-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
11325 {File: "cart.wasm", ExecPath: "/go/wasm_exec.js", GoGlobal: goGlobalStd},
11326 }
11327 got := wasmExecsFor(picks)
11328 if len(got) != 2 {
11329 t.Fatalf("got %d shims, want 2 (one per toolchain): %+v", len(got), got)
11330 }
11331 if got[0].GoGlobal != goGlobalTiny || got[1].GoGlobal != goGlobalStd {
11332 t.Errorf("shims out of order or wrong globals: %+v", got)
11333 }
11334 if goGlobalTiny == goGlobalStd {
11335 t.Error("the two globals must differ, or the second shim overwrites the first")
11336 }
11337}
11338
11339func TestWasmExecsForSkipsEmptyPath(t *testing.T) {
11340 if got := wasmExecsFor([]wasmPick{{File: "x.wasm"}}); len(got) != 0 {
11341 t.Errorf("a pick with no shim path yielded %+v, want none", got)
11342 }
11343}
11344
11345// The wasm partial must render from a template set built WITHOUT .Funcs().
11346//
11347// This is the bug that took the site down: the shim list was a FuncMap entry,
11348// but templates are parsed in several places here and only some call
11349// .Funcs(funcs). A page whose set was built bare died at parse time with
11350// `function "wasmexecs" not defined` and served a 500 for the whole front page.
11351// A method on the data has no such dependency, and this test is the proof.
11352func TestWasmPartialParsesWithoutFuncMap(t *testing.T) {
11353 src, err := os.ReadFile("../../htmpl/wasm.html")
11354 if err != nil {
11355 t.Skip("template not present:", err)
11356 }
11357 tmpl := htmpl.New("index")
11358 if _, err := tmpl.New("wasm").Parse(string(src)); err != nil {
11359 t.Fatalf("wasm.html needs a FuncMap it may not get: %v", err)
11360 }
11361 data := struct{ Page htmlTemplateData }{Page: htmlTemplateData{
11362 WasmBinary: []wasmPick{
11363 {File: "stl2-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
11364 {File: "cart.wasm", ExecPath: "/go/wasm_exec.js", GoGlobal: goGlobalStd},
11365 },
11366 }}
11367 var buf bytes.Buffer
11368 if err := tmpl.ExecuteTemplate(&buf, "wasm", data); err != nil {
11369 t.Fatalf("executing the wasm partial: %v", err)
11370 }
11371 out := buf.String()
11372 // Both shims, both stashed under distinct globals, and each binary using
11373 // the one that matches it.
11374 for _, want := range []string{
11375 "/tiny/wasm_exec.js", "/go/wasm_exec.js",
11376 `window["` + goGlobalTiny + `"] = Go`, `window["` + goGlobalStd + `"] = Go`,
11377 "stl2-tiny.wasm", "cart.wasm",
11378 } {
11379 if !strings.Contains(out, want) {
11380 t.Errorf("rendered wasm partial is missing %q", want)
11381 }
11382 }
11383}
11384
11385// A page with no wasm must render nothing at all β not an empty <script> that
11386// would still try to construct a Go runtime.
11387func TestWasmPartialEmptyWhenNoBinaries(t *testing.T) {
11388 src, err := os.ReadFile("../../htmpl/wasm.html")
11389 if err != nil {
11390 t.Skip("template not present:", err)
11391 }
11392 tmpl := htmpl.New("index")
11393 if _, err := tmpl.New("wasm").Parse(string(src)); err != nil {
11394 t.Fatal(err)
11395 }
11396 var buf bytes.Buffer
11397 data := struct{ Page htmlTemplateData }{}
11398 if err := tmpl.ExecuteTemplate(&buf, "wasm", data); err != nil {
11399 t.Fatal(err)
11400 }
11401 if strings.Contains(buf.String(), "<script") {
11402 t.Errorf("a page with no wasm still emitted a script tag:\n%s", buf.String())
11403 }
11404}
11405
11406// The cart rides on every catalog page, so it takes the tinygo build
11407// where there is one β 1MB against 4.5MB, fetched whether or not the
11408// reader ever opens the cart. pickWasm's newest-wins rule is right for a
11409// binary that IS the page and wrong for one that merely travels with it.
11410func TestPickWasmSmallPrefersTheTinyBuild(t *testing.T) {
11411 t.Chdir(t.TempDir())
11412 saved := *f
11413 t.Cleanup(func() { *f = saved })
11414 f.UseTinygo = true
11415 f.WasmExecPathGo = "/go.js"
11416 f.WasmExecPathTinyGo = "/tiny.js"
11417
11418 write := func(name string) {
11419 if err := os.WriteFile(name, []byte("x"), 0o600); err != nil {
11420 t.Fatal(err)
11421 }
11422 }
11423 // The Go build written LAST, so newest-wins would take it.
11424 write("cart-tiny.wasm")
11425 time.Sleep(10 * time.Millisecond)
11426 write("cart.wasm")
11427
11428 if got, ok := pickWasm("cart"); !ok || got.File != "cart.wasm" {
11429 t.Fatalf("pickWasm took %+v, want the newer cart.wasm β the premise of this test", got)
11430 }
11431 got, ok := pickWasmSmall("cart")
11432 if !ok {
11433 t.Fatal("pickWasmSmall found nothing")
11434 }
11435 if got.File != "cart-tiny.wasm" {
11436 t.Errorf("pickWasmSmall took %q, want cart-tiny.wasm", got.File)
11437 }
11438 if got.ExecPath != f.WasmExecPathTinyGo || got.GoGlobal != goGlobalTiny {
11439 t.Errorf("tiny build paired with %+v, want tinygo's shim", got)
11440 }
11441}
11442
11443// With no tinygo build β or no tinygo at all β the Go build is served
11444// rather than nothing.
11445func TestPickWasmSmallFallsBackToTheGoBuild(t *testing.T) {
11446 t.Chdir(t.TempDir())
11447 saved := *f
11448 t.Cleanup(func() { *f = saved })
11449 f.UseTinygo = true
11450 f.WasmExecPathGo = "/go.js"
11451 if err := os.WriteFile("cart.wasm", []byte("x"), 0o600); err != nil {
11452 t.Fatal(err)
11453 }
11454 got, ok := pickWasmSmall("cart")
11455 if !ok || got.File != "cart.wasm" || got.GoGlobal != goGlobalStd {
11456 t.Errorf("with no tinygo build got %+v, want the Go one", got)
11457 }
11458 f.UseTinygo = false
11459 if got, ok := pickWasmSmall("cart"); !ok || got.File != "cart.wasm" {
11460 t.Errorf("with tinygo off got %+v, want the Go one", got)
11461 }
11462}
11463
11464// The joint build wins when the deployment has one, and the page's own
11465// is the fallback β so a site that has not rebuilt yet keeps working.
11466func TestPageBinaryPrefersTheJointBuild(t *testing.T) {
11467 t.Chdir(t.TempDir())
11468 saved := *f
11469 t.Cleanup(func() { *f = saved })
11470 f.UseTinygo = false
11471 f.WasmExecPathGo = "/go.js"
11472 f.WasmExecPathTinyGo = "/tiny.js"
11473 write := func(name string) {
11474 if err := os.WriteFile(name, []byte("x"), 0o600); err != nil {
11475 t.Fatal(err)
11476 }
11477 }
11478
11479 // Only the separate builds so far: each page gets its own.
11480 write("tui.wasm")
11481 write("desk.wasm")
11482 if got, _ := pageBinary("term", "tui"); got != "tui.wasm" {
11483 t.Errorf("with no joint build /tui got %q, want tui.wasm", got)
11484 }
11485 if got, _ := pageBinary("term", "desk"); got != "desk.wasm" {
11486 t.Errorf("with no joint build /desk got %q, want desk.wasm", got)
11487 }
11488
11489 // The joint build arrives and both pages move to it.
11490 write("term.wasm")
11491 for _, page := range []string{"tui", "desk"} {
11492 got, exec := pageBinary("term", page)
11493 if got != "term.wasm" {
11494 t.Errorf("/%s got %q, want term.wasm", page, got)
11495 }
11496 if exec != f.WasmExecPathGo {
11497 t.Errorf("/%s paired term.wasm with %q, want the Go shim", page, exec)
11498 }
11499 }
11500}
11501
11502// The tinygo build of a name beats the Go build of that same name, and a
11503// name with neither is skipped rather than named.
11504func TestPageBinaryTinygoAndSkipping(t *testing.T) {
11505 t.Chdir(t.TempDir())
11506 saved := *f
11507 t.Cleanup(func() { *f = saved })
11508 f.UseTinygo = true
11509 f.WasmExecPathGo = "/go.js"
11510 f.WasmExecPathTinyGo = "/tiny.js"
11511
11512 if err := os.WriteFile("term-tiny.wasm", []byte("x"), 0o600); err != nil {
11513 t.Fatal(err)
11514 }
11515 got, exec := pageBinary("term", "tui")
11516 if got != "term-tiny.wasm" || exec != f.WasmExecPathTinyGo {
11517 t.Errorf("got %q with %q, want the tinygo joint build and its shim", got, exec)
11518 }
11519
11520 // Nothing built at all: name the page's own Go build rather than "",
11521 // so a page served during the first compile asks for the file that is
11522 // about to exist.
11523 t.Chdir(t.TempDir())
11524 if got, _ := pageBinary("term", "desk"); got != "desk.wasm" {
11525 t.Errorf("with nothing built got %q, want desk.wasm", got)
11526 }
11527}
11528
11529// The in-tab server has none of the binaries on its own filesystem. It must
11530// still name them: they read through to the origin like every other file it
11531// does not carry. Before this, every page it rendered referenced no drop-in
11532// at all β which is what left the attractor missing from the store inside
11533// the desk's browser window while the same page had it on the origin.
11534func TestPickFromOriginNamesWhatTheOriginServes(t *testing.T) {
11535 noDropins(t) // this case is about the origin, not what the build carried
11536 t.Chdir(t.TempDir()) // deliberately empty: the tab's disk has no wasm
11537 saved := *f
11538 t.Cleanup(func() { *f = saved })
11539 f.UseTinygo = false // the tab has no toolchain and never built anything
11540 f.WasmExecPathGo = "/go.js"
11541 f.WasmExecPathTinyGo = "/tiny.js"
11542
11543 var asked []string
11544 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11545 asked = append(asked, r.Method+" "+r.URL.Path)
11546 switch r.URL.Path {
11547 case "/stl2-tiny.wasm", "/cart.wasm":
11548 w.WriteHeader(http.StatusOK)
11549 default:
11550 w.WriteHeader(http.StatusNotFound)
11551 }
11552 }))
11553 t.Cleanup(srv.Close)
11554
11555 if servingInTab() {
11556 t.Fatal("a fresh process must not think it is in a tab")
11557 }
11558 serveFromOrigin(srv.URL)
11559 t.Cleanup(func() { serveFromOrigin("") })
11560 if !servingInTab() {
11561 t.Fatal("serveFromOrigin did not mark the in-tab role")
11562 }
11563
11564 // A small build at the origin wins even though UseTinygo is off here:
11565 // the flag describes this process's toolchain, not the other end.
11566 got, ok := pickWasmSmall("stl2")
11567 if !ok || got.File != "stl2-tiny.wasm" || got.ExecPath != "/tiny.js" || got.GoGlobal != goGlobalTiny {
11568 t.Errorf("stl2 = %+v, %v; want stl2-tiny.wasm on the tinygo shim", got, ok)
11569 }
11570 // Only a Go build at the origin: take it, with the matching shim.
11571 got, ok = pickWasmSmall("cart")
11572 if !ok || got.File != "cart.wasm" || got.ExecPath != "/go.js" || got.GoGlobal != goGlobalStd {
11573 t.Errorf("cart = %+v, %v; want cart.wasm on the Go shim", got, ok)
11574 }
11575 // Nothing at the origin: name nothing, as a missing file does on a host.
11576 if got, ok = pickWasm("deco"); ok {
11577 t.Errorf("deco = %+v, %v; want no pick", got, ok)
11578 }
11579
11580 // Each name is asked about once and the answer kept.
11581 before := len(asked)
11582 for range 3 {
11583 pickWasmSmall("stl2")
11584 pickWasm("deco")
11585 }
11586 if len(asked) != before {
11587 t.Errorf("origin re-probed: %v", asked[before:])
11588 }
11589 for _, a := range asked {
11590 if !strings.HasPrefix(a, http.MethodHead+" ") {
11591 t.Errorf("probe %q should be a HEAD: a drop-in is up to 22MB", a)
11592 }
11593 }
11594}
11595
11596// wasmPicks gates on WasmSRC, which names the sources this process builds.
11597// In the tab that list is empty β it builds nothing β and the gate dropped
11598// every binary before the pick was reached, which is why naming them from
11599// the origin alone was not enough.
11600func TestWasmPicksIgnoresWasmSRCInTheTab(t *testing.T) {
11601 noDropins(t)
11602 t.Chdir(t.TempDir())
11603 saved := *f
11604 t.Cleanup(func() { *f = saved })
11605 f.WasmSRC = nil // as in the tab: nothing configured to build
11606 f.WasmExecPathTinyGo = "/tiny.js"
11607
11608 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11609 if r.URL.Path == "/cart-tiny.wasm" {
11610 w.WriteHeader(http.StatusOK)
11611 return
11612 }
11613 w.WriteHeader(http.StatusNotFound)
11614 }))
11615 t.Cleanup(srv.Close)
11616
11617 // On a host with nothing configured, nothing is named.
11618 if got := wasmSmallFor("cart"); len(got) != 0 {
11619 t.Errorf("host with empty WasmSRC named %+v, want nothing", got)
11620 }
11621
11622 serveFromOrigin(srv.URL)
11623 t.Cleanup(func() { serveFromOrigin("") })
11624 got := wasmSmallFor("cart", "stl2")
11625 if len(got) != 1 || got[0].File != "cart-tiny.wasm" {
11626 t.Errorf("in-tab picks = %+v, want just cart-tiny.wasm", got)
11627 }
11628}
11629
11630// noDropins makes the test behave as a binary built from a tree that staged
11631// nothing, whatever this one was built from.
11632func noDropins(t *testing.T) {
11633 t.Helper()
11634 saved := lookupDropin
11635 lookupDropin = func(string) ([]byte, bool) { return nil, false }
11636 t.Cleanup(func() { lookupDropin = saved })
11637}
11638
11639// A carried drop-in is answered from the binary; the origin is only asked for
11640// what the build did not stage. The tab pays a round trip per name otherwise,
11641// and pays it on the first render of every page.
11642func TestCarriedDropinBeatsTheOrigin(t *testing.T) {
11643 t.Chdir(t.TempDir())
11644 saved := *f
11645 t.Cleanup(func() { *f = saved })
11646 f.WasmSRC, f.UseTinygo = nil, false
11647 f.WasmExecPathGo, f.WasmExecPathTinyGo = "/go.js", "/tiny.js"
11648
11649 var asked []string
11650 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11651 asked = append(asked, r.URL.Path)
11652 w.WriteHeader(http.StatusOK) // the origin has everything
11653 }))
11654 t.Cleanup(srv.Close)
11655
11656 carried := map[string][]byte{"stl2-tiny.wasm": []byte("staged")}
11657 savedLookup := lookupDropin
11658 lookupDropin = func(file string) ([]byte, bool) { b, ok := carried[file]; return b, ok }
11659 t.Cleanup(func() { lookupDropin = savedLookup })
11660
11661 serveFromOrigin(srv.URL)
11662 t.Cleanup(func() { serveFromOrigin("") })
11663
11664 if got, ok := pickWasmSmall("stl2"); !ok || got.File != "stl2-tiny.wasm" {
11665 t.Errorf("stl2 = %+v %v, want the carried stl2-tiny.wasm", got, ok)
11666 }
11667 if len(asked) != 0 {
11668 t.Errorf("origin asked about a carried drop-in: %v", asked)
11669 }
11670 // Not carried: the origin answers, as before.
11671 if got, ok := pickWasmSmall("cart"); !ok || got.File != "cart-tiny.wasm" {
11672 t.Errorf("cart = %+v %v, want cart-tiny.wasm from the origin", got, ok)
11673 }
11674 if len(asked) == 0 {
11675 t.Error("origin should have been asked about the uncarried drop-in")
11676 }
11677}
11678
11679
11680// ===== cmd/m2/commands/gen.go =====
11681// Package commands cmd/m2/commands/gen.go β the `m2 gen` config template.
11682package commands
11683
11684import (
11685 "fmt"
11686
11687 "github.com/spf13/cobra"
11688)
11689
11690var genCmd = &cobra.Command{
11691 Use: "gen",
11692 Short: "generate conf template",
11693 Long: "generate conf template",
11694 // To the command's own writer, not os.Stdout. They are the same
11695 // thing at a shell prompt and not in a page, where the command runs
11696 // inside a wasm instance whose stdout goes to the JS console β so
11697 // `m2 gen > site.conf` wrote an empty file and `m2 gen | head`
11698 // printed nothing at all.
11699 RunE: func(cmd *cobra.Command, _ []string) error {
11700 _, err := fmt.Fprint(cmd.OutOrStdout(), envfiletemplate)
11701 return err
11702 },
11703}
11704
11705const envfiletemplate = `#########################################################################
11706# M2 CONFIG
11707#
11708# Copy to <yoursite>.conf and run with: MENV=<yoursite>.conf m2 run
11709# This file is sourced by bash; use shell syntax.
11710# Comment a value with # to use the built-in default.
11711#########################################################################
11712
11713### Stripe Configuration ################################################
11714
11715#-- Live and test API keys - REQUIRED for checkout
11716# https://dashboard.stripe.com/apikeys
11717STRIPELIVEPK='pk_live_...'
11718STRIPELIVESK='sk_live_...'
11719STRIPETESTPK='pk_test_...'
11720STRIPETESTSK='sk_test_...'
11721
11722#-- Use the test keys instead of the live keys
11723TESTSTRIPEKEY=true
11724
11725### Product Data ########################################################
11726
11727#-- Products CSV path (see products.example.csv for the schema)
11728PRODUCTSCSV='products.csv'
11729
11730### Site Identity #######################################################
11731
11732#-- Image subdomain, no trailing slash (ex. 'https://img.example.com')
11733# empty = serve images from ./img
11734SITEIMAGESRC=''
11735
11736#-- Orders subdomain, no trailing slash (ex. 'https://pay.example.com')
11737SITEORDERSURL=''
11738
11739#-- Website (Host) Name - domain minus extension (ex. 'example')
11740SITENAME='example'
11741
11742#-- Website Domain Extension (ex. '.com' '.net')
11743SITEEXT='.com'
11744
11745#-- Site Long Name (ex. 'example electronic surplus')
11746SITELONGNAME='example web store'
11747
11748#-- Site Tag Line
11749SITETAGLINE='an example web store'
11750
11751#-- Site Meta Description (SEO)
11752SITEMETA='an example web store selling example things'
11753
11754#-- Telegram contact + channel; username only, no 'https://t.me/'
11755TGCONTACT=''
11756TGCHANNEL=''
11757
11758### Web Server ##########################################################
11759
11760#-- Port to serve http on
11761WEBPORT='9883'
11762
11763
11764### WebAssembly #########################################################
11765
11766#-- Compile wasm with tinygo (smaller output) in addition to go
11767USETINYGO=true
11768
11769#-- wasm source directories, relative paths
11770# 'wasm/cart' powers checkout β keep it. 'wasm/term' is the terminal
11771# storefront at /tui and the desktop at /desk in one binary: the desk
11772# links the storefront anyway, so the two separately were fifty
11773# megabytes of very nearly the same fifty. 'wasm/tui' and 'wasm/desk'
11774# still build one page each if you would rather have them apart.
11775# Additional entries are drop-in wasm apps: any dir under wasm/ with a
11776# main package (a self-contained module with its own vendor/ also
11777# works). Empty () disables all wasm.
11778WASMSRC=('wasm/cart' 'wasm/term')
11779
11780### Receipt Printing (CUPS) #############################################
11781
11782#-- CUPS printer name (default: system default)
11783PRINTERNAME=''
11784
11785#-- CUPS options, comma separated (ex. 'media=Custom.80x200mm,fit-to-page')
11786CUPSOPTIONS=''
11787
11788#-- timeout for lp command
11789LPTIMEOUT='10s'
11790
11791### Terminal UI ##########################################################
11792
11793#-- m2 tui client mode: browse this store over http instead of the
11794# local products csv / img / content files (ex. 'https://example.com')
11795STOREURL=''
11796`
11797
11798
11799// ===== cmd/m2/commands/root.go =====
11800// Package commands cmd/m2/commands/root.go β the m2 CLI.
11801package commands
11802
11803import (
11804 "embed"
11805 "log"
11806
11807 "github.com/spf13/cobra"
11808 "github.com/stripe/stripe-go/v81"
11809
11810 "github.com/0magnet/calvin/clihelp"
11811
11812 "github.com/0magnet/m2/pkg/config"
11813 "github.com/0magnet/m2/pkg/web"
11814)
11815
11816//go:embed *.go
11817var source embed.FS
11818
11819// f aliases the shared configuration, as in pkg/web.
11820var f = &config.F
11821
11822func init() {
11823 stripe.EnableTelemetry = false
11824 RootCmd.CompletionOptions.DisableDefaultCmd = true
11825 RootCmd.AddCommand(
11826 runCmd,
11827 genCmd,
11828 wasmCmd,
11829 tuiCmd,
11830 )
11831 RootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
11832 // The banner keeps the domain, which is what this serves and what the
11833 // binary is short for.
11834 clihelp.Init(RootCmd, "magnetosphere.net", true)
11835
11836 web.RegisterSource("cmd/m2/commands", source)
11837}
11838
11839func init() {
11840 runCmd.Flags().SortFlags = false
11841 config.AddStringFlag([]*cobra.Command{runCmd}, &f.ProductsCSV, "products csv file")
11842 config.AddBoolFlag([]*cobra.Command{runCmd, wasmCmd}, &f.Teststripekey, "use stripe test api keys instead of live key")
11843 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripeliveSK, "stripe live api sk")
11844 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripelivePK, "stripe live api pk")
11845 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestSK, "stripe test api sk")
11846 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestPK, "stripe test api pk")
11847 config.AddIntFlag([]*cobra.Command{runCmd}, &f.WebPort, "port to serve on")
11848 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteimagesrc, "domain for images - leave blank to serve images")
11849 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteordersurl, "domain for orders - leave blank for same domain")
11850 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitename, "site name")
11851 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteext, "site domain extension")
11852 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitelongname, "site long name")
11853 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitetagline, "site tag line")
11854 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitemeta, "site meta")
11855 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgcontact, "telegram contact")
11856 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgchannel, "telegram channel")
11857 config.AddBoolFlag([]*cobra.Command{runCmd}, &f.UseTinygo, "use tinygo instead of go to compile wasm")
11858 config.AddStringSliceFlag([]*cobra.Command{runCmd, wasmCmd}, &f.WasmSRC, "wasm source code files RELATIVE PATHS without '..'")
11859 config.AddStringFlag([]*cobra.Command{runCmd}, &f.PrinterName, "CUPS printer name (default: system default)")
11860 config.AddStringFlag([]*cobra.Command{runCmd}, &f.CupsOptions, "e.g. 'media=Custom.80x200mm,fit-to-page'")
11861 config.AddStringFlag([]*cobra.Command{runCmd}, &f.BrowseSuffix, "browse-origin domain for the desk browser - a DIFFERENT registrable domain from this site")
11862 config.AddStringFlag([]*cobra.Command{runCmd}, &f.BrowseAddr, "listen address for the browse-origin bootstrap")
11863 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyXpub, "account-level skycoin xpub for crypto checkout - NOT the reward account")
11864 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyNodeURL, "skycoin node API root for settlement")
11865 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyRatePair, "exchange pair to price skycoin from, e.g. skycoin_usdt")
11866 config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyRateFix, "a fixed USD rate per SKY, instead of an exchange")
11867 config.AddStringFlag([]*cobra.Command{runCmd}, &f.RateMin, "refuse a crypto quote below this USD rate")
11868 config.AddStringFlag([]*cobra.Command{runCmd}, &f.RateMax, "refuse a crypto quote above this USD rate")
11869 config.AddIntFlag([]*cobra.Command{runCmd}, &f.InvoiceMins, "minutes a crypto quote is held before expiring")
11870 config.AddDurationFlag([]*cobra.Command{runCmd}, &f.LpTimeout, "timeout for lp command")
11871 config.AddStringFlag([]*cobra.Command{tuiCmd}, &f.Storeurl, "browse this store over http (ex. 'https://magnetosphere.net') instead of local files")
11872}
11873
11874// RootCmd is the top-level m2 command.
11875var RootCmd = &cobra.Command{
11876 Use: "m2",
11877 Short: "web store server",
11878 Long: "web store server",
11879}
11880
11881// Execute executes the root cli command
11882func Execute() {
11883 if err := RootCmd.Execute(); err != nil {
11884 log.Fatal("Failed to execute command: ", err)
11885 }
11886}
11887
11888
11889// ===== cmd/m2/commands/run.go =====
11890// Package commands cmd/m2/commands/run.go β the `m2 run` web server command.
11891package commands
11892
11893import (
11894 "fmt"
11895 "log"
11896 "os"
11897 "strings"
11898
11899 "github.com/spf13/cobra"
11900 "golang.org/x/text/cases"
11901 "golang.org/x/text/language"
11902
11903 "github.com/0magnet/calvin"
11904 "github.com/0magnet/calvin/clihelp"
11905
11906 "github.com/0magnet/m2/pkg/config"
11907 "github.com/0magnet/m2/pkg/web"
11908)
11909
11910var runCmd = &cobra.Command{
11911 Use: "run",
11912 Short: "run the web application",
11913 // The subcommand carries the same banner as the root, so `m2 run --help`
11914 // is not a plainer screen than `m2 --help`.
11915 Long: clihelp.Banner("magnetosphere.net") + "\n\n" + func() string {
11916 helptext := `Run the web application
11917Generate a config file first
11918
11919Config defaults file may also be specified with:
11920MENV=m2.conf m2 run
11921OR
11922MENV=/path/to/m2.conf m2 run
11923print the MENV file template with:
11924m2 gen`
11925 if config.MENV == "" {
11926 return helptext
11927 }
11928 if _, err := os.Stat(config.MENV); err == nil {
11929 return `Run the web application
11930
11931menv file detected: ` + config.MENV
11932 }
11933 return helptext
11934 }(),
11935 RunE: func(cmd *cobra.Command, _ []string) error {
11936 f.Sitedomain = f.Sitename + f.Siteext
11937 log.Println(" Initializing " + f.Sitedomain)
11938 out := cmd.OutOrStdout()
11939 // A banner that will not print is not a reason to refuse to serve.
11940 fmt.Fprintln(out, calvin.BlackboardBold(f.Sitedomain)) //nolint:errcheck
11941 fmt.Fprintln(out, calvin.AsciiFont(f.Sitedomain)) //nolint:errcheck
11942 config.InitStripe()
11943 f.Siteprettyname = calvin.BlackboardBold(f.Sitedomain) //"ππππππ₯π π€π‘πππ£π.πππ₯"
11944 c := cases.Title(language.English)
11945 f.Siteprettynamecap = calvin.BlackboardBold(c.String(f.Sitedomain)) //"ππππππ₯π π€π‘πππ£π.πππ₯"
11946 f.Siteprettynamecaps = calvin.BlackboardBold(strings.ToUpper(f.Sitedomain)) //"ππΈπΎβπΌπππββπΌβπΌ.βπΌπ"
11947 f.SiteASCIILogo = strings.Replace(strings.Replace(calvin.AsciiFont(f.Sitedomain), " ", " ", -1), "\n", "<br>\n", -1)
11948
11949 if f.UseTinygo {
11950 f.WasmExecPath = f.WasmExecPathTinyGo
11951 f.Buildwasmwith = f.Tinygobuild
11952 }
11953 if len(f.WasmSRC) == 0 {
11954 f.WasmExecPath = ""
11955 f.Buildwasmwith = ""
11956 }
11957 log.Println("Checking for products CSV")
11958 log.Println("Reading products CSV")
11959 if err := web.LoadCatalog(); err != nil {
11960 return fmt.Errorf("loading the catalog: %w", err)
11961 }
11962 go web.WatchCatalog()
11963 web.Serve()
11964 return nil
11965 },
11966}
11967
11968
11969// ===== cmd/m2/commands/tui.go =====
11970// Package commands cmd/m2/commands/tui.go β the `m2 tui` terminal storefront.
11971package commands
11972
11973import (
11974 "errors"
11975 "fmt"
11976
11977 "github.com/spf13/cobra"
11978
11979 "github.com/0magnet/m2/pkg/product"
11980 "github.com/0magnet/m2/pkg/tui"
11981)
11982
11983var tuiCmd = &cobra.Command{
11984 Use: "tui",
11985 Short: "browse the store in the terminal",
11986 Long: `Browse the store in a terminal UI.
11987
11988Reads the same config as 'run':
11989MENV=m2.conf m2 tui
11990
11991As a client of a running store (catalog, images, and checkout over
11992http; no local files needed):
11993m2 tui --storeurl https://magnetosphere.net`,
11994 // RunE with returned errors rather than log.Fatal: this command also
11995 // runs inside a page, and log.Fatal is os.Exit β which there takes down
11996 // the whole wasm instance, the shell it was typed into and every other
11997 // window on the desk with it.
11998 RunE: func(cmd *cobra.Command, _ []string) error {
11999 var prods product.Products
12000 if f.Storeurl != "" {
12001 var err error
12002 prods, err = tui.FetchCatalog(f.Storeurl)
12003 if err != nil {
12004 return fmt.Errorf("could not fetch the catalog from %s: %w", f.Storeurl, err)
12005 }
12006 } else {
12007 if f.ProductsCSV == "" {
12008 f.ProductsCSV = "products.csv"
12009 }
12010 prods = product.ReadCSV(f.ProductsCSV)
12011 }
12012 if len(prods) == 0 {
12013 return errors.New("no products in the catalog")
12014 }
12015 // The context carries how to get a screen where tcell.NewScreen is
12016 // the wrong answer β a browser, where it has to be bound to the
12017 // terminal this was typed into. See tui.WithScreen.
12018 return tui.Run(cmd.Context(), prods)
12019 },
12020}
12021
12022
12023// ===== cmd/m2/commands/wasm.go =====
12024// Package commands cmd/m2/commands/wasm.go β the `m2 wasm` compile command.
12025package commands
12026
12027import (
12028 "errors"
12029
12030 "github.com/spf13/cobra"
12031
12032 "github.com/0magnet/m2/pkg/config"
12033 "github.com/0magnet/m2/pkg/web"
12034)
12035
12036var wasmCmd = &cobra.Command{
12037 Use: "wasm",
12038 Short: "compile wasm",
12039 Long: "compile wasm",
12040 RunE: func(_ *cobra.Command, _ []string) error {
12041 if len(f.WasmSRC) == 0 {
12042 return errors.New("no wasm source code specified")
12043 }
12044 config.InitStripe()
12045 web.CompileWASM()
12046 return nil
12047 },
12048}
12049
12050