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&nbsp;",      // All Products
 6955		"β”œβ”€β”¬β”€68&nbsp;",      // resistor, has subcategories
 6956		"β”‚&nbsp;β”œβ”€67&nbsp;", // quarter watt 5%
 6957		"β”‚&nbsp;└──1&nbsp;", // ceramic, last subcategory, rule stretched two
 6958		"└───34&nbsp;",      // 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: "&nbsp;"},
 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(" &middot; ")
 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), " ", "&nbsp;", -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