From a7964f94107baf01767cfefb9ba033b8351a8835 Mon Sep 17 00:00:00 2001 From: Max Amundsen Date: Wed, 8 Jul 2026 15:45:16 -0400 Subject: [PATCH] Initial add backend stuff --- appenv/appenv.go | 17 + appenv/env_development.go | 8 + appenv/env_production.go | 6 + appenv/env_staging.go | 6 + basic/basic.go | 643 ++ basic/basic_test.go | 150 + bundler/build.go | 147 + bundler/compile_solid.go | 399 ++ bundler/compile_solid_gen.go | 1038 +++ bundler/compile_solid_render_test.go | 257 + bundler/compile_solid_test.go | 116 + bundler/css.go | 91 + bundler/export_shim.go | 89 + bundler/faicons.go | 201 + bundler/genroutes.go | 138 + bundler/genssr.go | 154 + bundler/hmr_browser_test.go | 201 + bundler/hmr_client.go | 279 + bundler/hmr_client_test.go | 32 + bundler/hmr_e2e_test.go | 188 + bundler/hmr_error_test.go | 78 + bundler/hmr_server.go | 497 ++ bundler/hmr_vendor.go | 118 + bundler/hmr_watch.go | 158 + bundler/hmr_ws.go | 235 + bundler/hmr_ws_integration_test.go | 88 + bundler/hmr_ws_test.go | 67 + bundler/import_check.go | 87 + bundler/js.go | 198 + bundler/js/ssr/dom.js | 352 + bundler/jsx.go | 94 + bundler/jsx_bench_test.go | 43 + bundler/jsx_dev_test.go | 48 + bundler/jsx_test.go | 61 + bundler/renderer.go | 228 + bundler/segment.go | 293 + bundler/segment_test.go | 108 + bundler/ssr.go | 340 + bundler/ssr_test.go | 145 + bundler/ssrcache.go | 192 + bundler/tailwind.go | 9586 ++++++++++++++++++++++++++ bundler/tailwind_test.go | 217 + bundler/tw_preflight.css | 393 ++ bundler/tw_theme.css | 510 ++ bundler/vendor_plugins.go | 111 + chrono/chrono.go | 391 ++ chrono/chrono_test.go | 86 + cmd/bundle/main.go | 18 + cmd/loc/main.go | 129 + cmd/migrate/database.go | 380 + cmd/migrate/engine.go | 260 + cmd/migrate/main.go | 273 + cmd/migrate/source.go | 151 + cmd/passgen/main.go | 23 + cmd/typecheck/main.go | 150 + config/config.go | 42 + csv/csv.go | 123 + dbutil/automapper.go | 554 ++ dbutil/automapper_test.go | 507 ++ dbutil/builder.go | 1270 ++++ dbutil/builder_test.go | 737 ++ dbutil/db_connect.go | 64 + dbutil/filters.go | 306 + dbutil/registry.go | 25 + dbutil/test_models_test.go | 52 + finance/helpers.go | 234 + go.mod | 33 + go.sum | 108 + httputil/cors.go | 57 + httputil/cors_test.go | 45 + httputil/doc.go | 5 + httputil/respond.go | 20 + httputil/useragent.go | 138 + l4g/database.go | 41 + l4g/debug.go | 51 + l4g/doc.go | 66 + l4g/entry.go | 23 + l4g/file.go | 109 + l4g/logger.go | 84 + l4g/terminal.go | 71 + security/crypt.go | 228 + security/crypt_test.go | 9 + security/init.go | 11 + security/random.go | 37 + security/random_test.go | 172 + snailmail/cloudflare.go | 89 + snailmail/smtp.go | 53 + snailmail/snailmail.go | 81 + validation/validation.go | 211 + 89 files changed, 25924 insertions(+) create mode 100644 appenv/appenv.go create mode 100644 appenv/env_development.go create mode 100644 appenv/env_production.go create mode 100644 appenv/env_staging.go create mode 100644 basic/basic.go create mode 100644 basic/basic_test.go create mode 100644 bundler/build.go create mode 100644 bundler/compile_solid.go create mode 100644 bundler/compile_solid_gen.go create mode 100644 bundler/compile_solid_render_test.go create mode 100644 bundler/compile_solid_test.go create mode 100644 bundler/css.go create mode 100644 bundler/export_shim.go create mode 100644 bundler/faicons.go create mode 100644 bundler/genroutes.go create mode 100644 bundler/genssr.go create mode 100644 bundler/hmr_browser_test.go create mode 100644 bundler/hmr_client.go create mode 100644 bundler/hmr_client_test.go create mode 100644 bundler/hmr_e2e_test.go create mode 100644 bundler/hmr_error_test.go create mode 100644 bundler/hmr_server.go create mode 100644 bundler/hmr_vendor.go create mode 100644 bundler/hmr_watch.go create mode 100644 bundler/hmr_ws.go create mode 100644 bundler/hmr_ws_integration_test.go create mode 100644 bundler/hmr_ws_test.go create mode 100644 bundler/import_check.go create mode 100644 bundler/js.go create mode 100644 bundler/js/ssr/dom.js create mode 100644 bundler/jsx.go create mode 100644 bundler/jsx_bench_test.go create mode 100644 bundler/jsx_dev_test.go create mode 100644 bundler/jsx_test.go create mode 100644 bundler/renderer.go create mode 100644 bundler/segment.go create mode 100644 bundler/segment_test.go create mode 100644 bundler/ssr.go create mode 100644 bundler/ssr_test.go create mode 100644 bundler/ssrcache.go create mode 100644 bundler/tailwind.go create mode 100644 bundler/tailwind_test.go create mode 100644 bundler/tw_preflight.css create mode 100644 bundler/tw_theme.css create mode 100644 bundler/vendor_plugins.go create mode 100644 chrono/chrono.go create mode 100644 chrono/chrono_test.go create mode 100644 cmd/bundle/main.go create mode 100644 cmd/loc/main.go create mode 100644 cmd/migrate/database.go create mode 100644 cmd/migrate/engine.go create mode 100644 cmd/migrate/main.go create mode 100644 cmd/migrate/source.go create mode 100644 cmd/passgen/main.go create mode 100644 cmd/typecheck/main.go create mode 100644 config/config.go create mode 100644 csv/csv.go create mode 100644 dbutil/automapper.go create mode 100644 dbutil/automapper_test.go create mode 100644 dbutil/builder.go create mode 100644 dbutil/builder_test.go create mode 100644 dbutil/db_connect.go create mode 100644 dbutil/filters.go create mode 100644 dbutil/registry.go create mode 100644 dbutil/test_models_test.go create mode 100644 finance/helpers.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 httputil/cors.go create mode 100644 httputil/cors_test.go create mode 100644 httputil/doc.go create mode 100644 httputil/respond.go create mode 100644 httputil/useragent.go create mode 100644 l4g/database.go create mode 100644 l4g/debug.go create mode 100644 l4g/doc.go create mode 100644 l4g/entry.go create mode 100644 l4g/file.go create mode 100644 l4g/logger.go create mode 100644 l4g/terminal.go create mode 100644 security/crypt.go create mode 100644 security/crypt_test.go create mode 100644 security/init.go create mode 100644 security/random.go create mode 100644 security/random_test.go create mode 100644 snailmail/cloudflare.go create mode 100644 snailmail/smtp.go create mode 100644 snailmail/snailmail.go create mode 100644 validation/validation.go diff --git a/appenv/appenv.go b/appenv/appenv.go new file mode 100644 index 00000000..43d2c088 --- /dev/null +++ b/appenv/appenv.go @@ -0,0 +1,17 @@ +// Package appenv exposes the deployment environment that is baked into the +// binary at compile time. The concrete value of Environment is selected by a +// build tag (see env_development.go / env_staging.go / env_production.go), so it +// can never be misconfigured or missing at startup. The bundler propagates it +// into the JS bundle via the esbuild `__ENV_TYPE__` define. +// +// This is the framework half of what used to live in each app's +// internal/constants: the app keeps its concrete constants (cookie names, +// password rules, ...) and reads the environment from here. +package appenv + +// The environment type names. Compared against Environment to branch behaviour. +const ( + EnvTypeDevelopment = "development" + EnvTypeStaging = "staging" + EnvTypeProduction = "production" +) diff --git a/appenv/env_development.go b/appenv/env_development.go new file mode 100644 index 00000000..df20ebd2 --- /dev/null +++ b/appenv/env_development.go @@ -0,0 +1,8 @@ +//go:build !staging && !production + +package appenv + +// Environment is the deployment environment baked in at compile time. +// Development is the default; build with `-tags staging` or `-tags production` +// to select another environment. +const Environment = EnvTypeDevelopment diff --git a/appenv/env_production.go b/appenv/env_production.go new file mode 100644 index 00000000..66d4788b --- /dev/null +++ b/appenv/env_production.go @@ -0,0 +1,6 @@ +//go:build production + +package appenv + +// Selected by `-tags production`. See env_development.go for the full rationale. +const Environment = EnvTypeProduction diff --git a/appenv/env_staging.go b/appenv/env_staging.go new file mode 100644 index 00000000..3b57ff1d --- /dev/null +++ b/appenv/env_staging.go @@ -0,0 +1,6 @@ +//go:build staging + +package appenv + +// Selected by `-tags staging`. See env_development.go for the full rationale. +const Environment = EnvTypeStaging diff --git a/basic/basic.go b/basic/basic.go new file mode 100644 index 00000000..485bab03 --- /dev/null +++ b/basic/basic.go @@ -0,0 +1,643 @@ +// General purpose "utilities" that act as my own "standard library" +package basic + +import ( + "fmt" + "math/rand" + "os" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "unicode" +) + +// Equivalent to Atoi, but returns int32 rather than (int, error) +func Atoi32(s string) int32 { + i, _ := strconv.ParseInt(s, 10, 32) + return int32(i) +} + +// Equivalent to Atoi, but returns int64 rather than (int, error) +func Atoi64(s string) int64 { + i, _ := strconv.ParseInt(s, 10, 64) + return i +} + +func GetPathParts(path string) []string { + trimmed := strings.TrimPrefix(path, "/") + return strings.Split(trimmed, "/") +} + +// Takes a tree pointer and a slice of path segments to insert. +// It looks for an existing child with the current segment name; if none is found, +// it creates a new node on the tree. Then it recurses on the remaining segments. +// +// Ex: Generate tree nodes from url segments +// `/app/examples/webpage` -> {"app", "examples", "webpage"} +// `/app/examples/hello-world` -> {"app", "examples", "hello-world" +// `/auth/login` -> {"auth", "login"} +// +// => +// +// root { +// app { +// examples { +// webpage +// hello-world +// } +// } +// +// auth { +// login +// } +// } +// +// This function is used in the code generation process to generate `pageinfo` structs +// from all known application page URLs. +func AddStringPartsToTree(tree *Tree, parts []string) { + if len(parts) == 0 { + return + } + + for i := range parts { + if parts[i] == "" { + parts[i] = "index" + } + } + + if tree.Children == nil { + tree.Children = new([]Tree) + } + + // Search for an existing child with the current part's name. + var child *Tree + for i := range *tree.Children { + if (*tree.Children)[i].Name == parts[0] { + child = &((*tree.Children)[i]) + break + } + } + + // If no child is found, create a new one and append it. + if child == nil { + newNode := Tree{Name: parts[0]} + *tree.Children = append(*tree.Children, newNode) + child = &((*tree.Children)[len(*tree.Children)-1]) + } + + AddStringPartsToTree(child, parts[1:]) +} + +type Tree struct { + Name string + Children *[]Tree +} + +func CapitalizeFirstLetter(s string) string { + if s == "" { + return s + } + // Convert the first rune to uppercase + first := []rune(s)[0] + return string(unicode.ToUpper(first)) + s[1:] +} + +// Known compound surnames where a simple prefix rule would cause false positives +// (e.g. "De" matches Dean/Dennis, "La" matches Laura/Lance). Add entries as needed. +var compoundSurnames = map[string]string{ + // De- + "deangelo": "DeAngelo", "decarlo": "DeCarlo", "dejesus": "DeJesus", + "deleon": "DeLeon", "deluca": "DeLuca", "demarco": "DeMarco", + "depaul": "DePaul", "derosa": "DeRosa", "desantis": "DeSantis", + "devries": "DeVries", "devris": "DeVris", "dewitt": "DeWitt", + "deyoung": "DeYoung", + // Di- + "dicaprio": "DiCaprio", "dicarlo": "DiCarlo", "dimaggio": "DiMaggio", + "dinapoli": "DiNapoli", "dipietro": "DiPietro", + // La- + "lafleur": "LaFleur", "lafrance": "LaFrance", "lamontagne": "LaMontagne", + "laporte": "LaPorte", "larocca": "LaRocca", "larue": "LaRue", + "lasalle": "LaSalle", + // Le- + "leblanc": "LeBlanc", "lebron": "LeBron", "legrand": "LeGrand", + "lemay": "LeMay", + // Lo- + "lopresti": "LoPresti", + // Du- + "dubois": "DuBois", "dupont": "DuPont", "dupree": "DuPree", +} + +// NormalizeName intelligently capitalizes a name field (first or last name). +// Handles edge cases like O'Brian, McDonald, MacArthur, hyphenated names, +// compound surnames (DeSantis, DiCaprio, LeBlanc), and suffixes (Jr, III). +func NormalizeName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return name + } + + // If the name has mixed capitalization, assume the user typed it intentionally. + hasUpper, hasLower := false, false + for _, r := range name { + if unicode.IsUpper(r) { + hasUpper = true + } else if unicode.IsLower(r) { + hasLower = true + } + if hasUpper && hasLower { + return name + } + } + + name = strings.ToLower(name) + + hyphenParts := strings.Split(name, "-") + for i, hpart := range hyphenParts { + words := strings.Fields(hpart) + for j, word := range words { + if mapped, ok := compoundSurnames[word]; ok { + words[j] = mapped + continue + } + + runes := []rune(word) + + switch { + case word == "ii" || word == "iii" || word == "iv" || word == "vi" || word == "vii" || word == "viii": + words[j] = strings.ToUpper(word) + case word == "jr" || word == "sr": + words[j] = CapitalizeFirstLetter(word) + "." + case strings.HasPrefix(word, "mc") && len(runes) >= 3: + words[j] = "Mc" + string(unicode.ToUpper(runes[2])) + string(runes[3:]) + case strings.HasPrefix(word, "mac") && len(runes) >= 5: + words[j] = "Mac" + string(unicode.ToUpper(runes[3])) + string(runes[4:]) + case strings.HasPrefix(word, "o'") && len(runes) >= 3: + words[j] = "O'" + string(unicode.ToUpper(runes[2])) + string(runes[3:]) + default: + words[j] = string(unicode.ToUpper(runes[0])) + string(runes[1:]) + } + } + hyphenParts[i] = strings.Join(words, " ") + } + return strings.Join(hyphenParts, "-") +} + +var profanityList = map[string]bool{ + // Common profanity — whole-word matches only, so substrings in real names + // (e.g. "Massimo", "Dickens", "Cockburn") are not flagged. + "ass": true, "arse": true, "asshole": true, + "bastard": true, "bitch": true, "bollocks": true, + "cock": true, "crap": true, "cunt": true, + "damn": true, "dildo": true, + "fag": true, "fuck": true, "fucker": true, + "goddamn": true, + "jackass": true, + "motherfucker": true, + "nigger": true, "nigga": true, + "piss": true, "prick": true, "pussy": true, + "shit": true, "slut": true, + "tit": true, "tits": true, "twat": true, + "wanker": true, "whore": true, +} + +var leetReplacer = strings.NewReplacer( + "0", "o", + "1", "i", + "3", "e", + "4", "a", + "5", "s", + "7", "t", + "8", "b", + "@", "a", + "$", "s", + "!", "i", +) + +// NameContainsProfanity checks whether any whole word in the name matches a known profane term. +// Words are split on spaces, hyphens, and apostrophes so that substrings within legitimate +// names (e.g. "Massimo", "Dickens", "Cockburn") are not flagged. +// Also normalizes leet speak substitutions (e.g. "b1tch", "a$$", "sh!t"). +func NameContainsProfanity(name string) bool { + name = strings.ToLower(name) + parts := strings.FieldsFunc(name, func(r rune) bool { + return r == ' ' || r == '-' || r == '\'' + }) + for _, part := range parts { + if profanityList[part] { + return true + } + // Check leet speak variant + normalized := leetReplacer.Replace(part) + if normalized != part && profanityList[normalized] { + return true + } + } + return false +} + +func IntAbs(x int) int { + if x < 0 { + return -x + } + + return x +} + +func MakeURLParams(base string, params ...[2]string) string { + output := base + + for i, v := range params { + if i == 0 { + output += "?" + v[0] + "=" + v[1] + } else { + output += "&" + v[0] + "=" + v[1] + } + } + + return output +} + +func ToSnakeCase(s string) string { + s = strings.ReplaceAll(s, " ", "_") + + return s +} + +func SnakeCaseToTitleCase(s string) string { + parts := strings.Split(s, "_") + + for i, part := range parts { + parts[i] = CapitalizeFirstLetter(part) + } + + return strings.Join(parts, " ") +} + +func NullableToString[T any](i *T) string { + if i == nil { + return "" + } + + return ToString(*i) +} + +func ToString(i any) string { + v := reflect.ValueOf(i) + + output := "" + + switch v.Kind() { + case reflect.String: + output = v.String() + case reflect.Int: + output = fmt.Sprintf("%d", v.Int()) + case reflect.Float64: + output = fmt.Sprintf("%f", v.Float()) + case reflect.Bool: + output = fmt.Sprintf("%t", v.Bool()) + default: + output = fmt.Sprintf("%v", i) + } + + return output +} + +func Int64ToStringWithCommas(i int64) string { + str := strconv.Itoa(int(i)) + + negative := false + if strings.HasPrefix(str, "-") { + negative = true + str = str[1:] + } + + result := "" + for i, char := range str { + if i > 0 && (len(str)-i)%3 == 0 { + result += "," + } + result += string(char) + } + + if negative { + result = "-" + result + } + + return result +} + +// Strips non-numeric characters from a string, then converts digits in string to int +func StringToInt(input string) int { + i, _ := strconv.Atoi(SanitizeNum(input)) + return i +} + +// Strips non-numeric characters from a string, then converts digits in string to int32 +func StringToInt32(input string) int32 { + return Atoi32(SanitizeNum(input)) +} + +// Strips non-numeric characters from a string, then converts digits in string to int64 +func StringToInt64(input string) int64 { + return Atoi64(SanitizeNum(input)) +} + +// ABC +func SafeIndex[T any](index int, arr []T) T { + var temp T + + if index > len(arr)-1 { + return temp + } + + return arr[index] +} + +func SafeDereference[T any](value *T, optionalDefault ...T) T { + if value != nil { + return *value + } + + if len(optionalDefault) > 0 { + return optionalDefault[0] + } + + return *new(T) +} + +func MakePtr[T any](value T) *T { + output := new(T) + *output = value + return output +} + +func Reverse[T comparable](s []T) { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } +} + +// Given two arrays, make sure that elements in array 1 are present in array 2 +// Return false if the requirements are not met +func ContainsAll[T comparable](arr1, arr2 []T) bool { + elements := make(map[T]bool) + + for _, num := range arr1 { + elements[num] = true + } + + for _, num := range arr2 { + if !elements[num] { + return false + } + } + + return true +} + +func IndexOf[T comparable](collection []T, el T) int { + for i, x := range collection { + if x == el { + return i + } + } + return -1 +} + +func Remove[T comparable](arr []T, s T) []T { + return append(arr[:IndexOf(arr, s)], arr[IndexOf(arr, s)+1:]...) +} + +func RemoveDuplicates[T comparable](sliceList []T) []T { + allKeys := make(map[T]bool) + list := []T{} + for _, item := range sliceList { + if _, value := allKeys[item]; !value { + allKeys[item] = true + list = append(list, item) + } + } + return list +} + +// Return elements in slice 1 minus elements in slice2 +func RemoveMany[T comparable](slice1, slice2 []T) []T { + removeMap := make(map[T]bool) + for _, item := range slice2 { + removeMap[item] = true + } + + result := make([]T, 0) + + for _, item := range slice1 { + if !removeMap[item] { + result = append(result, item) + } + } + + return result +} + +func GetFirstNChars(s string, n int) string { + i := 0 + for j := range s { + if i == n { + return s[:j] + } + i++ + } + return s +} + +func PrintStatus(b bool) { + var status string + + if b { + status = "SUCCESS" + } else { + status = "FAILED" + } + + fmt.Printf("... %s\n", status) +} + +func DirExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func MapToSortedArray(m map[string]int64) [][2]int64 { + pairs := make([][2]int64, 0, len(m)) + + for k, v := range m { + numKey, err := strconv.ParseInt(k, 10, 64) + if err != nil { + continue + } + pairs = append(pairs, [2]int64{numKey, v}) + } + + sort.Slice(pairs, func(i, j int) bool { + return pairs[i][0] < pairs[j][0] + }) + + return pairs +} + +func RandomSortInt32(n int) []int32 { + result := make([]int32, n) + for i := 0; i < n; i++ { + result[i] = int32(i + 1) + } + + // Shuffle the list using Fisher-Yates algorithm + for i := n - 1; i > 0; i-- { + j := rand.Intn(i + 1) + result[i], result[j] = result[j], result[i] + } + return result +} + +func SlicesEqual[T comparable](a []T, b []T) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if b[i] != a[i] { + return false + } + } + + return true +} + +type StructDiff struct { + FieldName string `json:"field_name"` + OldValue any `json:"old_value"` + NewValue any `json:"new_value"` +} + +type StructComparison struct { + Changes []StructDiff `json:"changes"` + Summary string `json:"summary"` +} + +func CompareStructs(oldStruct, newStruct any) StructComparison { + result := StructComparison{ + Changes: []StructDiff{}, + } + + if oldStruct == nil && newStruct == nil { + result.Summary = "Both structs are nil" + return result + } + + if oldStruct == nil { + result.Summary = "Old struct is nil, new struct has values" + return result + } + + if newStruct == nil { + result.Summary = "New struct is nil, old struct had values" + return result + } + + oldVal := reflect.ValueOf(oldStruct) + newVal := reflect.ValueOf(newStruct) + + if oldVal.Type() != newVal.Type() { + result.Summary = "Struct types do not match" + return result + } + + if oldVal.Kind() == reflect.Ptr { + oldVal = oldVal.Elem() + } + if newVal.Kind() == reflect.Ptr { + newVal = newVal.Elem() + } + + if oldVal.Kind() != reflect.Struct || newVal.Kind() != reflect.Struct { + result.Summary = "Both values must be structs" + return result + } + + oldType := oldVal.Type() + changeCount := 0 + + for i := 0; i < oldVal.NumField(); i++ { + field := oldType.Field(i) + + if !field.IsExported() { + continue + } + + oldFieldVal := oldVal.Field(i) + newFieldVal := newVal.Field(i) + + if !oldFieldVal.CanInterface() || !newFieldVal.CanInterface() { + continue + } + + oldInterface := oldFieldVal.Interface() + newInterface := newFieldVal.Interface() + + if !reflect.DeepEqual(oldInterface, newInterface) { + result.Changes = append(result.Changes, StructDiff{ + FieldName: field.Name, + OldValue: oldInterface, + NewValue: newInterface, + }) + changeCount++ + } + } + + if changeCount == 0 { + result.Summary = "No changes detected" + } else if changeCount == 1 { + result.Summary = "1 field changed" + } else { + result.Summary = fmt.Sprintf("%d fields changed", changeCount) + } + + return result +} + +// Strips non-numeric characters from a string including spaces. +func SanitizeNum(input string) string { + re := regexp.MustCompile(`[^\d]+`) + return re.ReplaceAllString(input, "") +} + +// Strips non-alphanumeric, non-space characters from a string. +func SanitizeAlphaNum(input string) string { + re := regexp.MustCompile(`[^a-zA-Z0-9 ]+`) + return re.ReplaceAllString(input, "") +} + +// Strips non-alphanumeric characters from a string including spaces. +func SanitizeAlphaNumStrict(input string) string { + re := regexp.MustCompile(`[^a-zA-Z0-9]+`) + return re.ReplaceAllString(input, "") +} + +// MapMerge merges two maps, with values from m2 overriding values from m1. +func MapMerge[K comparable, V any](m1, m2 map[K]V) map[K]V { + result := make(map[K]V, len(m1)+len(m2)) + for k, v := range m1 { + result[k] = v + } + for k, v := range m2 { + result[k] = v + } + return result +} diff --git a/basic/basic_test.go b/basic/basic_test.go new file mode 100644 index 00000000..18e0ff1e --- /dev/null +++ b/basic/basic_test.go @@ -0,0 +1,150 @@ +package basic + +import "testing" + +func TestNormalizeName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + // Basic capitalization + {"JOHN", "John"}, + {"doe", "Doe"}, + {"jOhN", "jOhN"}, // mixed case — trust user input + + // O' prefix + {"O'BRIAN", "O'Brian"}, + {"o'connor", "O'Connor"}, + {"O'NEIL", "O'Neil"}, + + // Mc prefix + {"MCDONALD", "McDonald"}, + {"mcdonald", "McDonald"}, + {"MCBRIDE", "McBride"}, + + // Mac prefix (len >= 5 to avoid false positives) + {"MACARTHUR", "MacArthur"}, + {"macarthur", "MacArthur"}, + {"MACDONALD", "MacDonald"}, + + // Mac prefix - short words should NOT get Mac treatment + {"MACK", "Mack"}, + {"macy", "Macy"}, + {"mace", "Mace"}, + {"mach", "Mach"}, + + // Hyphenated names + {"SMITH-JONES", "Smith-Jones"}, + {"smith-jones", "Smith-Jones"}, + {"O'BRIEN-MCDONALD", "O'Brien-McDonald"}, + + // Roman numerals and suffixes + {"iii", "III"}, + {"III", "III"}, + {"iv", "IV"}, + {"jr", "Jr."}, + {"sr", "Sr."}, + {"ii", "II"}, + + // Compound surnames (De, Di, La, Le, Lo, Du) + {"DESANTIS", "DeSantis"}, + {"devries", "DeVries"}, + {"DEMARCO", "DeMarco"}, + {"DIMAGGIO", "DiMaggio"}, + {"dicaprio", "DiCaprio"}, + {"LASALLE", "LaSalle"}, + {"lafleur", "LaFleur"}, + {"LEBLANC", "LeBlanc"}, + {"lebron", "LeBron"}, + {"LOPRESTI", "LoPresti"}, + {"DUBOIS", "DuBois"}, + {"dupont", "DuPont"}, + + // Compound surname prefixes should NOT affect regular names + {"DEAN", "Dean"}, + {"DENNIS", "Dennis"}, + {"DIANA", "Diana"}, + {"LAURA", "Laura"}, + {"LEON", "Leon"}, + + // Multi-word last names + {"DE LA CRUZ", "De La Cruz"}, + {"VAN DER BERG", "Van Der Berg"}, + + // Whitespace handling + {" JOHN ", "John"}, + {"", ""}, + {" ", ""}, + + // Mixed case — skip normalization, trust user input + {"John", "John"}, + {"Smith", "Smith"}, + {"DeSantis", "DeSantis"}, + {"DiCaprio", "DiCaprio"}, + {"O'Brien", "O'Brien"}, + {"LeBron", "LeBron"}, + {"MacArthur", "MacArthur"}, + {"McDonald", "McDonald"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := NormalizeName(tt.input) + if result != tt.expected { + t.Errorf("NormalizeName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestNameContainsProfanity(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + // Should flag + {"fuck", true}, + {"FUCK", true}, + {"Shit", true}, + {"ass", true}, + + // Should NOT flag — profane substrings inside real names + {"Massimo", false}, + {"Dickens", false}, + {"Cockburn", false}, + {"Draper", false}, + {"Ashton", false}, + {"Cassidy", false}, + + // Normal names + {"John", false}, + {"Smith", false}, + {"O'Brien", false}, + + // Profanity in hyphenated or multi-word name + {"Fuck-Face", true}, + {"Dick Head", true}, + + // Leet speak + {"b1tch", true}, + {"a$$", true}, + {"sh!t", true}, + {"fvck", false}, // not in leet map, won't match + {"f4g", true}, + {"4ss", true}, + {"d1ck", true}, + {"pu$$y", true}, + + // Empty + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := NameContainsProfanity(tt.input) + if result != tt.expected { + t.Errorf("NameContainsProfanity(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} diff --git a/bundler/build.go b/bundler/build.go new file mode 100644 index 00000000..1162e746 --- /dev/null +++ b/bundler/build.go @@ -0,0 +1,147 @@ +// Package bundler is the frontend build system: it drives esbuild's Go API for +// JS bundling, compiles Solid JSX/TSX and Tailwind v4 CSS with Go-native +// compilers (no Node, no Babel, no goja on the build path), and bakes the +// public-page SSR (which does still use goja to execute components). The +// `cmd/bundle` command is a thin CLI wrapper over this package. +// +// The package also carries the runtime SSR entry (RenderBundleWithData, in +// ssr.go/renderer.go) that the server imports for ISR. +// +// Pipeline stages live in sibling files: +// +// js.go esbuild-driven JS bundling + sourcemap fixup +// export_shim.go default/named export shim plugin +// import_check.go .js-extension import validation +// css.go Tailwind compile driver + minify +// tailwind.go official Tailwind v4 compiler run in goja +// candidates.go utility-class candidate scanner (feeds Tailwind's build()) +// genssr.go public-page SSR bake + Go registry generation +// ssr.go/renderer.go the goja SSR engine (also used by the server at runtime) +// watch.go poll-and-rebuild watch loop +package bundler + +import ( + "fmt" + "strings" + "sync" + "time" +) + +const frontendDir = "frontend" +const outputDir = "wwwroot" + +type bundleStats struct { + files int + bytes int +} + +// Build runs the full one-shot build: FA icon subset, public-route generation, +// the JS/CSS bundles, and the SSR bake, printing a stats summary. +func Build() error { + // esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`, + // which lets misnamed specifiers slip through. Catch them up front so the + // import path always names the file that actually exists on disk. + if violations := checkImportExtensions(); len(violations) > 0 { + reportImportViolations(violations) + return fmt.Errorf("%d import-extension violation(s)", len(violations)) + } + + buildStart := time.Now() + + fmt.Println("Generating FA icon subset...") + if err := generateFAIcons(); err != nil { + return fmt.Errorf("FA icon generation failed: %w", err) + } + + fmt.Println("Generating public routes...") + routesStart := time.Now() + if err := generatePublicRoutes(); err != nil { + return fmt.Errorf("public route generation failed: %w", err) + } + routesDur := time.Since(routesStart) + + // The four bundles are independent — distinct output files, shared read-only + // inputs plus the generated files above — so run them in parallel. The Go + // Solid compiler and Go Tailwind engine are both stateless per call. + fmt.Println("Bundling JS + CSS...") + results := runBundlesParallel() + + for _, r := range results { + if r.err != nil { + return fmt.Errorf("%s failed: %w", r.name, r.err) + } + } + + // One column layout drives header, rows, and footer so every field lines up. + // Files is %6d because candidate counts run into the thousands (a %3d would + // overflow and shift Size/Time right on the CSS rows). Total width = 55. + sep := strings.Repeat("-", 55) + fmt.Println() + fmt.Printf("%-27s %6s %8s %9s\n", "Bundle", "Files", "Size", "Time") + fmt.Println(sep) + for _, r := range results { + printStats(r.name, r.stats, r.dur) + } + fmt.Println(sep) + // %-46s%9s places the time in the same field (cols 47-55) as the bundle rows. + fmt.Printf("%-46s%9s\n", "Public routes (SSR)", formatDuration(routesDur)) + fmt.Printf("%-46s%9s\n", "Total build time", formatDuration(time.Since(buildStart))) + fmt.Println() + fmt.Println("Done!") + return nil +} + +type bundleResult struct { + name string + stats bundleStats + dur time.Duration + err error +} + +// runBundlesParallel runs the four output bundles concurrently and returns their +// results in a stable order. Per-bundle durations overlap, so they won't sum to +// wall-clock — that's the point. +func runBundlesParallel() []bundleResult { + jobs := []struct { + name string + fn func() (bundleStats, error) + }{ + {"bundle.min.js", bundleJS}, + {"public.bundle.min.js", bundlePublicJS}, + {"bundle.min.css", bundleSPACSS}, + {"public.bundle.min.css", bundlePublicCSS}, + } + results := make([]bundleResult, len(jobs)) + var wg sync.WaitGroup + for i, j := range jobs { + wg.Add(1) + go func(i int, name string, fn func() (bundleStats, error)) { + defer wg.Done() + t := time.Now() + s, err := fn() + results[i] = bundleResult{name: name, stats: s, dur: time.Since(t), err: err} + }(i, j.name, j.fn) + } + wg.Wait() + return results +} + +func printStats(name string, s bundleStats, d time.Duration) { + fmt.Printf("%-27s %6d %8s %9s\n", name, s.files, formatSize(s.bytes), formatDuration(d)) +} + +// formatDuration renders a build-phase duration compactly: sub-second in ms, +// otherwise seconds with two decimals. +func formatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + return fmt.Sprintf("%.2fs", d.Seconds()) +} + +func formatSize(bytes int) string { + if bytes >= 1024*1024 { + return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) + } + return fmt.Sprintf("%.1f KB", float64(bytes)/1024) +} diff --git a/bundler/compile_solid.go b/bundler/compile_solid.go new file mode 100644 index 00000000..b8b7f7d7 --- /dev/null +++ b/bundler/compile_solid.go @@ -0,0 +1,399 @@ +package bundler + +// Go-native Solid JSX compiler (replaces babel-preset-solid running in goja). +// +// Pipeline: esbuild strips TS types (JSX preserved), then this package parses the +// JSX trees out of the JS and rewrites each into Solid's dom-expressions runtime +// output (template cloning + fine-grained _$insert/_$effect/_$createComponent). +// JS expressions inside `{...}` are captured as opaque strings and, where they +// may contain nested JSX, recompiled recursively — so we never need a full JS +// parser, only a JSX-aware scanner. +// +// This file is the PARSER (JSX text -> tree). Codegen lives in compile_solid_gen.go. + +import ( + "fmt" + "strings" +) + +type jsxKind int + +const ( + jsxElement jsxKind = iota // lowercase tag -> real DOM element (templated) + jsxComponent // Capitalized/dotted tag -> _$createComponent + jsxFragment // <>... + jsxText // literal character data between tags + jsxExpr // {expr} — raw JS, may itself contain JSX +) + +type attrKind int + +const ( + attrStatic attrKind = iota // name="literal" or bare boolean + attrExpr // name={expr} + attrSpread // {...expr} +) + +type jsxAttr struct { + kind attrKind + name string + value string // literal string value (attrStatic with a value) + expr string // JS expression (attrExpr) or spread source (attrSpread) + boolt bool // bare boolean attribute (attrStatic, no `=`) +} + +type jsxNode struct { + kind jsxKind + tag string + attrs []jsxAttr + children []jsxNode + text string // jsxText + expr string // jsxExpr (raw, may contain nested JSX) + + marker bool // codegen: this dynamic child needs a `` insert anchor +} + +// parseJSX parses a JSX element/fragment beginning at src[i] == '<'. It returns +// the node and the index just past the element's closing `>`. +func parseJSX(src string, i int) (jsxNode, int, error) { + n := len(src) + if i >= n || src[i] != '<' { + return jsxNode{}, 0, fmt.Errorf("parseJSX: expected '<' at %d", i) + } + i++ // consume '<' + + // Fragment: <> ... + if i < n && src[i] == '>' { + i++ + children, ci, err := parseChildren(src, i) + if err != nil { + return jsxNode{}, 0, err + } + i, err = consumeCloseTag(src, ci) + if err != nil { + return jsxNode{}, 0, err + } + return jsxNode{kind: jsxFragment, children: children}, i, nil + } + + // Tag name. + start := i + for i < n && isTagChar(src[i]) { + i++ + } + if i == start { + return jsxNode{}, 0, fmt.Errorf("parseJSX: empty tag name at %d", start) + } + node := jsxNode{tag: src[start:i]} + if isComponentTag(node.tag) { + node.kind = jsxComponent + } else { + node.kind = jsxElement + } + + attrs, ai, selfClose, err := parseAttrs(src, i) + if err != nil { + return jsxNode{}, 0, err + } + node.attrs = attrs + i = ai + if selfClose { + return node, i, nil + } + + children, ci, err := parseChildren(src, i) + if err != nil { + return jsxNode{}, 0, err + } + node.children = children + i, err = consumeCloseTag(src, ci) + if err != nil { + return jsxNode{}, 0, err + } + return node, i, nil +} + +// parseAttrs parses attributes after the tag name until `>` or `/>`. It returns +// the attrs, the index past the terminator, and whether the tag self-closed. +func parseAttrs(src string, i int) ([]jsxAttr, int, bool, error) { + n := len(src) + var attrs []jsxAttr + for i < n { + for i < n && isSpace(src[i]) { + i++ + } + if i >= n { + break + } + switch { + case src[i] == '>': + return attrs, i + 1, false, nil + case src[i] == '/' && i+1 < n && src[i+1] == '>': + return attrs, i + 2, true, nil + case src[i] == '{': // {...spread} + expr, ni := captureBraces(src, i) + e := strings.TrimSpace(expr) + e = strings.TrimSpace(strings.TrimPrefix(e, "...")) + attrs = append(attrs, jsxAttr{kind: attrSpread, expr: e}) + i = ni + default: + ns := i + for i < n && isAttrNameChar(src[i]) { + i++ + } + if i == ns { + return nil, 0, false, fmt.Errorf("parseAttrs: unexpected %q at %d", src[i], i) + } + name := src[ns:i] + for i < n && isSpace(src[i]) { + i++ + } + if i < n && src[i] == '=' { + i++ + for i < n && isSpace(src[i]) { + i++ + } + if i >= n { + return nil, 0, false, fmt.Errorf("parseAttrs: attr value expected") + } + if src[i] == '{' { + expr, ni := captureBraces(src, i) + attrs = append(attrs, jsxAttr{kind: attrExpr, name: name, expr: strings.TrimSpace(expr)}) + i = ni + } else if src[i] == '"' || src[i] == '\'' { + q := src[i] + i++ + vs := i + for i < n && src[i] != q { + i++ + } + attrs = append(attrs, jsxAttr{kind: attrStatic, name: name, value: src[vs:i]}) + if i < n { + i++ // closing quote + } + } else { + return nil, 0, false, fmt.Errorf("parseAttrs: bad attr value at %d", i) + } + } else { + attrs = append(attrs, jsxAttr{kind: attrStatic, name: name, boolt: true}) + } + } + } + return nil, 0, false, fmt.Errorf("parseAttrs: unterminated tag") +} + +// parseChildren parses child nodes until the matching ` 0 { + nodes = append(nodes, jsxNode{kind: jsxText, text: text.String()}) + text.Reset() + } + } + for i < n { + c := src[i] + switch { + case c == '<' && i+1 < n && src[i+1] == '/': + flush() + return nodes, i, nil + case c == '<': + flush() + child, ni, err := parseJSX(src, i) + if err != nil { + return nil, 0, err + } + nodes = append(nodes, child) + i = ni + case c == '{': + flush() + expr, ni := captureBraces(src, i) + nodes = append(nodes, jsxNode{kind: jsxExpr, expr: strings.TrimSpace(expr)}) + i = ni + default: + text.WriteByte(c) + i++ + } + } + return nil, 0, fmt.Errorf("parseChildren: unterminated (missing close tag)") +} + +// consumeCloseTag consumes `` (or ``) starting at src[i] == '<'. +func consumeCloseTag(src string, i int) (int, error) { + n := len(src) + if i+1 >= n || src[i] != '<' || src[i+1] != '/' { + return 0, fmt.Errorf("consumeCloseTag: expected '' { + i++ + } + if i >= n { + return 0, fmt.Errorf("consumeCloseTag: unterminated close tag") + } + return i + 1, nil // past '>' +} + +// captureBraces returns the text between a `{` at src[i] and its matching `}` +// (exclusive), and the index just past that `}`. It tracks strings, template +// literals (with `${}` interpolation), comments, and regex literals so braces +// inside them don't miscount — the same lexer the segmenter uses. +func captureBraces(src string, i int) (inner string, next int) { + n := len(src) + start := i + 1 + i++ // skip opening '{' + depth := 0 + state := stNormal + var tmplStack []int + var prevSig byte + for ; i < n; i++ { + c := src[i] + switch state { + case stNormal: + switch c { + case '/': + if i+1 < n && src[i+1] == '/' { + state = stLineComment + i++ + continue + } + if i+1 < n && src[i+1] == '*' { + state = stBlockComment + i++ + continue + } + if regexAllowed(src, i, prevSig) { + state = stRegex + prevSig = c + continue + } + prevSig = c + case '\'': + state = stSingle + prevSig = c + case '"': + state = stDouble + prevSig = c + case '`': + state = stTemplate + prevSig = c + case '{', '(', '[': + depth++ + prevSig = c + case '}': + if len(tmplStack) > 0 && depth == tmplStack[len(tmplStack)-1] { + tmplStack = tmplStack[:len(tmplStack)-1] + depth-- + state = stTemplate + } else if depth > 0 { + depth-- + prevSig = c + } else { + return src[start:i], i + 1 // matching close of the outer '{' + } + case ')', ']': + if depth > 0 { + depth-- + } + prevSig = c + case '<': + // A `<` in expression position (and followed by a tag/fragment + // start) opens nested JSX, not a less-than: skip the whole element + // via parseJSX so its `` slashes and `{}` don't desync the JS + // lexer. Otherwise it's the comparison operator. + if regexAllowed(src, i, prevSig) && i+1 < n && (isASCIILetter(src[i+1]) || src[i+1] == '>') { + if _, ni, err := parseJSX(src, i); err == nil { + i = ni - 1 // loop's i++ lands just past the element + prevSig = '>' + continue + } + } + prevSig = c + default: + if !isSpace(c) { + prevSig = c + } + } + case stLineComment: + if c == '\n' { + state = stNormal + } + case stBlockComment: + if c == '*' && i+1 < n && src[i+1] == '/' { + state = stNormal + i++ + } + case stSingle: + if c == '\\' { + i++ + } else if c == '\'' { + state = stNormal + prevSig = c + } + case stDouble: + if c == '\\' { + i++ + } else if c == '"' { + state = stNormal + prevSig = c + } + case stTemplate: + if c == '\\' { + i++ + } else if c == '`' { + state = stNormal + prevSig = c + } else if c == '$' && i+1 < n && src[i+1] == '{' { + depth++ + tmplStack = append(tmplStack, depth) + state = stNormal + i++ + } + case stRegex: + if c == '\\' { + i++ + } else if c == '[' { + for i++; i < n; i++ { + if src[i] == '\\' { + i++ + continue + } + if src[i] == ']' { + break + } + } + } else if c == '/' { + state = stNormal + prevSig = c + } + } + } + return src[start:], n // unterminated +} + +func isComponentTag(tag string) bool { + if tag == "" { + return false + } + if strings.ContainsAny(tag, ".") { + return true // member expression component, e.g. + } + c := tag[0] + return c >= 'A' && c <= 'Z' +} + +func isTagChar(b byte) bool { + return isASCIILetter(b) || (b >= '0' && b <= '9') || b == '-' || b == '.' || b == ':' || b == '_' +} + +func isAttrNameChar(b byte) bool { + return isASCIILetter(b) || (b >= '0' && b <= '9') || b == '-' || b == ':' || b == '_' +} + +func isSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} diff --git a/bundler/compile_solid_gen.go b/bundler/compile_solid_gen.go new file mode 100644 index 00000000..4828b835 --- /dev/null +++ b/bundler/compile_solid_gen.go @@ -0,0 +1,1038 @@ +package bundler + +// Go-native Solid codegen: JSX tree -> dom-expressions runtime output. +// +// Strategy (behavior-parity with babel-preset-solid, not byte-parity): +// - Static element structure is baked into an HTML template string cloned at +// runtime via _$template(); we always quote attrs and emit closing tags +// (valid HTML the browser parses to the same DOM babel's terser template does). +// - Dynamic children -> _$insert(parent, () => expr, marker). Every dynamic +// expression is wrapped in a thunk: correct and (for static exprs) merely an +// extra no-op effect. babel unwraps `f()` -> `f` as an optimization; we skip +// that for now (behavior-identical). +// - Dynamic attrs -> _$effect(() => _$setAttribute(el, name, expr)). +// - Components -> _$createComponent(Tag, props) with reactive prop getters. + +import ( + "fmt" + "regexp" + "sort" + "strings" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +type solidGen struct { + templates []string // template HTML strings in first-seen order + tmplVars []string // parallel _tmpl$N names + tmplByStr map[string]string // dedupe identical templates + helpers map[string]bool // solid-js/web helpers used + events map[string]bool // delegated event names + source string // the module source, for ref binding-kind lookup + tmplN int +} + +func newSolidGen() *solidGen { + return &solidGen{tmplByStr: map[string]string{}, helpers: map[string]bool{}, events: map[string]bool{}} +} + +func (g *solidGen) helper(name string) string { + g.helpers[name] = true + return "_$" + name +} + +func (g *solidGen) template(html string) string { + if v, ok := g.tmplByStr[html]; ok { + return v + } + g.tmplN++ + v := "_tmpl$" + if g.tmplN > 1 { + v = fmt.Sprintf("_tmpl$%d", g.tmplN) + } + g.tmplByStr[html] = v + g.templates = append(g.templates, html) + g.tmplVars = append(g.tmplVars, v) + g.helper("template") + return v +} + +// compileSolidGo compiles a TSX source to Solid dom-expressions output. In dev +// mode each component is wrapped with solid-refresh so the module is an HMR +// boundary; prod (dev=false) leaves components bare (that's what SSR renders). +func compileSolidGo(src, _ string, dev bool) (string, error) { + stripped, err := stripTypesPreserveJSX(src) + if err != nil { + return "", err + } + refresh := false + if dev { + stripped, refresh = wrapComponents(stripped) + } + + g := newSolidGen() + g.source = stripped + body := g.transformJSX(stripped) + + var b strings.Builder + for _, h := range sortedKeys(g.helpers) { + fmt.Fprintf(&b, "import { %s as _$%s } from \"solid-js/web\";\n", h, h) + } + if refresh { + b.WriteString(`import { $$component as _$$component } from "solid-refresh";` + "\n") + b.WriteString(`import { $$registry as _$$registry } from "solid-refresh";` + "\n") + b.WriteString(`import { $$refresh as _$$refresh } from "solid-refresh";` + "\n") + b.WriteString("const _REGISTRY = _$$registry();\n") + } + for i, t := range g.templates { + fmt.Fprintf(&b, "var %s = /*#__PURE__*/_$template(`%s`);\n", g.tmplVars[i], t) + } + b.WriteString(body) + if refresh { + b.WriteString("if (import.meta.hot) { _$$refresh(\"esm\", import.meta.hot, _REGISTRY); }\n") + } + if len(g.events) > 0 { + evs := sortedKeys(g.events) + q := make([]string, len(evs)) + for i, e := range evs { + q[i] = `"` + e + `"` + } + fmt.Fprintf(&b, "_$delegateEvents([%s]);\n", strings.Join(q, ", ")) + } + return b.String(), nil +} + +// wrapComponents rewrites each top-level component declaration into a +// solid-refresh registry entry: `function A(){…}` → `const A = +// _$$component(_REGISTRY, "A", function A(){…});`. A declaration is a component +// if it's a PascalCase function (declaration or arrow/function const) whose body +// contains JSX. Returns the rewritten source and whether any component was found. +func wrapComponents(src string) (string, bool) { + var out strings.Builder + found := false + for _, ch := range segmentTopLevel(src) { + if rw, ok := rewrapComponent(ch); ok { + found = true + out.WriteString(rw) + } else { + out.WriteString(ch) + } + } + return out.String(), found +} + +var ( + reCompFunc = regexp.MustCompile(`^(export\s+)?(default\s+)?function\s+([A-Z][\w$]*)`) + reCompConst = regexp.MustCompile(`^(export\s+)?(?:const|let|var)\s+([A-Z][\w$]*)\s*=\s*`) +) + +func rewrapComponent(chunk string) (string, bool) { + trimmed := strings.TrimSpace(chunk) + // A top-level `function Foo` is a component when its body contains JSX. + if m := reCompFunc.FindStringSubmatch(trimmed); m != nil { + if !containsJSX(trimmed) { + return chunk, false + } + name := m[3] + fn := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, m[1]), m[2])) + wrap := fmt.Sprintf(`_$$component(_REGISTRY, %q, %s)`, name, fn) + switch { + case m[2] != "": // export default function + return fmt.Sprintf("const %s = %s;\nexport default %s;\n", name, wrap, name), true + case m[1] != "": // export function + return fmt.Sprintf("export const %s = %s;\n", name, wrap), true + default: + return fmt.Sprintf("const %s = %s;\n", name, wrap), true + } + } + // A PascalCase const is a component when its initializer is a function + // (inline JSX) OR a factory call, e.g. `const AlertGreen = makeAlert("green")`. + if m := reCompConst.FindStringSubmatch(trimmed); m != nil { + name := m[2] + expr := strings.TrimSuffix(strings.TrimSpace(trimmed[len(m[0]):]), ";") + if !containsJSX(trimmed) && !isComponentInitializer(expr) { + return chunk, false + } + wrap := fmt.Sprintf(`_$$component(_REGISTRY, %q, %s)`, name, expr) + prefix := "" + if m[1] != "" { + prefix = "export " + } + return fmt.Sprintf("%sconst %s = %s;\n", prefix, name, wrap), true + } + return chunk, false +} + +var reCompInit = regexp.MustCompile(`^[A-Za-z_$][\w$.]*\s*(\(|=>)`) + +// isComponentInitializer reports whether a const initializer is a function or a +// factory call producing a component (arrow, function expr, `id(...)`, `id => …`). +// Plain values ({…}, "…", [ … ], numbers) are excluded. +func isComponentInitializer(e string) bool { + e = strings.TrimSpace(e) + return strings.HasPrefix(e, "(") || strings.HasPrefix(e, "function ") || + strings.HasPrefix(e, "async ") || reCompInit.MatchString(e) +} + +// containsJSX reports whether src has a JSX element in expression position, +// skipping strings/comments so a `<` inside them isn't counted. +func containsJSX(src string) bool { + n := len(src) + state := stNormal + var prevSig byte + for i := 0; i < n; i++ { + c := src[i] + switch state { + case stNormal: + switch c { + case '/': + if i+1 < n && src[i+1] == '/' { + state = stLineComment + continue + } + if i+1 < n && src[i+1] == '*' { + state = stBlockComment + continue + } + prevSig = c + case '\'': + state = stSingle + case '"': + state = stDouble + case '`': + state = stTemplate + case '<': + if regexAllowed(src, i, prevSig) && i+1 < n && (isASCIILetter(src[i+1]) || src[i+1] == '>') { + return true + } + prevSig = c + default: + if !isSpace(c) { + prevSig = c + } + } + case stLineComment: + if c == '\n' { + state = stNormal + } + case stBlockComment: + if c == '*' && i+1 < n && src[i+1] == '/' { + state = stNormal + i++ + } + case stSingle: + if c == '\\' { + i++ + } else if c == '\'' { + state = stNormal + prevSig = c + } + case stDouble: + if c == '\\' { + i++ + } else if c == '"' { + state = stNormal + prevSig = c + } + case stTemplate: + if c == '\\' { + i++ + } else if c == '`' { + state = stNormal + prevSig = c + } + } + } + return false +} + +func stripTypesPreserveJSX(src string) (string, error) { + res := esbuild.Transform(src, esbuild.TransformOptions{ + Loader: esbuild.LoaderTSX, + JSX: esbuild.JSXPreserve, + Format: esbuild.FormatESModule, + LogLevel: esbuild.LogLevelSilent, + }) + if len(res.Errors) > 0 { + return "", fmt.Errorf("%s", res.Errors[0].Text) + } + return string(res.Code), nil +} + +// transformJSX scans JS, finds JSX in expression position, and replaces each with +// its compiled expression. Non-JSX text passes through. Same lexer as +// captureBraces so `<` inside strings/comments/regex isn't mistaken for JSX. +func (g *solidGen) transformJSX(src string) string { + var out strings.Builder + n := len(src) + state := stNormal + var tmplStack []int + var prevSig byte + depth := 0 + for i := 0; i < n; i++ { + c := src[i] + switch state { + case stNormal: + switch c { + case '/': + if i+1 < n && src[i+1] == '/' { + state = stLineComment + out.WriteByte(c) + continue + } + if i+1 < n && src[i+1] == '*' { + state = stBlockComment + out.WriteByte(c) + continue + } + if regexAllowed(src, i, prevSig) { + state = stRegex + } + prevSig = c + case '\'': + state = stSingle + prevSig = c + case '"': + state = stDouble + prevSig = c + case '`': + state = stTemplate + prevSig = c + case '{', '(', '[': + depth++ + prevSig = c + case '}': + if len(tmplStack) > 0 && depth == tmplStack[len(tmplStack)-1] { + tmplStack = tmplStack[:len(tmplStack)-1] + depth-- + state = stTemplate + } else { + if depth > 0 { + depth-- + } + prevSig = c + } + case ')', ']': + if depth > 0 { + depth-- + } + prevSig = c + case '<': + if regexAllowed(src, i, prevSig) && i+1 < n && (isASCIILetter(src[i+1]) || src[i+1] == '>') { + if node, ni, err := parseJSX(src, i); err == nil { + out.WriteString(g.genNode(&node)) + i = ni - 1 + prevSig = '>' + continue + } + } + prevSig = c + default: + if !isSpace(c) { + prevSig = c + } + } + case stLineComment: + if c == '\n' { + state = stNormal + } + case stBlockComment: + if c == '*' && i+1 < n && src[i+1] == '/' { + state = stNormal + out.WriteByte(c) + i++ + out.WriteByte(src[i]) + continue + } + case stSingle: + if c == '\\' { + out.WriteByte(c) + i++ + if i < n { + out.WriteByte(src[i]) + } + continue + } else if c == '\'' { + state = stNormal + prevSig = c + } + case stDouble: + if c == '\\' { + out.WriteByte(c) + i++ + if i < n { + out.WriteByte(src[i]) + } + continue + } else if c == '"' { + state = stNormal + prevSig = c + } + case stTemplate: + if c == '\\' { + out.WriteByte(c) + i++ + if i < n { + out.WriteByte(src[i]) + } + continue + } else if c == '`' { + state = stNormal + prevSig = c + } else if c == '$' && i+1 < n && src[i+1] == '{' { + depth++ + tmplStack = append(tmplStack, depth) + state = stNormal + out.WriteByte(c) + i++ + out.WriteByte(src[i]) + continue + } + case stRegex: + if c == '\\' { + out.WriteByte(c) + i++ + if i < n { + out.WriteByte(src[i]) + } + continue + } else if c == '/' { + state = stNormal + prevSig = c + } + } + out.WriteByte(c) + } + return out.String() +} + +// compileExpr recompiles any JSX nested inside an opaque expression string. +func (g *solidGen) compileExpr(expr string) string { + if !strings.Contains(expr, "<") { + return expr + } + return g.transformJSX(expr) +} + +// genNode compiles a top-level JSX node into a JS expression. +func (g *solidGen) genNode(node *jsxNode) string { + switch node.kind { + case jsxElement: + return g.genElement(node) + case jsxComponent: + return g.genComponent(node) + case jsxFragment: + return g.genFragment(node) + case jsxExpr: + return g.compileExpr(node.expr) + case jsxText: + return jsStringLit(collapseText(node.text)) + } + return "null" +} + +// iife accumulates the navigation var decls and operation statements for one +// element's cloned template. +type iife struct { + g *solidGen + elN int + decls []string + ops []string +} + +func (c *iife) nextEl() string { + c.elN++ + if c.elN == 1 { + return "_el$" + } + return fmt.Sprintf("_el$%d", c.elN) +} + +func (g *solidGen) genElement(node *jsxNode) string { + annotateMarkers(node) + tv := g.template(buildTemplate(node)) + + c := &iife{g: g} + root := c.nextEl() + c.decls = append(c.decls, root+" = "+tv+"()") + c.walk(node, root) + + if len(c.ops) == 0 { + return tv + "()" + } + var b strings.Builder + b.WriteString("(() => { var " + strings.Join(c.decls, ", ") + "; ") + for _, op := range c.ops { + b.WriteString(op + "; ") + } + b.WriteString("return " + root + "; })()") + return b.String() +} + +// walk emits attribute/event ops for node (referenced by ref) and recurses into +// children, navigating to those that need a DOM reference. +func (c *iife) walk(node *jsxNode, ref string) { + if hasSpread(node) { + c.spread(ref, node) // spread subsumes every attr; none are baked + } else { + for _, a := range node.attrs { + c.attr(ref, a) + } + } + // A 2-arg _$insert(parent, value) REPLACES all of parent's content, so it's + // only valid when the dynamic child is parent's sole content. With static or + // multiple children, every dynamic child needs a 3-arg insert whose marker is + // the next DOM node (or null to append without clearing). + contentCount := contentChildCount(node) + childRefs := c.assignChildRefs(node, ref) + for i := range node.children { + ch := &node.children[i] + switch ch.kind { + case jsxElement: + if r := childRefs[i]; r != "" { + c.walk(ch, r) + } + case jsxExpr, jsxComponent: + if ch.kind == jsxExpr && exprIsEmpty(ch.expr) { + continue // {/* comment */} / empty expression: nothing to insert + } + val := c.g.genNode(ch) + if ch.kind == jsxExpr { + val = wrapReactive(val) + } + if contentCount == 1 { + c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s)", c.g.helper("insert"), ref, val)) + } else { + marker := "null" + if ch.marker { + marker = childRefs[i] + } + c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s, %s)", c.g.helper("insert"), ref, val, marker)) + } + } + } +} + +func contentChildCount(node *jsxNode) int { + count := 0 + for i := range node.children { + if renderedChild(&node.children[i]) { + count++ + } + } + return count +} + +// renderedChild reports whether a child produces content — false for +// whitespace-only text and empty/comment-only JSX expressions ({/* ... */}). +func renderedChild(ch *jsxNode) bool { + switch ch.kind { + case jsxText: + return collapseText(ch.text) != "" + case jsxExpr: + return !exprIsEmpty(ch.expr) + case jsxElement, jsxComponent: + return true + } + return false +} + +// exprIsEmpty reports whether a JSX expression is empty or only comments — a +// no-op like {} or {/* note */}, which Solid drops. +func exprIsEmpty(expr string) bool { + e := strings.TrimSpace(expr) + for e != "" { + if strings.HasPrefix(e, "/*") { + if i := strings.Index(e, "*/"); i >= 0 { + e = strings.TrimSpace(e[i+2:]) + continue + } + } + if strings.HasPrefix(e, "//") { + if i := strings.IndexByte(e, '\n'); i >= 0 { + e = strings.TrimSpace(e[i+1:]) + continue + } + e = "" + } + break + } + return e == "" +} + +// assignChildRefs walks the DOM child sequence (static children + markers), +// assigning a nav var to each one that needs a reference, and returns a map from +// child index to its var ("" if none). Vars chain via firstChild/nextSibling. +func (c *iife) assignChildRefs(node *jsxNode, parentRef string) map[int]string { + refs := map[int]string{} + // Which children need a ref? + need := func(i int, ch *jsxNode) bool { + switch ch.kind { + case jsxElement: + return elementNeedsRef(ch) + case jsxExpr, jsxComponent: + return ch.marker // marker node is referenced as the insert anchor + } + return false + } + // Find the last DOM-producing child that needs a ref (waypoint boundary). + last := -1 + for i := range node.children { + ch := &node.children[i] + if isDOMChild(ch) && need(i, ch) { + last = i + } + } + if last < 0 { + return refs + } + var prev string + for i := 0; i <= last; i++ { + ch := &node.children[i] + if !isDOMChild(ch) { + continue // dynamic child without a marker contributes no DOM node + } + var nav string + if prev == "" { + nav = parentRef + ".firstChild" + } else { + nav = prev + ".nextSibling" + } + v := c.nextEl() + c.decls = append(c.decls, v+" = "+nav) + refs[i] = v + prev = v + } + return refs +} + +func (c *iife) attr(ref string, a jsxAttr) { + switch a.kind { + case attrStatic: + // baked into the template (nothing at runtime) + case attrExpr: + expr := c.g.compileExpr(a.expr) + switch { + case isEventAttr(a.name): + ev := strings.ToLower(a.name[2:]) + c.ops = append(c.ops, fmt.Sprintf("%s.addEventListener(%q, %s)", ref, ev, expr)) + case a.name == "ref": + // A ref that's a function/const (callback) or an inline function must + // only call _$use — emitting the `expr = el` assignment branch would be + // a static "assign to const" error even though it's dead at runtime. + // A mutable let/var (or member) gets the runtime typeof dispatch so the + // element can be assigned to it. + if isLValueExpr(expr) && !c.g.isConstBinding(expr) { + c.ops = append(c.ops, fmt.Sprintf("typeof %s === \"function\" ? %s(%s, %s) : %s = %s", + expr, c.g.helper("use"), expr, ref, expr, ref)) + } else { + c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s)", c.g.helper("use"), expr, ref)) + } + case a.name == "style": + // style accepts a string OR an object ({top: …}); _$style applies + // both (setProperty per key for objects), diffing against the previous + // value. Generic setAttribute would stringify an object to + // "[object Object]" and break positioning. + c.ops = append(c.ops, fmt.Sprintf("%s((_p$) => %s(%s, %s, _p$))", + c.g.helper("effect"), c.g.helper("style"), ref, expr)) + case a.name == "classList": + c.ops = append(c.ops, fmt.Sprintf("%s((_p$) => %s(%s, %s, _p$))", + c.g.helper("effect"), c.g.helper("classList"), ref, expr)) + case contentProps[a.name]: + // innerHTML/textContent/innerText are DOM properties, not reflected + // attributes — setAttribute would silently no-op (e.g. an 's + // innerHTML icon content would never appear). Assign the property. + c.ops = append(c.ops, fmt.Sprintf("%s(() => %s.%s = %s)", + c.g.helper("effect"), ref, a.name, expr)) + default: + c.ops = append(c.ops, fmt.Sprintf("%s(() => %s(%s, %q, %s))", + c.g.helper("effect"), c.g.helper("setAttribute"), ref, a.name, expr)) + } + } +} + +// spread routes every attribute of an element with a {...} through +// _$spread(el, _$mergeProps(...), isSVG=false, skipChildren=true), preserving +// source order so later props override earlier ones (matching babel). +func (c *iife) spread(ref string, node *jsxNode) { + var args []string + var obj []string + flush := func() { + if len(obj) > 0 { + args = append(args, "{ "+strings.Join(obj, ", ")+" }") + obj = nil + } + } + for _, a := range node.attrs { + switch a.kind { + case attrSpread: + flush() + args = append(args, c.g.compileExpr(a.expr)) + case attrStatic: + if a.boolt { + obj = append(obj, fmt.Sprintf("%s: true", propKey(a.name))) + } else { + obj = append(obj, fmt.Sprintf("%s: %s", propKey(a.name), jsStringLit(a.value))) + } + case attrExpr: + obj = append(obj, fmt.Sprintf("get %s() { return %s; }", propKey(a.name), c.g.compileExpr(a.expr))) + } + } + flush() + props := args[0] + if len(args) > 1 { + props = fmt.Sprintf("%s(%s)", c.g.helper("mergeProps"), strings.Join(args, ", ")) + } + c.ops = append(c.ops, fmt.Sprintf("%s(%s, %s, false, true)", c.g.helper("spread"), ref, props)) +} + +func hasSpread(node *jsxNode) bool { + for _, a := range node.attrs { + if a.kind == attrSpread { + return true + } + } + return false +} + +var reLValue = regexp.MustCompile(`^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$`) + +func isLValueExpr(e string) bool { return reLValue.MatchString(strings.TrimSpace(e)) } + +var reSimpleIdent = regexp.MustCompile(`^[A-Za-z_$][\w$]*$`) + +// isConstBinding reports whether a simple-identifier ref target is a +// const/function (a ref callback that must never be assigned to). Only an +// explicit `let`/`var` declaration in the source makes it assignable; everything +// else (const, function, param, unknown) is treated as a callback — the safe +// default, since emitting an assignment to a const is a hard build error while +// use-only is always valid. Member expressions are assignable (return false). +func (g *solidGen) isConstBinding(name string) bool { + name = strings.TrimSpace(name) + if !reSimpleIdent.MatchString(name) { + return false + } + if regexp.MustCompile(`\b(?:let|var)\s+`+regexp.QuoteMeta(name)+`\b`).MatchString(g.source) { + return false + } + return true +} + +func (g *solidGen) genComponent(node *jsxNode) string { + tag := node.tag + props := g.componentProps(node) + return fmt.Sprintf("%s(%s, %s)", g.helper("createComponent"), tag, props) +} + +// componentProps builds the props object: static attrs as plain values, dynamic +// attrs as reactive getters, and children as a `children` prop. +func (g *solidGen) componentProps(node *jsxNode) string { + var parts []string + for _, a := range node.attrs { + switch a.kind { + case attrStatic: + if a.boolt { + parts = append(parts, fmt.Sprintf("%s: true", propKey(a.name))) + } else { + parts = append(parts, fmt.Sprintf("%s: %s", propKey(a.name), jsStringLit(a.value))) + } + case attrExpr: + parts = append(parts, fmt.Sprintf("get %s() { return %s; }", propKey(a.name), g.compileExpr(a.expr))) + case attrSpread: + // milestone 3 (mergeProps) + } + } + if ch := g.childrenProp(node); ch != "" { + parts = append(parts, ch) + } + if len(parts) == 0 { + return "{}" + } + return "{ " + strings.Join(parts, ", ") + " }" +} + +// childrenProp builds a component's `children` prop entry. JSX children are +// wrapped in a `get children()` accessor so they evaluate lazily inside the +// parent component's execution — essential for context providers (children must +// run after the parent sets context) and for correct reactivity. Static text has +// no such need and is passed as a plain value (matching babel). +func (g *solidGen) childrenProp(node *jsxNode) string { + var kids []*jsxNode + for i := range node.children { + ch := &node.children[i] + if !renderedChild(ch) { + continue // drop whitespace-only text and empty expressions + } + kids = append(kids, ch) + } + switch len(kids) { + case 0: + return "" + case 1: + if kids[0].kind == jsxText { + return "children: " + jsStringLit(collapseText(kids[0].text)) + } + return "get children() { return " + g.genNode(kids[0]) + "; }" + default: + parts := make([]string, len(kids)) + for i, k := range kids { + parts[i] = g.genNode(k) + } + return "get children() { return [" + strings.Join(parts, ", ") + "]; }" + } +} + +func (g *solidGen) genFragment(node *jsxNode) string { + var kids []*jsxNode + for i := range node.children { + ch := &node.children[i] + if ch.kind == jsxText && strings.TrimSpace(ch.text) == "" { + continue + } + kids = append(kids, ch) + } + if len(kids) == 1 { + return g.genNode(kids[0]) + } + parts := make([]string, len(kids)) + for i, k := range kids { + if k.kind == jsxExpr { + parts[i] = fmt.Sprintf("%s(() => %s)", g.helper("memo"), g.compileExpr(k.expr)) + } else { + parts[i] = g.genNode(k) + } + } + return "[" + strings.Join(parts, ", ") + "]" +} + +// ---- template building ----------------------------------------------------- + +func buildTemplate(node *jsxNode) string { + var sb strings.Builder + writeTemplate(node, &sb) + return sb.String() +} + +func writeTemplate(node *jsxNode, sb *strings.Builder) { + sb.WriteString("<" + node.tag) + if !hasSpread(node) { // with a spread, all attrs are applied at runtime + for _, a := range node.attrs { + if a.kind == attrStatic { + if a.boolt { + sb.WriteString(" " + a.name) + } else { + sb.WriteString(" " + a.name + `="` + a.value + `"`) + } + } + } + } + sb.WriteString(">") + for i := range node.children { + ch := &node.children[i] + switch ch.kind { + case jsxText: + sb.WriteString(escapeTemplateText(collapseText(ch.text))) + case jsxElement: + writeTemplate(ch, sb) + case jsxExpr, jsxComponent: + if ch.marker { + sb.WriteString("") + } + } + } + sb.WriteString("") +} + +// annotateMarkers sets node.marker on dynamic children that need a `` anchor: +// those with a DOM-producing sibling after them. Recurses into element children. +func annotateMarkers(node *jsxNode) { + after := false + for i := len(node.children) - 1; i >= 0; i-- { + ch := &node.children[i] + switch ch.kind { + case jsxText: + if collapseText(ch.text) != "" { + after = true + } + case jsxElement: + after = true + annotateMarkers(ch) + case jsxExpr, jsxComponent: + if ch.kind == jsxExpr && exprIsEmpty(ch.expr) { + continue // empty/comment expression produces nothing + } + if after { + ch.marker = true // the it emits is itself a DOM node + } + } + } +} + +// ---- reactivity + helpers -------------------------------------------------- + +var reSimpleCall = regexp.MustCompile(`^[A-Za-z_$][\w$]*\(\)$`) + +// wrapReactive wraps a child expression so _$insert treats it reactively. Bare +// accessor calls stay as-is via a thunk; correctness over babel's unwrap opt. +func wrapReactive(expr string) string { + e := strings.TrimSpace(expr) + if isStaticLiteral(e) { + return e + } + return "() => " + e +} + +func isStaticLiteral(e string) bool { + if e == "true" || e == "false" || e == "null" || e == "undefined" { + return true + } + if len(e) >= 2 && (e[0] == '"' || e[0] == '\'' || e[0] == '`') { + return true + } + // number + allNum := true + for i := 0; i < len(e); i++ { + if !(e[i] >= '0' && e[i] <= '9' || e[i] == '.') { + allNum = false + break + } + } + return allNum && e != "" +} + +// contentProps are DOM properties (not reflected attributes) that must be set by +// direct property assignment rather than setAttribute. +var contentProps = map[string]bool{"innerHTML": true, "textContent": true, "innerText": true} + +func isEventAttr(name string) bool { + // An event handler is `on` followed by the event name; casing after `on` is + // irrelevant (onClick and onclick both mean click) — the name is lowercased + // before addEventListener. This matches solid-js/html's runtime, which keys + // off the `on` prefix alone, so .tsx and html`` templates agree. Require a + // letter so namespaced forms (on:click / oncapture:) fall through untouched. + return len(name) > 2 && name[0] == 'o' && name[1] == 'n' && + ((name[2] >= 'A' && name[2] <= 'Z') || (name[2] >= 'a' && name[2] <= 'z')) +} + +// elementNeedsRef reports whether a static element needs a nav var: it has +// runtime ops (dynamic attrs/events/ref) or dynamic children (inserts). +func elementNeedsRef(node *jsxNode) bool { + for _, a := range node.attrs { + if a.kind != attrStatic { + return true + } + } + for i := range node.children { + ch := &node.children[i] + switch ch.kind { + case jsxExpr, jsxComponent: + return true + case jsxElement: + if elementNeedsRef(ch) { + return true + } + } + } + return false +} + +func isDOMChild(ch *jsxNode) bool { + switch ch.kind { + case jsxText: + return collapseText(ch.text) != "" + case jsxElement: + return true + case jsxExpr, jsxComponent: + return ch.marker + } + return false +} + +// collapseText applies JSX whitespace normalization: lines are trimmed and +// joined by a single space; text that is only whitespace-with-newline vanishes. +func collapseText(s string) string { + if strings.TrimSpace(s) == "" { + if strings.ContainsAny(s, "\n") { + return "" + } + return s // significant single-line whitespace (e.g. "a {x} b") + } + if !strings.ContainsAny(s, "\n") { + return s + } + lines := strings.Split(s, "\n") + var kept []string + for _, l := range lines { + l = strings.Trim(l, " \t\r") + if l != "" { + kept = append(kept, l) + } + } + return strings.Join(kept, " ") +} + +func escapeTemplateText(s string) string { + // Templates are raw HTML; escape only the backtick that would end the JS + // template literal and `${` interpolation. + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "`", "\\`") + s = strings.ReplaceAll(s, "${", "\\${") + return s +} + +func propKey(name string) string { + if name == "class" { + return `"class"` + } + for i := 0; i < len(name); i++ { + if !(isASCIILetter(name[i]) || name[i] == '_' || name[i] == '$' || (i > 0 && name[i] >= '0' && name[i] <= '9')) { + return jsStringLit(name) + } + } + return name +} + +func jsStringLit(s string) string { + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\n': + b.WriteString(`\n`) + default: + b.WriteRune(r) + } + } + b.WriteByte('"') + return b.String() +} + +// validateJS checks code with esbuild and returns the first error. It uses Build +// (Bundle:false, so imports are left unresolved) rather than Transform because +// Build runs semantic checks Transform skips — notably assign-to-const, which a +// mis-generated ref would produce and which only surfaces in the real bundle. +func validateJS(code string) error { + res := esbuild.Build(esbuild.BuildOptions{ + Stdin: &esbuild.StdinOptions{Contents: code, Loader: esbuild.LoaderJS}, + Bundle: false, + Write: false, + LogLevel: esbuild.LogLevelSilent, + }) + if len(res.Errors) > 0 { + return fmt.Errorf("%s", res.Errors[0].Text) + } + return nil +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/bundler/compile_solid_render_test.go b/bundler/compile_solid_render_test.go new file mode 100644 index 00000000..38ce4f62 --- /dev/null +++ b/bundler/compile_solid_render_test.go @@ -0,0 +1,257 @@ +package bundler + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// renderComponent bundles a compiled component module (which must `export const A`) +// with a render harness, runs it in the goja SSR engine, and returns the HTML. +func renderComponent(t *testing.T, compiledJS string) (string, error) { + t.Helper() + entry := compiledJS + "\n" + + `import { render as _$r, createComponent as _$cc } from "solid-js/web"; +globalThis.__render = function () { + var root = document.createElement("div"); + var dispose = _$r(function () { return _$cc(A, {}); }, root); + var out = globalThis.__serialize(root); + dispose(); + return out; +};` + bundle, err := BundleEntry(entry, ".") + if err != nil { + return "", err + } + eng, err := New() + if err != nil { + return "", err + } + if err := eng.LoadBundle(bundle); err != nil { + return "", err + } + return eng.Render() +} + +// assertRenderEquivalent compiles src with babel AND the Go compiler, renders +// both, and requires identical HTML. This is the compiler's correctness oracle. +func assertRenderEquivalent(t *testing.T, name, src string) { + t.Helper() + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + t.Chdir(root) + + babelJS, err := Compile(src, name+".tsx") + if err != nil { + t.Fatalf("[%s] babel compile: %v", name, err) + } + goJS, err := compileSolidGo(src, name+".tsx", false) + if err != nil { + t.Fatalf("[%s] go compile: %v\n--- go output ---\n%s", name, err, goJS) + } + + babelHTML, err := renderComponent(t, babelJS) + if err != nil { + t.Fatalf("[%s] render babel: %v", name, err) + } + goHTML, err := renderComponent(t, goJS) + if err != nil { + t.Fatalf("[%s] render go: %v\n--- go output ---\n%s", name, err, goJS) + } + + if babelHTML != goHTML { + t.Errorf("[%s] render mismatch:\n babel: %q\n go: %q\n--- go compiled ---\n%s", name, babelHTML, goHTML, goJS) + } +} + +func TestGoCompilerRenderCore(t *testing.T) { + cases := []struct{ name, src string }{ + {"static", `export const A = () =>
hi
;`}, + {"dyn-text", `export const A = () => { const c = () => 42; return
{c()}
; };`}, + {"nested", `export const A = () => { const x = () => "X"; return
a{x()}
; };`}, + {"mixed-children", `export const A = () => { const x = () => "X"; const y = () => "Y"; return
before {x()} after {y()}
; };`}, + {"dyn-attr", `export const A = () => { const id = () => "foo"; return
hi
; };`}, + {"list", `export const A = () => { const items = ["a", "b", "c"]; return ; };`}, + {"deep-static", `export const A = () =>

Title

body text

;`}, + {"multi-attr", `export const A = () => ;`}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + assertRenderEquivalent(t, c.name, c.src) + }) + } +} + +func TestGoCompilerRenderComponents(t *testing.T) { + cases := []struct{ name, src string }{ + {"component-children", `export const A = () => { const Box = (props) =>
{props.children}
; return hi; };`}, + {"component-dyn-prop", `export const A = () => { const Lbl = (props) => {props.text}; const t = () => "yo"; return ; };`}, + {"nested-components", `export const A = () => { const Row = (props) =>
  • {props.children}
  • ; return
      onetwo
    ; };`}, + {"show-true", `import { Show } from "solid-js"; export const A = () =>
    no

    }>yes
    ;`}, + {"show-false", `import { Show } from "solid-js"; export const A = () =>
    no

    }>yes
    ;`}, + {"for", `import { For } from "solid-js"; export const A = () =>
      {(n) =>
    • {n}
    • }
    ;`}, + {"fragment", `export const A = () => { const a = () => "A"; const b = () => "B"; return
    {a()}{b()}
    ; };`}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { assertRenderEquivalent(t, c.name, c.src) }) + } +} + +func TestGoCompilerRenderSpreadRef(t *testing.T) { + cases := []struct{ name, src string }{ + {"spread", `export const A = () => { const p = { id: "pid", title: "t" }; return
    hi
    ; };`}, + {"spread-override", `export const A = () => { const p = { class: "from-p" }; return
    hi
    ; };`}, + {"ref", `export const A = () => { let r; return
    hi
    ; };`}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { assertRenderEquivalent(t, c.name, c.src) }) + } +} + +// Compile every real .tsx with the Go compiler and assert it produces parseable +// output. This surfaces constructs the codebase uses that the codegen doesn't +// handle yet (the failures ARE the milestone-3/4 gap list). +func TestGoCompilerCompilesRealFiles(t *testing.T) { + files := walkRepoTSX(t) + var failed, ok int + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + continue + } + out, err := compileSolidGo(string(data), f, false) + if err != nil { + failed++ + t.Logf("COMPILE-ERR %s: %v", f, err) + continue + } + if err := validateJS(out); err != nil { + failed++ + t.Logf("PARSE-ERR %s: %v", f, err) + continue + } + ok++ + } + t.Logf("Go compiler: %d/%d real .tsx produced parseable output (%d failed)", ok, len(files), failed) +} + +// M5: dev mode wraps components with solid-refresh. Verify the shape and that +// every real file still produces parseable output with instrumentation on. +func TestGoCompilerRefreshInstrumentation(t *testing.T) { + src := `export function Counter() { const c = () => 1; return
    {c()}
    ; } +export const Banner = () => hi; +const NOT_A_COMPONENT = 42; +function helper() { return 5; }` + out, err := compileSolidGo(src, "Refresh.tsx", true) + if err != nil { + t.Fatalf("dev compile: %v", err) + } + if err := validateJS(out); err != nil { + t.Fatalf("dev output does not parse: %v\n%s", err, out) + } + for _, want := range []string{ + `from "solid-refresh"`, + "const _REGISTRY = _$$registry();", + `_$$component(_REGISTRY, "Counter", function Counter`, + `_$$component(_REGISTRY, "Banner",`, + `if (import.meta.hot) { _$$refresh("esm", import.meta.hot, _REGISTRY); }`, + } { + if !strings.Contains(out, want) { + t.Errorf("dev output missing %q\n--- output ---\n%s", want, out) + } + } + // Non-components must NOT be wrapped. + if strings.Contains(out, `"NOT_A_COMPONENT"`) || strings.Contains(out, `"helper"`) { + t.Errorf("non-component was wrapped:\n%s", out) + } + // Prod mode must have no refresh instrumentation. + prod, _ := compileSolidGo(src, "Refresh.tsx", false) + if strings.Contains(prod, "solid-refresh") { + t.Errorf("prod output leaked refresh instrumentation:\n%s", prod) + } +} + +func TestGoCompilerDevCompilesRealFiles(t *testing.T) { + files := walkRepoTSX(t) + var failed, ok int + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + continue + } + out, err := compileSolidGo(string(data), f, true) + if err != nil { + failed++ + t.Logf("DEV-COMPILE-ERR %s: %v", f, err) + continue + } + if err := validateJS(out); err != nil { + failed++ + t.Logf("DEV-PARSE-ERR %s: %v", f, err) + continue + } + ok++ + } + t.Logf("Go compiler (dev): %d/%d real .tsx parseable with refresh (%d failed)", ok, len(files), failed) +} + +// Regression: a context provider's JSX children must evaluate lazily (inside the +// provider), or a consumer reads the context before it's set. Eager `children:` +// makes Consumer throw "no ctx"; lazy `get children()` renders correctly. +func TestGoCompilerContextChildren(t *testing.T) { + src := `import { createContext, useContext } from "solid-js"; +const Ctx = createContext(); +function Provider(props) { return {props.children}; } +function Consumer() { const v = useContext(Ctx); if (!v) throw new Error("no ctx"); return {v}; } +export const A = () => ;` + root, _ := filepath.Abs("../..") + t.Chdir(root) + goJS, err := compileSolidGo(src, "Ctx.tsx", false) + if err != nil { + t.Fatalf("compile: %v", err) + } + html, err := renderComponent(t, goJS) + if err != nil { + t.Fatalf("render (context leaked?): %v\n--- compiled ---\n%s", err, goJS) + } + if !strings.Contains(html, "ok") { + t.Errorf("expected context value in output, got: %q", html) + } +} + +// Regression: object `style` must go through _$style (setProperty per key), not +// setAttribute (which stringifies to "[object Object]" and breaks positioning); +// innerHTML must be a property assignment (setAttribute is a no-op → empty icons). +func TestGoCompilerStyleAndInnerHTML(t *testing.T) { + root, _ := filepath.Abs("../..") + t.Chdir(root) + cases := []struct{ name, src, want, absent string }{ + {"object-style", `export const A = () => { const s = () => ({ top: "10px", left: "20px" }); return
    x
    ; };`, "10px", "[object Object]"}, + {"innerHTML", "export const A = () => { const h = () => ''; return ; };", "hi`) + if n.kind != jsxElement || n.tag != "div" { + t.Fatalf("kind/tag = %d/%q", n.kind, n.tag) + } + if len(n.attrs) != 1 || n.attrs[0].kind != attrStatic || n.attrs[0].name != "class" || n.attrs[0].value != "x" { + t.Fatalf("attrs = %+v", n.attrs) + } + if len(n.children) != 1 || n.children[0].kind != jsxText || n.children[0].text != "hi" { + t.Fatalf("children = %+v", n.children) + } +} + +func TestParseComponent(t *testing.T) { + n := parseOne(t, `child`) + if n.kind != jsxComponent || n.tag != "Foo" { + t.Fatalf("kind/tag = %d/%q", n.kind, n.tag) + } + if len(n.attrs) != 1 || n.attrs[0].kind != attrExpr || n.attrs[0].name != "bar" || n.attrs[0].expr != "1" { + t.Fatalf("attrs = %+v", n.attrs) + } +} + +func TestParseNested(t *testing.T) { + n := parseOne(t, `
    a{x()}
    `) + if len(n.children) != 2 { + t.Fatalf("want 2 element children, got %d: %+v", len(n.children), n.children) + } + if n.children[0].tag != "span" || n.children[1].tag != "b" { + t.Fatalf("child tags = %q, %q", n.children[0].tag, n.children[1].tag) + } + b := n.children[1] + if len(b.children) != 1 || b.children[0].kind != jsxExpr || b.children[0].expr != "x()" { + t.Fatalf("b children = %+v", b.children) + } +} + +func TestParseFragment(t *testing.T) { + n := parseOne(t, `<>{a()}{b()}`) + if n.kind != jsxFragment || len(n.children) != 2 { + t.Fatalf("fragment: kind=%d children=%d", n.kind, len(n.children)) + } + if n.children[0].expr != "a()" || n.children[1].expr != "b()" { + t.Fatalf("fragment children = %+v", n.children) + } +} + +func TestParseSelfCloseAndBool(t *testing.T) { + n := parseOne(t, ``) + if n.tag != "input" || len(n.children) != 0 { + t.Fatalf("input: tag=%q children=%d", n.tag, len(n.children)) + } + if len(n.attrs) != 2 || !n.attrs[0].boolt || n.attrs[0].name != "disabled" { + t.Fatalf("attrs = %+v", n.attrs) + } + if n.attrs[1].name != "type" || n.attrs[1].value != "text" { + t.Fatalf("type attr = %+v", n.attrs[1]) + } +} + +func TestParseSpread(t *testing.T) { + n := parseOne(t, `
    hi
    `) + if len(n.attrs) != 2 || n.attrs[0].kind != attrSpread || n.attrs[0].expr != "p" { + t.Fatalf("attrs = %+v", n.attrs) + } +} + +// The hardest case: an expression child containing nested JSX AND braces inside +// strings/templates. The parser must capture the whole expression opaquely. +func TestParseExprWithNestedJSX(t *testing.T) { + n := parseOne(t, "
      {items.map((x) =>
    • {x}
    • )}
    ") + if len(n.children) != 1 || n.children[0].kind != jsxExpr { + t.Fatalf("children = %+v", n.children) + } + if n.children[0].expr != "items.map((x) =>
  • {x}
  • )" { + t.Fatalf("expr = %q", n.children[0].expr) + } +} + +func TestCaptureBracesLexer(t *testing.T) { + cases := []struct{ in, want string }{ + {`{a + "}" + b}`, `a + "}" + b`}, + {"{`a${nested}b`}", "`a${nested}b`"}, + {`{ {k: 1} }`, ` {k: 1} `}, + {`{f(/[}]/)}`, `f(/[}]/)`}, + {`{x} rest`, `x`}, + } + for _, c := range cases { + got, next := captureBraces(c.in, 0) + if got != c.want { + t.Errorf("captureBraces(%q) = %q, want %q", c.in, got, c.want) + } + _ = next + } +} diff --git a/bundler/css.go b/bundler/css.go new file mode 100644 index 00000000..e5300b03 --- /dev/null +++ b/bundler/css.go @@ -0,0 +1,91 @@ +package bundler + +// CSS pipeline: compiles the Tailwind entry stylesheet (frontend/css/style.css) +// with the native Go Tailwind v4 engine (tailwind.go — twCompile/scanSources, no +// goja), feeding it the utility-class candidates scanned from source files, then +// minifies via tdewolff/minify. style.css is the single entry/config for both +// bundles — they differ only in which source files are scanned for candidates. + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/tdewolff/minify/v2" + mincss "github.com/tdewolff/minify/v2/css" +) + +// Tailwind source patterns - configured here rather than in CSS so each bundle +// scans only the files it actually needs. The scanner filters by the suffix of +// each pattern, so separate entries are needed for .js and .ts source files. +var twSourcesSPA = []string{ + "../src/**/*.js", + "../src/**/*.ts", + "../src/**/*.jsx", + "../src/**/*.tsx", + "../../internal/handlers/templates/**/*.html", +} +var twSourcesPublic = []string{ + "../../internal/handlers/templates/**/*.html", + // Public pages authored as Solid components in .tsx (SSR'd via goja). + "../src/pages/public/**/*.tsx", + "../src/pages/public/**/*.jsx", + "../src/pages/public/**/*.ts", + "../src/pages/public/**/*.js", + // Shared UI component library. If a public page renders any ui/ component + // (data tables, tabs, icons, etc.), its classes must be in this bundle too, + // so scan the whole library rather than just the env badge / layout. + "../src/ui/**/*.ts", + "../src/ui/**/*.js", +} + +// styleEntry is the Tailwind entry/config, relative to frontendDir. Both the SPA +// and public bundles compile it; include.css (a former one-line passthrough) is gone. +const styleEntry = "css/style.css" + +var m *minify.M + +func init() { + m = minify.New() + m.AddFunc("text/css", mincss.Minify) +} + +func bundleSPACSS() (bundleStats, error) { + return compileCSSBundle("SPA", twSourcesSPA, "bundle.min.css") +} + +func bundlePublicCSS() (bundleStats, error) { + return compileCSSBundle("public", twSourcesPublic, "public.bundle.min.css") +} + +// compileCSSBundle scans twSources for candidates, compiles style.css with the +// official Tailwind compiler, minifies, and writes outName to wwwroot. It prints +// a timing line for the Tailwind step so the compile can be profiled. +func compileCSSBundle(label string, twSources []string, outName string) (bundleStats, error) { + entryPath := filepath.Join(frontendDir, styleEntry) + src, err := os.ReadFile(entryPath) + if err != nil { + return bundleStats{}, fmt.Errorf("reading %s: %w", styleEntry, err) + } + cssDir := filepath.Dir(entryPath) + + candidates := scanSources(cssDir, twSources) + + compiled, count, err := twCompile(string(src), cssDir, candidates) + if err != nil { + return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err) + } + fmt.Printf(" Tailwind (%s): %d candidates, %d utilities compiled\n", + label, len(candidates), count) + + minified, err := m.String("text/css", compiled) + if err != nil { + return bundleStats{}, fmt.Errorf("minifying %s CSS: %w", label, err) + } + + outPath := filepath.Join(outputDir, outName) + if err := os.WriteFile(outPath, []byte(minified), 0644); err != nil { + return bundleStats{}, err + } + return bundleStats{files: len(candidates), bytes: len(minified)}, nil +} diff --git a/bundler/export_shim.go b/bundler/export_shim.go new file mode 100644 index 00000000..9ac4e5e0 --- /dev/null +++ b/bundler/export_shim.go @@ -0,0 +1,89 @@ +package bundler + +// defaultExportShimPlugin bridges two ESM-strictness mismatches that +// existed in the previous custom bundler: +// +// 1. `export default function Name` also emits `export { Name }` so +// callers using `import { Name } from "..."` resolve correctly. +// 2. If the source declares `export function/class/const Name` (or +// `Name`) where Name matches the file's basename, synthesize +// `export { Name as default }` so callers using `import Name from +// "..."` resolve correctly. The old bundler made every IIFE +// return value available as both the named symbol AND the default +// import; standard ESM does not, so esbuild needs the shim until +// callers migrate to named imports throughout. + +import ( + "os" + "path/filepath" + "regexp" + "strings" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +func defaultExportShimPlugin() esbuild.Plugin { + return esbuild.Plugin{ + Name: "default-export-shim", + Setup: func(b esbuild.PluginBuild) { + b.OnLoad(esbuild.OnLoadOptions{Filter: `\.(js|ts)$`}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { + raw, err := os.ReadFile(args.Path) + if err != nil { + return esbuild.OnLoadResult{}, err + } + out := addNamedExportForDefault(string(raw)) + out = addDefaultExportForFilenameMatch(out, args.Path) + loader := esbuild.LoaderJS + if strings.HasSuffix(args.Path, ".ts") { + loader = esbuild.LoaderTS + } + return esbuild.OnLoadResult{Contents: &out, Loader: loader}, nil + }) + }, + } +} + +var reFilenameMatchExport = regexp.MustCompile(`(?m)^export\s+(?:async\s+)?(?:function|class|const|let|var)\s+(\w+)`) +var reExistingDefault = regexp.MustCompile(`(?m)^export\s+default\b`) + +// addDefaultExportForFilenameMatch synthesizes `export { Name as +// default }` if the source declares a top-level export whose name +// matches the file's basename and the file does not already have a +// default export. This preserves the old bundler's behavior where +// `import Foo from "./Foo.js"` resolved against `export function +// Foo(...)`. +func addDefaultExportForFilenameMatch(src, path string) string { + if reExistingDefault.MatchString(src) { + return src + } + base := filepath.Base(path) + base = strings.TrimSuffix(base, filepath.Ext(base)) + if base == "" { + return src + } + for _, m := range reFilenameMatchExport.FindAllStringSubmatch(src, -1) { + if m[1] == base { + return src + "\nexport { " + base + " as default };\n" + } + } + return src +} + +var reExportDefaultDecl = regexp.MustCompile(`(?m)^export\s+default\s+(?:async\s+)?(?:function|class)\s+(\w+)`) + +func addNamedExportForDefault(src string) string { + matches := reExportDefaultDecl.FindAllStringSubmatch(src, -1) + if len(matches) == 0 { + return src + } + names := make([]string, 0, len(matches)) + seen := map[string]bool{} + for _, m := range matches { + if seen[m[1]] { + continue + } + seen[m[1]] = true + names = append(names, m[1]) + } + return src + "\nexport { " + strings.Join(names, ", ") + " };\n" +} diff --git a/bundler/faicons.go b/bundler/faicons.go new file mode 100644 index 00000000..df5a2701 --- /dev/null +++ b/bundler/faicons.go @@ -0,0 +1,201 @@ +package bundler + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// Tree-shaken FontAwesome. Instead of shipping the 41.5 MB `all.min.js` kit and +// looking icons up by runtime string, we scan the app for the icon names it +// actually references and emit a registry of just those icons' SVG data, pulled +// from the FontAwesome SVGs under frontend/icons/. Icons.tsx looks up that +// registry exactly like it used to call FontAwesome.findIconDefinition. + +// FA prefix -> frontend/icons/. cdrateline renders classic far/fas; a Sharp +// project (fasr/fass) would add "fasr": "sharp-regular", "fass": "sharp-solid". +var faStyleDirs = map[string]string{ + "far": "regular", + "fas": "solid", +} + +// faIconsDir holds the FontAwesome SVGs (the kit's svgs-full/, relocated here), +// grouped by style. Only the styles in faStyleDirs are read; the rest are unused. +const faIconsDir = "frontend/icons" + +var faOutFile = filepath.Join(frontendDir, "src", "ui", "generated", "faIcons.ts") + +var ( + // icon="name" / icon: "name" + reIconAttr = regexp.MustCompile(`\bicon\s*(?:=|:)\s*"([a-z0-9][a-z0-9-]*)"`) + // icon={ ... } — dynamic expressions; pull any string literals (ternaries etc.) + reIconBrace = regexp.MustCompile(`\bicon\s*=\s*\{([^}]*)\}`) + reStrLit = regexp.MustCompile(`"([a-z0-9][a-z0-9-]*)"`) + reViewBox = regexp.MustCompile(`viewBox="0 0 ([0-9.]+) ([0-9.]+)"`) + rePathD = regexp.MustCompile(`]*\bd="([^"]+)"`) + // registerIcon("name", …) — custom (non-FontAwesome) icons defined in-app. + reRegisterIcon = regexp.MustCompile(`registerIcon\(\s*"([a-z0-9][a-z0-9-]*)"`) +) + +// generateFAIcons regenerates the icon registry from the FontAwesome kit. It's a +// no-op when the kit isn't present (CI builds use the committed registry). +func generateFAIcons() error { + if _, err := os.Stat(faIconsDir); err != nil { + return nil // SVGs absent — keep the committed registry + } + + names, custom, err := scanIconNames(filepath.Join(frontendDir, "src")) + if err != nil { + return fmt.Errorf("scanning icon names: %w", err) + } + + type entry struct { + key, x, y, w, h, path string + } + var entries []entry + var missing []string + for _, name := range names { + found := false + for prefix, dir := range faStyleDirs { + svg, err := os.ReadFile(filepath.Join(faIconsDir, dir, name+".svg")) + if err != nil { + continue + } + x, y, w, h, d, ok := parseFASvg(string(svg)) + if !ok { + continue + } + entries = append(entries, entry{prefix + ":" + name, x, y, w, h, d}) + found = true + } + if !found { + missing = append(missing, name) + } + } + sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key }) + + var b strings.Builder + b.WriteString("// AUTO-GENERATED by cmd/bundle (generateFAIcons) — do not edit.\n") + b.WriteString("// A tree-shaken subset of FontAwesome: only the icons this app references,\n") + b.WriteString("// as [x, y, width, height, svgPath] keyed by \"prefix:name\" (viewBox inset to FA's\n") + b.WriteString("// 512 design box within the 640 kit canvas). Regenerated each build while\n") + b.WriteString("// frontend/icons/ is present; committed so CI needs no SVGs.\n") + b.WriteString("export const FA_ICONS: Record = {\n") + for _, e := range entries { + b.WriteString(fmt.Sprintf(" %q: [%s, %s, %s, %s, %q],\n", e.key, e.x, e.y, e.w, e.h, e.path)) + } + b.WriteString("};\n") + + if err := os.MkdirAll(filepath.Dir(faOutFile), 0755); err != nil { + return err + } + if err := os.WriteFile(faOutFile, []byte(b.String()), 0644); err != nil { + return err + } + + // A referenced name that's not in the FA kit is either a registered custom icon + // (expected) or an unknown name — almost always a typo (report separately). + var customUsed, unknown []string + for _, name := range missing { + if custom[name] { + customUsed = append(customUsed, name) + } else { + unknown = append(unknown, name) + } + } + + fmt.Printf(" FA icons: %d defs for %d names", len(entries), len(names)) + if len(customUsed) > 0 { + sort.Strings(customUsed) + fmt.Printf(" (%d custom: %s)", len(customUsed), strings.Join(customUsed, ", ")) + } + if len(unknown) > 0 { + sort.Strings(unknown) + fmt.Printf(" (%d unknown, likely typos: %s)", len(unknown), strings.Join(unknown, ", ")) + } + fmt.Println() + return nil +} + +// scanIconNames walks dir once and returns two things: the sorted list of icon +// names referenced anywhere (static `icon="x"`/`icon: "x"` plus string literals +// inside `icon={...}` expressions), and the set of custom icon names registered +// via registerIcon("name", …). The latter lets the caller tell a legitimate +// custom icon apart from a typo when a referenced name isn't in the FA kit. +func scanIconNames(dir string) (names []string, custom map[string]bool, err error) { + set := map[string]bool{} + custom = map[string]bool{} + err = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + switch filepath.Ext(path) { + case ".ts", ".tsx", ".js", ".jsx": + default: + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + s := string(data) + for _, m := range reIconAttr.FindAllStringSubmatch(s, -1) { + set[m[1]] = true + } + for _, bm := range reIconBrace.FindAllStringSubmatch(s, -1) { + for _, sm := range reStrLit.FindAllStringSubmatch(bm[1], -1) { + set[sm[1]] = true + } + } + for _, m := range reRegisterIcon.FindAllStringSubmatch(s, -1) { + custom[m[1]] = true + } + return nil + }) + if err != nil { + return nil, nil, err + } + names = make([]string, 0, len(set)) + for n := range set { + names = append(names, n) + } + sort.Strings(names) + return names, custom, nil +} + +// parseFASvg pulls the concatenated path data (solid/regular icons are +// single-path; joining is safe) and a viewBox out of a FontAwesome kit SVG. +// +// The kit's "full" SVGs keep FA's 512-unit icon design centred inside a 640x640 +// canvas — a uniform 10% margin (square-full/circle/bars all span 64..576). We +// inset the viewBox to that 512 design box so icons render at their intended +// (FA6-equivalent) size instead of ~20% small on the padded canvas. The inset is +// UNIFORM across every icon, so proportions are preserved — a caret stays a small +// glyph. (A per-glyph bounding-box crop was wrong: it can't tell an icon meant to +// fill its box from one deliberately padded, so it blew small glyphs up to fill it.) +func parseFASvg(svg string) (x, y, w, h, path string, ok bool) { + var ds []string + for _, m := range rePathD.FindAllStringSubmatch(svg, -1) { + ds = append(ds, m[1]) + } + if len(ds) == 0 { + return "", "", "", "", "", false + } + vb := reViewBox.FindStringSubmatch(svg) + if vb == nil { + return "", "", "", "", "", false + } + vw, e1 := strconv.ParseFloat(vb[1], 64) + vh, e2 := strconv.ParseFloat(vb[2], 64) + if e1 != nil || e2 != nil { + return "0", "0", vb[1], vb[2], strings.Join(ds, " "), true // unparseable dims — use as-is + } + mx, my := vw/10, vh/10 + return numStr(mx), numStr(my), numStr(vw-2*mx), numStr(vh-2*my), strings.Join(ds, " "), true +} + +func numStr(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) } diff --git a/bundler/genroutes.go b/bundler/genroutes.go new file mode 100644 index 00000000..1c62e097 --- /dev/null +++ b/bundler/genroutes.go @@ -0,0 +1,138 @@ +package bundler + +// Public-route code generation. The single source of truth is the TypeScript +// manifest frontend/src/pages/public/pages.ts. This reads it (via esbuild + +// goja, so it's real evaluation, not fragile text parsing) and generates: +// +// - internal/handlers/public_pages.gen.go Go registry: routes + + +// the page's pre-rendered body (baked here, see genssr.go: writeGoRegistry) +// - frontend/src/pages/public/routes.gen.ts client router maps: route → body +// component (publicRoutes) and route → <title> (publicTitles) +// +// Generated files are committed like the other build artifacts; regenerate by +// running the bundler. + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/dop251/goja" + esbuild "github.com/evanw/esbuild/pkg/api" +) + +const pagesManifest = "src/pages/public/pages.ts" // relative to frontendDir + +type pageDef struct { + Path string `json:"path"` + Module string `json:"module"` // relative to frontend/src + Component string `json:"component"` + Title string `json:"title"` + Dynamic bool `json:"dynamic"` // ISR page: also bake its render JS for request-time data rendering +} + +func generatePublicRoutes() error { + defs, err := loadPageDefs() + if err != nil { + return fmt.Errorf("loading page manifest: %w", err) + } + if err := writeGoRegistry(defs); err != nil { + return fmt.Errorf("writing Go registry: %w", err) + } + if err := writeClientRoutes(defs); err != nil { + return fmt.Errorf("writing client routes: %w", err) + } + fmt.Printf(" Public routes: %d page(s) generated\n", len(defs)) + return nil +} + +// loadPageDefs bundles and evaluates the TS manifest to get the page list. +func loadPageDefs() ([]pageDef, error) { + absCwd, err := filepath.Abs(".") + if err != nil { + return nil, err + } + pagesAbs := filepath.ToSlash(filepath.Join(absCwd, frontendDir, pagesManifest)) + entry := fmt.Sprintf("import { publicPages } from %q;\nglobalThis.__PAGES__ = JSON.stringify(publicPages);", pagesAbs) + + res := esbuild.Build(esbuild.BuildOptions{ + Stdin: &esbuild.StdinOptions{ + Contents: entry, + ResolveDir: absCwd, + Sourcefile: "pages-manifest.js", + Loader: esbuild.LoaderTS, + }, + Bundle: true, + Format: esbuild.FormatIIFE, + Target: esbuild.ES2017, + Platform: esbuild.PlatformNeutral, + LogLevel: esbuild.LogLevelSilent, + Write: false, + }) + if len(res.Errors) > 0 { + msgs := esbuild.FormatMessages(res.Errors, esbuild.FormatMessagesOptions{}) + return nil, fmt.Errorf("esbuild: %s", strings.Join(msgs, "\n")) + } + + vm := goja.New() + if _, err := vm.RunString(string(res.OutputFiles[0].Contents)); err != nil { + return nil, err + } + raw := vm.Get("__PAGES__") + if raw == nil { + return nil, fmt.Errorf("manifest did not export publicPages") + } + var defs []pageDef + if err := json.Unmarshal([]byte(raw.String()), &defs); err != nil { + return nil, err + } + return defs, nil +} + +// goRoute maps a URL pathname to a Go ServeMux pattern. Root needs "/{$}" so +// it matches exactly instead of as a catch-all subtree. +func goRoute(pathname string) string { + if pathname == "/" { + return "/{$}" + } + return pathname +} + +func writeClientRoutes(defs []pageDef) error { + // Dedupe imports by component name (a component may back several routes). + seen := map[string]bool{} + var imports, entries, titles strings.Builder + for _, d := range defs { + if !seen[d.Component] { + seen[d.Component] = true + rel, err := filepath.Rel("pages/public", filepath.FromSlash(d.Module)) + if err != nil { + return err + } + imp := "./" + filepath.ToSlash(rel) + fmt.Fprintf(&imports, "import { %s } from %q;\n", d.Component, imp) + } + fmt.Fprintf(&entries, " %q: %s,\n", d.Path, d.Component) + fmt.Fprintf(&titles, " %q: %q,\n", d.Path, d.Title) + } + + var b strings.Builder + b.WriteString("// Code generated by cmd/bundle; DO NOT EDIT.\n") + b.WriteString("// Source: frontend/src/pages/public/pages.ts\n\n") + b.WriteString("import { JSXElement } from \"solid-js\";\n") + b.WriteString(imports.String()) + b.WriteString("\n// Body component for each public route, keyed by URL pathname. The client\n") + b.WriteString("// router (public.ts) renders these when navigating without a full reload.\n") + b.WriteString("export const publicRoutes: Record<string, () => JSXElement> = {\n") + b.WriteString(entries.String()) + b.WriteString("};\n") + b.WriteString("\n// <title> for each public route, applied by the client router on navigation\n") + b.WriteString("// (the first load gets its title from the server-rendered shell).\n") + b.WriteString("export const publicTitles: Record<string, string> = {\n") + b.WriteString(titles.String()) + b.WriteString("};\n") + + return os.WriteFile(filepath.Join(frontendDir, "src", "pages", "public", "routes.gen.ts"), []byte(b.String()), 0644) +} diff --git a/bundler/genssr.go b/bundler/genssr.go new file mode 100644 index 00000000..ef3886a5 --- /dev/null +++ b/bundler/genssr.go @@ -0,0 +1,154 @@ +package bundler + +// Public-page SSR code generation. For each page in the manifest +// (frontend/src/pages/public/pages.ts) this renders the solid-js/html component +// to its data-free skeleton HTML — at build time, via the same in-package goja +// engine (ssr.go/renderer.go) the server re-runs for ISR — and bakes it into the +// Go registry: +// +// - internal/handlers/public_pages.gen.go routes + <title> + rendered body +// +// The server then just wraps each baked body in the document shell and serves +// it (internal/handlers/public_ssr.go); the browser bundle takes over on load. +// +// The env badge is intentionally absent from the rendered markup: this SSR +// render doesn't define esbuild's __ENV_TYPE__ (see ssr.go), so env.ts reads "" +// and the badge renders nothing here. The client takeover bundle bakes the real +// compile-time environment, so the badge appears after takeover. + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +// writeGoRegistry renders each page's SSR skeleton and writes the Go registry +// (internal/handlers/public_pages.gen.go). Rendering reads frontend/src and +// wwwroot/vendor relative to the project root, so the bundler must run from +// there (it always does). +// +// Rendering is cached per page (tmp/ssr-cache.json) keyed by the page's entry +// source + the content hashes of every file esbuild bundled into it, so a build +// only re-renders pages whose sources actually changed. Cache misses render +// concurrently (one goja runtime per worker). See ssrcache.go. +func writeGoRegistry(defs []pageDef) error { + start := time.Now() + engine := EngineHash() + prev := loadSSRCache() + reuse := prev.Engine == engine // an engine change invalidates every page + hasher := newFileHasher() + next := ssrCache{Engine: engine, Pages: make(map[string]ssrCacheEntry, len(defs))} + + bodies := make([]string, len(defs)) + renderJSs := make([]string, len(defs)) // bundled render entry, baked for dynamic (ISR) pages + entries := make([]string, len(defs)) + cached := make([]bool, len(defs)) + + // First pass (cheap, serial): reuse unchanged pages, collect the rest. + var misses []renderJob + for i, d := range defs { + module := filepath.ToSlash(path.Join("frontend", "src", d.Module)) + entries[i] = ssrEntrySolid(module, d.Component, d.Path) + if reuse { + if ce, ok := prev.Pages[d.Path]; ok && ce.EntryHash == pageEntryHash(entries[i], d.Dynamic) && inputsUnchanged(ce.Inputs, hasher) { + bodies[i] = ce.HTML + renderJSs[i] = ce.RenderJS + cached[i] = true + next.Pages[d.Path] = ce // carry the fingerprint forward + continue + } + } + misses = append(misses, renderJob{idx: i, path: d.Path, component: d.Component, entry: entries[i]}) + } + + // Second pass (parallel): render the misses, then fingerprint their inputs. + results, err := renderMisses(misses) + if err != nil { + return err + } + for _, r := range results { + bodies[r.idx] = r.html + // Bake the bundled render entry only for dynamic pages — the server + // re-runs it with data at request time (ISR), so no esbuild or source + // files are needed at runtime. + js := "" + if defs[r.idx].Dynamic { + js = r.js + } + renderJSs[r.idx] = js + next.Pages[r.path] = ssrCacheEntry{ + EntryHash: pageEntryHash(entries[r.idx], defs[r.idx].Dynamic), + Inputs: hashInputs(r.inputs, hasher), + HTML: r.html, + RenderJS: js, + } + } + + // Emit the registry in manifest order. + var b strings.Builder + b.WriteString("// Code generated by cmd/bundle; DO NOT EDIT.\n") + b.WriteString("// Source: frontend/src/pages/public/pages.ts\n") + b.WriteString("//\n") + b.WriteString("// Each html field is the page's data-free SSR skeleton, rendered from its\n") + b.WriteString("// Solid component at bundle time. The browser bundle re-renders it on load.\n\n") + b.WriteString("package handlers\n\n") + b.WriteString("var publicPages = []publicPage{\n") + for i, d := range defs { + fmt.Fprintf(&b, "\t{route: %q, title: %q, module: %q, component: %q, html: %q, renderJS: %q},\n", goRoute(d.Path), d.Title, d.Module, d.Component, bodies[i], renderJSs[i]) + status := "rendered" + if cached[i] { + status = "cached" + } + fmt.Printf(" %-20s %s (%s, %d bytes)\n", d.Path, d.Component, status, len(bodies[i])) + } + b.WriteString("}\n") + + if err := os.WriteFile(filepath.Join("internal", "handlers", "public_pages.gen.go"), []byte(b.String()), 0644); err != nil { + return err + } + + saveSSRCache(next) + fmt.Printf(" SSR: %d rendered, %d cached in %s\n", len(misses), len(defs)-len(misses), time.Since(start).Round(time.Millisecond)) + return nil +} + +// pageEntryHash keys the render cache. It folds in the page's `dynamic` flag so +// toggling ISR on/off re-renders the page (a dynamic page also bakes its render +// JS, which an entry-only hash wouldn't notice changed). +func pageEntryHash(entry string, dynamic bool) string { + if dynamic { + return hashString(entry + "\x00dynamic") + } + return hashString(entry) +} + +// ssrEntrySolid builds the goja entry for one page: import its body component, +// wrap it in PublicLayout (currentPath = the page's path), render into a detached +// DOM-shim root with solid-js/web's render, and serialize. Mirrors the client +// takeover (public.tsx), which wraps the same body in the same layout — so the +// server markup and post-takeover markup match. +// +// The entry is written as plain JS using createComponent (what compiled Solid JSX +// would emit) rather than JSX, so it needs no transform; the imported .tsx page + +// layout ARE Solid-compiled by the Go-native compiler (Plugin). +// +// esbuild's __ENV_TYPE__ define is not applied to this SSR build, so env.ts +// yields "" and the EnvBadge in PublicLayout renders nothing during SSR; the +// client takeover bundle carries the baked value on load. +func ssrEntrySolid(module, component, currentPath string) string { + return fmt.Sprintf("import { render, createComponent } from \"solid-js/web\";\n"+ + "import { PublicLayout } from \"./frontend/src/pages/public/PublicLayout.tsx\";\n"+ + "import { %[1]s } from \"./%[2]s\";\n"+ + "globalThis.__render = function () {\n"+ + "\tconst root = document.createElement(\"div\");\n"+ + "\tconst dispose = render(function () {\n"+ + "\t\treturn createComponent(PublicLayout, { currentPath: %[3]q, get children() { return createComponent(%[1]s, {}); } });\n"+ + "\t}, root);\n"+ + "\tconst out = globalThis.__serialize(root);\n"+ + "\tdispose();\n"+ + "\treturn out;\n"+ + "};", component, module, currentPath) +} diff --git a/bundler/hmr_browser_test.go b/bundler/hmr_browser_test.go new file mode 100644 index 00000000..42bdf690 --- /dev/null +++ b/bundler/hmr_browser_test.go @@ -0,0 +1,201 @@ +//go:build dev + +package bundler + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +// chromePath returns a headless-capable Chrome binary, or "" if none is found. +func chromePath() string { + candidates := []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + } + for _, name := range []string{"google-chrome", "chromium", "chromium-browser", "google-chrome-stable"} { + if p, err := exec.LookPath(name); err == nil { + candidates = append(candidates, p) + } + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + return "" +} + +// TestBrowserNativeESMRenders proves the whole native-ESM path works in a real +// browser: the import map resolves solid-js to one vendored instance, the module +// server transforms + serves the entry and a .tsx component (solid-refresh +// compiled), and Solid mounts it reactively. The page reports its rendered text +// back to the test via a beacon (robust against headless Chrome's one-shot exit +// quirks). It does NOT assert the interactive hot-swap — that needs a persistent +// CDP session and is the final manual check — but removes the largest +// browser-integration risk. +func TestBrowserNativeESMRenders(t *testing.T) { + chrome := chromePath() + if chrome == "" { + t.Skip("no headless Chrome/Chromium available") + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skip("browser smoke test only wired for macOS/Linux") + } + + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + t.Chdir(root) + frontendAbs, _ := filepath.Abs(frontendDir) + + // Fixtures in a temp src root so the real source tree (and Tailwind scan / + // route gen) is untouched. The component uses JSX (→ solid-refresh) and a + // signal (→ real reactivity); the entry imports it via a `.js` specifier + // (→ .tsx resolution), then beacons back the rendered text. + tmpSrc := evalSymlinks(t.TempDir()) + writeFile(t, filepath.Join(tmpSrc, "Widget.tsx"), + `import { createSignal } from "solid-js"; +export default function Widget() { + const [msg] = createSignal("hello-hmr-rendered"); + return <div id="w">{msg()}</div>; +} +`) + writeFile(t, filepath.Join(tmpSrc, "entry.tsx"), + `import { render } from "solid-js/web"; +import Widget from "./Widget.js"; +render(() => <Widget/>, document.getElementById("app")); +setTimeout(() => { + const el = document.getElementById("w"); + fetch("/__result?text=" + encodeURIComponent(el ? el.textContent : "EMPTY")); +}, 0); +`) + + eps, err := loadVendorManifest(filepath.Join(frontendAbs, "vendor")) + if err != nil { + t.Fatalf("vendor manifest: %v", err) + } + d := &devServer{ + hub: newHub(), + frontend: frontendAbs, + srcRoot: tmpSrc, + vendor: filepath.Join(frontendAbs, "vendor"), + graph: newModuleGraph(), + vendorCache: map[string][]byte{}, + cssTrigger: make(chan struct{}, 1), + vendorEntrypoints: eps, + } + d.importMapJSON = d.buildImportMap() + + rendered := make(chan string, 1) + var diagMu sync.Mutex + var diag []string + mux := http.NewServeMux() + d.register(mux) + mux.HandleFunc("/__result", func(w http.ResponseWriter, r *http.Request) { + select { + case rendered <- r.URL.Query().Get("text"): + default: + } + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("/__diag", func(w http.ResponseWriter, r *http.Request) { + diagMu.Lock() + diag = append(diag, r.URL.Query().Get("text")) + diagMu.Unlock() + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `<!DOCTYPE html><html><head> +<script> +function beacon(p,t){fetch(p+'?text='+encodeURIComponent(t));} +window.addEventListener('error', function(e){beacon('/__diag','ERR:'+((e.error&&e.error.stack)||e.message));}); +window.addEventListener('unhandledrejection', function(e){beacon('/__diag','REJ:'+((e.reason&&e.reason.stack)||String(e.reason)));}); +window.addEventListener('DOMContentLoaded', function(){beacon('/__diag','DOMCONTENTLOADED');}); +</script> +<script type="importmap">%s</script> +</head><body><div id="app"></div> +<script type="module" src="/@src/entry.tsx"></script> +</body></html>`, d.importMapJSON) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + // Sanity-check that every module the page needs serves (200) before the browser. + for _, m := range []string{"/@src/entry.tsx", "/@src/Widget.tsx", "/@hmr/client", + vendorURLPrefix + "solid-js.js", vendorURLPrefix + "solid-js/web.js"} { + resp, err := http.Get(srv.URL + m) + if err != nil { + t.Fatalf("GET %s: %v", m, err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("GET %s -> %d", m, resp.StatusCode) + } + } + + // Launch headless Chrome to load the page; wait for the render beacon rather + // than for Chrome to exit (new headless doesn't reliably one-shot-exit here). + // Use a best-effort temp profile dir (not t.TempDir): Chrome's detached helper + // processes may still be writing to it at cleanup time, and t.TempDir's strict + // RemoveAll would then fail the test. + userDir, _ := os.MkdirTemp("", "hmr-chrome-*") + defer os.RemoveAll(userDir) + devNull, _ := os.Open(os.DevNull) + defer devNull.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, chrome, + "--headless=new", "--disable-gpu", "--no-sandbox", "--no-first-run", + "--user-data-dir="+userDir, + "--disable-background-networking", "--disable-component-update", + "--disable-default-apps", "--disable-sync", "--no-default-browser-check", + srv.URL, + ) + cmd.Stdout = devNull + cmd.Stderr = devNull + if err := cmd.Start(); err != nil { + cancel() + t.Fatalf("start chrome: %v", err) + } + defer func() { cancel(); cmd.Wait() }() + + select { + case text := <-rendered: + if text != "hello-hmr-rendered" { + t.Fatalf("browser rendered %q, want %q", text, "hello-hmr-rendered") + } + t.Log("native-ESM app rendered in headless Chrome (import map + single solid-js + solid-refresh component)") + case <-time.After(25 * time.Second): + diagMu.Lock() + msgs := strings.Join(diag, "\n ") + diagMu.Unlock() + t.Fatalf("timed out waiting for the browser render beacon.\nbrowser diagnostics:\n %s", msgs) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/bundler/hmr_client.go b/bundler/hmr_client.go new file mode 100644 index 00000000..60e602a6 --- /dev/null +++ b/bundler/hmr_client.go @@ -0,0 +1,279 @@ +//go:build dev + +package bundler + +import "net/http" + +// hmrUpdate names one boundary module to re-import at a given version. +type hmrUpdate struct { + Path string `json:"path"` + Timestamp int64 `json:"timestamp"` +} + +// hmrError describes a compile/transform failure surfaced to the browser as a +// full-screen error overlay (Vite-style). Message is the full formatted error +// text (esbuild carries a code frame; the Solid compiler a plain message). +type hmrError struct { + Message string `json:"message"` + File string `json:"file,omitempty"` +} + +// hmrMessage is the WebSocket payload pushed to the browser. +type hmrMessage struct { + Type string `json:"type"` // "update" | "full-reload" | "css-update" | "error" + Updates []hmrUpdate `json:"updates,omitempty"` + Path string `json:"path,omitempty"` // css-update: the stylesheet path + Err *hmrError `json:"err,omitempty"` // error: the compile failure to display +} + +// serveClient serves the HMR client runtime as an ES module. +func (d *devServer) serveClient(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Write([]byte(hmrClientJS)) +} + +// hmrClientJS is the browser runtime: it owns the WebSocket, exposes +// createHotContext (the import.meta.hot the transformed modules bind), and +// applies updates. The accept/dispose/data protocol mirrors Vite's so +// solid-refresh's `esm` path (hot.data + hot.accept(cb) + hot.invalidate) works +// unchanged — a changed boundary is re-imported, and the PREVIOUS instance's +// accept callback runs with the new module namespace, patching the live registry. +// +// It also exports showErrorOverlay/clearErrorOverlay and renders a Vite-style +// full-screen compile-error overlay. A module that fails to transform is served +// as a tiny stub that imports showErrorOverlay and calls it (see errorModule in +// hmr_server.go), so the overlay pops the instant a broken module is imported — +// on first load or on a hot re-import. The overlay auto-clears once a hot-update +// batch completes without any module surfacing an error (see applyUpdates). +const hmrClientJS = ` +// --- module-level HMR state, keyed by base module URL -------------------------- +const hotModulesMap = new Map(); // id -> { id, callbacks: [{fn}] } +const dataMap = new Map(); // id -> persistent data object (survives reloads) +const disposeMap = new Map(); // id -> dispose callback +const declined = new Set(); // ids that opted out of HMR + +export function createHotContext(id) { + if (!dataMap.has(id)) dataMap.set(id, {}); + const existing = hotModulesMap.get(id); + // A fresh instance of this module is registering; clear its accept callbacks + // (applyUpdate has already snapshotted the previous instance's). + if (existing) existing.callbacks = []; + + function pushAccept(fn) { + let mod = hotModulesMap.get(id); + if (!mod) { mod = { id, callbacks: [] }; hotModulesMap.set(id, mod); } + mod.callbacks.push({ fn }); + } + + return { + get data() { return dataMap.get(id); }, + accept(deps, cb) { + // accept() | accept(fn) | accept(deps, fn) — self-accept in every form we use. + if (typeof deps === 'function' || deps == null) pushAccept(deps); + else pushAccept(cb); + }, + dispose(cb) { disposeMap.set(id, cb); }, + prune(cb) { disposeMap.set(id, cb); }, + invalidate() { fullReload(); }, + decline() { declined.add(id); }, + on() {}, off() {}, send() {}, + }; +} + +// --- recompile indicator ------------------------------------------------------- +// A small, non-blocking badge so a recompile doesn't look like a frozen page: +// shown the moment an update arrives and hidden once the new module is imported +// and applied. The +// await below yields the event loop, so the spinner paints and animates while the +// server compiles. +let hmrBusy = 0; +let hmrEl = null; +function hmrIndicator() { + if (hmrEl || typeof document === 'undefined') return hmrEl; + const head = document.head || document.documentElement; + const style = document.createElement('style'); + style.textContent = '@keyframes hmr-spin{to{transform:rotate(360deg)}}'; + head.appendChild(style); + hmrEl = document.createElement('div'); + hmrEl.setAttribute('style', + 'position:fixed;bottom:14px;right:14px;z-index:2147483647;display:none;' + + 'align-items:center;gap:8px;padding:7px 12px;border-radius:9px;' + + 'font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e5e7eb;' + + 'background:rgba(17,24,39,.92);box-shadow:0 6px 18px rgba(0,0,0,.35);' + + 'pointer-events:none;user-select:none'); + hmrEl.innerHTML = + '<span style="width:11px;height:11px;border-radius:50%;display:inline-block;' + + 'border:2px solid rgba(148,163,184,.4);border-top-color:#60a5fa;' + + 'animation:hmr-spin .6s linear infinite"></span><span data-hmr-label></span>'; + (document.body || document.documentElement).appendChild(hmrEl); + return hmrEl; +} +function hmrShow(label) { + const el = hmrIndicator(); + if (!el) return; + const l = el.querySelector('[data-hmr-label]'); + if (l) l.textContent = label || 'recompiling…'; + el.style.display = 'flex'; +} +function hmrBusyStart() { hmrBusy++; hmrShow('recompiling…'); } +function hmrBusyEnd() { hmrBusy = Math.max(0, hmrBusy - 1); if (hmrBusy === 0 && hmrEl) hmrEl.style.display = 'none'; } + +// --- compile-error overlay ----------------------------------------------------- +// A Vite-style full-screen overlay for compile/transform failures. errorEpoch is +// bumped every time an error surfaces; applyUpdates snapshots it around a hot +// batch and clears the overlay only if the batch introduced no new error, so a +// fixed file dismisses the overlay automatically. The overlay lives in a shadow +// root so the app's stylesheet (Tailwind reset et al.) can't restyle it. +let overlayEl = null; +let errorEpoch = 0; +const HMR_OVERLAY_ID = '__hmr-error-overlay'; + +function escapeHTML(s) { + return String(s).replace(/[&<>]/g, function (c) { + return c === '&' ? '&' : c === '<' ? '<' : '>'; + }); +} + +function overlayHTML(message, file) { + const css = + ':host{all:initial}' + + '.backdrop{position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.66);' + + 'display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:6vh 20px;' + + 'font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}' + + '.panel{width:100%;max-width:min(1000px,92vw);margin:auto 0;background:#1b1b1f;color:#e6e6e6;' + + 'border:1px solid #ff5555;border-radius:10px;box-shadow:0 20px 60px rgba(0,0,0,.5);overflow:hidden}' + + '.head{display:flex;align-items:center;gap:10px;padding:12px 14px;background:#2a1416;' + + 'border-bottom:1px solid rgba(255,85,85,.35)}' + + '.badge{color:#ff6b6b;font-weight:700;letter-spacing:.03em;text-transform:uppercase;font-size:12px}' + + '.file{color:#9aa0a6;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + + '.close{margin-left:auto;background:transparent;border:0;color:#9aa0a6;cursor:pointer;' + + 'font-size:16px;line-height:1;padding:4px 7px;border-radius:6px}' + + '.close:hover{color:#fff;background:rgba(255,255,255,.08)}' + + '.body{margin:0;padding:16px;white-space:pre-wrap;word-break:break-word;color:#ffb4b4;' + + 'font-size:13px;max-height:62vh;overflow:auto}' + + '.hint{padding:10px 14px;border-top:1px solid rgba(255,255,255,.06);color:#7c828a;font-size:12px}'; + return '<style>' + css + '</style>' + + '<div class="backdrop">' + + '<div class="panel">' + + '<div class="head">' + + '<span class="badge">Compile Error</span>' + + (file ? '<span class="file">' + escapeHTML(file) + '</span>' : '') + + '<button class="close" title="Dismiss (Esc)">✕</button>' + + '</div>' + + '<pre class="body">' + escapeHTML(message) + '</pre>' + + '<div class="hint">Fix the error and save — this overlay clears automatically.</div>' + + '</div>' + + '</div>'; +} + +export function showErrorOverlay(err) { + errorEpoch++; + if (typeof document === 'undefined') return; + const message = (err && (err.message || err.msg)) || String(err || 'Unknown error'); + const file = (err && err.file) || ''; + console.error('[hmr] compile error' + (file ? ' in ' + file : '') + '\n' + message); + clearErrorOverlay(); + const host = document.createElement('div'); + host.id = HMR_OVERLAY_ID; + const root = host.attachShadow ? host.attachShadow({ mode: 'open' }) : host; + root.innerHTML = overlayHTML(message, file); + const closeBtn = root.querySelector('.close'); + if (closeBtn) closeBtn.addEventListener('click', clearErrorOverlay); + (document.body || document.documentElement).appendChild(host); + overlayEl = host; +} + +export function clearErrorOverlay() { + if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl); + overlayEl = null; +} + +if (typeof document !== 'undefined') { + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && overlayEl) clearErrorOverlay(); + }); +} + +async function applyUpdate(update) { + const id = update.path; + hmrBusyStart(); + try { + if (declined.has(id)) return fullReload(); + const mod = hotModulesMap.get(id); + if (!mod) return fullReload(); // module not tracked yet — reload to be safe + + const callbacks = mod.callbacks; // the live instance's accept callbacks + const disposer = disposeMap.get(id); + if (disposer) { try { await disposer(dataMap.get(id)); } catch (e) { console.error(e); } } + + let newMod; + try { + newMod = await import(id + (id.includes('?') ? '&' : '?') + 't=' + update.timestamp); + } catch (e) { + console.error('[hmr] failed to re-import', id, e); + return fullReload(); + } + for (const cb of callbacks) { + if (cb.fn) { try { cb.fn(newMod); } catch (e) { console.error(e); } } + } + console.log('[hmr] updated', id); + } finally { + hmrBusyEnd(); + } +} + +function updateCSS(path) { + const links = document.querySelectorAll('link[rel="stylesheet"]'); + for (const link of links) { + const url = new URL(link.href, location.href); + if (url.pathname === path) { + const next = link.cloneNode(); + next.href = url.pathname + '?t=' + Date.now(); + next.onload = () => link.remove(); + link.after(next); + console.log('[hmr] css updated', path); + return; + } + } + // The stylesheet isn't linked on this page — the dev server broadcasts CSS + // updates for both the SPA (/bundle.min.css) and public (/public.bundle.min.css) + // bundles to every client, but each page carries only one. An update for the + // other bundle is simply not applicable here, so ignore it. (Forcing a full + // reload instead would defeat the .tsx component HMR that ran moments earlier.) +} + +function fullReload() { hmrShow('reloading…'); location.reload(); } + +// applyUpdates runs a hot-update batch, then clears the error overlay iff no +// module surfaced a compile error while importing (errorEpoch unchanged). Module +// evaluation is synchronous within an import(), so a broken module's +// showErrorOverlay() has already run by the time its applyUpdate resolves — the +// check is race-free. +async function applyUpdates(updates) { + const before = errorEpoch; + for (const u of updates) { await applyUpdate(u); } + if (errorEpoch === before) clearErrorOverlay(); +} + +function handle(raw) { + let msg; + try { msg = JSON.parse(raw); } catch { return; } + switch (msg.type) { + case 'update': applyUpdates(msg.updates || []); break; + case 'css-update': updateCSS(msg.path); break; + case 'full-reload': fullReload(); break; + case 'error': showErrorOverlay(msg.err || {}); break; + } +} + +function connect() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(proto + '://' + location.host + '/@hmr/ws'); + ws.addEventListener('message', (e) => handle(e.data)); + ws.addEventListener('open', () => console.log('[hmr] connected')); + ws.addEventListener('close', () => { console.log('[hmr] connection lost, retrying...'); setTimeout(connect, 1000); }); + ws.addEventListener('error', () => ws.close()); +} +connect(); +` diff --git a/bundler/hmr_client_test.go b/bundler/hmr_client_test.go new file mode 100644 index 00000000..d77fe8d5 --- /dev/null +++ b/bundler/hmr_client_test.go @@ -0,0 +1,32 @@ +//go:build dev + +package bundler + +import ( + "strings" + "testing" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +// hmrClientJS is a hand-written JS blob served to the browser; a syntax slip in it +// silently breaks every hot update, and the headless-browser smoke test is skipped +// off Linux/macOS. Parse it as an ES module here so a bad edit fails at test time, +// and assert the recompile-indicator hooks are wired. +func TestHMRClientJSValid(t *testing.T) { + res := esbuild.Transform(hmrClientJS, esbuild.TransformOptions{ + Loader: esbuild.LoaderJS, + Format: esbuild.FormatESModule, + LogLevel: esbuild.LogLevelSilent, + }) + if len(res.Errors) > 0 { + msgs := esbuild.FormatMessages(res.Errors, esbuild.FormatMessagesOptions{}) + t.Fatalf("HMR client JS failed to parse:\n%s", strings.Join(msgs, "\n")) + } + for _, want := range []string{"createHotContext", "hmrBusyStart", "hmrBusyEnd", "recompiling", + "showErrorOverlay", "clearErrorOverlay", "errorEpoch"} { + if !strings.Contains(hmrClientJS, want) { + t.Errorf("HMR client missing %q", want) + } + } +} diff --git a/bundler/hmr_e2e_test.go b/bundler/hmr_e2e_test.go new file mode 100644 index 00000000..875950b1 --- /dev/null +++ b/bundler/hmr_e2e_test.go @@ -0,0 +1,188 @@ +//go:build dev + +package bundler + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// A `?url` import must resolve to a shim module whose default export is the raw +// /@fs/ file URL — not the file itself (which would fail with "no default export", +// as the pdfjs worker did and cascaded into a stuck loading spinner). +func TestDevServerAssetURL(t *testing.T) { + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + t.Chdir(root) + frontendAbs, _ := filepath.Abs(frontendDir) + + tmpSrc := evalSymlinks(t.TempDir()) + os.WriteFile(filepath.Join(tmpSrc, "uses-worker.tsx"), + []byte(`import u from "pdfjs-dist/build/pdf.worker.min.mjs?url"; +export const url = u;`), 0o644) + + eps, _ := loadVendorManifest(filepath.Join(frontendAbs, "vendor")) + d := &devServer{hub: newHub(), frontend: frontendAbs, srcRoot: tmpSrc, + vendor: filepath.Join(frontendAbs, "vendor"), graph: newModuleGraph(), + vendorCache: map[string][]byte{}, cssTrigger: make(chan struct{}, 1), vendorEntrypoints: eps} + + out, err := d.transformModule(filepath.Join(tmpSrc, "uses-worker.tsx")) + if err != nil { + t.Fatalf("transform: %v", err) + } + const shimURL = "/@url/vendor/pdfjs-dist/build/pdf.worker.min.mjs" + if !strings.Contains(string(out), shimURL) { + t.Fatalf("?url import not rewritten to the shim path %q:\n%s", shimURL, out) + } + + req := httptest.NewRequest("GET", shimURL, nil) + w := httptest.NewRecorder() + d.serveAssetURL(w, req) + body := w.Body.String() + const want = `export default "/@fs/vendor/pdfjs-dist/build/pdf.worker.min.mjs"` + if w.Code != 200 || !strings.Contains(body, want) { + t.Fatalf("shim module = %q (status %d), want default export of the /@fs/ URL", body, w.Code) + } +} + +// resolveSource must mirror esbuild's resolution: a `.js` specifier for a sibling +// .ts/.tsx, extensionless specifiers, and directory index files. +func TestResolveSourceExtensions(t *testing.T) { + dir := t.TempDir() + write := func(rel string) { + p := filepath.Join(dir, rel) + os.MkdirAll(filepath.Dir(p), 0o755) + os.WriteFile(p, []byte("export default 1;"), 0o644) + } + write("Foo.tsx") + write("Bar.ts") + write("baz/index.ts") + write("Real.js") + + cases := []struct{ spec, want string }{ + {"./Foo.js", "Foo.tsx"}, // .js specifier -> sibling .tsx + {"./Foo.tsx", "Foo.tsx"}, // exact + {"./Bar", "Bar.ts"}, // extensionless + {"./baz", "baz/index.ts"}, // directory index + {"./Real.js", "Real.js"}, // real .js wins + {"./missing.js", ""}, // dangling + } + for _, c := range cases { + got := resolveSource(dir, c.spec) + want := "" + if c.want != "" { + want = filepath.Join(dir, filepath.FromSlash(c.want)) + } + if got != want { + t.Errorf("resolveSource(%q) = %q, want %q", c.spec, got, want) + } + } +} + +// End-to-end over the dev server's HTTP surface: the SPA entry, a .tsx component, +// a vendor bundle, the client runtime, the import map, and graph-driven update vs. +// full-reload decisions — all without a database or the full server. +func TestDevServerEndToEnd(t *testing.T) { + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + t.Chdir(root) + + d, err := newDevServer() + if err != nil { + t.Fatalf("newDevServer: %v", err) + } + + get := func(path string) (int, string) { + req := httptest.NewRequest("GET", path, nil) + w := httptest.NewRecorder() + switch { + case strings.HasPrefix(path, srcURLPrefix): + d.serveModule(w, req) + case strings.HasPrefix(path, vendorURLPrefix): + d.serveVendor(w, req) + case path == "/@hmr/client": + d.serveClient(w, req) + } + return w.Code, w.Body.String() + } + + // --- SPA entry: hot bootstrap + bare imports kept + relative imports rewritten + code, app := get(srcURLPrefix + "app.ts") + if code != 200 { + t.Fatalf("app.ts status %d:\n%s", code, app) + } + for _, want := range []string{ + `__createHotContext("/@src/app.ts")`, // hot bootstrap + `/@hmr/client`, // client import injected + `"solid-js/web"`, // bare specifier preserved for import map + `"@solidjs/router"`, // bare specifier preserved + `/@src/routes/app-routes.ts`, // relative import rewritten + `/@src/layouts/AppLayout.ts`, // relative import rewritten + } { + if !strings.Contains(app, want) { + t.Errorf("app.ts missing %q", want) + } + } + + // --- a .tsx component: Solid + solid-refresh instrumentation + code, alerts := get(srcURLPrefix + "ui/Alerts.tsx") + if code != 200 { + t.Fatalf("Alerts.tsx status %d:\n%s", code, alerts) + } + for _, want := range []string{ + `__createHotContext("/@src/ui/Alerts.tsx")`, + `solid-refresh`, // runtime import + "import.meta.hot", // esm HMR accept + } { + if !strings.Contains(alerts, want) { + t.Errorf("Alerts.tsx missing %q", want) + } + } + + // --- vendor: solid-js/web must import "solid-js" externally (single instance) + code, web := get(vendorURLPrefix + "solid-js/web.js") + if code != 200 { + t.Fatalf("vendor solid-js/web status %d", code) + } + if !strings.Contains(web, `"solid-js"`) { + t.Errorf("solid-js/web should keep a bare `solid-js` import (shared instance)") + } + + // --- client runtime is an ES module exporting createHotContext + code, client := get("/@hmr/client") + if code != 200 || !strings.Contains(client, "export function createHotContext") { + t.Errorf("client runtime missing createHotContext (status %d)", code) + } + + // --- import map maps the key vendored specifiers to /@vendor/ URLs + var im struct { + Imports map[string]string `json:"imports"` + } + if err := json.Unmarshal([]byte(d.importMapJSON), &im); err != nil { + t.Fatalf("import map JSON: %v", err) + } + for _, spec := range []string{"solid-js", "solid-js/web", "@solidjs/router", "solid-refresh"} { + if got := im.Imports[spec]; got != vendorURLPrefix+spec+".js" { + t.Errorf("import map[%q] = %q, want %q", spec, got, vendorURLPrefix+spec+".js") + } + } + + // --- graph: editing a routes table (non-boundary, reachable from the entry) + // forces a full reload; editing a .tsx component is a hot update. + routes := filepath.Join(d.srcRoot, "routes", "app-routes.ts") + if _, _, reload := d.graph.invalidate(routes); !reload { + t.Errorf("changing app-routes.ts should force a full reload") + } + alertsPath := filepath.Join(d.srcRoot, "ui", "Alerts.tsx") + if boundaries, _, reload := d.graph.invalidate(alertsPath); reload || len(boundaries) == 0 { + t.Errorf("changing Alerts.tsx should be a hot update, got reload=%v boundaries=%v", reload, boundaries) + } +} diff --git a/bundler/hmr_error_test.go b/bundler/hmr_error_test.go new file mode 100644 index 00000000..05a8d6e3 --- /dev/null +++ b/bundler/hmr_error_test.go @@ -0,0 +1,78 @@ +//go:build dev + +package bundler + +import ( + "errors" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +// errorModule must emit a valid ES module that imports the client's +// showErrorOverlay and calls it with the error text + file, so a failed +// transform pops the compile-error overlay instead of just logging. +func TestErrorModuleShape(t *testing.T) { + src := errorModule("ui/Broken.tsx", errors.New("Unexpected \"}\" at line 3")) + + for _, want := range []string{ + `from "/@hmr/client"`, + "showErrorOverlay", + "__hmrShowError(", + "ui/Broken.tsx", + `Unexpected`, + } { + if !strings.Contains(src, want) { + t.Errorf("errorModule() missing %q:\n%s", want, src) + } + } + + // It must parse as an ES module (the payload is embedded JSON-as-JS). + res := esbuild.Transform(src, esbuild.TransformOptions{ + Loader: esbuild.LoaderJS, Format: esbuild.FormatESModule, LogLevel: esbuild.LogLevelSilent, + }) + if len(res.Errors) > 0 { + msgs := esbuild.FormatMessages(res.Errors, esbuild.FormatMessagesOptions{}) + t.Fatalf("errorModule() is not valid JS:\n%s\n--- source ---\n%s", strings.Join(msgs, "\n"), src) + } +} + +// A source file that fails to compile must be served as the overlay stub (HTTP +// 200, not a 500 that would tear the module graph), so the browser shows the +// error and the next save can recover. +func TestServeModuleCompileErrorServesOverlay(t *testing.T) { + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + t.Chdir(root) + frontendAbs, _ := filepath.Abs(frontendDir) + + tmpSrc := evalSymlinks(t.TempDir()) + // Deliberately broken JSX: an unclosed tag the compiler/esbuild must reject. + os.WriteFile(filepath.Join(tmpSrc, "Broken.tsx"), + []byte("export default function Broken() {\n return <div><span></div>;\n}\n"), 0o644) + + eps, _ := loadVendorManifest(filepath.Join(frontendAbs, "vendor")) + d := &devServer{hub: newHub(), frontend: frontendAbs, srcRoot: tmpSrc, + vendor: filepath.Join(frontendAbs, "vendor"), graph: newModuleGraph(), + vendorCache: map[string][]byte{}, cssTrigger: make(chan struct{}, 1), vendorEntrypoints: eps} + + req := httptest.NewRequest("GET", srcURLPrefix+"Broken.tsx", nil) + w := httptest.NewRecorder() + d.serveModule(w, req) + + if w.Code != 200 { + t.Fatalf("broken module served status %d, want 200 (overlay stub)", w.Code) + } + body := w.Body.String() + for _, want := range []string{"showErrorOverlay", `from "/@hmr/client"`, "Broken.tsx"} { + if !strings.Contains(body, want) { + t.Errorf("overlay stub missing %q:\n%s", want, body) + } + } +} diff --git a/bundler/hmr_server.go b/bundler/hmr_server.go new file mode 100644 index 00000000..8854a547 --- /dev/null +++ b/bundler/hmr_server.go @@ -0,0 +1,497 @@ +//go:build dev + +package bundler + +// The development HMR server: it serves the SPA source tree as unbundled native +// ES modules (transformed on the fly), tracks the module import graph, watches +// the filesystem, and pushes hot-update / reload messages to the browser over a +// WebSocket. Editing a .tsx component swaps it in place (solid-refresh); editing +// a non-boundary module bubbles up to a full page reload. +// +// This is the reason internal/bundler grew a `dev` build tag: the whole HMR +// subsystem (this file, hmr_ws.go, hmr_vendor.go, hmr_client.go, hmr_watch.go, +// and the solid-refresh bits of jsx.go) compiles only under `-tags dev`, so the +// production server and the plain `cmd/bundle` build carry none of it. + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +// devURLPrefix roots the served source tree: GET /@src/<rel> serves the +// transformed module frontend/src/<rel>. +const ( + srcURLPrefix = "/@src/" // transformed source modules + assetURLPrefix = "/@url/" // `?url` shim: a module whose default export is the file URL + fsURLPrefix = "/@fs/" // raw file passthrough (the URL the shim points at) +) + +type devServer struct { + hub *hub + frontend string // abs frontend/ + srcRoot string // abs frontend/src + vendor string // abs frontend/vendor + output string // abs wwwroot/ + + graph *moduleGraph + + importMapJSON string + vendorEntrypoints map[string]string + + vendorMu sync.Mutex + vendorCache map[string][]byte // /@vendor/<spec>.js -> bundled bytes + + cssTrigger chan struct{} // coalesced CSS rebuild requests (buffered, size 1) +} + +// StartDevHMR registers the dev routes on mux, launches the filesystem watcher, +// and returns the import map (JSON object body) the SPA shell must inline so bare +// specifiers resolve to the single vendored copies. Called only from cmd/server's +// `dev`-tagged shim. +func StartDevHMR(mux *http.ServeMux) (importMap string, err error) { + d, err := newDevServer() + if err != nil { + return "", err + } + d.register(mux) + go d.watch() + + fmt.Println("HMR dev server: serving native-ESM source from /@src/, WebSocket at /@hmr/ws") + return d.importMapJSON, nil +} + +// newDevServer constructs the dev server (abs paths, vendor manifest, import map) +// without registering routes or starting the watcher — the split lets tests drive +// the handlers directly. +func newDevServer() (*devServer, error) { + frontendAbs, err := filepath.Abs(frontendDir) + if err != nil { + return nil, err + } + outputAbs, err := filepath.Abs(outputDir) + if err != nil { + return nil, err + } + // esbuild reports realpaths (symlinks resolved), so the roots must be too or + // the containment/URL math breaks on symlinked trees (e.g. macOS /var→/private/var). + frontendAbs = evalSymlinks(frontendAbs) + outputAbs = evalSymlinks(outputAbs) + d := &devServer{ + hub: newHub(), + frontend: frontendAbs, + srcRoot: filepath.Join(frontendAbs, "src"), + vendor: filepath.Join(frontendAbs, "vendor"), + output: outputAbs, + graph: newModuleGraph(), + vendorCache: map[string][]byte{}, + cssTrigger: make(chan struct{}, 1), + } + + eps, err := loadVendorManifest(d.vendor) + if err != nil { + return nil, fmt.Errorf("loading vendor manifest: %w", err) + } + d.vendorEntrypoints = eps + d.importMapJSON = d.buildImportMap() + return d, nil +} + +func (d *devServer) register(mux *http.ServeMux) { + mux.HandleFunc(srcURLPrefix, d.serveModule) + mux.HandleFunc(vendorURLPrefix, d.serveVendor) + mux.HandleFunc(assetURLPrefix, d.serveAssetURL) + mux.HandleFunc(fsURLPrefix, d.serveFS) + mux.HandleFunc("/@hmr/client", d.serveClient) + mux.HandleFunc("/@hmr/ws", d.hub.ServeWS) +} + +// serveModule transforms and serves one source module as native ESM. +func (d *devServer) serveModule(w http.ResponseWriter, r *http.Request) { + rel := strings.TrimPrefix(r.URL.Path, srcURLPrefix) + abs := filepath.Join(d.srcRoot, filepath.FromSlash(rel)) + // Contain the request to the source tree. + if !within(d.srcRoot, abs) { + http.NotFound(w, r) + return + } + if _, statErr := os.Stat(abs); statErr != nil { + http.NotFound(w, r) + return + } + + code, err := d.transformModule(abs) + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + if err != nil { + // Surface the failure as a browser overlay rather than a hard 500 that + // would break the module graph; the next save re-runs the transform. + fmt.Fprintf(os.Stderr, "HMR transform %s: %v\n", rel, err) + w.Write([]byte(errorModule(rel, err))) + return + } + w.Write(code) +} + +// transformModule runs esbuild over a single entry file with all imports marked +// external (so only this file is transformed) and rewritten to dev URLs, then +// prepends the import.meta.hot bootstrap. .tsx/.jsx are Solid+refresh compiled +// via the goja pipeline first. +func (d *devServer) transformModule(abs string) ([]byte, error) { + result := esbuild.Build(esbuild.BuildOptions{ + EntryPoints: []string{abs}, + Bundle: true, + Write: false, + Format: esbuild.FormatESModule, + Platform: esbuild.PlatformBrowser, + Target: esbuild.ES2022, + Sourcemap: esbuild.SourceMapInline, + SourcesContent: esbuild.SourcesContentInclude, + LogLevel: esbuild.LogLevelSilent, + // Same compile-time env define as the production bundle, so env.ts reads + // the baked value under HMR too (see esbuildDefine). + Define: esbuildDefine(), + // Order matters: the Solid/JSX loader and the export shim handle the entry + // file's contents; the external-rewrite resolver must be able to see every + // import, so it runs last (its OnResolve filter is `.*`). + Plugins: []esbuild.Plugin{ + d.solidRefreshLoadPlugin(), + defaultExportShimPlugin(), + d.externalRewritePlugin(), + }, + }) + if len(result.Errors) > 0 { + msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{}) + return nil, fmt.Errorf("%s", strings.Join(msgs, "\n")) + } + if len(result.OutputFiles) == 0 { + return nil, fmt.Errorf("no output") + } + + selfURL := srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, abs)) + prelude := "import { createHotContext as __createHotContext } from \"/@hmr/client\";\n" + + "import.meta.hot = __createHotContext(" + jsString(selfURL) + ");\n" + return append([]byte(prelude), result.OutputFiles[0].Contents...), nil +} + +// solidRefreshLoadPlugin Solid-compiles .tsx/.jsx via the goja pipeline WITH +// solid-refresh instrumentation, passing the src-relative path as the stable id +// solid-refresh keys its HMR registry on (so it survives across recompiles). +func (d *devServer) solidRefreshLoadPlugin() esbuild.Plugin { + return esbuild.Plugin{ + Name: "dev-solid-refresh", + Setup: func(b esbuild.PluginBuild) { + b.OnLoad(esbuild.OnLoadOptions{Filter: `\.(tsx|jsx)$`}, func(a esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { + data, err := os.ReadFile(a.Path) + if err != nil { + return esbuild.OnLoadResult{}, err + } + id := filepath.ToSlash(mustRel(d.srcRoot, a.Path)) + out, err := CompileDev(string(data), id) + if err != nil { + return esbuild.OnLoadResult{}, err + } + // Load as TS, not JS: esbuild only re-emits (and thus rewrites this + // module's external import paths to our /@src/ URLs) when it has to + // transpile. A JS loader leaves the already-JS CompileDev output + // untouched, leaking the raw ./x.js specifiers to the browser. esbuild + // chains solid-refresh's inline sourcemap into its own, so the browser + // still debugs against the original .tsx. + loader := esbuild.LoaderTS + dir := filepath.Dir(a.Path) + return esbuild.OnLoadResult{Contents: &out, Loader: loader, ResolveDir: dir}, nil + }) + }, + } +} + +// externalRewritePlugin marks every non-entry import external and rewrites its +// path to a dev URL: relative/absolute → /@src/<resolved> (recording a graph +// edge), `?url` → /@fs/<file> raw passthrough, bare → left as-is for the import +// map. esbuild emits the returned path verbatim, so we control the URL. +func (d *devServer) externalRewritePlugin() esbuild.Plugin { + return esbuild.Plugin{ + Name: "dev-external-rewrite", + Setup: func(b esbuild.PluginBuild) { + b.OnResolve(esbuild.OnResolveOptions{Filter: `.*`}, func(a esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) { + if a.Kind == esbuild.ResolveEntryPoint { + return esbuild.OnResolveResult{}, nil // let esbuild load the entry + } + spec := a.Path + + // `import x from "...?url"` — emit the referenced file as a raw asset. + if strings.HasSuffix(spec, "?url") { + real := strings.TrimSuffix(spec, "?url") + if target := d.resolveAsset(a.ResolveDir, real); target != "" { + return esbuild.OnResolveResult{Path: target, External: true}, nil + } + } + + // Bare specifier → import map (single vendored copy). + if !strings.HasPrefix(spec, ".") && !filepath.IsAbs(spec) { + return esbuild.OnResolveResult{Path: spec, External: true}, nil + } + + // Relative/absolute → resolve to the real source file and rewrite to + // its /@src/ URL, recording the importer→import edge for HMR. + resolved := resolveSource(a.ResolveDir, spec) + if resolved == "" || !within(d.srcRoot, resolved) { + // Unknown relative import: leave it and let the browser 404 loudly. + return esbuild.OnResolveResult{Path: spec, External: true}, nil + } + if a.Importer != "" { + d.graph.recordEdge(a.Importer, resolved) + } + url := srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, resolved)) + if v := d.graph.versionOf(resolved); v > 0 { + url += fmt.Sprintf("?t=%d", v) + } + return esbuild.OnResolveResult{Path: url, External: true}, nil + }) + }, + } +} + +// resolveAsset resolves a `?url` target (relative to importer, or a vendored +// subpath) to a /@url/ shim-module path rooted at the frontend dir. The shim's +// default export is the raw /@fs/ URL — matching esbuild's file-loader `?url` +// semantics, where `import u from "x?url"` binds u to the asset's URL string, not +// the module's exports. +func (d *devServer) resolveAsset(resolveDir, spec string) string { + var abs string + if strings.HasPrefix(spec, ".") || filepath.IsAbs(spec) { + abs = filepath.Join(resolveDir, spec) + } else { + abs = filepath.Join(d.vendor, filepath.FromSlash(spec)) // vendored subpath + } + if !within(d.frontend, abs) || !fileExists(abs) { + return "" + } + return assetURLPrefix + filepath.ToSlash(mustRel(d.frontend, abs)) +} + +// serveAssetURL serves the `?url` shim module: `export default "<raw file URL>"`. +func (d *devServer) serveAssetURL(w http.ResponseWriter, r *http.Request) { + rel := strings.TrimPrefix(r.URL.Path, assetURLPrefix) + abs := filepath.Join(d.frontend, filepath.FromSlash(rel)) + if !within(d.frontend, abs) || !fileExists(abs) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + fmt.Fprintf(w, "export default %s;\n", jsString(fsURLPrefix+rel)) +} + +// serveFS serves a raw file under the frontend dir (the target of a `?url` shim, +// e.g. the pdfjs worker, which must load from a real URL for import.meta.url). +func (d *devServer) serveFS(w http.ResponseWriter, r *http.Request) { + rel := strings.TrimPrefix(r.URL.Path, fsURLPrefix) + abs := filepath.Join(d.frontend, filepath.FromSlash(rel)) + if !within(d.frontend, abs) || !fileExists(abs) { + http.NotFound(w, r) + return + } + // Pin the JS MIME so module workers (.mjs) aren't rejected for a text/plain type. + switch strings.ToLower(filepath.Ext(abs)) { + case ".mjs", ".js": + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + } + w.Header().Set("Cache-Control", "no-cache") + http.ServeFile(w, r, abs) +} + +// ---- module graph ---------------------------------------------------------- + +// moduleGraph tracks importer→import edges and per-module versions so a change +// can be propagated to the nearest self-accepting boundary (a .tsx/.jsx compiled +// with solid-refresh) or, failing that, a full reload. Keys are absolute paths. +type moduleGraph struct { + mu sync.Mutex + importers map[string]map[string]bool // module -> set of modules that import it + version map[string]int64 // module -> current version (0 = never changed) + counter int64 +} + +func newModuleGraph() *moduleGraph { + return &moduleGraph{importers: map[string]map[string]bool{}, version: map[string]int64{}} +} + +func (g *moduleGraph) recordEdge(importer, imported string) { + g.mu.Lock() + defer g.mu.Unlock() + set := g.importers[imported] + if set == nil { + set = map[string]bool{} + g.importers[imported] = set + } + set[importer] = true +} + +func (g *moduleGraph) versionOf(module string) int64 { + g.mu.Lock() + defer g.mu.Unlock() + return g.version[module] +} + +// invalidate walks up from the changed file to the nearest HMR boundaries, +// bumping the version of every module on the path (so a re-imported boundary +// re-fetches the changed dep, not a cached copy). It returns the boundary URLs to +// re-import and the shared version, or fullReload if the change reaches a +// non-accepting root (the entry, a routes table). +func (g *moduleGraph) invalidate(changed string) (boundaries []string, version int64, fullReload bool) { + g.mu.Lock() + defer g.mu.Unlock() + + g.counter++ + v := g.counter + visited := map[string]bool{} + queue := []string{changed} + for len(queue) > 0 { + m := queue[0] + queue = queue[1:] + if visited[m] { + continue + } + visited[m] = true + g.version[m] = v + + if isHMRBoundary(m) { + boundaries = append(boundaries, m) + continue // don't climb past a self-accepting boundary + } + imps := g.importers[m] + if len(imps) == 0 { + fullReload = true // reached a root that can't accept -> reload + continue + } + for imp := range imps { + queue = append(queue, imp) + } + } + if fullReload { + return nil, v, true + } + return boundaries, v, false +} + +// isHMRBoundary reports whether a module can self-accept — only solid-refresh +// -instrumented JSX (.tsx/.jsx). .ts/.js (incl. the solid-js/html app pages) +// bubble to a full reload. +func isHMRBoundary(path string) bool { + switch filepath.Ext(path) { + case ".tsx", ".jsx": + return true + default: + return false + } +} + +// ---- helpers --------------------------------------------------------------- + +// resolveSource replicates esbuild's resolution of a relative import to a real +// source file, including the project's `.js`-specifier-for-a-.ts-file convention +// (see import_check.go) and extensionless / index resolution. +func resolveSource(resolveDir, spec string) string { + base := filepath.Join(resolveDir, filepath.FromSlash(spec)) + if fileExists(base) { + return base + } + // `./Foo.js` may name a sibling .ts/.tsx/.jsx (esbuild resolves it silently). + if strings.HasSuffix(base, ".js") { + stem := strings.TrimSuffix(base, ".js") + for _, ext := range []string{".ts", ".tsx", ".jsx"} { + if fileExists(stem + ext) { + return stem + ext + } + } + } + // Extensionless specifier. + for _, ext := range []string{".ts", ".tsx", ".jsx", ".js"} { + if fileExists(base + ext) { + return base + ext + } + } + // Directory index. + for _, ext := range []string{".ts", ".tsx", ".jsx", ".js"} { + if idx := filepath.Join(base, "index"+ext); fileExists(idx) { + return idx + } + } + return "" +} + +func fileExists(p string) bool { + info, err := os.Stat(p) + return err == nil && !info.IsDir() +} + +// evalSymlinks resolves symlinks so paths compare equal to esbuild's realpaths; +// returns the input unchanged if it can't be resolved (e.g. doesn't exist yet). +func evalSymlinks(p string) string { + if r, err := filepath.EvalSymlinks(p); err == nil { + return r + } + return p +} + +// within reports whether abs is inside root (after cleaning), guarding against +// `..` traversal out of the served tree. +func within(root, abs string) bool { + rel, err := filepath.Rel(root, abs) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func mustRel(root, abs string) string { + rel, err := filepath.Rel(root, abs) + if err != nil { + return abs + } + return rel +} + +// jsString renders s as a double-quoted JS string literal. +func jsString(s string) string { + b := strings.Builder{} + b.WriteByte('"') + for _, r := range s { + switch r { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\n': + b.WriteString(`\n`) + default: + b.WriteRune(r) + } + } + b.WriteByte('"') + return b.String() +} + +// errorModule returns an ES module that surfaces a compile/transform failure as +// a full-screen overlay in the browser (Vite-style) instead of breaking the page +// hard. It imports the HMR client's showErrorOverlay and calls it with the error +// text; because the stub evaluates synchronously as part of the importing +// boundary's module graph, the overlay appears the moment a broken module is +// (re-)imported — on first load or on a hot re-import. A later successful hot +// update clears it (see applyUpdates in hmr_client.go). rel is the src-relative +// path of the failing module, shown in the overlay header. +func errorModule(rel string, err error) string { + payload, jerr := json.Marshal(hmrError{Message: err.Error(), File: rel}) + if jerr != nil { + return "console.error(" + jsString("[hmr] build error:\n"+err.Error()) + ");\n" + } + return "import { showErrorOverlay as __hmrShowError } from \"/@hmr/client\";\n" + + "__hmrShowError(" + string(payload) + ");\n" +} diff --git a/bundler/hmr_vendor.go b/bundler/hmr_vendor.go new file mode 100644 index 00000000..c1a89de3 --- /dev/null +++ b/bundler/hmr_vendor.go @@ -0,0 +1,118 @@ +//go:build dev + +package bundler + +// Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router, +// solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/, +// wired up by an import map the SPA shell inlines. Because each specifier maps to +// exactly one URL, and every vendor bundle re-externalizes the OTHER vendored +// specifiers (rather than inlining them), solid-js stays a single runtime +// instance across the whole app — the invariant the prod bundler also guards. + +import ( + "encoding/json" + "fmt" + "net/http" + "path/filepath" + "strings" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +const vendorURLPrefix = "/@vendor/" + +// buildImportMap returns the JSON `{"imports": {...}}` body the SPA shell inlines +// in <script type="importmap">, mapping every vendored bare specifier to its +// /@vendor/ URL. +func (d *devServer) buildImportMap() string { + imports := make(map[string]string, len(d.vendorEntrypoints)) + for spec := range d.vendorEntrypoints { + imports[spec] = vendorURLPrefix + spec + ".js" + } + body, err := json.MarshalIndent(map[string]any{"imports": imports}, "", " ") + if err != nil { + return `{"imports":{}}` + } + return string(body) +} + +// serveVendor serves a pre-bundled vendored package (lazily built + cached). +func (d *devServer) serveVendor(w http.ResponseWriter, r *http.Request) { + spec := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, vendorURLPrefix), ".js") + relFile, ok := d.vendorEntrypoints[spec] + if !ok { + http.NotFound(w, r) + return + } + + d.vendorMu.Lock() + bundled, cached := d.vendorCache[r.URL.Path] + d.vendorMu.Unlock() + + if !cached { + b, err := d.bundleVendor(relFile) + if err != nil { + http.Error(w, "vendor bundle failed: "+err.Error(), http.StatusInternalServerError) + return + } + d.vendorMu.Lock() + d.vendorCache[r.URL.Path] = b + d.vendorMu.Unlock() + bundled = b + } + + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + // Vendor rarely changes; let the browser cache within a session. + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(bundled) +} + +// bundleVendor bundles one vendored package entrypoint into a single ESM file, +// re-externalizing the OTHER vendored specifiers so they resolve (once) through +// the import map. Mirrors the prod vendor resolution (NodePaths + development +// condition) so the dev copy matches the shipped one. +func (d *devServer) bundleVendor(relFile string) ([]byte, error) { + entry := filepath.Join(d.vendor, filepath.FromSlash(relFile)) + result := esbuild.Build(esbuild.BuildOptions{ + EntryPoints: []string{entry}, + Bundle: true, + Write: false, + Format: esbuild.FormatESModule, + Target: esbuild.ES2022, + Platform: esbuild.PlatformBrowser, + Conditions: []string{"development"}, + NodePaths: []string{d.vendor}, + Sourcemap: esbuild.SourceMapInline, + LogLevel: esbuild.LogLevelSilent, + Plugins: []esbuild.Plugin{d.vendorSharedExternalPlugin()}, + }) + if len(result.Errors) > 0 { + msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{}) + return nil, fmt.Errorf("%s", strings.Join(msgs, "\n")) + } + if len(result.OutputFiles) == 0 { + return nil, fmt.Errorf("no output") + } + return result.OutputFiles[0].Contents, nil +} + +// vendorSharedExternalPlugin marks a bare import external ONLY when it's an exact +// vendored entrypoint (thus resolvable via the import map). That keeps shared +// singletons — above all solid-js — as one instance across every vendor bundle, +// while deep subpaths and non-vendored transitive deps get bundled in. +func (d *devServer) vendorSharedExternalPlugin() esbuild.Plugin { + return esbuild.Plugin{ + Name: "dev-vendor-shared-external", + Setup: func(b esbuild.PluginBuild) { + b.OnResolve(esbuild.OnResolveOptions{Filter: `^[^./]`}, func(a esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) { + if a.Kind == esbuild.ResolveEntryPoint { + return esbuild.OnResolveResult{}, nil + } + if _, ok := d.vendorEntrypoints[a.Path]; ok { + return esbuild.OnResolveResult{Path: a.Path, External: true}, nil + } + return esbuild.OnResolveResult{}, nil // bundle via NodePaths + }) + }, + } +} diff --git a/bundler/hmr_watch.go b/bundler/hmr_watch.go new file mode 100644 index 00000000..f385198c --- /dev/null +++ b/bundler/hmr_watch.go @@ -0,0 +1,158 @@ +//go:build dev + +package bundler + +// The dev watcher: a lightweight mtime poll over frontend/src and frontend/css +// (no external dependency). A source-module change is turned into an HMR update +// or a full reload via the module graph; a CSS/Tailwind change triggers a +// (coalesced) stylesheet rebuild and hot-swap. The public-route manifest is +// regenerated on edit, then the page reloads. + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// pollInterval is the mtime scan cadence. Kept tight so save→update latency isn't +// dominated by detection lag: the WalkDir+Stat over frontend/src+css is sub-ms, so +// scanning this often is cheap. (For zero detection lag, swap the poll for native +// FS events — deliberately avoided to keep the watcher dependency-free.) +const pollInterval = 50 * time.Millisecond + +func (d *devServer) watch() { + go d.cssLoop() + + roots := []string{d.srcRoot, filepath.Join(d.frontend, "css")} + mtimes := map[string]time.Time{} + d.scan(roots, mtimes, nil) // seed: record current state, emit nothing + + for { + time.Sleep(pollInterval) + var changed []string + d.scan(roots, mtimes, func(p string) { changed = append(changed, p) }) + if len(changed) > 0 { + d.handleChanges(changed) + } + } +} + +// scan walks roots, calling onchange for every file whose mtime advanced since +// the last scan (or is new). mtimes is updated in place. +func (d *devServer) scan(roots []string, mtimes map[string]time.Time, onchange func(string)) { + for _, root := range roots { + filepath.WalkDir(root, func(p string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return nil + } + switch filepath.Ext(p) { + case ".ts", ".tsx", ".js", ".jsx", ".css": + default: + return nil + } + info, err := entry.Info() + if err != nil { + return nil + } + mt := info.ModTime() + if prev, ok := mtimes[p]; !ok || mt.After(prev) { + mtimes[p] = mt + // onchange is nil on the seed pass, so nothing is reported until the + // baseline is recorded; afterwards every new/changed file is reported. + if onchange != nil { + onchange(p) + } + } + return nil + }) + } +} + +func (d *devServer) handleChanges(changed []string) { + pagesManifestAbs := filepath.Join(d.frontend, filepath.FromSlash(pagesManifest)) + cssDirty := false + srcDirty := false + + for _, p := range changed { + base := filepath.Base(p) + // Generated files are written by the tools below; ignore to avoid loops. + if strings.HasSuffix(base, ".gen.ts") || strings.HasSuffix(base, ".gen.go") { + continue + } + + if p == pagesManifestAbs { + // The public-page manifest drives generated route tables — regenerate, + // then reload (these pages are live-reload scope, not component HMR). + if err := generatePublicRoutes(); err != nil { + fmt.Fprintf(os.Stderr, "[hmr] regenerating public routes: %v\n", err) + } + d.hub.broadcastJSON(hmrMessage{Type: "full-reload"}) + return + } + + switch filepath.Ext(p) { + case ".css": + cssDirty = true + case ".ts", ".tsx", ".js", ".jsx": + d.hmrJS(p) + cssDirty = true // a class may have been added/removed + srcDirty = true + } + } + + if cssDirty { + select { + case d.cssTrigger <- struct{}{}: + default: // a rebuild is already queued + } + } + if srcDirty { + // Proactively re-render the public pages' SSR so the no-JS ("static") + // version served on the next reload is already current, not rendered lazily. + RewarmDevPublicPages() + } +} + +// hmrJS turns one changed source module into a hot update or a full reload. +func (d *devServer) hmrJS(abs string) { + rel := filepath.ToSlash(mustRel(d.srcRoot, abs)) + boundaries, version, reload := d.graph.invalidate(abs) + if reload { + fmt.Printf("[hmr] full reload (%s)\n", rel) + d.hub.broadcastJSON(hmrMessage{Type: "full-reload"}) + return + } + if len(boundaries) == 0 { + return // not on the current page's graph — nothing to do + } + + updates := make([]hmrUpdate, 0, len(boundaries)) + for _, b := range boundaries { + updates = append(updates, hmrUpdate{ + Path: srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, b)), + Timestamp: version, + }) + } + fmt.Printf("[hmr] update %s -> %d boundary(ies)\n", rel, len(boundaries)) + d.hub.broadcastJSON(hmrMessage{Type: "update", Updates: updates}) +} + +// cssLoop serializes and coalesces CSS rebuilds, hot-swapping the stylesheet when +// each completes. +func (d *devServer) cssLoop() { + for range d.cssTrigger { + if _, err := bundleSPACSS(); err != nil { + fmt.Fprintf(os.Stderr, "[hmr] CSS rebuild failed: %v\n", err) + } else { + d.hub.broadcastJSON(hmrMessage{Type: "css-update", Path: "/bundle.min.css"}) + } + // Public pages hot-reload too, off their own stylesheet. + if _, err := bundlePublicCSS(); err != nil { + fmt.Fprintf(os.Stderr, "[hmr] public CSS rebuild failed: %v\n", err) + } else { + d.hub.broadcastJSON(hmrMessage{Type: "css-update", Path: "/public.bundle.min.css"}) + } + } +} diff --git a/bundler/hmr_ws.go b/bundler/hmr_ws.go new file mode 100644 index 00000000..31df61f1 --- /dev/null +++ b/bundler/hmr_ws.go @@ -0,0 +1,235 @@ +//go:build dev + +package bundler + +// A minimal RFC 6455 WebSocket server — just enough for one-way server→browser +// push of HMR messages. We hand-roll it (rather than add a dependency) because +// the surface we need is tiny: the handshake, unmasked server text frames, and a +// read loop that answers pings and notices close. No per-message compression, no +// fragmentation, no client→server application data. All of internal/bundler's +// HMR support is behind `//go:build dev`, so prod builds compile none of it. + +import ( + "bufio" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "sync" +) + +// wsGUID is the RFC 6455 magic value concatenated with Sec-WebSocket-Key to +// derive the accept token. +const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +type hub struct { + mu sync.Mutex + clients map[*wsClient]struct{} +} + +func newHub() *hub { return &hub{clients: map[*wsClient]struct{}{}} } + +type wsClient struct { + conn net.Conn + brw *bufio.ReadWriter + wmu sync.Mutex // serialize frame writes across broadcast + pong +} + +// ServeWS upgrades an HTTP/1.1 request to a WebSocket and registers the client. +func (h *hub) ServeWS(w http.ResponseWriter, r *http.Request) { + key := r.Header.Get("Sec-WebSocket-Key") + if key == "" { + http.Error(w, "expected a WebSocket handshake", http.StatusBadRequest) + return + } + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "connection does not support hijacking", http.StatusInternalServerError) + return + } + conn, brw, err := hj.Hijack() + if err != nil { + return + } + + accept := computeAccept(key) + if _, err := brw.WriteString( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + accept + "\r\n\r\n", + ); err != nil { + conn.Close() + return + } + if err := brw.Flush(); err != nil { + conn.Close() + return + } + + c := &wsClient{conn: conn, brw: brw} + h.mu.Lock() + h.clients[c] = struct{}{} + h.mu.Unlock() + + go c.readLoop(h) +} + +// broadcast writes a text frame to every connected client, dropping any that +// error (disconnected tab). +func (h *hub) broadcast(payload []byte) { + h.mu.Lock() + clients := make([]*wsClient, 0, len(h.clients)) + for c := range h.clients { + clients = append(clients, c) + } + h.mu.Unlock() + + for _, c := range clients { + if err := c.writeText(payload); err != nil { + h.drop(c) + } + } +} + +// broadcastJSON marshals v and broadcasts it as a text frame. +func (h *hub) broadcastJSON(v any) { + b, err := json.Marshal(v) + if err != nil { + fmt.Fprintf(os.Stderr, "devhmr: marshal ws message: %v\n", err) + return + } + h.broadcast(b) +} + +func (h *hub) drop(c *wsClient) { + h.mu.Lock() + if _, ok := h.clients[c]; ok { + delete(h.clients, c) + c.conn.Close() + } + h.mu.Unlock() +} + +// clientCount reports how many browsers are currently connected. +func (h *hub) clientCount() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.clients) +} + +// readLoop consumes client frames only to answer pings and to notice a close or +// dead connection, at which point the client is dropped. Application data from +// the client is ignored — this channel is server→browser only. +func (c *wsClient) readLoop(h *hub) { + defer h.drop(c) + for { + op, payload, err := readFrame(c.brw.Reader) + if err != nil { + return + } + switch op { + case opClose: + c.writeFrame(opClose, payload) + return + case opPing: + if c.writeFrame(opPong, payload) != nil { + return + } + } + } +} + +func (c *wsClient) writeText(payload []byte) error { return c.writeFrame(opText, payload) } + +// opcodes we handle. +const ( + opText byte = 0x1 + opClose byte = 0x8 + opPing byte = 0x9 + opPong byte = 0xA +) + +// writeFrame writes a single unmasked, unfragmented frame (server frames must +// not be masked). Writes are serialized so a broadcast and a pong can't interleave. +func (c *wsClient) writeFrame(opcode byte, payload []byte) error { + c.wmu.Lock() + defer c.wmu.Unlock() + + header := make([]byte, 0, 10) + header = append(header, 0x80|opcode) // FIN + opcode + n := len(payload) + switch { + case n <= 125: + header = append(header, byte(n)) + case n <= 0xFFFF: + header = append(header, 126, byte(n>>8), byte(n)) + default: + header = append(header, 127) + for i := 7; i >= 0; i-- { + header = append(header, byte(n>>(8*i))) + } + } + if _, err := c.brw.Write(header); err != nil { + return err + } + if _, err := c.brw.Write(payload); err != nil { + return err + } + return c.brw.Flush() +} + +// readFrame reads one frame, unmasking the client payload (client→server frames +// are always masked). Returns the opcode and payload. +func readFrame(r *bufio.Reader) (opcode byte, payload []byte, err error) { + var h [2]byte + if _, err = io.ReadFull(r, h[:]); err != nil { + return + } + opcode = h[0] & 0x0F + masked := h[1]&0x80 != 0 + n := int(h[1] & 0x7F) + switch n { + case 126: + var ext [2]byte + if _, err = io.ReadFull(r, ext[:]); err != nil { + return + } + n = int(ext[0])<<8 | int(ext[1]) + case 127: + var ext [8]byte + if _, err = io.ReadFull(r, ext[:]); err != nil { + return + } + n = 0 + for _, b := range ext { + n = n<<8 | int(b) + } + } + var mask [4]byte + if masked { + if _, err = io.ReadFull(r, mask[:]); err != nil { + return + } + } + payload = make([]byte, n) + if _, err = io.ReadFull(r, payload); err != nil { + return + } + if masked { + for i := range payload { + payload[i] ^= mask[i%4] + } + } + return +} + +// computeAccept derives the Sec-WebSocket-Accept response header from the key. +func computeAccept(key string) string { + s := sha1.Sum([]byte(key + wsGUID)) + return base64.StdEncoding.EncodeToString(s[:]) +} diff --git a/bundler/hmr_ws_integration_test.go b/bundler/hmr_ws_integration_test.go new file mode 100644 index 00000000..c143ba28 --- /dev/null +++ b/bundler/hmr_ws_integration_test.go @@ -0,0 +1,88 @@ +//go:build dev + +package bundler + +import ( + "bufio" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// A real TCP client completes the WebSocket handshake against the hub (exercising +// the http.Hijacker path a recorder can't), then receives a broadcast frame. This +// covers the end-to-end push channel the browser HMR client relies on. +func TestWebSocketHandshakeAndBroadcast(t *testing.T) { + h := newHub() + mux := http.NewServeMux() + mux.HandleFunc("/@hmr/ws", h.ServeWS) + srv := httptest.NewServer(mux) + defer srv.Close() + + conn, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://")) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + _, err = conn.Write([]byte( + "GET /@hmr/ws HTTP/1.1\r\n" + + "Host: " + srv.Listener.Addr().String() + "\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n", + )) + if err != nil { + t.Fatalf("write handshake: %v", err) + } + + br := bufio.NewReader(conn) + status, err := br.ReadString('\n') + if err != nil || !strings.Contains(status, "101") { + t.Fatalf("expected 101 Switching Protocols, got %q (err %v)", status, err) + } + var acceptOK bool + for { + line, err := br.ReadString('\n') + if err != nil { + t.Fatalf("reading headers: %v", err) + } + if strings.HasPrefix(line, "Sec-WebSocket-Accept:") && + strings.Contains(line, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") { + acceptOK = true + } + if line == "\r\n" { + break + } + } + if !acceptOK { + t.Fatal("missing/incorrect Sec-WebSocket-Accept header") + } + + // The client must be registered before we broadcast. + deadline := time.Now().Add(2 * time.Second) + for h.clientCount() == 0 && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + if h.clientCount() != 1 { + t.Fatalf("hub client count = %d, want 1", h.clientCount()) + } + + h.broadcastJSON(hmrMessage{Type: "full-reload"}) + + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + op, payload, err := readFrame(br) + if err != nil { + t.Fatalf("reading broadcast frame: %v", err) + } + if op != opText { + t.Errorf("broadcast opcode = %#x, want text", op) + } + if !strings.Contains(string(payload), `"full-reload"`) { + t.Errorf("broadcast payload = %q, want full-reload message", payload) + } +} diff --git a/bundler/hmr_ws_test.go b/bundler/hmr_ws_test.go new file mode 100644 index 00000000..2a5e6c62 --- /dev/null +++ b/bundler/hmr_ws_test.go @@ -0,0 +1,67 @@ +//go:build dev + +package bundler + +import ( + "bufio" + "bytes" + "testing" +) + +// The canonical handshake vector from RFC 6455 §1.3. +func TestComputeAccept(t *testing.T) { + got := computeAccept("dGhlIHNhbXBsZSBub25jZQ==") + const want = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" + if got != want { + t.Fatalf("computeAccept = %q, want %q", got, want) + } +} + +// A masked client frame round-trips through readFrame (payload unmasked, opcode +// preserved) — the path exercised when the browser sends a ping or close. +func TestReadFrameMaskedText(t *testing.T) { + payload := []byte("hello hmr") + mask := [4]byte{0x12, 0x34, 0x56, 0x78} + + var buf bytes.Buffer + buf.WriteByte(0x80 | opText) // FIN + text + buf.WriteByte(0x80 | byte(len(payload))) // MASK + len + buf.Write(mask[:]) + for i, b := range payload { + buf.WriteByte(b ^ mask[i%4]) + } + + op, got, err := readFrame(bufio.NewReader(&buf)) + if err != nil { + t.Fatalf("readFrame: %v", err) + } + if op != opText { + t.Errorf("opcode = %#x, want %#x", op, opText) + } + if !bytes.Equal(got, payload) { + t.Errorf("payload = %q, want %q", got, payload) + } +} + +// A server frame is written unmasked and parses back to the same payload. +func TestWriteFrameRoundTrip(t *testing.T) { + payload := bytes.Repeat([]byte("x"), 300) // exercises the 16-bit length path + var raw bytes.Buffer + c := &wsClient{brw: bufio.NewReadWriter(bufio.NewReader(nil), bufio.NewWriter(&raw))} + if err := c.writeText(payload); err != nil { + t.Fatalf("writeText: %v", err) + } + if raw.Bytes()[0] != (0x80 | opText) { + t.Fatalf("first byte = %#x, want %#x", raw.Bytes()[0], 0x80|opText) + } + if raw.Bytes()[1]&0x80 != 0 { + t.Fatalf("server frame must not set the mask bit") + } + op, got, err := readFrame(bufio.NewReader(&raw)) + if err != nil { + t.Fatalf("readFrame: %v", err) + } + if op != opText || !bytes.Equal(got, payload) { + t.Errorf("round-trip mismatch: op=%#x len=%d", op, len(got)) + } +} diff --git a/bundler/import_check.go b/bundler/import_check.go new file mode 100644 index 00000000..c476f7fe --- /dev/null +++ b/bundler/import_check.go @@ -0,0 +1,87 @@ +package bundler + +// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`, +// which lets misnamed specifiers slip through. This check catches them up +// front so every import path names the file that actually exists on disk. + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +type importViolation struct { + file string + line int + spec string // the written specifier, e.g. "./Icons.js" + actual string // the corrected specifier, e.g. "./Icons.ts" +} + +// reImportSpec matches a quoted relative module specifier ending in ".js". +var reImportSpec = regexp.MustCompile(`(["'])((?:\.\.?/)[^"']*?\.js)["']`) + +// tsExtensions are the non-.js source extensions a .js specifier may +// actually resolve to, in esbuild's resolution order. +var tsExtensions = []string{".ts", ".tsx", ".jsx"} + +// checkImportExtensions scans every JS/TS source under frontend/src for +// relative import specifiers written with a ".js" extension whose literal +// target does not exist but a sibling ".ts"/".tsx"/".jsx" does. esbuild +// resolves these transparently, so they bundle fine — but the import path +// lies about the file it points to. Specifiers that resolve to a real ".js" +// file, and dangling specifiers with no sibling at all, are left for esbuild. +func checkImportExtensions() []importViolation { + var violations []importViolation + srcRoot := filepath.Join(frontendDir, "src") + + filepath.WalkDir(srcRoot, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + switch filepath.Ext(path) { + case ".js", ".ts", ".tsx", ".jsx": + default: + return nil + } + + raw, err := os.ReadFile(path) + if err != nil { + return err + } + dir := filepath.Dir(path) + + for i, line := range strings.Split(string(raw), "\n") { + for _, m := range reImportSpec.FindAllStringSubmatch(line, -1) { + spec := m[2] + if _, statErr := os.Stat(filepath.Join(dir, spec)); statErr == nil { + continue // resolves to a real .js file — fine + } + base := strings.TrimSuffix(spec, ".js") + for _, ext := range tsExtensions { + if _, statErr := os.Stat(filepath.Join(dir, base+ext)); statErr == nil { + violations = append(violations, importViolation{ + file: path, + line: i + 1, + spec: spec, + actual: base + ext, + }) + break + } + } + } + } + return nil + }) + + return violations +} + +func reportImportViolations(violations []importViolation) { + fmt.Fprintf(os.Stderr, "\nImport extension check failed: %d import(s) use a .js extension for a non-.js file.\n", len(violations)) + for _, v := range violations { + fmt.Fprintf(os.Stderr, " %s:%d: %q should be %q\n", v.file, v.line, v.spec, v.actual) + } + fmt.Fprintln(os.Stderr, "Rename each specifier to match the file's real extension.") +} diff --git a/bundler/js.go b/bundler/js.go new file mode 100644 index 00000000..d92aaded --- /dev/null +++ b/bundler/js.go @@ -0,0 +1,198 @@ +package bundler + +import ( + "encoding/json" + "fmt" + "kjol/appenv" + "os" + "path/filepath" + "strconv" + "strings" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +// esbuildDefine returns the compile-time constants substituted into every JS +// bundle — both the production bundles here and the dev HMR per-module +// transforms (hmr_server.go). __ENV_TYPE__ carries the Go compile-time +// deployment environment (appenv.Environment) into the JS, where +// frontend/src/env.ts reads it. The build-time SSR render deliberately does NOT +// define it (see internal/bundler/ssr.go), so the env badge renders only after +// the client takeover. +func esbuildDefine() map[string]string { + return map[string]string{ + "__ENV_TYPE__": strconv.Quote(appenv.Environment), + } +} + +// resolveEntryPoint returns the path (relative to frontendDir) of the +// SPA entry point, preferring app.ts over app.js. +func resolveEntryPoint() string { + if _, err := os.Stat(filepath.Join(frontendDir, "src/app.ts")); err == nil { + return "src/app.ts" + } + return "src/app.js" +} + +// bundleJS bundles the SPA entry (app.ts/app.js) into bundle.min.js. +func bundleJS() (bundleStats, error) { + return bundleJSEntry(filepath.Join(frontendDir, resolveEntryPoint()), "bundle.min.js") +} + +// bundlePublicJS bundles the public-page takeover entry into +// public.bundle.min.js. This is the client half of the SSR'd public pages: +// it re-renders the Solid tree the server emitted (re-render takeover). +func bundlePublicJS() (bundleStats, error) { + return bundleJSEntry(filepath.Join(frontendDir, "src/public.tsx"), "public.bundle.min.js") +} + +// bundleJSEntry runs esbuild with the default-export shim plugin for one entry +// point. Vendor modules (anything not a relative import) are kept external so +// the import map resolves them at runtime. .tsx/.jsx files are Solid-compiled by +// the Go-native compiler (Plugin); the SPA entry is solid-js/html tagged +// templates (no JSX) and is unaffected. +func bundleJSEntry(entry, outName string) (bundleStats, error) { + // Keep `debugger` statements in development so they can be hit when + // DevTools is attached; strip them in staging/production builds. + var drop esbuild.Drop + if appenv.Environment != appenv.EnvTypeDevelopment { + drop = esbuild.DropDebugger + } + + vendorDir := filepath.Join(frontendDir, "vendor") + + // Every bundled vendored package's entrypoint is declared in vendor.json rather + // than discovered from each package's `exports` — those mis-resolve (e.g. + // solid-js's bare core to its SSR build, dist/server.js, where createEffect/ + // onMount are no-ops, so the DOM renders but every effect is silently dead). + entrypoints, err := loadVendorManifest(vendorDir) + if err != nil { + return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err) + } + + // The Go-native Solid compiler (Plugin) sits between the export shim and the + // vendor resolvers so it compiles .tsx/.jsx before they're resolved. + plugins := []esbuild.Plugin{defaultExportShimPlugin(), Plugin()} + plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(vendorDir, entrypoints)) + + result := esbuild.Build(esbuild.BuildOptions{ + EntryPoints: []string{entry}, + Outfile: filepath.Join(outputDir, outName), + Bundle: true, + Write: true, + Format: esbuild.FormatESModule, + Target: esbuild.ES2022, + Sourcemap: esbuild.SourceMapLinked, + SourceRoot: "./", + Outbase: "frontend", + SourcesContent: esbuild.SourcesContentInclude, + MinifyWhitespace: true, + MinifyIdentifiers: true, + MinifySyntax: true, + Drop: drop, + Define: esbuildDefine(), + // Vendored packages live under frontend/vendor/<pkg> (npm-pack layout). + // NodePaths makes esbuild resolve bare imports there and bundle + tree-shake + // them like source (no node_modules). Bundling solid-js from a single vendored + // copy — rather than leaving it external for the import map — keeps ONE reactive + // runtime instance; a split instance silently breaks effect flushing (onMount + // never fires). Browser + development conditions pick each package's DOM dev + // build over any SSR entry its `module` field points at. Packages whose default + // entry pulls an un-bundled dep tree point their own package.json at a + // self-contained dist (e.g. pdf-lib's `browser` field) — config lives with the + // package, not here. vendorResolvePlugin marks bare imports NOT under + // frontend/vendor as external (import-map resolved), so the vendor set stays + // data-driven with no per-package list in this source. + Platform: esbuild.PlatformBrowser, + Conditions: []string{"development"}, + NodePaths: []string{vendorDir}, + // Assets emitted by assetURLPlugin (e.g. the pdfjs worker, which can't be + // inlined because it reads import.meta.url) land in wwwroot/vendor and are + // referenced by a root-absolute URL. + AssetNames: "vendor/[name]", + PublicPath: "/", + LogLevel: esbuild.LogLevelWarning, + Plugins: plugins, + }) + + if len(result.Errors) > 0 { + for _, e := range result.Errors { + loc := "" + if e.Location != nil { + loc = fmt.Sprintf("%s:%d:%d ", e.Location.File, e.Location.Line, e.Location.Column) + } + fmt.Fprintf(os.Stderr, " %s%s\n", loc, e.Text) + } + return bundleStats{}, fmt.Errorf("esbuild produced %d error(s)", len(result.Errors)) + } + + jsPath := filepath.Join(outputDir, outName) + if err := stripSourcemapParentPrefix(jsPath + ".map"); err != nil { + return bundleStats{}, fmt.Errorf("rewriting sourcemap paths: %w", err) + } + info, err := os.Stat(jsPath) + if err != nil { + return bundleStats{}, err + } + return bundleStats{files: countSources(result), bytes: int(info.Size())}, nil +} + +// stripSourcemapParentPrefix rewrites the sourcemap's `sources` entries to +// drop leading `../` segments. esbuild writes paths relative to the output +// file's directory; since the bundle lives in wwwroot/ and the sources live +// in frontend/, every entry starts with `../frontend/`. Stripping the prefix +// yields project-rooted paths like `frontend/src/app.ts`. +func stripSourcemapParentPrefix(mapPath string) error { + data, err := os.ReadFile(mapPath) + if err != nil { + return err + } + var m map[string]json.RawMessage + if err := json.Unmarshal(data, &m); err != nil { + return err + } + raw, ok := m["sources"] + if !ok { + return nil + } + var sources []string + if err := json.Unmarshal(raw, &sources); err != nil { + return err + } + for i, s := range sources { + for strings.HasPrefix(s, "../") { + s = s[3:] + } + sources[i] = s + } + newRaw, err := json.Marshal(sources) + if err != nil { + return err + } + m["sources"] = newRaw + out, err := json.Marshal(m) + if err != nil { + return err + } + return os.WriteFile(mapPath, out, 0644) +} + +// countSources returns the number of input files that contributed to +// the bundle, derived from esbuild's metafile. When the metafile is +// not requested, falls back to the entry-count. +func countSources(result esbuild.BuildResult) int { + if result.Metafile == "" { + return 1 + } + // Each " \"path\":" line in the inputs section counts as a file. + // Cheap heuristic — avoids pulling in encoding/json for a stat. + idx := strings.Index(result.Metafile, `"inputs":{`) + if idx < 0 { + return 1 + } + end := strings.Index(result.Metafile[idx:], `},"outputs"`) + if end < 0 { + return 1 + } + return strings.Count(result.Metafile[idx:idx+end], `":{`) +} diff --git a/bundler/js/ssr/dom.js b/bundler/js/ssr/dom.js new file mode 100644 index 00000000..8a19a713 --- /dev/null +++ b/bundler/js/ssr/dom.js @@ -0,0 +1,352 @@ +// Minimal server-side DOM for running solid-js/html + solid-js/web inside +// goja. It implements only the surface Solid's client runtime actually +// touches (enumerated from wwwroot/vendor/solid-js-web.js and +// solid-js-html.js): linked-list tree mutation, template.innerHTML/.content, +// element/text/comment creation, attributes, className/textContent, and +// no-op event wiring. +// +// The tree is the source of truth as a doubly-linked list (firstChild, +// nextSibling, ...) which is how the real DOM models it and what Solid's +// clone-walk assumes. childNodes is a derived snapshot array so Solid's +// `[...el.childNodes]` spreads work. +// +// HTML *parsing* (innerHTML setter) is the one genuinely hard operation, so +// it is delegated to Go via __parseHTML (x/net/html) which returns a JSON +// tree. Serialization back to a string is straightforward and lives here. +// -mta + +(function (global) { + "use strict"; + + var ELEMENT_NODE = 1, TEXT_NODE = 3, COMMENT_NODE = 8, FRAGMENT_NODE = 11; + + var VOID = { + area: 1, base: 1, br: 1, col: 1, embed: 1, hr: 1, img: 1, input: 1, + keygen: 1, link: 1, meta: 1, param: 1, source: 1, track: 1, wbr: 1, + }; + + var SVG_NS = "http://www.w3.org/2000/svg"; + + function escapeText(s) { + return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + } + function escapeAttr(s) { + return String(s).replace(/&/g, "&").replace(/"/g, """); + } + + // ---- Node ---------------------------------------------------------- + + class Node { + constructor(type) { + this.nodeType = type; + this.parentNode = null; + this.firstChild = null; + this.lastChild = null; + this.previousSibling = null; + this.nextSibling = null; + this._$host = null; + this.host = null; + } + + get childNodes() { + var out = [], n = this.firstChild; + while (n) { out.push(n); n = n.nextSibling; } + return out; + } + + appendChild(child) { + detach(child); + child.parentNode = this; + child.previousSibling = this.lastChild; + child.nextSibling = null; + if (this.lastChild) this.lastChild.nextSibling = child; + else this.firstChild = child; + this.lastChild = child; + return child; + } + + insertBefore(child, ref) { + if (ref == null) return this.appendChild(child); + if (ref.parentNode !== this) throw new Error("insertBefore: ref not a child"); + detach(child); + child.parentNode = this; + child.nextSibling = ref; + child.previousSibling = ref.previousSibling; + if (ref.previousSibling) ref.previousSibling.nextSibling = child; + else this.firstChild = child; + ref.previousSibling = child; + return child; + } + + removeChild(child) { + if (child.parentNode !== this) throw new Error("removeChild: not a child"); + detach(child); + return child; + } + + replaceChild(newNode, oldNode) { + this.insertBefore(newNode, oldNode); + this.removeChild(oldNode); + return oldNode; + } + + remove() { detach(this); } + + replaceWith() { + var args = Array.prototype.slice.call(arguments); + var parent = this.parentNode, ref = this.nextSibling; + if (!parent) return; + detach(this); + for (var i = 0; i < args.length; i++) { + var a = args[i]; + if (typeof a === "string") a = new Text(a); + parent.insertBefore(a, ref); + } + } + + cloneNode(deep) { + var copy = this._shallowClone(); + if (deep) { + var n = this.firstChild; + while (n) { copy.appendChild(n.cloneNode(true)); n = n.nextSibling; } + } + return copy; + } + + get textContent() { + if (this.nodeType === TEXT_NODE || this.nodeType === COMMENT_NODE) return this.data; + var out = "", n = this.firstChild; + while (n) { + if (n.nodeType !== COMMENT_NODE) out += n.textContent; + n = n.nextSibling; + } + return out; + } + set textContent(value) { + if (this.nodeType === TEXT_NODE || this.nodeType === COMMENT_NODE) { this.data = String(value); return; } + while (this.firstChild) this.removeChild(this.firstChild); + if (value !== "" && value != null) this.appendChild(new Text(String(value))); + } + + querySelectorAll(sel) { return querySelectorAll(this, sel); } + querySelector(sel) { var r = querySelectorAll(this, sel); return r.length ? r[0] : null; } + } + + function detach(node) { + var p = node.parentNode; + if (!p) return; + if (node.previousSibling) node.previousSibling.nextSibling = node.nextSibling; + else p.firstChild = node.nextSibling; + if (node.nextSibling) node.nextSibling.previousSibling = node.previousSibling; + else p.lastChild = node.previousSibling; + node.parentNode = null; + node.previousSibling = null; + node.nextSibling = null; + } + + // ---- Text / Comment ------------------------------------------------ + + class Text extends Node { + constructor(data) { super(TEXT_NODE); this.data = data == null ? "" : String(data); this.nodeName = "#text"; } + _shallowClone() { return new Text(this.data); } + } + + class Comment extends Node { + constructor(data) { super(COMMENT_NODE); this.data = data == null ? "" : String(data); this.nodeName = "#comment"; } + _shallowClone() { return new Comment(this.data); } + } + + // ---- Element ------------------------------------------------------- + + class Element extends Node { + constructor(tagName, ns) { + super(ELEMENT_NODE); + this.tagName = tagName; + this.localName = String(tagName).toLowerCase(); + this.nodeName = this.localName; + this.namespaceURI = ns || null; + this.attributes = {}; + this._style = null; + this._classList = null; + if (this.localName === "template") this.content = new Fragment(); + } + + _shallowClone() { + var copy = new Element(this.tagName, this.namespaceURI); + for (var k in this.attributes) copy.attributes[k] = this.attributes[k]; + if (this.content) { + var n = this.content.firstChild; + while (n) { copy.content.appendChild(n.cloneNode(true)); n = n.nextSibling; } + } + return copy; + } + + setAttribute(name, value) { this.attributes[name] = String(value); } + setAttributeNS(_ns, name, value) { this.attributes[name] = String(value); } + getAttribute(name) { return name in this.attributes ? this.attributes[name] : null; } + hasAttribute(name) { return name in this.attributes; } + removeAttribute(name) { delete this.attributes[name]; } + removeAttributeNS(_ns, name) { delete this.attributes[name]; } + + get className() { return this.attributes["class"] || ""; } + set className(v) { this.attributes["class"] = String(v); } + + get id() { return this.attributes["id"] || ""; } + set id(v) { this.attributes["id"] = String(v); } + + get innerHTML() { return serializeChildren(this); } + set innerHTML(htmlStr) { + var target = this.content ? this.content : this; + while (target.firstChild) target.removeChild(target.firstChild); + var json = global.__parseHTML(String(htmlStr)); + var nodes = buildNodes(JSON.parse(json)); + for (var i = 0; i < nodes.length; i++) target.appendChild(nodes[i]); + } + + get style() { + if (!this._style) this._style = makeStyle(this); + return this._style; + } + get classList() { + if (!this._classList) this._classList = makeClassList(this); + return this._classList; + } + + // Event wiring is irrelevant to server output. + addEventListener() {} + removeEventListener() {} + } + + class Fragment extends Node { + constructor() { super(FRAGMENT_NODE); this.nodeName = "#document-fragment"; } + _shallowClone() { return new Fragment(); } + } + + // ---- style / classList shims -------------------------------------- + + function makeStyle(el) { + return { + setProperty: function (k, v) { + var cur = parseStyle(el.attributes["style"] || ""); + cur[k] = v; + el.attributes["style"] = stringifyStyle(cur); + }, + removeProperty: function (k) { + var cur = parseStyle(el.attributes["style"] || ""); + delete cur[k]; + el.attributes["style"] = stringifyStyle(cur); + }, + get cssText() { return el.attributes["style"] || ""; }, + set cssText(v) { el.attributes["style"] = String(v); }, + }; + } + function parseStyle(s) { + var out = {}; + s.split(";").forEach(function (decl) { + var i = decl.indexOf(":"); + if (i > -1) out[decl.slice(0, i).trim()] = decl.slice(i + 1).trim(); + }); + return out; + } + function stringifyStyle(o) { + return Object.keys(o).map(function (k) { return k + ":" + o[k]; }).join(";"); + } + + function makeClassList(el) { + function read() { return (el.attributes["class"] || "").split(/\s+/).filter(Boolean); } + function write(list) { el.attributes["class"] = list.join(" "); } + return { + add: function () { var l = read(); for (var i = 0; i < arguments.length; i++) if (l.indexOf(arguments[i]) < 0) l.push(arguments[i]); write(l); }, + remove: function () { var l = read(), a = Array.prototype.slice.call(arguments); write(l.filter(function (c) { return a.indexOf(c) < 0; })); }, + toggle: function (c, force) { var l = read(), has = l.indexOf(c) > -1; if (force === undefined ? has : !force) write(l.filter(function (x) { return x !== c; })); else if (!has) { l.push(c); write(l); } }, + contains: function (c) { return read().indexOf(c) > -1; }, + }; + } + + // ---- build from parsed JSON --------------------------------------- + + function buildNodes(arr) { + var out = []; + for (var i = 0; i < arr.length; i++) out.push(buildNode(arr[i])); + return out; + } + function buildNode(j) { + if (j.t === "t") return new Text(j.d); + if (j.t === "c") return new Comment(j.d); + var el = new Element(j.n, j.ns === "svg" ? SVG_NS : null); + if (j.a) for (var k in j.a) el.attributes[k] = j.a[k]; + if (j.c) for (var i = 0; i < j.c.length; i++) el.appendChild(buildNode(j.c[i])); + return el; + } + + // ---- serialization ------------------------------------------------- + + function serializeChildren(node) { + var out = "", n = node.firstChild; + while (n) { out += serializeNode(n); n = n.nextSibling; } + return out; + } + function serializeNode(node) { + if (node.nodeType === TEXT_NODE) return escapeText(node.data); + // "#" is solid-js/html's template insertion placeholder; any that + // survive instantiation are framework artifacts, not page content. + if (node.nodeType === COMMENT_NODE) return node.data === "#" ? "" : "<!--" + node.data + "-->"; + if (node.nodeType === FRAGMENT_NODE) return serializeChildren(node); + // Output the original-case tag (SVG is case-sensitive: viewBox, + // linearGradient); use the lowercased localName only for lookups. + var tag = node.tagName, lname = node.localName; + var s = "<" + tag; + for (var k in node.attributes) s += " " + k + '="' + escapeAttr(node.attributes[k]) + '"'; + s += ">"; + if (VOID[lname]) return s; + if (lname === "template" && node.content) s += serializeChildren(node.content); + else s += serializeChildren(node); + return s + "</" + tag + ">"; + } + + // ---- minimal querySelectorAll (only script,style and *[data-hk]) --- + + function querySelectorAll(root, sel) { + var wantHk = /\[data-hk\]/.test(sel); + var tags = sel.split(",").map(function (s) { return s.trim().replace(/\[.*\]/, "").replace("*", "").toLowerCase(); }).filter(Boolean); + var out = []; + (function walk(n) { + var c = n.firstChild; + while (c) { + if (c.nodeType === ELEMENT_NODE) { + if (wantHk && c.attributes["data-hk"] != null) out.push(c); + else if (tags.indexOf(c.localName) > -1) out.push(c); + walk(c); + } + c = c.nextSibling; + } + })(root.content || root); + return out; + } + + // ---- document ------------------------------------------------------ + + var document = { + createElement: function (tag) { return new Element(tag, null); }, + createElementNS: function (ns, tag) { return new Element(tag, ns); }, + createTextNode: function (data) { return new Text(data); }, + createComment: function (data) { return new Comment(data); }, + createDocumentFragment: function () { return new Fragment(); }, + importNode: function (node, deep) { return node.cloneNode(deep); }, + addEventListener: function () {}, + removeEventListener: function () {}, + nodeType: 9, + }; + + global.document = document; + global.Node = Node; + global.Element = Element; + global.Text = Text; + global.Comment = Comment; + global.window = global; + + // Serialize a node's children (innerHTML) — the Go side calls this to + // extract the rendered markup from the render root. + global.__serialize = function (node) { return serializeChildren(node); }; + +})(globalThis); diff --git a/bundler/jsx.go b/bundler/jsx.go new file mode 100644 index 00000000..25ad764c --- /dev/null +++ b/bundler/jsx.go @@ -0,0 +1,94 @@ +// Solid JSX/TSX compilation (part of package bundler): compiles Solid JSX/TSX to +// optimized Solid dom-expressions output (template cloning + fine-grained +// updates) with a Go-native compiler — no Node, no Babel, no goja. The compiler +// lives in compile_solid.go (parser) and compile_solid_gen.go (codegen); +// segment.go supplies the top-level splitter it uses to find component +// declarations for solid-refresh instrumentation. +// +// Compile is the prod path (SSR + release bundles); CompileDev adds solid-refresh +// HMR instrumentation. Both are plain function calls — fast enough that the old +// per-declaration incremental caching is gone. A small in-memory cache dedups +// identical transforms within a process (e.g. a module served to several page +// loads under HMR); there is no on-disk cache and no compiler bootstrap. +package bundler + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "sync" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +// cache dedups transforms by content within a process, so a module unchanged +// across dev page reloads (or repeated in a build) isn't recompiled. +var cache = struct { + sync.RWMutex + m map[string]string +}{m: map[string]string{}} + +// cacheKey keys a transform by filename + mode + source. +func cacheKey(src, filename string, dev bool) string { + h := sha256.New() + h.Write([]byte(filename)) + h.Write([]byte{0}) + if dev { + h.Write([]byte{1}) + } else { + h.Write([]byte{0}) + } + h.Write([]byte{0}) + h.Write([]byte(src)) + return hex.EncodeToString(h.Sum(nil)) +} + +// Compile compiles one JSX/TSX source to Solid dom-expressions output (prod: no +// HMR instrumentation). Results are cached by content for this process. +func Compile(src, filename string) (string, error) { return compileCached(src, filename, false) } + +// CompileDev is Compile with solid-refresh HMR instrumentation — the dev module +// server's entry. +func CompileDev(src, filename string) (string, error) { return compileCached(src, filename, true) } + +func compileCached(src, filename string, dev bool) (string, error) { + key := cacheKey(src, filename, dev) + cache.RLock() + if out, ok := cache.m[key]; ok { + cache.RUnlock() + return out, nil + } + cache.RUnlock() + + out, err := compileSolidGo(src, filename, dev) + if err != nil { + return "", err + } + cache.Lock() + cache.m[key] = out + cache.Unlock() + return out, nil +} + +// Plugin returns the esbuild OnLoad hook that Solid-compiles every .tsx/.jsx file +// in the bundle graph (prod path). Plain .ts/.js files are left to esbuild. +func Plugin() esbuild.Plugin { + return esbuild.Plugin{ + Name: "solid-jsx", + Setup: func(b esbuild.PluginBuild) { + b.OnLoad(esbuild.OnLoadOptions{Filter: `\.(tsx|jsx)$`}, func(a esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { + data, err := os.ReadFile(a.Path) + if err != nil { + return esbuild.OnLoadResult{}, err + } + out, err := Compile(string(data), filepath.Base(a.Path)) + if err != nil { + return esbuild.OnLoadResult{}, err + } + loader := esbuild.LoaderJS + return esbuild.OnLoadResult{Contents: &out, Loader: loader}, nil + }) + }, + } +} diff --git a/bundler/jsx_bench_test.go b/bundler/jsx_bench_test.go new file mode 100644 index 00000000..fcd96248 --- /dev/null +++ b/bundler/jsx_bench_test.go @@ -0,0 +1,43 @@ +package bundler + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +func benchRead(b *testing.B, rel string) string { + b.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", rel)) + if err != nil { + b.Skipf("cannot read %s: %v", rel, err) + } + return string(data) +} + +var benchComponents = []string{ + "ui/AutoTable.tsx", // ~212 KB + "ui/Forms.tsx", // ~75 KB + "pages/app/user/Profile.tsx", // ~26 KB + "pages/public/PublicLayout.tsx", // ~27 KB +} + +// BenchmarkCompileDev times the full dev HMR compile (Babel solid + solid-refresh +// in goja) per component, busting the cache each iteration so every run is a real +// recompile — the cost the browser waits on for a single-file HMR update. This is +// the harness for judging whether a compile-path change actually moves the needle; +// as of this writing the goja/Babel transform dominates (seconds for large files). +func BenchmarkCompileDev(b *testing.B) { + for _, rel := range benchComponents { + src := benchRead(b, rel) + b.Run(rel, func(b *testing.B) { + for i := 0; i < b.N; i++ { + id := fmt.Sprintf("bench/c%d.tsx", i) // unique id -> cache miss + if _, err := CompileDev(src, id); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/bundler/jsx_dev_test.go b/bundler/jsx_dev_test.go new file mode 100644 index 00000000..0042e409 --- /dev/null +++ b/bundler/jsx_dev_test.go @@ -0,0 +1,48 @@ +package bundler + +import ( + "strings" + "testing" +) + +// CompileDev should Solid-compile the component AND wrap it with solid-refresh +// HMR instrumentation: an import from the solid-refresh runtime, a registry hot +// boundary, and an import.meta.hot accept (bundler:"esm"). None of these appear +// in the plain (prod) Compile output. +func TestCompileDevSolidRefresh(t *testing.T) { + src := `import { createSignal } from "solid-js"; +export default function Counter() { + const [c, setC] = createSignal(0); + return <button onclick={() => setC(c() + 1)}>Count: {c()}</button>; +} +` + dev, err := CompileDev(src, "ui/Counter.tsx") + if err != nil { + t.Fatalf("CompileDev: %v", err) + } + t.Logf("DEV OUTPUT:\n%s", dev) + + for _, want := range []string{ + `solid-refresh`, // runtime import + "$$registry", // HMR registry boundary + "import.meta.hot", // esm bundler hot API + "_tmpl$", // still Solid-compiled + } { + if !strings.Contains(dev, want) { + t.Errorf("dev output missing %q", want) + } + } + + // Prod compile of the same source must NOT carry refresh instrumentation, + // and (regression for the mode-keyed cache) must differ from the dev output. + prod, err := Compile(src, "ui/Counter.tsx") + if err != nil { + t.Fatalf("Compile: %v", err) + } + if strings.Contains(prod, "solid-refresh") || strings.Contains(prod, "import.meta.hot") { + t.Errorf("prod output leaked HMR instrumentation:\n%s", prod) + } + if prod == dev { + t.Errorf("mode-keyed cache broken: dev and prod output identical") + } +} diff --git a/bundler/jsx_test.go b/bundler/jsx_test.go new file mode 100644 index 00000000..5378a770 --- /dev/null +++ b/bundler/jsx_test.go @@ -0,0 +1,61 @@ +package bundler + +import ( + "strings" + "testing" +) + +// Compiling a Solid component should yield Solid's optimized dom output: a +// template clone, fine-grained insert, delegated events, and imports from +// solid-js/web — none of which a runtime factory would emit. TS types must be +// stripped too. +func TestCompileSolidComponent(t *testing.T) { + src := `import { createSignal } from "solid-js"; + +interface Props { start: number } + +export function Counter(props: Props) { + const [c, setC] = createSignal<number>(props.start); + return <button class="btn" onclick={() => setC(c() + 1)}>Count: {c()}</button>; +} +` + out, err := Compile(src, "Counter.tsx") + if err != nil { + t.Fatalf("Compile: %v", err) + } + t.Logf("OUTPUT:\n%s", out) + + for _, want := range []string{ + `from "solid-js/web"`, // compiled helpers + "_tmpl$", // template clone + "template(", // template factory + "createSignal", // user code preserved + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q", want) + } + } + // TS type syntax must be gone. + for _, bad := range []string{"interface Props", ": Props", "<number>"} { + if strings.Contains(out, bad) { + t.Errorf("TS syntax leaked: %q", bad) + } + } +} + +// A second call with identical input hits the cache and still returns the same +// compiled output. +func TestCompileCache(t *testing.T) { + src := `export function A() { return <div>hi</div>; }` + a, err := Compile(src, "A.tsx") + if err != nil { + t.Fatalf("first: %v", err) + } + b, err := Compile(src, "A.tsx") + if err != nil { + t.Fatalf("second: %v", err) + } + if a != b { + t.Errorf("cached output differs") + } +} diff --git a/bundler/renderer.go b/bundler/renderer.go new file mode 100644 index 00000000..ae95c68a --- /dev/null +++ b/bundler/renderer.go @@ -0,0 +1,228 @@ +package bundler + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" +) + +// devPageRenderers caches one dev Renderer per public-page SSR entry so repeated +// requests reuse the cached HTML until frontend/src changes. +var devPageRenderers sync.Map // entry string -> *Renderer + +// DevRenderPublicPage live-renders a public page's SSR body for the dev server, +// re-rendering only when frontend/src changes (Renderer's dev-mode mtime check). +// module is relative to frontend/src (e.g. "pages/public/AboutUs.tsx"), component +// is the exported body name, and currentPath drives PublicLayout's active nav. +// The server runs from the repo root, so projectRoot is ".". +func DevRenderPublicPage(module, component, currentPath string) (string, error) { + fullModule := filepath.ToSlash(filepath.Join("frontend", "src", module)) + entry := ssrEntrySolid(fullModule, component, currentPath) + r, _ := devPageRenderers.LoadOrStore(entry, NewRenderer(".", entry, true)) + return r.(*Renderer).HTML() +} + +// devPublicBundles caches one compiled SSR bundle per ISR entry, re-bundled only +// when frontend/src changes. Unlike devPageRenderers (which caches the finished +// data-free HTML), ISR renders with fresh data each request, so we cache the +// bundle and re-render it per request. +var devPublicBundles sync.Map // entry string -> *devBundle + +type devBundle struct { + root, entry string + mu sync.Mutex + bundle string + builtAt time.Time + ok bool +} + +func (b *devBundle) get() (string, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.ok && !newestSourceMtime(b.root).After(b.builtAt) { + return b.bundle, nil + } + bundled, err := BundleEntry(b.entry, b.root) + if err != nil { + return "", err + } + b.bundle, b.builtAt, b.ok = bundled, newestSourceMtime(b.root), true + return bundled, nil +} + +// DevRenderPublicPageWithData live-renders an ISR public page in dev: it bundles +// the page from current source (re-bundling only when frontend/src changes) and +// renders it with the supplied server data. So editing an ISR page's component +// shows on the next reload even with JS disabled — only the Go data-loading code +// (the real server component) still needs a server restart. +func DevRenderPublicPageWithData(module, component, currentPath, dataJSON string) (string, error) { + fullModule := filepath.ToSlash(filepath.Join("frontend", "src", module)) + entry := ssrEntrySolid(fullModule, component, currentPath) + bv, _ := devPublicBundles.LoadOrStore(entry, &devBundle{root: ".", entry: entry}) + bundle, err := bv.(*devBundle).get() + if err != nil { + return "", err + } + return RenderBundleWithData(bundle, dataJSON) +} + +// RewarmDevPublicPages re-renders, in the background, every public page whose dev +// SSR has already been requested — so after an edit the fresh no-JS SSR is ready +// before the next reload instead of being rendered lazily on it. The dev watcher +// calls this on a source change. Each Renderer only actually re-renders if +// frontend/src changed, so redundant calls are cheap; errors are left for the +// request path to surface. +func RewarmDevPublicPages() { + devPageRenderers.Range(func(_, v any) bool { + go v.(*Renderer).HTML() + return true + }) + devPublicBundles.Range(func(_, v any) bool { + go v.(*devBundle).get() + return true + }) +} + +// newestSourceMtime returns the newest modification time under +// projectRoot/frontend/src, or the zero time if the tree can't be walked +// (treated as "unchanged"). Drives the dev-mode rebuild checks. +func newestSourceMtime(projectRoot string) time.Time { + var newest time.Time + srcDir := filepath.Join(projectRoot, "frontend", "src") + filepath.WalkDir(srcDir, func(_ string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if info, e := d.Info(); e == nil && info.ModTime().After(newest) { + newest = info.ModTime() + } + return nil + }) + return newest +} + +// Renderer renders one public-page entry to HTML and caches the result. +// Because SSR output is data-free it is identical every request, so a cached +// string can be reused indefinitely. In dev the cache is invalidated when any +// file under frontend/src changes (a cheap mtime scan), so source edits show +// up on reload without a restart and without paying the ~1s bundle+render on +// every request. +type Renderer struct { + projectRoot string + entry string + dev bool + + mu sync.Mutex + cached string + builtAt time.Time // newest source mtime seen at last build + ok bool +} + +func NewRenderer(projectRoot, entry string, dev bool) *Renderer { + return &Renderer{projectRoot: projectRoot, entry: entry, dev: dev} +} + +// HTML returns the rendered markup, rebuilding only when necessary. +func (r *Renderer) HTML() (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.ok { + if !r.dev { + return r.cached, nil + } + if !r.maxSourceMtime().After(r.builtAt) { + return r.cached, nil // sources unchanged since last build + } + } + + out, err := renderOnce(r.entry, r.projectRoot) + if err != nil { + return "", err + } + r.cached, r.builtAt, r.ok = out, r.maxSourceMtime(), true + return out, nil +} + +// maxSourceMtime returns the newest modification time under frontend/src. +func (r *Renderer) maxSourceMtime() time.Time { return newestSourceMtime(r.projectRoot) } + +func renderOnce(entry, projectRoot string) (string, error) { + bundle, err := BundleEntry(entry, projectRoot) + if err != nil { + return "", err + } + eng, err := New() + if err != nil { + return "", err + } + if err := eng.LoadBundle(bundle); err != nil { + return "", err + } + return eng.Render() +} + +// RenderBundleWithData runs a pre-bundled render entry (baked into +// public_pages.gen.go by RenderEntryFull) in goja with server data injected, and +// returns the rendered HTML. This is the runtime ISR path — no esbuild, no +// source files on disk, so it works in a single-binary release. +func RenderBundleWithData(bundleJS, dataJSON string) (string, error) { + eng, err := New() + if err != nil { + return "", err + } + if err := eng.SetServerData(dataJSON); err != nil { + return "", err + } + if err := eng.LoadBundle(bundleJS); err != nil { + return "", err + } + return eng.Render() +} + +// RenderEntryFull bundles and renders one SSR entry, returning the rendered +// (data-free) skeleton HTML, the bundled JS, and the on-disk input files esbuild +// pulled in. The bundler (genssr.go) uses this at build time: it +// bakes the HTML for every page and, for dynamic (ISR) pages, also bakes the JS +// so the server can re-render it with data at request time (RenderBundleWithData) +// — no esbuild or source files on disk in production. The inputs feed +// build-time change detection. +func RenderEntryFull(entry, projectRoot string) (html, js string, inputs []string, err error) { + bundled, metafile, err := bundleEntry(entry, projectRoot, true) + if err != nil { + return "", "", nil, err + } + eng, err := New() + if err != nil { + return "", "", nil, err + } + if err := eng.LoadBundle(bundled); err != nil { + return "", "", nil, err + } + out, err := eng.Render() + if err != nil { + return "", "", nil, err + } + return out, bundled, metafileInputs(metafile), nil +} + +// metafileInputs returns the existing-on-disk input files from an esbuild +// metafile (paths are relative to the build's working directory). Virtual +// inputs like the inline entry are skipped — they don't os.Stat. +func metafileInputs(metafile string) []string { + var mf struct { + Inputs map[string]json.RawMessage `json:"inputs"` + } + if metafile == "" || json.Unmarshal([]byte(metafile), &mf) != nil { + return nil + } + out := make([]string, 0, len(mf.Inputs)) + for p := range mf.Inputs { + if _, err := os.Stat(p); err == nil { + out = append(out, filepath.ToSlash(p)) + } + } + return out +} diff --git a/bundler/segment.go b/bundler/segment.go new file mode 100644 index 00000000..247d6a48 --- /dev/null +++ b/bundler/segment.go @@ -0,0 +1,293 @@ +package bundler + +// Top-level source segmentation. segmentTopLevel splits a module into contiguous +// chunks at top-level declaration boundaries; the Go Solid compiler uses it to +// find component declarations for solid-refresh instrumentation (wrapComponents +// in compile_solid_gen.go). +// +// Correctness contract: join(chunks) == src, always. A chunk always begins at a +// top-level declaration keyword and contains only whole top-level statements. The +// splitter is deliberately conservative — when the lexer is unsure it simply +// doesn't cut, producing fewer/larger chunks (still correct). + +import "strings" + +// declKeywords begin a top-level declaration. A line that starts (at brace depth +// 0, outside any string/comment/template/regex) with one of these — as a whole +// word — is a chunk boundary. `export` covers `export default`, `export const`, +// `export function`, and re-exports; `async` covers `async function`. +var declKeywords = []string{ + "import", "export", "const", "let", "var", "function", + "async", "class", "type", "interface", "enum", "declare", "abstract", +} + +// regexPrefixKeywords are the identifiers after which a `/` begins a regex +// literal rather than a division (e.g. `return /x/`), needed so the lexer keeps +// an accurate brace depth through regexes that contain braces or quotes. +var regexPrefixKeywords = map[string]bool{ + "return": true, "typeof": true, "instanceof": true, "in": true, "of": true, + "new": true, "delete": true, "void": true, "do": true, "else": true, + "yield": true, "await": true, "case": true, +} + +// segmentTopLevel splits src into chunks whose concatenation is exactly src. +// Returns a single chunk (the whole source) when there is nothing safe to split. +func segmentTopLevel(src string) []string { + cuts := topLevelCuts(src) + if len(cuts) <= 1 { + return []string{src} + } + chunks := make([]string, 0, len(cuts)) + for i := range cuts { + end := len(src) + if i+1 < len(cuts) { + end = cuts[i+1] + } + chunks = append(chunks, src[cuts[i]:end]) + } + // Defensive: the construction above is lossless, but never return a + // non-lossless split — a single chunk is always safe. + if strings.Join(chunks, "") != src { + return []string{src} + } + return chunks +} + +// lexical states +const ( + stNormal = iota + stLineComment + stBlockComment + stSingle // '...' + stDouble // "..." + stTemplate + stRegex +) + +// topLevelCuts returns the sorted byte offsets at which chunks begin. Always +// includes 0. A cut is placed at the start of any line that begins in column 0 +// (no leading whitespace) with a declaration keyword, provided the lexer is in a +// clean state there — i.e. not inside a block comment, template body, or `${}` +// interpolation that spans into this line. +// +// Column 0 is the top-level signal: these files indent everything inside a +// function/JSX, so a keyword in column 0 is a top-level declaration. That lets +// the lexer ignore brace depth and JSX entirely (JSX and nested statements are +// always indented) — it need only track string/comment/template state so a +// keyword *inside* a multi-line string or comment isn't mistaken for a boundary. +// Regexes and single/double strings can't span lines, so any mis-lex of them +// self-heals at the newline before the next candidate line. +func topLevelCuts(src string) []int { + cuts := []int{0} + n := len(src) + + state := stNormal + depth := 0 // only tracked to match `${ ... }` interpolation braces + // tmplStack holds the interpolation brace depth captured at each `${` so the + // matching `}` resumes the template body instead of being counted as a plain + // brace. Non-empty ⇒ we're inside an interpolation (line not a clean start). + var tmplStack []int + var prevSig byte // last significant byte, for regex-vs-division + + addCut := func(off int) { + if off > cuts[len(cuts)-1] { + cuts = append(cuts, off) + } + } + // A newline just moved us to lineStart; if the lexer is clean there and the + // line begins in column 0 with a declaration keyword, it's a chunk boundary. + checkCut := func(lineStart int) { + if state == stNormal && len(tmplStack) == 0 && startsDeclKeyword(src, lineStart) { + addCut(lineStart) + } + } + + checkCut(0) + + for i := 0; i < n; i++ { + c := src[i] + switch state { + case stNormal: + switch c { + case '/': + if i+1 < n && src[i+1] == '/' { + state = stLineComment + i++ + continue + } + if i+1 < n && src[i+1] == '*' { + state = stBlockComment + i++ + continue + } + if regexAllowed(src, i, prevSig) { + state = stRegex + prevSig = c + continue + } + prevSig = c + case '\'': + state = stSingle + prevSig = c + case '"': + state = stDouble + prevSig = c + case '`': + state = stTemplate + prevSig = c + case '{', '(', '[': + depth++ + prevSig = c + case '}': + if len(tmplStack) > 0 && depth == tmplStack[len(tmplStack)-1] { + tmplStack = tmplStack[:len(tmplStack)-1] + depth-- + state = stTemplate + } else { + if depth > 0 { + depth-- + } + prevSig = c + } + case ')', ']': + if depth > 0 { + depth-- + } + prevSig = c + case '\n': + checkCut(i + 1) + case ' ', '\t', '\r': + // insignificant; leave prevSig + default: + prevSig = c + } + + case stLineComment: + if c == '\n' { + state = stNormal + checkCut(i + 1) + } + + case stBlockComment: + if c == '*' && i+1 < n && src[i+1] == '/' { + state = stNormal + i++ + } + // a newline inside a block comment is not a clean start: no checkCut + + case stSingle: + if c == '\\' { + i++ + } else if c == '\'' { + state = stNormal + prevSig = c + } else if c == '\n' { + state = stNormal // strings can't span lines; recover + checkCut(i + 1) + } + + case stDouble: + if c == '\\' { + i++ + } else if c == '"' { + state = stNormal + prevSig = c + } else if c == '\n' { + state = stNormal + checkCut(i + 1) + } + + case stTemplate: + if c == '\\' { + i++ + } else if c == '`' { + state = stNormal + prevSig = c + } else if c == '$' && i+1 < n && src[i+1] == '{' { + depth++ + tmplStack = append(tmplStack, depth) + state = stNormal + i++ + } + // templates may span lines; the continuation is not a clean start + + case stRegex: + if c == '\\' { + i++ + } else if c == '[' { + for i++; i < n; i++ { // character class: skip to `]` + if src[i] == '\\' { + i++ + continue + } + if src[i] == ']' { + break + } + } + } else if c == '/' { + state = stNormal + prevSig = c + } else if c == '\n' { + state = stNormal // regexes can't span lines; recover + checkCut(i + 1) + } + } + } + + return cuts +} + +// startsDeclKeyword reports whether src[i:] begins with a declaration keyword as +// a whole word (the next character is not part of an identifier). +func startsDeclKeyword(src string, i int) bool { + for _, kw := range declKeywords { + if strings.HasPrefix(src[i:], kw) { + j := i + len(kw) + if j >= len(src) || !isIdentPart(src[j]) { + return true + } + } + } + return false +} + +// regexAllowed reports whether a `/` at position i begins a regex literal (as +// opposed to a division operator), from the preceding significant byte and, when +// that byte ends an identifier, whether the identifier is a regex-prefix keyword. +func regexAllowed(src string, i int, prevSig byte) bool { + if prevSig == 0 { + return true // start of input + } + if isIdentPart(prevSig) { + // value context (identifier/number) unless the word is a keyword like + // `return` after which a regex is expected. + word := trailingWord(src, i) + return regexPrefixKeywords[word] + } + switch prevSig { + case ')', ']', '}': + return false // end of a value/call/index + default: + // after operators, punctuation, `(`, `,`, `=`, etc. → regex expected + return true + } +} + +// trailingWord returns the identifier word ending just before the run of +// whitespace that precedes position i (used to classify the token before a `/`). +func trailingWord(src string, i int) string { + j := i + for j > 0 && (src[j-1] == ' ' || src[j-1] == '\t' || src[j-1] == '\r' || src[j-1] == '\n') { + j-- + } + end := j + for j > 0 && isIdentPart(src[j-1]) { + j-- + } + return src[j:end] +} + +func isIdentPart(b byte) bool { + return b == '_' || b == '$' || + (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') +} diff --git a/bundler/segment_test.go b/bundler/segment_test.go new file mode 100644 index 00000000..d0e9033d --- /dev/null +++ b/bundler/segment_test.go @@ -0,0 +1,108 @@ +package bundler + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// walkRepoTSX returns every .tsx/.jsx under frontend/src (relative to the +// package dir, which is internal/bundler). +func walkRepoTSX(t *testing.T) []string { + t.Helper() + root := filepath.Join("..", "..", "frontend", "src") + var files []string + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + switch filepath.Ext(p) { + case ".tsx", ".jsx": + files = append(files, p) + } + return nil + }) + if err != nil { + t.Skipf("cannot walk frontend/src: %v", err) + } + if len(files) == 0 { + t.Skip("no .tsx files found") + } + return files +} + +// Segmentation must be lossless for every real component in the repo, and the +// join invariant must hold exactly (byte-for-byte). +func TestSegmentLosslessRepo(t *testing.T) { + files := walkRepoTSX(t) + totalChunks, multiChunkFiles := 0, 0 + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + src := string(data) + chunks := segmentTopLevel(src) + if strings.Join(chunks, "") != src { + t.Errorf("%s: segmentation not lossless (%d chunks)", f, len(chunks)) + continue + } + totalChunks += len(chunks) + if len(chunks) > 1 { + multiChunkFiles++ + } + // Every chunk (except possibly the first) must begin at a declaration + // keyword after optional leading blank lines/comments were attached to + // the previous chunk — i.e. its first non-space line starts with a kw. + } + t.Logf("segmented %d files: %d split into >1 chunk, %d chunks total (avg %.1f)", + len(files), multiChunkFiles, totalChunks, float64(totalChunks)/float64(len(files))) +} + +// Focused check on the pain file: it should split into many chunks so that +// editing any single declaration recompiles only that declaration. +func TestSegmentAutoTable(t *testing.T) { + p := filepath.Join("..", "..", "frontend", "src", "ui", "AutoTable.tsx") + data, err := os.ReadFile(p) + if err != nil { + t.Skipf("cannot read AutoTable: %v", err) + } + chunks := segmentTopLevel(string(data)) + if strings.Join(chunks, "") != string(data) { + t.Fatal("AutoTable segmentation not lossless") + } + // Size distribution: how big is the largest chunk (the residual worst case)? + maxLen, maxIdx := 0, 0 + for i, c := range chunks { + if len(c) > maxLen { + maxLen, maxIdx = len(c), i + } + } + head := strings.TrimSpace(chunks[maxIdx]) + if len(head) > 80 { + head = head[:80] + } + t.Logf("AutoTable: %d chunks, largest = %d bytes (%.0f%% of file), starts: %q", + len(chunks), maxLen, 100*float64(maxLen)/float64(len(data)), head) + if len(chunks) < 20 { + t.Errorf("expected AutoTable to split into many chunks, got %d", len(chunks)) + } +} + +// A few hand-written cases exercise the lexer edge cases the repo may not cover. +func TestSegmentEdgeCases(t *testing.T) { + cases := map[string]string{ + "template with braces and decl-looking text": "const a = `x${ {y:1} }z\nconst notReal = 2`;\nexport const b = 3;\n", + "regex with braces": "const re = /[{}]/g;\nfunction f() { return /a{2}/; }\nexport function g() {}\n", + "block comment spanning decl keyword": "/*\nconst hidden = 1;\nfunction alsoHidden() {}\n*/\nexport const real = 1;\n", + "string with keyword": "const s = \"export function fake() {}\";\nfunction real() {}\n", + "nested template": "const t = `a${`b${1}c`}d`;\nexport const u = 1;\n", + } + for name, src := range cases { + chunks := segmentTopLevel(src) + if got := strings.Join(chunks, ""); got != src { + t.Errorf("%s: not lossless\n src=%q\n got=%q", name, src, got) + } + } +} diff --git a/bundler/ssr.go b/bundler/ssr.go new file mode 100644 index 00000000..3728a81a --- /dev/null +++ b/bundler/ssr.go @@ -0,0 +1,340 @@ +// SSR (part of package bundler): server-renders the public-page Solid components +// to HTML strings by running the (unmodified) client Solid runtime inside a goja +// JS engine against a minimal Go-backed DOM (dom.js). Components are authored in +// JSX/TSX and compiled to optimized Solid output at build time by the Go-native +// compiler (compile_solid.go). The compiled code mounts via solid-js/web's +// render into the DOM shim and __serialize walks the shim tree to HTML. +// +// The rendered HTML is the component's initial markup (data-free at build time, or +// rendered with injected __SERVER_DATA__ for ISR); the browser bundle re-renders it +// on load (Solid re-render takeover). The runtime ISR entry (RenderBundleWithData) +// is imported by the server; the build-time entries are used by the bundler. +// +// -mta +package bundler + +import ( + "crypto/sha256" + _ "embed" + "encoding/hex" + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/dop251/goja" + esbuild "github.com/evanw/esbuild/pkg/api" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +//go:embed js/ssr/dom.js +var domJS string + +// preludeJS installs the browser globals Solid's client runtime reaches for +// that don't exist in goja. They are deliberately inert: no timer or +// microtask fires during the synchronous render, and fetch never resolves — +// so a component's onMount data load can't run, leaving exactly the +// pre-hydration skeleton we want to serialize. +const preludeJS = ` +var console = { + log: function () { __log.apply(null, ["log"].concat(Array.prototype.slice.call(arguments))); }, + info: function () { __log.apply(null, ["info"].concat(Array.prototype.slice.call(arguments))); }, + warn: function () { __log.apply(null, ["warn"].concat(Array.prototype.slice.call(arguments))); }, + error: function () { __log.apply(null, ["error"].concat(Array.prototype.slice.call(arguments))); }, + debug: function () {}, +}; +globalThis.queueMicrotask = function (cb) { (globalThis.__mt || (globalThis.__mt = [])).push(cb); }; +globalThis.setTimeout = function () { return 0; }; +globalThis.clearTimeout = function () {}; +globalThis.setInterval = function () { return 0; }; +globalThis.clearInterval = function () {}; +globalThis.requestAnimationFrame = function () { return 0; }; +globalThis.cancelAnimationFrame = function () {}; +globalThis.fetch = function () { + return Promise.resolve({ ok: false, status: 0, json: function () { return Promise.resolve(null); }, text: function () { return Promise.resolve(""); } }); +}; +globalThis._$HY = { events: [], completed: (typeof WeakSet !== "undefined" ? new WeakSet() : null), r: {}, done: true, fe: function () {} }; +// Marker so page components can render a lightweight skeleton during SSR and +// defer heavy, browser-only UI (charts, PDF, icon fonts) to the client takeover. +globalThis.__SSR__ = true; +// Minimal Intl shim: goja has no Intl, but some modules construct formatters at +// import time. The shim just needs to not throw; real formatting happens on the +// client. format() returns the stringified input. +if (typeof Intl === "undefined") { + globalThis.Intl = { + NumberFormat: function () { return { format: function (n) { return String(n); }, formatToParts: function (n) { return [{ type: "literal", value: String(n) }]; } }; }, + DateTimeFormat: function () { return { format: function (d) { return String(d); } }; }, + }; +} +` + +// Engine is a single goja runtime with the DOM shim installed. It is NOT +// safe for concurrent use; the eventual handler keeps a pool of these. +type Engine struct { + vm *goja.Runtime +} + +// New builds a runtime, installs the Go bridges (__parseHTML, __log), runs +// the prelude and the DOM shim, and returns a ready engine. +func New() (*Engine, error) { + vm := goja.New() + + if err := vm.Set("__parseHTML", parseHTMLToJSON); err != nil { + return nil, err + } + if err := vm.Set("__log", func(args ...interface{}) { + fmt.Println(append([]interface{}{"[ssr]"}, args...)...) + }); err != nil { + return nil, err + } + + if _, err := vm.RunString(preludeJS); err != nil { + return nil, fmt.Errorf("prelude: %w", err) + } + if _, err := vm.RunString(domJS); err != nil { + return nil, fmt.Errorf("dom shim: %w", err) + } + + return &Engine{vm: vm}, nil +} + +// LoadBundle evaluates a bundled Solid entry (see BundleEntry). The entry is +// expected to define globalThis.__render. +func (e *Engine) LoadBundle(js string) error { + _, err := e.vm.RunString(js) + return err +} + +// Render invokes the entry's __render() and returns the serialized HTML. +func (e *Engine) Render() (string, error) { + v, err := e.vm.RunString("__render()") + if err != nil { + return "", err + } + return v.String(), nil +} + +// SetServerData installs the page's per-render data as globalThis.__SERVER_DATA__, +// which components read (via the frontend serverData() accessor) to render with +// real data instead of a skeleton. dataJSON must be a JSON document — it's valid +// JS, wrapped in parens so an object literal parses as an expression. Used by the +// ISR path (request-time render-with-data, cached); the static skeleton bake sets +// nothing, so components fall back to placeholders there. +func (e *Engine) SetServerData(dataJSON string) error { + if dataJSON == "" { + return nil + } + _, err := e.vm.RunString("globalThis.__SERVER_DATA__ = (" + dataJSON + ");") + return err +} + +// BundleEntry bundles an inline JS entry into a single IIFE that goja can run, +// resolving bare solid-js* specifiers to the vendored runtime files under +// projectRoot/wwwroot/vendor. Any .tsx/.jsx pulled into the graph is Solid- +// compiled by the Go-native compiler (Plugin). The generated entry itself is +// plain JS — it uses solid-js/web's createComponent rather than JSX — so it needs +// no transform. +func BundleEntry(entrySource, projectRoot string) (string, error) { + js, _, err := bundleEntry(entrySource, projectRoot, false) + return js, err +} + +// ssrStubModule is the inert CommonJS module the ssr-stub plugin loads for every +// browser-only library under SSR. A permissive Proxy satisfies any named or +// default import and any no-op property access, call, construction, or assignment, +// so it works for any package without knowing its shape. +const ssrStubModule = ` +var handler = { + get: function (_t, p) { return p === "__esModule" ? true : stub; }, + apply: function () { return stub; }, + construct: function () { return stub; }, + set: function () { return true; }, +}; +var stub = new Proxy(function () {}, handler); +module.exports = stub; +` + +// bundleEntry is the shared esbuild pass behind BundleEntry. When withMeta is +// set it also returns the esbuild metafile JSON, whose "inputs" map lets the +// bundler discover (and fingerprint) the source files a page pulled in, for +// build-time change detection. Computing the metafile is skipped otherwise. +func bundleEntry(entrySource, projectRoot string, withMeta bool) (js, metafile string, err error) { + // esbuild's Alias targets and ResolveDir must be absolute: a relative + // target like "wwwroot/vendor/solid-js.js" (no leading "./") is read as a + // bare package specifier and fails to resolve. Callers pass "." (the + // server's cwd), so absolutize here. + absRoot, err := filepath.Abs(projectRoot) + if err != nil { + return "", "", fmt.Errorf("resolve project root: %w", err) + } + vendorDir := filepath.Join(absRoot, "frontend", "vendor") + + // vendor.json pins each vendored package's exact entrypoint file (see + // vendor_plugins.go) because a package's own `exports`/`main`/`module` fields + // mis-resolve under NodePaths resolution — e.g. solid-js/web's `module` field + // points at dist/server.js (the seroval-based SSR build), not the DOM dev + // build. The client bundle avoids that via vendorManifestPlugin; SSR needs the + // same pin for the solid-js family it resolves for real (see stubPlugin below). + vendorEntrypoints, err := loadVendorManifest(vendorDir) + if err != nil { + return "", "", fmt.Errorf("loading vendor manifest: %w", err) + } + + // The public pages are Solid/TSX. The .tsx files are Solid-compiled by the + // Go-native compiler (Plugin); the compiled output + + // the tagged-template pages import from solid-js/web. Resolve the solid runtime + // from frontend/vendor (dev DOM builds — SSR renders into a DOM shim, not + // renderToString), pinning bare solid-js* specifiers to their vendor.json + // entrypoint (same single source of truth as the client bundle) and falling + // back to NodePaths for any solid-js* subpath vendor.json doesn't list. Every + // OTHER bare import (fontawesome, pdf-lib, pdfjs-dist, chart.js, @solidjs/router, + // …) resolves to an inert stub: public pages skip their heavy UI under SSR + // (globalThis.__SSR__), so the stub only needs to satisfy the graph. Generating + // the stub means no hardcoded list and no stub files to maintain. + stubPlugin := esbuild.Plugin{ + Name: "ssr-stub", + Setup: func(build esbuild.PluginBuild) { + build.OnResolve(esbuild.OnResolveOptions{Filter: `^[^./]`}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) { + if args.Kind == esbuild.ResolveEntryPoint || filepath.IsAbs(args.Path) || strings.HasPrefix(args.Path, ".") { + return esbuild.OnResolveResult{}, nil + } + if strings.HasPrefix(args.Path, "solid-js") { + if target, ok := vendorEntrypoints[args.Path]; ok { + abs, err := filepath.Abs(filepath.Join(vendorDir, filepath.FromSlash(target))) + if err != nil { + return esbuild.OnResolveResult{}, err + } + return esbuild.OnResolveResult{Path: filepath.ToSlash(abs)}, nil // pinned dev entrypoint + } + return esbuild.OnResolveResult{}, nil // unpinned subpath -> real solid runtime via NodePaths + } + return esbuild.OnResolveResult{Path: args.Path, Namespace: "ssr-stub"}, nil + }) + build.OnLoad(esbuild.OnLoadOptions{Filter: `.*`, Namespace: "ssr-stub"}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { + contents := ssrStubModule + loader := esbuild.LoaderJS + return esbuild.OnLoadResult{Contents: &contents, Loader: loader}, nil + }) + }, + } + // The Go Solid compiler (Plugin) compiles .tsx before the catch-all stub sees + // any of its imports. + plugins := []esbuild.Plugin{Plugin(), stubPlugin} + + result := esbuild.Build(esbuild.BuildOptions{ + Stdin: &esbuild.StdinOptions{ + Contents: entrySource, + ResolveDir: absRoot, + Sourcefile: "ssr-entry.js", + Loader: esbuild.LoaderJS, + }, + Bundle: true, + Format: esbuild.FormatIIFE, + Target: esbuild.ES2017, + Platform: esbuild.PlatformBrowser, + Conditions: []string{"development"}, + NodePaths: []string{vendorDir}, + Plugins: plugins, + LogLevel: esbuild.LogLevelSilent, + Write: false, + Metafile: withMeta, + }) + if len(result.Errors) > 0 { + msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{}) + return "", "", fmt.Errorf("esbuild: %s", strings.Join(msgs, "\n")) + } + if len(result.OutputFiles) == 0 { + return "", "", fmt.Errorf("esbuild produced no output") + } + return string(result.OutputFiles[0].Contents), result.Metafile, nil +} + +// engineCacheVersion is bumped by hand when the SSR engine changes in a way +// that affects rendered output but isn't captured by the dom.js/prelude source +// below (e.g. a change in serialization in renderer.go). +// +// "2" — public pages migrated from solid-js/html to React/TSX; SSR now renders +// via react-dom/server renderToString instead of the DOM-shim serializer. +// "3" — public pages migrated to Solid JSX/TSX (compiled via the in-package Solid JSX compiler, jsx.go); +// SSR back on the DOM-shim + serialize path, React prelude shims removed. +const engineCacheVersion = "3" + +// EngineHash fingerprints the SSR engine — the DOM shim, the prelude, and a +// manual version. The bundler folds it into its public-page render cache so an +// engine change invalidates every cached page (their input files wouldn't have +// changed, but their rendered output would). +func EngineHash() string { + h := sha256.New() + h.Write([]byte(engineCacheVersion)) + h.Write([]byte{0}) + h.Write([]byte(preludeJS)) + h.Write([]byte{0}) + h.Write([]byte(domJS)) + return hex.EncodeToString(h.Sum(nil)) +} + +// ---- HTML parse bridge (x/net/html) ------------------------------------ + +// jnode is the compact JSON shape dom.js rebuilds shim nodes from. +type jnode struct { + T string `json:"t"` // "e" element, "t" text, "c" comment + N string `json:"n,omitempty"` // element tag name + NS string `json:"ns,omitempty"` // "svg" / "math" for foreign content + A map[string]string `json:"a,omitempty"` // attributes + C []*jnode `json:"c,omitempty"` // children + D string `json:"d,omitempty"` // text / comment data +} + +// parseHTMLToJSON parses an innerHTML fragment using template-content +// semantics (so <table> gets its implicit <tbody>, void elements close, and +// entities decode per the HTML5 spec) and returns it as a JSON node array. +func parseHTMLToJSON(fragment string) string { + ctx := &html.Node{Type: html.ElementNode, DataAtom: atom.Template, Data: "template"} + nodes, err := html.ParseFragment(strings.NewReader(fragment), ctx) + if err != nil { + return "[]" + } + roots := make([]*jnode, 0, len(nodes)) + for _, n := range nodes { + if jn := convert(n); jn != nil { + roots = append(roots, jn) + } + } + b, err := json.Marshal(roots) + if err != nil { + return "[]" + } + return string(b) +} + +func convert(n *html.Node) *jnode { + switch n.Type { + case html.ElementNode: + jn := &jnode{T: "e", N: n.Data} + if n.Namespace == "svg" || n.Namespace == "math" { + jn.NS = n.Namespace + } + if len(n.Attr) > 0 { + jn.A = make(map[string]string, len(n.Attr)) + for _, a := range n.Attr { + key := a.Key + if a.Namespace != "" { + key = a.Namespace + ":" + a.Key + } + jn.A[key] = a.Val + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if cj := convert(c); cj != nil { + jn.C = append(jn.C, cj) + } + } + return jn + case html.TextNode: + return &jnode{T: "t", D: n.Data} + case html.CommentNode: + return &jnode{T: "c", D: n.Data} + } + return nil +} diff --git a/bundler/ssr_test.go b/bundler/ssr_test.go new file mode 100644 index 00000000..8b8daacd --- /dev/null +++ b/bundler/ssr_test.go @@ -0,0 +1,145 @@ +package bundler + +import ( + "path/filepath" + "strings" + "testing" + "time" +) + +// entryHome renders the real home-page component the way ssrEntrySolid does: +// plain JS (createComponent, no JSX in the entry) importing the .tsx page, which +// the Go Solid compiler compiles. PublicLayout is omitted to target the body. +const entryHome = ` +import { render, createComponent } from "solid-js/web"; +import { Home } from "./frontend/src/pages/public/Home.tsx"; +globalThis.__render = function () { + const root = document.createElement("div"); + const dispose = render(function () { return createComponent(Home, {}); }, root); + const out = globalThis.__serialize(root); + dispose(); + return out; +}; +` + +// Proves the ISR data-injection path the server uses: a pre-bundled render entry +// run in goja with server data injected (RenderBundleWithData) makes Home render +// the injected rates (via serverData()) instead of the loading skeleton. +func TestRenderHomeWithServerData(t *testing.T) { + root, err := filepath.Abs("../..") + if err != nil { + t.Fatalf("abs project root: %v", err) + } + t.Chdir(root) + + data := `{"rates":[{"term":"90 Day","low":"1.111%","high":"2.222%","avg":"1.500%"}]}` + js, err := BundleEntry(entryHome, ".") + if err != nil { + t.Fatalf("bundle: %v", err) + } + out, err := RenderBundleWithData(js, data) + if err != nil { + t.Fatalf("render with data: %v", err) + } + for _, want := range []string{"1.111%", "2.222%", "1.500%"} { + if !strings.Contains(out, want) { + t.Errorf("output missing injected rate %q", want) + } + } + // With data present the table shows it, not the loading skeleton. + if strings.Contains(out, "animate-pulse") { + t.Errorf("skeleton rendered despite injected data") + } +} + +// Reproduces the server's setup (cwd at repo root, relative "." project root). +// Guards the esbuild alias-resolution bug where a non-absolute root yields +// bare-specifier alias targets that fail to resolve. +func TestRenderWithRelativeRoot(t *testing.T) { + root, err := filepath.Abs("../..") + if err != nil { + t.Fatalf("abs project root: %v", err) + } + t.Chdir(root) + + r := NewRenderer(".", entryHome, true) + out, err := r.HTML() + if err != nil { + t.Fatalf("render with relative root: %v", err) + } + if !strings.Contains(out, `class="page-home"`) { + t.Errorf("output missing page-home") + } +} + +// Confirms the cache: the first HTML() pays bundle+render, the second (no source +// change) returns the cached string near-instantly. +func TestRendererCaching(t *testing.T) { + root, err := filepath.Abs("../..") + if err != nil { + t.Fatalf("abs project root: %v", err) + } + r := NewRenderer(root, entryHome, false) // prod: warm HTML() returns the cached string directly + + t0 := time.Now() + first, err := r.HTML() + if err != nil { + t.Fatalf("cold render: %v", err) + } + cold := time.Since(t0) + + t1 := time.Now() + second, err := r.HTML() + if err != nil { + t.Fatalf("warm render: %v", err) + } + warm := time.Since(t1) + t.Logf("cold=%v warm=%v", cold, warm) + + if first != second { + t.Errorf("cached output differs from first render") + } + if warm > cold/4 { + t.Errorf("cache hit too slow: cold=%v warm=%v", cold, warm) + } +} + +// No data → the home page renders its loading skeleton, exercising compiled Solid +// against inline SVGs (viewBox case), string style attributes, entities, and the +// table. SVG attribute case and the style string must survive serialization. +func TestRenderHomeSkeleton(t *testing.T) { + root, err := filepath.Abs("../..") + if err != nil { + t.Fatalf("abs project root: %v", err) + } + bundle, err := BundleEntry(entryHome, root) + if err != nil { + t.Fatalf("bundle: %v", err) + } + eng, err := New() + if err != nil { + t.Fatalf("engine: %v", err) + } + if err := eng.LoadBundle(bundle); err != nil { + t.Fatalf("load bundle: %v", err) + } + out, err := eng.Render() + if err != nil { + t.Fatalf("render: %v", err) + } + t.Logf("HOME SKELETON (%d bytes):\n%s", len(out), out) + + for _, want := range []string{ + `class="page-home"`, + `The Ultimate Funding and Investing Solution`, + `viewBox="0 0 24 24"`, // SVG attribute case preserved + `background-image: url(/images/public/hero-bg.jpg)`, // static string style baked into the template + `animate-pulse`, // skeleton bars (SSR forces showSkeleton) + `90 Day`, // term labels shown in the skeleton + `Schedule a free demo`, + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q", want) + } + } +} diff --git a/bundler/ssrcache.go b/bundler/ssrcache.go new file mode 100644 index 00000000..b4a5d841 --- /dev/null +++ b/bundler/ssrcache.go @@ -0,0 +1,192 @@ +package bundler + +// Build-time caching + parallelism for public-page SSR (see genssr.go). +// +// Rendering a page means bundling it with esbuild and running it through a goja +// runtime — too slow to repeat for every page on every build when nothing +// changed. So each render records the source files esbuild pulled in (from the +// metafile) and their content hashes; a later build reuses the cached HTML when +// the page's entry and every input file are byte-identical. Cache misses are +// rendered concurrently, one goja runtime per worker. +// +// The cache lives under tmp/ (gitignored) and is purely an optimization: any +// read/parse/write error just falls back to rendering. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sync" +) + +// ssrCachePath is the on-disk render cache (gitignored via tmp/). +var ssrCachePath = filepath.Join("tmp", "ssr-cache.json") + +// ssrCacheEntry fingerprints one rendered page. +type ssrCacheEntry struct { + EntryHash string `json:"entry"` // hash of the goja entry source + Inputs map[string]string `json:"inputs"` // input file path -> content hash + HTML string `json:"html"` // the rendered, baked body + RenderJS string `json:"renderJS,omitempty"` // bundled JS, baked for dynamic (ISR) pages +} + +// ssrCache is the whole render cache, keyed by page URL path. +type ssrCache struct { + Engine string `json:"engine"` // EngineHash() at write time + Pages map[string]ssrCacheEntry `json:"pages"` +} + +func loadSSRCache() ssrCache { + data, err := os.ReadFile(ssrCachePath) + if err != nil { + return ssrCache{} + } + var c ssrCache + if json.Unmarshal(data, &c) != nil || c.Pages == nil { + return ssrCache{} + } + return c +} + +func saveSSRCache(c ssrCache) { + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(ssrCachePath), 0o755); err != nil { + return + } + _ = os.WriteFile(ssrCachePath, data, 0o644) +} + +func hashString(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// fileHasher memoizes content hashes within a single build so files shared by +// several pages (the vendored Solid runtime, PublicLayout, shared UI) are read +// and hashed once. Safe for concurrent use. +type fileHasher struct { + mu sync.Mutex + m map[string]string // path -> hex hash; "" records a read failure +} + +func newFileHasher() *fileHasher { return &fileHasher{m: map[string]string{}} } + +// hash returns the file's content hash and whether it was readable. +func (f *fileHasher) hash(path string) (string, bool) { + f.mu.Lock() + if h, ok := f.m[path]; ok { + f.mu.Unlock() + return h, h != "" + } + f.mu.Unlock() + + h := "" + if data, err := os.ReadFile(path); err == nil { + sum := sha256.Sum256(data) + h = hex.EncodeToString(sum[:]) + } + + f.mu.Lock() + f.m[path] = h + f.mu.Unlock() + return h, h != "" +} + +// inputsUnchanged reports whether every recorded input still hashes the same. +// A page can't gain a new dependency without editing one of these files, so an +// all-match means the bundle (and thus the rendered output) is identical. +func inputsUnchanged(old map[string]string, h *fileHasher) bool { + if len(old) == 0 { + return false + } + for path, want := range old { + got, ok := h.hash(path) + if !ok || got != want { + return false + } + } + return true +} + +func hashInputs(paths []string, h *fileHasher) map[string]string { + m := make(map[string]string, len(paths)) + for _, p := range paths { + if sum, ok := h.hash(p); ok { + m[p] = sum + } + } + return m +} + +// renderJob / renderResult carry a cache-miss page through the worker pool. +type renderJob struct { + idx int + path string + component string + entry string +} + +type renderResult struct { + idx int + path string + html string + js string // bundled render entry (baked for dynamic/ISR pages) + inputs []string +} + +// renderMisses renders the given jobs concurrently — one goja runtime per +// worker, capped at NumCPU — and returns their results in input order. On the +// first render error it stops reporting and returns that error. +func renderMisses(jobs []renderJob) ([]renderResult, error) { + if len(jobs) == 0 { + return nil, nil + } + + limit := runtime.NumCPU() + if limit < 1 { + limit = 1 + } + if limit > len(jobs) { + limit = len(jobs) + } + + results := make([]renderResult, len(jobs)) + sem := make(chan struct{}, limit) + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr error + + for k := range jobs { + wg.Add(1) + go func(k int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + j := jobs[k] + html, js, inputs, err := RenderEntryFull(j.entry, ".") + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = fmt.Errorf("rendering %s (%s): %w", j.path, j.component, err) + } + mu.Unlock() + return + } + results[k] = renderResult{idx: j.idx, path: j.path, html: html, js: js, inputs: inputs} + }(k) + } + wg.Wait() + + if firstErr != nil { + return nil, firstErr + } + return results, nil +} diff --git a/bundler/tailwind.go b/bundler/tailwind.go new file mode 100644 index 00000000..b4e94af2 --- /dev/null +++ b/bundler/tailwind.go @@ -0,0 +1,9586 @@ +package bundler + +// Native Go Tailwind v4 compiler. A from-scratch, pure-Go implementation of the +// Tailwind v4 engine — CSS parser (AST), candidate scanner, utility/variant +// generation, design system + theme resolution, sorting, and preflight — with no +// Node, no goja, no official tailwindcss distribution. Consolidated here from the +// former cmd/bundle/tw_*.go engine. Entry points: twCompile (compile a config + +// candidates to CSS) and scanSources (scan source files for utility candidates). + +import ( + _ "embed" + "fmt" + "math" + "math/big" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// Port of packages/tailwindcss/src/ast.ts +// +// A single mutable node struct with a Kind discriminator (rather than one Go +// type per kind). This mirrors the upstream structurally-typed objects and is +// required because variants morph a node's kind in place (the equivalent of +// `Object.assign(node, styleRule(...))`), which is `*node = *other` here. +// +// Deviations (tracked follow-ups): +// @INCOMPLETE optimizeAst does not yet prune unused @theme variables/keyframes +// or emit color-mix()/@property browser fallbacks (Polyfills). -mta + +type nodeKind int + +const ( + nRule nodeKind = iota + nAtRule + nDeclaration + nComment + nContext + nAtRoot +) + +type AstNode struct { + Kind nodeKind + + // rule + Selector string + + // at-rule + Name string + Params string + + // declaration + Property string + Value string + Important bool + Undefined bool // value is `undefined` upstream; dropped by optimizeAst + + // context + Context map[string]string + + // children: rule / at-rule / context / at-root + Nodes []*AstNode +} + +// ---- factories ---------------------------------------------------------- + +func styleRule(selector string, nodes ...*AstNode) *AstNode { + return &AstNode{Kind: nRule, Selector: selector, Nodes: nodes} +} + +func atRule(name, params string, nodes ...*AstNode) *AstNode { + return &AstNode{Kind: nAtRule, Name: name, Params: params, Nodes: nodes} +} + +func rule(selector string, nodes ...*AstNode) *AstNode { + if len(selector) > 0 && selector[0] == '@' { + return parseAtRule(selector, nodes) + } + return styleRule(selector, nodes...) +} + +func decl(property, value string) *AstNode { + return &AstNode{Kind: nDeclaration, Property: property, Value: value} +} + +func declImportant(property, value string) *AstNode { + return &AstNode{Kind: nDeclaration, Property: property, Value: value, Important: true} +} + +func comment(value string) *AstNode { return &AstNode{Kind: nComment, Value: value} } + +func contextNode(ctx map[string]string, nodes []*AstNode) *AstNode { + return &AstNode{Kind: nContext, Context: ctx, Nodes: nodes} +} + +func atRoot(nodes []*AstNode) *AstNode { return &AstNode{Kind: nAtRoot, Nodes: nodes} } + +// nodeChildren returns a pointer to a node's child slice, or nil for leaves. +func nodeChildren(n *AstNode) *[]*AstNode { + switch n.Kind { + case nRule, nAtRule, nContext, nAtRoot: + return &n.Nodes + } + return nil +} + +// ---- clone -------------------------------------------------------------- + +func cloneAstNode(node *AstNode) *AstNode { + cp := *node + if node.Nodes != nil { + cp.Nodes = cloneAstNodes(node.Nodes) + } + if node.Context != nil { + m := make(map[string]string, len(node.Context)) + for k, v := range node.Context { + m[k] = v + } + cp.Context = m + } + return &cp +} + +func cloneAstNodes(nodes []*AstNode) []*AstNode { + out := make([]*AstNode, len(nodes)) + for i, n := range nodes { + out[i] = cloneAstNode(n) + } + return out +} + +// ---- optimize ----------------------------------------------------------- + +func optimizeAst(ast []*AstNode, ds *DesignSystem) []*AstNode { + var atRoots []*AstNode + seenAtProperties := make(map[string]bool) + + var transform func(node *AstNode, parent *[]*AstNode, ctx map[string]bool, depth int) + transform = func(node *AstNode, parent *[]*AstNode, ctx map[string]bool, depth int) { + switch node.Kind { + case nDeclaration: + if node.Property == "--tw-sort" || node.Undefined { + return + } + if ctx["theme"] && strings.HasPrefix(node.Property, "--") && node.Value == "initial" { + return + } + if ds != nil && strings.Contains(node.Value, "var(") { + if !(ctx["theme"] && strings.HasPrefix(node.Property, "--")) { + ds.trackUsedVariables(node.Value) + } + } + *parent = append(*parent, node) + + case nRule: + var nodes []*AstNode + for _, child := range node.Nodes { + transform(child, &nodes, ctx, depth+1) + } + nodes = dedupeDeclarations(nodes) + if len(nodes) == 0 { + return + } + if node.Selector == "&" { + *parent = append(*parent, nodes...) + } else { + *parent = append(*parent, &AstNode{Kind: nRule, Selector: node.Selector, Nodes: nodes}) + } + + case nAtRule: + if node.Name == "@property" && depth == 0 { + if seenAtProperties[node.Params] { + return + } + seenAtProperties[node.Params] = true + var copyNodes []*AstNode + for _, child := range node.Nodes { + transform(child, ©Nodes, ctx, depth+1) + } + *parent = append(*parent, &AstNode{Kind: nAtRule, Name: node.Name, Params: node.Params, Nodes: copyNodes}) + return + } + + childCtx := ctx + if node.Name == "@keyframes" { + childCtx = mergeCtx(ctx, "keyframes") + } else if node.Name == "@supports" && strings.Contains(node.Params, "color-mix(") { + childCtx = mergeCtx(ctx, "supportsColorMix") + } + var copyNodes []*AstNode + for _, child := range node.Nodes { + transform(child, ©Nodes, childCtx, depth+1) + } + if len(copyNodes) > 0 || + node.Name == "@layer" || node.Name == "@charset" || node.Name == "@custom-media" || + node.Name == "@namespace" || node.Name == "@import" || node.Name == "@apply" { + *parent = append(*parent, &AstNode{Kind: nAtRule, Name: node.Name, Params: node.Params, Nodes: copyNodes}) + } + + case nAtRoot: + for _, child := range node.Nodes { + var newParent []*AstNode + transform(child, &newParent, ctx, 0) + atRoots = append(atRoots, newParent...) + } + + case nContext: + if node.Context["reference"] != "" { + return + } + merged := ctx + for k := range node.Context { + merged = mergeCtx(merged, k) + } + for _, child := range node.Nodes { + transform(child, parent, merged, depth) + } + + case nComment: + *parent = append(*parent, node) + } + } + + var newAst []*AstNode + for _, node := range ast { + transform(node, &newAst, map[string]bool{}, 0) + } + newAst = append(newAst, atRoots...) + return newAst +} + +func mergeCtx(ctx map[string]bool, key string) map[string]bool { + m := make(map[string]bool, len(ctx)+1) + for k, v := range ctx { + m[k] = v + } + m[key] = true + return m +} + +func dedupeDeclarations(nodes []*AstNode) []*AstNode { + seen := map[string][]int{} + for i, child := range nodes { + if child.Kind != nDeclaration { + continue + } + key := child.Property + ":" + child.Value + ":" + if child.Important { + key += "!" + } + seen[key] = append(seen[key], i) + } + remove := map[int]bool{} + for _, idxs := range seen { + for i := 0; i < len(idxs)-1; i++ { + remove[idxs[i]] = true + } + } + if len(remove) == 0 { + return nodes + } + out := make([]*AstNode, 0, len(nodes)) + for i, n := range nodes { + if !remove[i] { + out = append(out, n) + } + } + return out +} + +// ---- serialization ------------------------------------------------------ + +func toCss(ast []*AstNode) string { + var b strings.Builder + for _, node := range ast { + stringifyNode(&b, node, 0) + } + return b.String() +} + +func stringifyNode(b *strings.Builder, node *AstNode, depth int) { + indent := strings.Repeat(" ", depth) + switch node.Kind { + case nDeclaration: + if node.Undefined { + return + } + b.WriteString(indent) + b.WriteString(node.Property) + b.WriteString(": ") + b.WriteString(node.Value) + if node.Important { + b.WriteString(" !important") + } + b.WriteString(";\n") + + case nRule: + b.WriteString(indent) + b.WriteString(node.Selector) + b.WriteString(" {\n") + for _, child := range node.Nodes { + stringifyNode(b, child, depth+1) + } + b.WriteString(indent) + b.WriteString("}\n") + + case nAtRule: + if len(node.Nodes) == 0 { + b.WriteString(indent) + b.WriteString(node.Name) + if node.Params != "" { + b.WriteString(" ") + b.WriteString(node.Params) + } + b.WriteString(";\n") + return + } + b.WriteString(indent) + b.WriteString(node.Name) + if node.Params != "" { + b.WriteString(" ") + b.WriteString(node.Params) + } + b.WriteString(" {\n") + for _, child := range node.Nodes { + stringifyNode(b, child, depth+1) + } + b.WriteString(indent) + b.WriteString("}\n") + + case nComment: + b.WriteString(indent) + b.WriteString("/*") + b.WriteString(node.Value) + b.WriteString("*/\n") + } +} + +func extractUsedVariables(value string) []string { + if !strings.Contains(value, "var(") { + return nil + } + var out []string + var rec func(nodes []ValueNode) + rec = func(nodes []ValueNode) { + for _, n := range nodes { + f, ok := n.(*ValueFunction) + if !ok { + continue + } + if f.Value == "var" || strings.HasSuffix(f.Value, "_var") { + if len(f.Nodes) > 0 { + if w, ok := f.Nodes[0].(*ValueWord); ok && strings.HasPrefix(w.Value, "--") { + out = append(out, w.Value) + } + } + } + rec(f.Nodes) + } + } + rec(valueParse(value)) + return out +} + +// Port of packages/tailwindcss/src/candidate.ts +// +// Parses a class name into structured candidate(s). The TS discriminated unions +// are modeled as structs with a Kind field. parseCandidate may yield multiple +// candidates (e.g. a static match plus functional roots); the caller compiles +// the first that succeeds. + +var reValidNamedValue = regexp.MustCompile(`^[a-zA-Z0-9_.%-]+$`) + +type candidateKind int + +const ( + candArbitrary candidateKind = iota + candStatic + candFunctional +) + +type modifierKind int + +const ( + modArbitrary modifierKind = iota + modNamed +) + +type CandidateModifier struct { + Kind modifierKind + Value string +} + +type utilityValueKind int + +const ( + uvArbitrary utilityValueKind = iota + uvNamed +) + +// UtilityValue is the value of a functional candidate. DataType/Fraction are "" +// when absent (upstream null). +type UtilityValue struct { + Kind utilityValueKind + DataType string // arbitrary only + Value string + Fraction string // named only +} + +type variantKind int + +const ( + varArbitrary variantKind = iota + varStatic + varFunctional + varCompound +) + +type variantValueKind int + +const ( + vvArbitrary variantValueKind = iota + vvNamed +) + +type VariantValue struct { + Kind variantValueKind + Value string +} + +type Variant struct { + Kind variantKind + + // arbitrary + Selector string + Relative bool + + // static / functional / compound + Root string + + // functional + Value *VariantValue + + // functional / compound + Modifier *CandidateModifier + + // compound + Variant *Variant +} + +type Candidate struct { + Kind candidateKind + + // arbitrary + Property string + ArbitraryValue string + + // static / functional + Root string + + // functional + Value *UtilityValue + + // arbitrary / functional + Modifier *CandidateModifier + + Variants []*Variant + Important bool + Raw string +} + +type rootMatch struct { + root string + value *string // nil = null +} + +func parseCandidate(input string, ds *DesignSystem) []*Candidate { + rawVariants := segment(input, ":") + + if ds.theme.Prefix != "" { + if len(rawVariants) == 1 { + return nil + } + if rawVariants[0] != ds.theme.Prefix { + return nil + } + rawVariants = rawVariants[1:] + } + + base := rawVariants[len(rawVariants)-1] + rawVariants = rawVariants[:len(rawVariants)-1] + + var parsedVariants []*Variant + for i := len(rawVariants) - 1; i >= 0; i-- { + pv := ds.parseVariant(rawVariants[i]) + if pv == nil { + return nil + } + parsedVariants = append(parsedVariants, pv) + } + + important := false + if len(base) > 0 && base[len(base)-1] == '!' { + important = true + base = base[:len(base)-1] + } else if len(base) > 0 && base[0] == '!' { + important = true + base = base[1:] + } + + var out []*Candidate + + if ds.utilities.has(base, utilStatic) && !strings.Contains(base, "[") { + out = append(out, &Candidate{ + Kind: candStatic, Root: base, Variants: parsedVariants, Important: important, Raw: input, + }) + } + + parts := segment(base, "/") + baseWithoutModifier := parts[0] + var modifierSegment *string + if len(parts) >= 2 { + modifierSegment = &parts[1] + } + if len(parts) >= 3 { + return out // more than one modifier -> invalid + } + + var parsedModifier *CandidateModifier + if modifierSegment != nil { + parsedModifier = parseModifier(*modifierSegment) + if parsedModifier == nil { + return out + } + } + + // Arbitrary property, e.g. [color:red] + if len(baseWithoutModifier) > 0 && baseWithoutModifier[0] == '[' { + if baseWithoutModifier[len(baseWithoutModifier)-1] != ']' { + return out + } + charCode := baseWithoutModifier[1] + if charCode != '-' && !(charCode >= 'a' && charCode <= 'z') { + return out + } + inner := baseWithoutModifier[1 : len(baseWithoutModifier)-1] + idx := strings.IndexByte(inner, ':') + if idx == -1 || idx == 0 || idx == len(inner)-1 { + return out + } + property := inner[:idx] + value := decodeArbitraryValue(inner[idx+1:]) + if !isValidArbitrary(value) { + return out + } + out = append(out, &Candidate{ + Kind: candArbitrary, Property: property, ArbitraryValue: value, + Modifier: parsedModifier, Variants: parsedVariants, Important: important, Raw: input, + }) + return out + } + + var roots []rootMatch + if n := len(baseWithoutModifier); n > 0 && baseWithoutModifier[n-1] == ']' { + idx := strings.Index(baseWithoutModifier, "-[") + if idx == -1 { + return out + } + root := baseWithoutModifier[:idx] + if !ds.utilities.has(root, utilFunctional) { + return out + } + value := baseWithoutModifier[idx+1:] + roots = []rootMatch{{root: root, value: &value}} + } else if n > 0 && baseWithoutModifier[n-1] == ')' { + idx := strings.Index(baseWithoutModifier, "-(") + if idx == -1 { + return out + } + root := baseWithoutModifier[:idx] + if !ds.utilities.has(root, utilFunctional) { + return out + } + value := baseWithoutModifier[idx+2 : len(baseWithoutModifier)-1] + vparts := segment(value, ":") + dataType := "" + if len(vparts) == 2 { + dataType = vparts[0] + value = vparts[1] + } + if len(value) < 2 || value[0] != '-' || value[1] != '-' { + return out + } + if !isValidArbitrary(value) { + return out + } + var wrapped string + if dataType == "" { + wrapped = "[var(" + value + ")]" + } else { + wrapped = "[" + dataType + ":var(" + value + ")]" + } + roots = []rootMatch{{root: root, value: &wrapped}} + } else { + roots = findRoots(baseWithoutModifier, func(r string) bool { return ds.utilities.has(r, utilFunctional) }) + } + + for _, rm := range roots { + cand := &Candidate{ + Kind: candFunctional, Root: rm.root, Modifier: parsedModifier, Value: nil, + Variants: parsedVariants, Important: important, Raw: input, + } + + if rm.value == nil { + out = append(out, cand) + continue + } + + value := *rm.value + startArb := strings.IndexByte(value, '[') + if startArb != -1 { + if value[len(value)-1] != ']' { + return out + } + arbitraryValue := decodeArbitraryValue(value[startArb+1 : len(value)-1]) + if !isValidArbitrary(arbitraryValue) { + continue + } + typehint := "" + typehintFound := false + for i := 0; i < len(arbitraryValue); i++ { + code := arbitraryValue[i] + if code == ':' { + typehint = arbitraryValue[:i] + arbitraryValue = arbitraryValue[i+1:] + typehintFound = true + break + } + if code == '-' || (code >= 'a' && code <= 'z') { + continue + } + break + } + if len(arbitraryValue) == 0 || strings.TrimSpace(arbitraryValue) == "" { + continue + } + if typehintFound && typehint == "" { + continue + } + cand.Value = &UtilityValue{Kind: uvArbitrary, DataType: typehint, Value: arbitraryValue} + } else { + fraction := "" + if modifierSegment != nil && !(parsedModifier != nil && parsedModifier.Kind == modArbitrary) { + fraction = value + "/" + *modifierSegment + } + if !reValidNamedValue.MatchString(value) { + continue + } + cand.Value = &UtilityValue{Kind: uvNamed, Value: value, Fraction: fraction} + } + + out = append(out, cand) + } + + return out +} + +func parseModifier(modifier string) *CandidateModifier { + if len(modifier) >= 2 && modifier[0] == '[' && modifier[len(modifier)-1] == ']' { + arb := decodeArbitraryValue(modifier[1 : len(modifier)-1]) + if !isValidArbitrary(arb) { + return nil + } + if len(arb) == 0 || strings.TrimSpace(arb) == "" { + return nil + } + return &CandidateModifier{Kind: modArbitrary, Value: arb} + } + + if len(modifier) >= 2 && modifier[0] == '(' && modifier[len(modifier)-1] == ')' { + inner := modifier[1 : len(modifier)-1] + if len(inner) < 2 || inner[0] != '-' || inner[1] != '-' { + return nil + } + if !isValidArbitrary(inner) { + return nil + } + arb := decodeArbitraryValue("var(" + inner + ")") + return &CandidateModifier{Kind: modArbitrary, Value: arb} + } + + if !reValidNamedValue.MatchString(modifier) { + return nil + } + return &CandidateModifier{Kind: modNamed, Value: modifier} +} + +func parseVariant(variant string, ds *DesignSystem) *Variant { + // Arbitrary variants, e.g. [&_p] + if len(variant) >= 2 && variant[0] == '[' && variant[len(variant)-1] == ']' { + if variant[1] == '@' && strings.Contains(variant, "&") { + return nil + } + selector := decodeArbitraryValue(variant[1 : len(variant)-1]) + if !isValidArbitrary(selector) { + return nil + } + if len(selector) == 0 || strings.TrimSpace(selector) == "" { + return nil + } + relative := selector[0] == '>' || selector[0] == '+' || selector[0] == '~' + if !relative && selector[0] != '@' && !strings.Contains(selector, "&") { + selector = "&:is(" + selector + ")" + } + return &Variant{Kind: varArbitrary, Selector: selector, Relative: relative} + } + + parts := segment(variant, "/") + variantWithoutModifier := parts[0] + var modifier *string + if len(parts) >= 2 { + modifier = &parts[1] + } + if len(parts) >= 3 { + return nil + } + + roots := findRoots(variantWithoutModifier, func(r string) bool { return ds.variants.has(r) }) + for _, rm := range roots { + root := rm.root + value := rm.value + switch ds.variants.kind(root) { + case varStatic: + if value != nil { + return nil + } + if modifier != nil { + return nil + } + return &Variant{Kind: varStatic, Root: root} + + case varFunctional: + var parsedModifier *CandidateModifier + if modifier != nil { + parsedModifier = parseModifier(*modifier) + if parsedModifier == nil { + return nil + } + } + if value == nil { + return &Variant{Kind: varFunctional, Root: root, Modifier: parsedModifier, Value: nil} + } + v := *value + if v[len(v)-1] == ']' { + if v[0] != '[' { + continue + } + arb := decodeArbitraryValue(v[1 : len(v)-1]) + if !isValidArbitrary(arb) { + return nil + } + if len(arb) == 0 || strings.TrimSpace(arb) == "" { + return nil + } + return &Variant{Kind: varFunctional, Root: root, Modifier: parsedModifier, Value: &VariantValue{Kind: vvArbitrary, Value: arb}} + } + if v[len(v)-1] == ')' { + if v[0] != '(' { + continue + } + arb := decodeArbitraryValue(v[1 : len(v)-1]) + if !isValidArbitrary(arb) { + return nil + } + if len(arb) == 0 || strings.TrimSpace(arb) == "" { + return nil + } + if len(arb) < 2 || arb[0] != '-' || arb[1] != '-' { + return nil + } + return &Variant{Kind: varFunctional, Root: root, Modifier: parsedModifier, Value: &VariantValue{Kind: vvArbitrary, Value: "var(" + arb + ")"}} + } + if !reValidNamedValue.MatchString(v) { + continue + } + return &Variant{Kind: varFunctional, Root: root, Modifier: parsedModifier, Value: &VariantValue{Kind: vvNamed, Value: v}} + + case varCompound: + if value == nil { + return nil + } + v := *value + mod := modifier + if mod != nil && (root == "not" || root == "has" || root == "in") { + v = v + "/" + *mod + mod = nil + } + subVariant := ds.parseVariant(v) + if subVariant == nil { + return nil + } + if !ds.variants.compoundsWith(root, subVariant) { + return nil + } + var parsedModifier *CandidateModifier + if mod != nil { + parsedModifier = parseModifier(*mod) + if parsedModifier == nil { + return nil + } + } + return &Variant{Kind: varCompound, Root: root, Modifier: parsedModifier, Variant: subVariant} + } + } + + return nil +} + +func findRoots(input string, exists func(string) bool) []rootMatch { + var out []rootMatch + if exists(input) { + out = append(out, rootMatch{root: input, value: nil}) + } + + idx := strings.LastIndexByte(input, '-') + for idx > 0 { + maybeRoot := input[:idx] + if exists(maybeRoot) { + val := input[idx+1:] + if val == "" { + break + } + if len(maybeRoot) > 0 && maybeRoot[0] == '@' && exists("@") && input[idx] == '-' { + break + } + v := val + out = append(out, rootMatch{root: maybeRoot, value: &v}) + } + idx = strings.LastIndexByte(input[:idx], '-') + } + + if len(input) > 0 && input[0] == '@' && exists("@") { + v := input[1:] + out = append(out, rootMatch{root: "@", value: &v}) + } + + return out +} + +// Port of packages/tailwindcss/src/utils/compare-breakpoints.ts + +var reBpDigits = regexp.MustCompile(`[\d.]+`) + +func parseIntJS(s string) (int, bool) { + i := 0 + for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r' || s[i] == '\f' || s[i] == '\v') { + i++ + } + sign := 1 + if i < len(s) && (s[i] == '+' || s[i] == '-') { + if s[i] == '-' { + sign = -1 + } + i++ + } + start := i + n := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + n = n*10 + int(s[i]-'0') + i++ + } + if i == start { + return 0, false + } + return sign * n, true +} + +func compareBreakpoints(a, z, direction string) int { + if a == z { + return 0 + } + aIs := strings.IndexByte(a, '(') + zIs := strings.IndexByte(z, '(') + + var aBucket, zBucket string + if aIs == -1 { + aBucket = reBpDigits.ReplaceAllString(a, "") + } else { + aBucket = a[:aIs] + } + if zIs == -1 { + zBucket = reBpDigits.ReplaceAllString(z, "") + } else { + zBucket = z[:zIs] + } + + if aBucket != zBucket { + if aBucket < zBucket { + return -1 + } + return 1 + } + + ai, aok := parseIntJS(a) + zi, zok := parseIntJS(z) + if !aok || !zok { + if a < z { + return -1 + } + return 1 + } + if direction == "asc" { + return ai - zi + } + return zi - ai +} + +// Port of packages/tailwindcss/src/compile.ts + +type CompileAstFlags int + +const ( + CompileNone CompileAstFlags = 0 + RespectImportant CompileAstFlags = 1 << 0 +) + +type propertySort struct { + order []int + count int +} + +type compiledNode struct { + node *AstNode + propertySort propertySort +} + +type nodeSortMeta struct { + properties propertySort + variants *big.Int + candidate string +} + +var twPropertyOrderIndex = func() map[string]int { + m := make(map[string]int, len(twPropertyOrder)) + for i, p := range twPropertyOrder { + if _, ok := m[p]; !ok { + m[p] = i + } + } + return m +}() + +func propOrderIndex(p string) int { + if i, ok := twPropertyOrderIndex[p]; ok { + return i + } + return -1 +} + +func utilKindMatchesCandidate(uk utilKind, ck candidateKind) bool { + return (uk == utilStatic && ck == candStatic) || (uk == utilFunctional && ck == candFunctional) +} + +func isFallbackUtility(u *Utility) bool { + if u.options == nil { + return false + } + types := u.options.Types + if len(types) <= 1 { + return false + } + for _, t := range types { + if t == "any" { + return true + } + } + return false +} + +func compileBaseUtility(candidate *Candidate, ds *DesignSystem) [][]*AstNode { + if candidate.Kind == candArbitrary { + value := candidate.ArbitraryValue + if candidate.Modifier != nil { + v, ok := asColor(value, candidate.Modifier, ds.theme) + if !ok { + return nil + } + value = v + } + return [][]*AstNode{{decl(candidate.Property, value)}} + } + + utils := ds.utilities.get(candidate.Root) + var asts [][]*AstNode + + run := func(list []*Utility) (bail bool) { + for _, utility := range list { + if !utilKindMatchesCandidate(utility.kind, candidate.Kind) { + continue + } + res := utility.compileFn(candidate) + if res == nil { + continue + } + if res.null { + if utility.options != nil && len(utility.options.Types) > 0 { + return true + } + continue + } + asts = append(asts, res.nodes) + } + return false + } + + var normal, fallback []*Utility + for _, u := range utils { + if isFallbackUtility(u) { + fallback = append(fallback, u) + } else { + normal = append(normal, u) + } + } + + if run(normal) { + return asts + } + if len(asts) > 0 { + return asts + } + if run(fallback) { + return asts + } + return asts +} + +func compileAstNodes(candidate *Candidate, ds *DesignSystem, flags CompileAstFlags) []compiledNode { + asts := compileBaseUtility(candidate, ds) + if len(asts) == 0 { + return nil + } + respectImportant := ds.important && (flags&RespectImportant != 0) + selector := "." + escape(candidate.Raw) + + var rules []compiledNode + for _, nodes := range asts { + ps := getPropertySort(nodes) + if candidate.Important || respectImportant { + applyImportant(nodes) + } + node := styleRule(selector, nodes...) + ok := true + for _, variant := range candidate.Variants { + if !applyVariant(node, variant, ds.variants, 0) { + ok = false + break + } + } + if !ok { + return nil + } + rules = append(rules, compiledNode{node: node, propertySort: ps}) + } + return rules +} + +func applyVariant(node *AstNode, variant *Variant, variants *Variants, depth int) bool { + if variant.Kind == varArbitrary { + if variant.Relative && depth == 0 { + return false + } + node.Nodes = []*AstNode{rule(variant.Selector, node.Nodes...)} + return true + } + + info := variants.get(variant.Root) + if info == nil { + return false + } + + if variant.Kind == varCompound { + isolated := atRule("@slot", "") + if !applyVariant(isolated, variant.Variant, variants, depth+1) { + return false + } + if variant.Root == "not" && len(isolated.Nodes) > 1 { + return false + } + for _, child := range isolated.Nodes { + if child.Kind != nRule && child.Kind != nAtRule { + return false + } + if !info.applyFn(child, variant) { + return false + } + } + nodesCopy := isolated.Nodes + walkAst(&nodesCopy, func(child *AstNode, _ *VisitContext) WalkResult { + if (child.Kind == nRule || child.Kind == nAtRule) && len(child.Nodes) <= 0 { + child.Nodes = node.Nodes + return WSkip + } + return WContinue + }) + node.Nodes = isolated.Nodes + return true + } + + return info.applyFn(node, variant) +} + +func applyImportant(ast []*AstNode) { + for _, node := range ast { + if node.Kind == nAtRoot { + continue + } + if node.Kind == nDeclaration { + node.Important = true + } else if node.Kind == nRule || node.Kind == nAtRule { + applyImportant(node.Nodes) + } + } +} + +func getPropertySort(nodes []*AstNode) propertySort { + orderSet := map[int]bool{} + count := 0 + q := append([]*AstNode{}, nodes...) + seenTwSort := false + + for len(q) > 0 { + node := q[0] + q = q[1:] + if node.Kind == nDeclaration { + if node.Undefined { + continue + } + count++ + if seenTwSort { + continue + } + if node.Property == "--tw-sort" { + idx := propOrderIndex(node.Value) + if idx != -1 { + orderSet[idx] = true + seenTwSort = true + continue + } + } + idx := propOrderIndex(node.Property) + if idx != -1 { + orderSet[idx] = true + } + } else if node.Kind == nRule || node.Kind == nAtRule { + q = append(q, node.Nodes...) + } + } + + order := make([]int, 0, len(orderSet)) + for k := range orderSet { + order = append(order, k) + } + sort.Ints(order) + return propertySort{order: order, count: count} +} + +func compileCandidates(rawCandidates []string, ds *DesignSystem, onInvalid func(string), respectImportant bool) ([]*AstNode, map[*AstNode]nodeSortMeta) { + nodeSorting := map[*AstNode]nodeSortMeta{} + var astNodes []*AstNode + matches := map[string][]*Candidate{} + var order []string + + for _, raw := range rawCandidates { + if ds.invalidCandidates[raw] { + if onInvalid != nil { + onInvalid(raw) + } + continue + } + cands := ds.parseCandidate(raw) + if len(cands) == 0 { + if onInvalid != nil { + onInvalid(raw) + } + continue + } + if _, ok := matches[raw]; !ok { + order = append(order, raw) + } + matches[raw] = cands + } + + flags := CompileNone + if respectImportant { + flags |= RespectImportant + } + + variantOrderMap := ds.getVariantOrder() + + for _, raw := range order { + cands := matches[raw] + found := false + for _, candidate := range cands { + rules := ds.compileAstNodes(candidate, flags) + if len(rules) == 0 { + continue + } + found = true + for _, cr := range rules { + variantOrder := big.NewInt(0) + for _, variant := range candidate.Variants { + ord := variantOrderMap[variant] + variantOrder.SetBit(variantOrder, ord, 1) + } + nodeSorting[cr.node] = nodeSortMeta{properties: cr.propertySort, variants: variantOrder, candidate: raw} + astNodes = append(astNodes, cr.node) + } + } + if !found && onInvalid != nil { + onInvalid(raw) + } + } + + const inf = int(^uint(0) >> 1) + sort.SliceStable(astNodes, func(i, j int) bool { + a := nodeSorting[astNodes[i]] + z := nodeSorting[astNodes[j]] + if cmp := a.variants.Cmp(z.variants); cmp != 0 { + return cmp < 0 + } + offset := 0 + for offset < len(a.properties.order) && offset < len(z.properties.order) && a.properties.order[offset] == z.properties.order[offset] { + offset++ + } + ao := inf + if offset < len(a.properties.order) { + ao = a.properties.order[offset] + } + zo := inf + if offset < len(z.properties.order) { + zo = z.properties.order[offset] + } + if ao != zo { + return ao < zo + } + if a.properties.count != z.properties.count { + return z.properties.count < a.properties.count // most properties first + } + return strings.Compare(a.candidate, z.candidate) < 0 + }) + + return astNodes, nodeSorting +} + +// Port of the compile pipeline from packages/tailwindcss/src/index.ts, adapted +// for the bundler: the default theme.css / preflight.css are embedded, local +// @imports are resolved against baseDir, and the scanner (cmd/bundle) supplies +// the candidate list. +// +// @INCOMPLETE Only static @utility blocks are supported (no functional +// @utility/--value()); @custom-variant is not yet wired. -mta + +//go:embed tw_theme.css +var defaultThemeCSS string + +//go:embed tw_preflight.css +var defaultPreflightCSS string + +func parseThemeOptions(params string) ThemeOptions { + o := themeNone + for _, f := range strings.Fields(params) { + switch f { + case "default": + o |= themeDefault + case "inline": + o |= themeInline + case "reference": + o |= themeReference + case "static": + o |= themeStatic + } + } + return o +} + +func importSpecifier(params string) string { + p := strings.TrimSpace(params) + // Drop a trailing layer(...)/supports(...)/media query after the string. + if len(p) > 0 && (p[0] == '"' || p[0] == '\'') { + q := p[0] + if end := strings.IndexByte(p[1:], q); end >= 0 { + return p[1 : 1+end] + } + } + return strings.Trim(p, `"'`) +} + +// twCompile compiles a Tailwind entry stylesheet to CSS. +func twCompile(input, baseDir string, candidates []string) (string, int, error) { + theme := NewTheme() + var keyframes []*AstNode + var passthrough []*AstNode + var customUtilities []*AstNode + hasPreflight := false + hasUtilities := false + + processTheme := func(node *AstNode) { + opts := parseThemeOptions(node.Params) + for _, child := range node.Nodes { + if child.Kind == nDeclaration { + theme.add(child.Property, child.Value, opts) + } else if child.Kind == nAtRule && child.Name == "@keyframes" { + keyframes = append(keyframes, child) + } + } + } + + // 1. Default theme. + defAst, err := cssParse(defaultThemeCSS) + if err != nil { + return "", 0, err + } + for _, node := range defAst { + if node.Kind == nAtRule && node.Name == "@theme" { + processTheme(node) + } + } + + var processInput func(ast []*AstNode, dir string) + processInput = func(ast []*AstNode, dir string) { + for _, node := range ast { + switch { + case node.Kind == nAtRule && node.Name == "@import": + spec := importSpecifier(node.Params) + switch spec { + case "tailwindcss": + hasPreflight = true + hasUtilities = true + case "tailwindcss/preflight", "tailwindcss/preflight.css": + hasPreflight = true + case "tailwindcss/utilities", "tailwindcss/utilities.css": + hasUtilities = true + case "tailwindcss/theme", "tailwindcss/theme.css": + // default theme already loaded + default: + // Local @import: resolve relative to dir. + content, e := os.ReadFile(filepath.Join(dir, spec)) + if e == nil { + sub, e2 := cssParse(string(content)) + if e2 == nil { + processInput(sub, filepath.Dir(filepath.Join(dir, spec))) + } + } + } + case node.Kind == nAtRule && node.Name == "@theme": + processTheme(node) + case node.Kind == nAtRule && node.Name == "@utility": + customUtilities = append(customUtilities, node) + default: + passthrough = append(passthrough, node) + } + } + } + + inAst, err := cssParse(input) + if err != nil { + return "", 0, err + } + processInput(inAst, baseDir) + + ds := buildDesignSystem(theme) + + // Register @utility blocks as static utilities. + for _, u := range customUtilities { + name := strings.TrimSpace(u.Params) + var decls []*AstNode + for _, child := range u.Nodes { + if child.Kind == nDeclaration { + decls = append(decls, child) + } + } + captured := decls + ds.utilities.static(name, func(_ *Candidate) *utilResult { return uList(cloneAstNodes(captured)) }) + } + + astNodes, _ := compileCandidates(candidates, ds, nil, true) + + // Assemble the output document. + var out []*AstNode + if hasUtilities { + out = append(out, atRule("@layer", "theme, base, components, utilities")) + } + + var rootDecls []*AstNode + for _, key := range theme.order { + tv := theme.values[key] + if tv.options&(themeInline|themeReference) != 0 { + continue + } + val := tv.value + if reThemeFnInvocation.MatchString(val) { + if v, ok := substituteFunctionsInValue(val, decl(key, val), ds); ok { + val = v + } + } + rootDecls = append(rootDecls, decl(key, val)) + } + if len(rootDecls) > 0 { + out = append(out, atRule("@layer", "theme", styleRule(":root, :host", rootDecls...))) + } + for _, kf := range keyframes { + out = append(out, kf) + } + + if hasPreflight { + pfAst, e := cssParse(defaultPreflightCSS) + if e != nil { + return "", 0, e + } + out = append(out, atRule("@layer", "base", pfAst...)) + } + + out = append(out, passthrough...) + + if hasUtilities && len(astNodes) > 0 { + out = append(out, atRule("@layer", "utilities", astNodes...)) + } + + out = optimizeAst(out, ds) + return toCss(out), len(astNodes), nil +} + +// scanSources scans the given glob/** patterns (relative to baseDir) for +// candidate class names using the bundler's scanner. +func scanSources(baseDir string, patterns []string) []string { + var all []string + seen := make(map[string]bool) + add := func(cands []string) { + for _, c := range cands { + if !seen[c] { + seen[c] = true + all = append(all, c) + } + } + } + + for _, pattern := range patterns { + absPattern := filepath.Clean(filepath.Join(baseDir, pattern)) + if strings.Contains(pattern, "**") { + parts := strings.SplitN(absPattern, "**", 2) + root := filepath.Clean(parts[0]) + suffix := "" + if len(parts) > 1 { + s := strings.TrimLeft(parts[1], string(filepath.Separator)) + if idx := strings.LastIndex(s, "."); idx >= 0 { + suffix = s[idx:] + } + } + filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if suffix != "" && !strings.HasSuffix(path, suffix) { + return nil + } + cands, err := twScanFile(path) + if err != nil { + return nil + } + add(cands) + return nil + }) + } else { + matches, err := filepath.Glob(absPattern) + if err != nil { + continue + } + for _, path := range matches { + cands, err := twScanFile(path) + if err != nil { + continue + } + add(cands) + } + } + } + + sort.Strings(all) + return all +} + +// Port of packages/tailwindcss/src/css-functions.ts +// +// Resolves the inline CSS functions Tailwind emits/accepts in values: +// --spacing(), --alpha(), --theme() and the legacy theme(). Returns ok=false +// when a function cannot be resolved (the candidate then produces no CSS, +// matching the upstream try/catch that drops it). +// +// @INCOMPLETE injectFallbackForInitialFallback nuance for --theme(...) chained +// fallbacks is not modeled. -mta + +type cssFnHandler func(ds *DesignSystem, source *AstNode, args []string) (string, bool) + +var cssFunctions = map[string]cssFnHandler{ + "--alpha": cssAlpha, + "--spacing": cssSpacing, + "--theme": cssTheme, + "theme": cssLegacyTheme, +} + +var reThemeFnInvocation = regexp.MustCompile(`--alpha\(|--spacing\(|--theme\(|theme\(`) + +func cssAlpha(_ *DesignSystem, _ *AstNode, args []string) (string, bool) { + if len(args) != 1 { + return "", false + } + parts := segment(args[0], "/") + if len(parts) < 2 { + return "", false + } + color := strings.TrimSpace(parts[0]) + alpha := strings.TrimSpace(parts[1]) + if color == "" || alpha == "" { + return "", false + } + return withAlpha(color, alpha), true +} + +func cssSpacing(ds *DesignSystem, _ *AstNode, args []string) (string, bool) { + if len(args) != 1 || args[0] == "" { + return "", false + } + value := args[0] + multiplier, ok := ds.theme.resolve(nil, []string{"--spacing"}, themeNone) + if !ok { + return "", false + } + if n, _, ok := parseDimension(value); ok { + if n == 0 { + return "0", true + } + if n == 1 { + return multiplier, true + } + } + return "calc(" + multiplier + " * " + value + ")", true +} + +func cssTheme(ds *DesignSystem, source *AstNode, args []string) (string, bool) { + if len(args) == 0 { + return "", false + } + path := args[0] + fallback := args[1:] + if !strings.HasPrefix(path, "--") { + return "", false + } + inline := false + if strings.HasSuffix(path, " inline") { + inline = true + path = path[:len(path)-7] + } + if source != nil && source.Kind == nAtRule { + inline = true + } + resolved, ok := ds.resolveThemeValue(path, inline) + if !ok { + if len(fallback) > 0 { + return strings.Join(fallback, ", "), true + } + return "", false + } + if len(fallback) == 0 { + return resolved, true + } + joined := strings.Join(fallback, ", ") + if joined == "initial" { + return resolved, true + } + if resolved == "initial" { + return joined, true + } + return resolved, true +} + +func cssLegacyTheme(ds *DesignSystem, _ *AstNode, args []string) (string, bool) { + if len(args) == 0 { + return "", false + } + path := eventuallyUnquote(args[0]) + fallback := args[1:] + resolved, ok := ds.resolveThemeValue(path, true) + if !ok { + if len(fallback) > 0 { + return strings.Join(fallback, ", "), true + } + return "", false + } + return resolved, true +} + +func substituteFunctions(ast []*AstNode, ds *DesignSystem) bool { + okAll := true + a := ast + walkAst(&a, func(node *AstNode, _ *VisitContext) WalkResult { + if node.Kind == nDeclaration && node.Value != "" && reThemeFnInvocation.MatchString(node.Value) { + v, ok := substituteFunctionsInValue(node.Value, node, ds) + if !ok { + okAll = false + return WStop + } + node.Value = v + return WContinue + } + if node.Kind == nAtRule { + if (node.Name == "@media" || node.Name == "@custom-media" || node.Name == "@container" || node.Name == "@supports") && + reThemeFnInvocation.MatchString(node.Params) { + v, ok := substituteFunctionsInValue(node.Params, node, ds) + if !ok { + okAll = false + return WStop + } + node.Params = v + } + } + return WContinue + }) + return okAll +} + +func substituteFunctionsInValue(value string, source *AstNode, ds *DesignSystem) (string, bool) { + ast := valueParse(value) + out, ok := valueSubstitute(ast, source, ds) + if !ok { + return "", false + } + return valueToCss(out), true +} + +func valueSubstitute(nodes []ValueNode, source *AstNode, ds *DesignSystem) ([]ValueNode, bool) { + var out []ValueNode + for _, n := range nodes { + f, isFn := n.(*ValueFunction) + if !isFn { + out = append(out, n) + continue + } + if handler, isCss := cssFunctions[f.Value]; isCss { + args := segment(strings.TrimSpace(valueToCss(f.Nodes)), ",") + for i := range args { + args[i] = strings.TrimSpace(args[i]) + } + result, ok := handler(ds, source, args) + if !ok { + return nil, false + } + out = append(out, valueParse(result)...) + continue + } + sub, ok := valueSubstitute(f.Nodes, source, ds) + if !ok { + return nil, false + } + f.Nodes = sub + out = append(out, f) + } + return out, true +} + +func eventuallyUnquote(value string) string { + if len(value) == 0 || (value[0] != '\'' && value[0] != '"') { + return value + } + var b strings.Builder + quote := value[0] + for i := 1; i < len(value)-1; i++ { + cur := value[i] + var next byte + if i+1 < len(value) { + next = value[i+1] + } + if cur == '\\' && (next == quote || next == '\\') { + b.WriteByte(next) + i++ + } else { + b.WriteByte(cur) + } + } + return b.String() +} + +// Port of packages/tailwindcss/src/css-parser.ts +// +// A single-pass CSS parser producing the AstNode tree. Source-map tracking is +// omitted (the bundler minifies the output). Operates on bytes; all structural +// characters are ASCII so multi-byte UTF-8 content passes through untouched. + +func cAt(s string, i int) int { + if i < 0 || i >= len(s) { + return -1 + } + return int(s[i]) +} + +const ( + cBackslash = 0x5c + cSlashCh = 0x2f + cAsterisk = 0x2a + cDQuote = 0x22 + cSQuote = 0x27 + cColon = 0x3a + cSemicolon = 0x3b + cLF = 0x0a + cCR = 0x0d + cSpaceCh = 0x20 + cTabCh = 0x09 + cLCurly = 0x7b + cRCurly = 0x7d + cLParen = 0x28 + cRParen = 0x29 + cLBracket = 0x5b + cRBracket = 0x5d + cDash = 0x2d + cAtSign = 0x40 + cBang = 0x21 +) + +func appendChild(parent *AstNode, child *AstNode) { + if ch := nodeChildren(parent); ch != nil { + *ch = append(*ch, child) + } +} + +func cssParse(input string) ([]*AstNode, error) { + if len(input) >= 3 && input[0] == 0xEF && input[1] == 0xBB && input[2] == 0xBF { + input = input[3:] + } + + var ast []*AstNode + var licenseComments []*AstNode + + var stack []*AstNode + var parent *AstNode + var node *AstNode + + var buffer []byte + var closingBracketStack []byte + + for i := 0; i < len(input); i++ { + currentChar := int(input[i]) + + // Skip the CR in CRLF. + if currentChar == cCR { + if cAt(input, i+1) == cLF { + continue + } + } + + switch { + case currentChar == cBackslash: + if i+1 < len(input) { + buffer = append(buffer, input[i], input[i+1]) + i++ + } else { + buffer = append(buffer, input[i]) + } + + case currentChar == cSlashCh && cAt(input, i+1) == cAsterisk: + start := i + for j := i + 2; j < len(input); j++ { + pc := int(input[j]) + if pc == cBackslash { + j++ + } else if pc == cAsterisk && cAt(input, j+1) == cSlashCh { + i = j + 1 + break + } + } + commentString := input[start : i+1] + // Hoist license comments (/*! ... */). + if cAt(commentString, 2) == cBang { + licenseComments = append(licenseComments, comment(commentString[2:len(commentString)-2])) + } + + case currentChar == cSQuote || currentChar == cDQuote: + end, err := parseString(input, i, byte(currentChar)) + if err != nil { + return nil, err + } + buffer = append(buffer, input[i:end+1]...) + i = end + + case (currentChar == cSpaceCh || currentChar == cLF || currentChar == cTabCh) && func() bool { + pc := cAt(input, i+1) + if pc == cSpaceCh || pc == cLF || pc == cTabCh { + return true + } + if pc == cCR && cAt(input, i+2) == cLF { + return true + } + return false + }(): + // Collapse consecutive whitespace. + continue + + case currentChar == cLF: + if len(buffer) == 0 { + continue + } + last := buffer[len(buffer)-1] + if last != cSpaceCh && last != cLF && last != cTabCh { + buffer = append(buffer, ' ') + } + + case currentChar == cDash && cAt(input, i+1) == cDash && len(buffer) == 0: + // Custom property: permissive, balance brackets to find the end. + var localStack []byte + start := i + colonIdx := -1 + for j := i + 2; j < len(input); j++ { + pc := int(input[j]) + if pc == cBackslash { + j++ + } else if pc == cSQuote || pc == cDQuote { + var err error + j, err = parseString(input, j, byte(pc)) + if err != nil { + return nil, err + } + } else if pc == cSlashCh && cAt(input, j+1) == cAsterisk { + for k := j + 2; k < len(input); k++ { + pk := int(input[k]) + if pk == cBackslash { + k++ + } else if pk == cAsterisk && cAt(input, k+1) == cSlashCh { + j = k + 1 + break + } + } + } else if colonIdx == -1 && pc == cColon { + colonIdx = len(buffer) + j - start + } else if pc == cSemicolon && len(localStack) == 0 { + buffer = append(buffer, input[start:j]...) + i = j + break + } else if pc == cLParen { + localStack = append(localStack, ')') + } else if pc == cLBracket { + localStack = append(localStack, ']') + } else if pc == cLCurly { + localStack = append(localStack, '}') + } else if (pc == cRCurly || len(input)-1 == j) && len(localStack) == 0 { + i = j - 1 + buffer = append(buffer, input[start:j]...) + break + } else if pc == cRParen || pc == cRBracket || pc == cRCurly { + if len(localStack) > 0 && input[j] == localStack[len(localStack)-1] { + localStack = localStack[:len(localStack)-1] + } + } + } + + declaration := parseDeclaration(string(buffer), colonIdx) + if declaration == nil { + return nil, fmt.Errorf("invalid custom property, expected a value") + } + if parent != nil { + appendChild(parent, declaration) + } else { + ast = append(ast, declaration) + } + buffer = buffer[:0] + + case currentChar == cSemicolon && len(buffer) > 0 && buffer[0] == cAtSign: + node = parseAtRule(string(buffer), nil) + if parent != nil { + appendChild(parent, node) + } else { + ast = append(ast, node) + } + buffer = buffer[:0] + node = nil + + case currentChar == cSemicolon && lastByte(closingBracketStack) != ')': + declaration := parseDeclaration(string(buffer), -1) + if declaration == nil { + if len(buffer) == 0 { + continue + } + return nil, fmt.Errorf("invalid declaration: `%s`", strings.TrimSpace(string(buffer))) + } + if parent != nil { + appendChild(parent, declaration) + } else { + ast = append(ast, declaration) + } + buffer = buffer[:0] + + case currentChar == cLCurly && lastByte(closingBracketStack) != ')': + closingBracketStack = append(closingBracketStack, '}') + node = rule(strings.TrimSpace(string(buffer))) + if parent != nil { + appendChild(parent, node) + } + stack = append(stack, parent) + parent = node + buffer = buffer[:0] + node = nil + + case currentChar == cRCurly && lastByte(closingBracketStack) != ')': + if len(closingBracketStack) == 0 { + return nil, fmt.Errorf("missing opening {") + } + closingBracketStack = closingBracketStack[:len(closingBracketStack)-1] + + if len(buffer) > 0 { + if buffer[0] == cAtSign { + node = parseAtRule(string(buffer), nil) + if parent != nil { + appendChild(parent, node) + } else { + ast = append(ast, node) + } + buffer = buffer[:0] + node = nil + } else { + colonIdx := strings.IndexByte(string(buffer), ':') + if parent != nil { + d := parseDeclaration(string(buffer), colonIdx) + if d == nil { + return nil, fmt.Errorf("invalid declaration: `%s`", strings.TrimSpace(string(buffer))) + } + appendChild(parent, d) + } + } + } + + var grandParent *AstNode + if len(stack) > 0 { + grandParent = stack[len(stack)-1] + stack = stack[:len(stack)-1] + } + if grandParent == nil && parent != nil { + ast = append(ast, parent) + } + parent = grandParent + buffer = buffer[:0] + node = nil + + case currentChar == cLParen: + closingBracketStack = append(closingBracketStack, ')') + buffer = append(buffer, '(') + + case currentChar == cRParen: + if lastByte(closingBracketStack) != ')' { + return nil, fmt.Errorf("missing opening (") + } + closingBracketStack = closingBracketStack[:len(closingBracketStack)-1] + buffer = append(buffer, ')') + + default: + if len(buffer) == 0 && (currentChar == cSpaceCh || currentChar == cLF || currentChar == cTabCh) { + continue + } + buffer = append(buffer, byte(currentChar)) + } + } + + if len(buffer) > 0 && buffer[0] == cAtSign { + ast = append(ast, parseAtRule(string(buffer), nil)) + } + + if len(closingBracketStack) > 0 && parent != nil { + switch parent.Kind { + case nRule: + return nil, fmt.Errorf("missing closing } at %s", parent.Selector) + case nAtRule: + return nil, fmt.Errorf("missing closing } at %s %s", parent.Name, parent.Params) + } + } + + if len(licenseComments) > 0 { + return append(licenseComments, ast...), nil + } + return ast, nil +} + +func lastByte(b []byte) byte { + if len(b) == 0 { + return 0 + } + return b[len(b)-1] +} + +func parseAtRule(buffer string, nodes []*AstNode) *AstNode { + name := buffer + params := "" + // Smallest common at-rule is `@page` (5 chars); scan from index 5. + for i := 5; i < len(buffer); i++ { + c := buffer[i] + if c == cSpaceCh || c == cTabCh || c == cLParen { + name = buffer[:i] + params = buffer[i:] + break + } + } + return atRule(strings.TrimSpace(name), strings.TrimSpace(params), nodes...) +} + +func parseDeclaration(buffer string, colonIdx int) *AstNode { + if colonIdx == -1 { + colonIdx = strings.IndexByte(buffer, ':') + } + if colonIdx == -1 { + return nil + } + importantIdx := strings.Index(buffer[colonIdx+1:], "!important") + property := strings.TrimSpace(buffer[:colonIdx]) + var value string + if importantIdx == -1 { + value = strings.TrimSpace(buffer[colonIdx+1:]) + } else { + value = strings.TrimSpace(buffer[colonIdx+1 : colonIdx+1+importantIdx]) + } + return &AstNode{Kind: nDeclaration, Property: property, Value: value, Important: importantIdx != -1} +} + +func parseString(input string, startIdx int, quoteChar byte) (int, error) { + for i := startIdx + 1; i < len(input); i++ { + pc := input[i] + if pc == cBackslash { + i++ + } else if pc == quoteChar { + return i, nil + } else if pc == cSemicolon && (cAt(input, i+1) == cLF || (cAt(input, i+1) == cCR && cAt(input, i+2) == cLF)) { + return 0, fmt.Errorf("unterminated string: %s", input[startIdx:i+1]+string(quoteChar)) + } else if pc == cLF || (pc == cCR && cAt(input, i+1) == cLF) { + return 0, fmt.Errorf("unterminated string: %s", input[startIdx:i]+string(quoteChar)) + } + } + return startIdx, nil +} + +// Port of packages/tailwindcss/src/utils/decode-arbitrary-value.ts +// +// Turns Tailwind's underscore-escaped arbitrary value syntax into real CSS: +// `_` becomes a space (except `\_` which becomes a literal `_`), function +// names are decoded, url()/var()/theme() contents are handled specially, and +// math operators inside calc()-family functions get whitespace normalized. +func decodeArbitraryValue(input string) string { + if !strings.Contains(input, "(") { + return convertUnderscoresToWhitespace(input, false) + } + + ast := valueParse(input) + recursivelyDecodeArbitraryValues(ast) + input = valueToCss(ast) + + input = addWhitespaceAroundMathOperators(input) + + return input +} + +// convertUnderscoresToWhitespace converts `_` to ` `, and `\_` to `_`. When +// skipUnderscoreToSpace is true, bare underscores are left untouched (used for +// the first argument of var()/theme()). +func convertUnderscoresToWhitespace(input string, skipUnderscoreToSpace bool) string { + var b strings.Builder + for i := 0; i < len(input); i++ { + ch := input[i] + if ch == '\\' && i+1 < len(input) && input[i+1] == '_' { + b.WriteByte('_') + i++ + } else if ch == '_' && !skipUnderscoreToSpace { + b.WriteByte(' ') + } else { + b.WriteByte(ch) + } + } + return b.String() +} + +func recursivelyDecodeArbitraryValues(ast []ValueNode) { + for _, node := range ast { + switch n := node.(type) { + case *ValueFunction: + if n.Value == "url" || strings.HasSuffix(n.Value, "_url") { + // Don't decode underscores in url() contents, only the name. + n.Value = convertUnderscoresToWhitespace(n.Value, false) + break + } + if n.Value == "var" || strings.HasSuffix(n.Value, "_var") || + n.Value == "theme" || strings.HasSuffix(n.Value, "_theme") { + n.Value = convertUnderscoresToWhitespace(n.Value, false) + for i := 0; i < len(n.Nodes); i++ { + // First argument (the variable name) keeps its underscores. + if i == 0 { + if w, ok := n.Nodes[i].(*ValueWord); ok { + w.Value = convertUnderscoresToWhitespace(w.Value, true) + continue + } + } + recursivelyDecodeArbitraryValues([]ValueNode{n.Nodes[i]}) + } + break + } + n.Value = convertUnderscoresToWhitespace(n.Value, false) + recursivelyDecodeArbitraryValues(n.Nodes) + case *ValueWord: + n.Value = convertUnderscoresToWhitespace(n.Value, false) + case *ValueSeparator: + n.Value = convertUnderscoresToWhitespace(n.Value, false) + } + } +} + +// Port of packages/tailwindcss/src/utils/default-map.ts +// +// A map that lazily computes (and memoizes) a default value for missing keys +// via a factory. The factory receives the map itself to support recursive +// definitions, matching the upstream `DefaultMap`. +type DefaultMap[K comparable, V any] struct { + m map[K]V + order []K + factory func(key K, self *DefaultMap[K, V]) V +} + +func NewDefaultMap[K comparable, V any](factory func(key K, self *DefaultMap[K, V]) V) *DefaultMap[K, V] { + return &DefaultMap[K, V]{m: make(map[K]V), factory: factory} +} + +func (d *DefaultMap[K, V]) Get(key K) V { + if v, ok := d.m[key]; ok { + return v + } + v := d.factory(key, d) + d.set(key, v) + return v +} + +func (d *DefaultMap[K, V]) set(key K, v V) { + if _, ok := d.m[key]; !ok { + d.order = append(d.order, key) + } + d.m[key] = v +} + +func (d *DefaultMap[K, V]) Set(key K, v V) { d.set(key, v) } + +func (d *DefaultMap[K, V]) Has(key K) bool { + _, ok := d.m[key] + return ok +} + +// Values returns the memoized values in insertion order. +func (d *DefaultMap[K, V]) Values() []V { + out := make([]V, 0, len(d.order)) + for _, k := range d.order { + out = append(out, d.m[k]) + } + return out +} + +// Port of packages/tailwindcss/src/design-system.ts +// +// The DesignSystem ties together the theme, utilities and variants, and caches +// parsed candidates/variants and compiled AST nodes. IntelliSense-only methods +// (getClassList/getVariants/canonicalizeCandidates/candidatesToCss) are omitted +// — they don't affect generated CSS. -mta + +type DesignSystem struct { + theme *Theme + utilities *Utilities + variants *Variants + + invalidCandidates map[string]bool + important bool + + parsedVariants *DefaultMap[string, *Variant] + parsedCandidates *DefaultMap[string, []*Candidate] + compiledAstNodes *DefaultMap[CompileAstFlags, *DefaultMap[*Candidate, []compiledNode]] + trackedVariables *DefaultMap[string, bool] +} + +func buildDesignSystem(theme *Theme) *DesignSystem { + ds := &DesignSystem{ + theme: theme, + utilities: createUtilities(theme), + variants: createVariants(theme), + invalidCandidates: map[string]bool{}, + } + + ds.parsedVariants = NewDefaultMap(func(v string, _ *DefaultMap[string, *Variant]) *Variant { + return parseVariant(v, ds) + }) + ds.parsedCandidates = NewDefaultMap(func(c string, _ *DefaultMap[string, []*Candidate]) []*Candidate { + return parseCandidate(c, ds) + }) + ds.compiledAstNodes = NewDefaultMap(func(flags CompileAstFlags, _ *DefaultMap[CompileAstFlags, *DefaultMap[*Candidate, []compiledNode]]) *DefaultMap[*Candidate, []compiledNode] { + return NewDefaultMap(func(cand *Candidate, _ *DefaultMap[*Candidate, []compiledNode]) []compiledNode { + ast := compileAstNodes(cand, ds, flags) + nodes := make([]*AstNode, len(ast)) + for i, v := range ast { + nodes[i] = v.node + } + substituteFunctions(nodes, ds) + substituteAtVariant(nodes, ds) + return ast + }) + }) + ds.trackedVariables = NewDefaultMap(func(raw string, _ *DefaultMap[string, bool]) bool { + for _, variable := range extractUsedVariables(raw) { + theme.markUsedVariable(variable) + } + return true + }) + + return ds +} + +func (ds *DesignSystem) parseCandidate(candidate string) []*Candidate { + return ds.parsedCandidates.Get(candidate) +} + +func (ds *DesignSystem) parseVariant(variant string) *Variant { + return ds.parsedVariants.Get(variant) +} + +func (ds *DesignSystem) compileAstNodes(candidate *Candidate, flags CompileAstFlags) []compiledNode { + return ds.compiledAstNodes.Get(flags).Get(candidate) +} + +func (ds *DesignSystem) trackUsedVariables(raw string) { + ds.trackedVariables.Get(raw) +} + +func (ds *DesignSystem) getVariantOrder() map[*Variant]int { + vs := ds.parsedVariants.Values() + sort.SliceStable(vs, func(i, j int) bool { return ds.variants.compare(vs[i], vs[j]) < 0 }) + + order := map[*Variant]int{} + var prev *Variant + hasPrev := false + index := 0 + for _, variant := range vs { + if variant == nil { + continue + } + if hasPrev && ds.variants.compare(prev, variant) != 0 { + index++ + } + order[variant] = index + prev = variant + hasPrev = true + } + return order +} + +func (ds *DesignSystem) resolveThemeValue(path string, forceInline bool) (string, bool) { + modifier := "" + if lastSlash := strings.LastIndex(path, "/"); lastSlash != -1 { + modifier = strings.TrimSpace(path[lastSlash+1:]) + path = strings.TrimSpace(path[:lastSlash]) + } + opt := themeNone + if forceInline { + opt = themeInline + } + themeValue, ok := ds.theme.resolve(nil, []string{path}, opt) + if !ok { + return "", false + } + if modifier != "" { + return withAlpha(themeValue, modifier), true + } + return themeValue, true +} + +func (ds *DesignSystem) getClassOrder(classes []string) []classOrderEntry { + return getClassOrder(ds, classes) +} + +// Port of packages/tailwindcss/src/utils/dimensions.ts +// Parses a dimension like "64rem" into (64, "rem"). unit == "" means no unit. + +var reDimension = regexp.MustCompile(`(?i)^([-+]?(?:\d*\.)?\d+)([a-z]+|%)?$`) + +func parseDimension(input string) (float64, string, bool) { + m := reDimension.FindStringSubmatch(input) + if m == nil { + return 0, "", false + } + v, ok := jsParseNumber(m[1]) + if !ok { + return 0, "", false + } + return v, m[2], true +} + +// Port of packages/tailwindcss/src/utils/escape.ts +// https://drafts.csswg.org/cssom/#serialize-an-identifier + +func escape(value string) string { + if value == "" { + return value + } + runes := []rune(value) + length := len(runes) + first := runes[0] + + // If the character is the first character and is a `-` (U+002D), and there + // is no second character, escape it. + if length == 1 && first == 0x002d { + return "\\" + value + } + + var b strings.Builder + for index, codeUnit := range runes { + // NULL (U+0000) -> REPLACEMENT CHARACTER (U+FFFD). + if codeUnit == 0x0000 { + b.WriteRune('�') + continue + } + + if (codeUnit >= 0x0001 && codeUnit <= 0x001f) || + codeUnit == 0x007f || + (index == 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (index == 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && first == 0x002d) { + // Escape as a code point. + b.WriteByte('\\') + b.WriteString(strconv.FormatInt(int64(codeUnit), 16)) + b.WriteByte(' ') + continue + } + + if codeUnit >= 0x0080 || + codeUnit == 0x002d || + codeUnit == 0x005f || + (codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (codeUnit >= 0x0041 && codeUnit <= 0x005a) || + (codeUnit >= 0x0061 && codeUnit <= 0x007a) { + b.WriteRune(codeUnit) + continue + } + + // Otherwise, the escaped character. + b.WriteByte('\\') + b.WriteRune(codeUnit) + } + return b.String() +} + +var reUnescape = regexp.MustCompile(`\\([0-9A-Fa-f]{1,6}[\t\n\f\r ]?|[\s\S])`) + +func unescape(escaped string) string { + return reUnescape.ReplaceAllStringFunc(escaped, func(match string) string { + r := []rune(match) + if len(r) <= 2 { + return string(r[1]) + } + codePoint, err := strconv.ParseInt(strings.TrimSpace(string(r[1:])), 16, 64) + if err != nil { + return "�" + } + if codePoint == 0x0000 || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff) { + return "�" + } + return string(rune(codePoint)) + }) +} + +// Port of packages/tailwindcss/src/utils/infer-data-type.ts +// +// Data types recognised by inferDataType. Used by functional utilities to +// dispatch arbitrary values (e.g. text-[10px] is a length, text-[#fff] a color). + +const ( + dtColor = "color" + dtLength = "length" + dtPercentage = "percentage" + dtRatio = "ratio" + dtNumber = "number" + dtInteger = "integer" + dtURL = "url" + dtPosition = "position" + dtBgSize = "bg-size" + dtLineWidth = "line-width" + dtImage = "image" + dtFamilyName = "family-name" + dtGenericName = "generic-name" + dtAbsoluteSize = "absolute-size" + dtRelativeSize = "relative-size" + dtAngle = "angle" + dtVector = "vector" +) + +var dataTypeChecks = map[string]func(string) bool{ + dtColor: isColor, + dtLength: isLength, + dtPercentage: isPercentage, + dtRatio: isFraction, + dtNumber: isNumber, + dtInteger: isPositiveInteger, + dtURL: isURL, + dtPosition: isBackgroundPosition, + dtBgSize: isBackgroundSize, + dtLineWidth: isLineWidth, + dtImage: isImage, + dtFamilyName: isFamilyName, + dtGenericName: isGenericName, + dtAbsoluteSize: isAbsoluteSize, + dtRelativeSize: isRelativeSize, + dtAngle: isAngle, + dtVector: isVector, +} + +// inferDataType returns the first matching data type from types, or "" (null). +func inferDataType(value string, types []string) string { + if strings.HasPrefix(value, "var(") { + return "" + } + for _, t := range types { + if check, ok := dataTypeChecks[t]; ok && check(value) { + return t + } + } + return "" +} + +// ---- individual checks -------------------------------------------------- + +var reIsURL = regexp.MustCompile(`^url\(.*\)$`) + +func isURL(value string) bool { return reIsURL.MatchString(value) } + +func isLineWidth(value string) bool { + for _, v := range segment(value, " ") { + if !(isLength(v) || isNumber(v) || v == "thin" || v == "medium" || v == "thick") { + return false + } + } + return true +} + +var ( + reIsImageFn = regexp.MustCompile(`^(?:element|image|cross-fade|image-set)\(`) + reIsGradientFn = regexp.MustCompile(`^(repeating-)?(conic|linear|radial)-gradient\(`) +) + +func isImage(value string) bool { + count := 0 + for _, part := range segment(value, ",") { + if strings.HasPrefix(part, "var(") { + continue + } + if isURL(part) || reIsGradientFn.MatchString(part) || reIsImageFn.MatchString(part) { + count++ + continue + } + return false + } + return count > 0 +} + +func isGenericName(value string) bool { + switch value { + case "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", + "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", "math", "emoji", "fangsong": + return true + } + return false +} + +func isFamilyName(value string) bool { + count := 0 + for _, part := range segment(value, ",") { + if len(part) > 0 && part[0] >= '0' && part[0] <= '9' { + return false + } + if strings.HasPrefix(part, "var(") { + continue + } + count++ + } + return count > 0 +} + +func isAbsoluteSize(value string) bool { + switch value { + case "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "xxx-large": + return true + } + return false +} + +func isRelativeSize(value string) bool { + return value == "larger" || value == "smaller" +} + +const hasNumber = `[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?` + +var ( + reIsNumber = regexp.MustCompile(`^` + hasNumber + `$`) + reIsPercentage = regexp.MustCompile(`^` + hasNumber + `%$`) + reIsFraction = regexp.MustCompile(`^` + hasNumber + `\s*/\s*` + hasNumber + `$`) +) + +func isNumber(value string) bool { return reIsNumber.MatchString(value) || hasMathFn(value) } +func isPercentage(value string) bool { return reIsPercentage.MatchString(value) || hasMathFn(value) } +func isFraction(value string) bool { return reIsFraction.MatchString(value) || hasMathFn(value) } + +var lengthUnits = []string{ + "cm", "mm", "Q", "in", "pc", "pt", "px", "em", "ex", "ch", "rem", "lh", "rlh", + "vw", "vh", "vmin", "vmax", "vb", "vi", "svw", "svh", "lvw", "lvh", "dvw", "dvh", + "cqw", "cqh", "cqi", "cqb", "cqmin", "cqmax", +} + +var ( + reIsLength = regexp.MustCompile(`^` + hasNumber + `(` + strings.Join(lengthUnits, "|") + `)$`) + reIsLengthFn = regexp.MustCompile(`(?i)^(--spacing)\(`) +) + +func isLength(value string) bool { + return reIsLength.MatchString(value) || reIsLengthFn.MatchString(value) || hasMathFn(value) +} + +func isBackgroundPosition(value string) bool { + count := 0 + for _, part := range segment(value, " ") { + switch part { + case "center", "top", "right", "bottom", "left": + count++ + continue + } + if strings.HasPrefix(part, "var(") { + continue + } + if isLength(part) || isPercentage(part) { + count++ + continue + } + return false + } + return count > 0 +} + +func isBackgroundSize(value string) bool { + count := 0 + for _, size := range segment(value, ",") { + if size == "cover" || size == "contain" { + count++ + continue + } + values := segment(size, " ") + if len(values) != 1 && len(values) != 2 { + return false + } + ok := true + for _, v := range values { + if !(v == "auto" || isLength(v) || isPercentage(v)) { + ok = false + break + } + } + if ok { + count++ + } + } + return count > 0 +} + +var angleUnits = []string{"deg", "rad", "grad", "turn"} + +var reIsAngle = regexp.MustCompile(`^` + hasNumber + `(` + strings.Join(angleUnits, "|") + `)$`) + +func isAngle(value string) bool { return reIsAngle.MatchString(value) } + +var reIsVector = regexp.MustCompile(`^` + hasNumber + ` +` + hasNumber + ` +` + hasNumber + `$`) + +func isVector(value string) bool { return reIsVector.MatchString(value) } + +// ---- numeric predicates ------------------------------------------------- + +func jsParseNumber(s string) (float64, bool) { + f, err := strconv.ParseFloat(s, 64) + if err != nil || math.IsInf(f, 0) || math.IsNaN(f) { + return 0, false + } + return f, true +} + +// jsNumberToString mirrors JS `String(Number(x))` for the small decimal values +// these predicates see (multiples of 0.25, small integers). +func jsNumberToString(f float64) string { + return strconv.FormatFloat(f, 'g', -1, 64) +} + +func isPositiveInteger(value string) bool { + f, ok := jsParseNumber(value) + if !ok { + return false + } + return f == math.Trunc(f) && f >= 0 && jsNumberToString(f) == value +} + +func isStrictPositiveInteger(value string) bool { + f, ok := jsParseNumber(value) + if !ok { + return false + } + return f == math.Trunc(f) && f > 0 && jsNumberToString(f) == value +} + +func isValidSpacingMultiplier(value string) bool { return isMultipleOf(value, 0.25) } +func isValidOpacityValue(value string) bool { return isMultipleOf(value, 0.25) } + +func isMultipleOf(value string, divisor float64) bool { + f, ok := jsParseNumber(value) + if !ok || f < 0 { + return false + } + q := f / divisor + if math.Abs(q-math.Round(q)) > 1e-9 { + return false + } + return jsNumberToString(f) == value +} + +// Port of packages/tailwindcss/src/utils/is-color.ts + +var twNamedColors = func() map[string]bool { + names := []string{ + // CSS Level 1 + "black", "silver", "gray", "white", "maroon", "red", "purple", "fuchsia", + "green", "lime", "olive", "yellow", "navy", "blue", "teal", "aqua", + // CSS Level 2/3 + "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", "bisque", + "black", "blanchedalmond", "blue", "blueviolet", "brown", "burlywood", "cadetblue", + "chartreuse", "chocolate", "coral", "cornflowerblue", "cornsilk", "crimson", "cyan", + "darkblue", "darkcyan", "darkgoldenrod", "darkgray", "darkgreen", "darkgrey", "darkkhaki", + "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon", + "darkseagreen", "darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", + "darkviolet", "deeppink", "deepskyblue", "dimgray", "dimgrey", "dodgerblue", "firebrick", + "floralwhite", "forestgreen", "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", + "gray", "green", "greenyellow", "grey", "honeydew", "hotpink", "indianred", "indigo", + "ivory", "khaki", "lavender", "lavenderblush", "lawngreen", "lemonchiffon", "lightblue", + "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", + "lightpink", "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", + "lightslategrey", "lightsteelblue", "lightyellow", "lime", "limegreen", "linen", "magenta", + "maroon", "mediumaquamarine", "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen", + "mediumslateblue", "mediumspringgreen", "mediumturquoise", "mediumvioletred", "midnightblue", + "mintcream", "mistyrose", "moccasin", "navajowhite", "navy", "oldlace", "olive", "olivedrab", + "orange", "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise", + "palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "purple", + "rebeccapurple", "red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", + "seagreen", "seashell", "sienna", "silver", "skyblue", "slateblue", "slategray", "slategrey", + "snow", "springgreen", "steelblue", "tan", "teal", "thistle", "tomato", "turquoise", "violet", + "wheat", "white", "whitesmoke", "yellow", "yellowgreen", + // Keywords + "transparent", "currentcolor", + // System colors + "canvas", "canvastext", "linktext", "visitedtext", "activetext", "buttonface", "buttontext", + "buttonborder", "field", "fieldtext", "highlight", "highlighttext", "selecteditem", + "selecteditemtext", "mark", "marktext", "graytext", "accentcolor", "accentcolortext", + } + m := make(map[string]bool, len(names)) + for _, n := range names { + m[n] = true + } + return m +}() + +var reIsColorFn = regexp.MustCompile(`(?i)^(rgba?|hsla?|hwb|color|(ok)?(lab|lch)|light-dark|color-mix|--alpha)\(`) + +func isColor(value string) bool { + if len(value) > 0 && value[0] == '#' { + return true + } + return reIsColorFn.MatchString(value) || twNamedColors[strings.ToLower(value)] +} + +func isNamedColor(value string) bool { + return twNamedColors[strings.ToLower(value)] +} + +// Port of packages/tailwindcss/src/utils/is-valid-arbitrary.ts +// +// An arbitrary value is valid when parens/brackets are balanced and there is no +// top-level `;`. Note `{` intentionally does not push the stack, so a candidate +// like `[&{color:red}]` is rejected. +func isValidArbitrary(input string) bool { + var stack []byte + for i := 0; i < len(input); i++ { + c := input[i] + switch c { + case '\\': + i++ + case '\'', '"': + for i++; i < len(input); i++ { + nc := input[i] + if nc == '\\' { + i++ + continue + } + if nc == c { + break + } + } + case '(': + stack = append(stack, ')') + case '[': + stack = append(stack, ']') + case ')', ']', '}': + if len(stack) == 0 { + return false + } + if c == stack[len(stack)-1] { + stack = stack[:len(stack)-1] + } + case ';': + if len(stack) == 0 { + return false + } + } + } + return true +} + +// Port of packages/tailwindcss/src/utils/math-operators.ts + +var mathFunctions = []string{ + "calc", "min", "max", "clamp", "mod", "rem", "sin", "cos", "tan", + "asin", "acos", "atan", "atan2", "pow", "sqrt", "hypot", "log", "exp", "round", +} + +func hasMathFn(input string) bool { + if !strings.Contains(input, "(") { + return false + } + for _, fn := range mathFunctions { + if strings.Contains(input, fn+"(") { + return true + } + } + return false +} + +func isMathFunctionName(fn string) bool { + for _, m := range mathFunctions { + if m == fn { + return true + } + } + return false +} + +const ( + mLowerA = 0x61 + mLowerZ = 0x7a + mUpperA = 0x41 + mUpperZ = 0x5a + mLowerE = 0x65 + mUpperE = 0x45 + mZero = 0x30 + mNine = 0x39 + mAdd = '+' + mSub = '-' + mMul = '*' + mDiv = '/' + mLParen = '(' + mRParen = ')' + mComma = ',' + mSpace = ' ' + mPct = '%' +) + +func addWhitespaceAroundMathOperators(input string) string { + containsAny := false + for _, fn := range mathFunctions { + if strings.Contains(input, fn) { + containsAny = true + break + } + } + if !containsAny { + return input + } + + var result []byte + var formattable []bool // stack; index 0 == top + + valuePos := -1 + lastValuePos := -1 + + for i := 0; i < len(input); i++ { + char := input[i] + + // Track number-then-unit so we know it's a value, not a function call. + if char >= mZero && char <= mNine { + valuePos = i + } else if valuePos != -1 && + (char == mPct || (char >= mLowerA && char <= mLowerZ) || (char >= mUpperA && char <= mUpperZ)) { + valuePos = i + } else { + lastValuePos = valuePos + valuePos = -1 + } + + switch { + case char == mLParen: + result = append(result, char) + // Scan backwards for the function name (lowercase alnum). + start := i + for j := i - 1; j >= 0; j-- { + inner := input[j] + if inner >= mZero && inner <= mNine { + start = j + } else if inner >= mLowerA && inner <= mLowerZ { + start = j + } else { + break + } + } + fn := input[start:i] + if isMathFunctionName(fn) { + formattable = append([]bool{true}, formattable...) + } else if len(formattable) > 0 && formattable[0] && fn == "" { + formattable = append([]bool{true}, formattable...) + } else { + formattable = append([]bool{false}, formattable...) + } + + case char == mRParen: + result = append(result, char) + if len(formattable) > 0 { + formattable = formattable[1:] + } + + case char == mComma && len(formattable) > 0 && formattable[0]: + result = append(result, ',', ' ') + + case char == mSpace && len(formattable) > 0 && formattable[0] && len(result) > 0 && result[len(result)-1] == mSpace: + // Skip consecutive whitespace. + + case (char == mAdd || char == mMul || char == mDiv || char == mSub) && len(formattable) > 0 && formattable[0]: + trimmed := trimRightSpace(result) + var prev, prevPrev byte + if len(trimmed) >= 1 { + prev = trimmed[len(trimmed)-1] + } + if len(trimmed) >= 2 { + prevPrev = trimmed[len(trimmed)-2] + } + var next byte + if i+1 < len(input) { + next = input[i+1] + } + + switch { + case (prev == mLowerE || prev == mUpperE) && prevPrev >= mZero && prevPrev <= mNine: + // Scientific notation, e.g. -3.4e-2. + result = append(result, char) + case prev == mAdd || prev == mMul || prev == mDiv || prev == mSub: + result = append(result, char) + case prev == mLParen || prev == mComma: + result = append(result, char) + case i-1 >= 0 && input[i-1] == mSpace: + result = append(result, char, ' ') + case (prev >= mZero && prev <= mNine) || + (next >= mZero && next <= mNine) || + prev == mRParen || + next == mLParen || + next == mAdd || next == mMul || next == mDiv || next == mSub || + (lastValuePos != -1 && lastValuePos == i-1): + result = append(result, ' ', char, ' ') + default: + result = append(result, char) + } + + default: + result = append(result, char) + } + } + + return string(result) +} + +func trimRightSpace(b []byte) []byte { + end := len(b) + for end > 0 { + c := b[end-1] + if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v' { + end-- + continue + } + break + } + return b[:end] +} + +// Port of packages/tailwindcss/src/property-order.ts +// The canonical order in which CSS properties are sorted within a rule. + +var twPropertyOrder = []string{ + "container-type", + "pointer-events", + "visibility", + "position", + "inset", + "inset-inline", + "inset-block", + "inset-inline-start", + "inset-inline-end", + "inset-block-start", + "inset-block-end", + "top", + "right", + "bottom", + "left", + "isolation", + "z-index", + "order", + "grid-column", + "grid-column-start", + "grid-column-end", + "grid-row", + "grid-row-start", + "grid-row-end", + "float", + "clear", + "--tw-container-component", + "margin", + "margin-inline", + "margin-block", + "margin-inline-start", + "margin-inline-end", + "margin-block-start", + "margin-block-end", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "box-sizing", + "display", + "field-sizing", + "aspect-ratio", + "height", + "max-height", + "min-height", + "width", + "max-width", + "min-width", + "flex", + "flex-shrink", + "flex-grow", + "flex-basis", + "table-layout", + "caption-side", + "border-collapse", + "border-spacing", + "--tw-border-spacing-x", + "--tw-border-spacing-y", + "transform-origin", + "translate", + "--tw-translate-x", + "--tw-translate-y", + "--tw-translate-z", + "scale", + "--tw-scale-x", + "--tw-scale-y", + "--tw-scale-z", + "rotate", + "--tw-rotate-x", + "--tw-rotate-y", + "--tw-rotate-z", + "--tw-skew-x", + "--tw-skew-y", + "transform", + "zoom", + "animation", + "cursor", + "touch-action", + "--tw-pan-x", + "--tw-pan-y", + "--tw-pinch-zoom", + "resize", + "scroll-snap-type", + "--tw-scroll-snap-strictness", + "scroll-snap-align", + "scroll-snap-stop", + "scroll-margin", + "scroll-margin-inline", + "scroll-margin-block", + "scroll-margin-inline-start", + "scroll-margin-inline-end", + "scroll-margin-block-start", + "scroll-margin-block-end", + "scroll-margin-top", + "scroll-margin-right", + "scroll-margin-bottom", + "scroll-margin-left", + "scroll-padding", + "scroll-padding-inline", + "scroll-padding-block", + "scroll-padding-inline-start", + "scroll-padding-inline-end", + "scroll-padding-block-start", + "scroll-padding-block-end", + "scroll-padding-top", + "scroll-padding-right", + "scroll-padding-bottom", + "scroll-padding-left", + "scrollbar-width", + "scrollbar-color", + "scrollbar-gutter", + "list-style-position", + "list-style-type", + "list-style-image", + "appearance", + "columns", + "break-before", + "break-inside", + "break-after", + "grid-auto-columns", + "grid-auto-flow", + "grid-auto-rows", + "grid-template-columns", + "grid-template-rows", + "flex-direction", + "flex-wrap", + "place-content", + "place-items", + "align-content", + "align-items", + "justify-content", + "justify-items", + "gap", + "column-gap", + "row-gap", + "--tw-space-x-reverse", + "--tw-space-y-reverse", + "divide-x-width", + "divide-y-width", + "--tw-divide-y-reverse", + "divide-style", + "divide-color", + "place-self", + "align-self", + "justify-self", + "overflow", + "overflow-x", + "overflow-y", + "overscroll-behavior", + "overscroll-behavior-x", + "overscroll-behavior-y", + "scroll-behavior", + "border-radius", + "border-start-radius", + "border-end-radius", + "border-top-radius", + "border-right-radius", + "border-bottom-radius", + "border-left-radius", + "border-start-start-radius", + "border-start-end-radius", + "border-end-end-radius", + "border-end-start-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", + "border-width", + "border-inline-width", + "border-block-width", + "border-inline-start-width", + "border-inline-end-width", + "border-block-start-width", + "border-block-end-width", + "border-top-width", + "border-right-width", + "border-bottom-width", + "border-left-width", + "border-style", + "border-inline-style", + "border-block-style", + "border-inline-start-style", + "border-inline-end-style", + "border-block-start-style", + "border-block-end-style", + "border-top-style", + "border-right-style", + "border-bottom-style", + "border-left-style", + "border-color", + "border-inline-color", + "border-block-color", + "border-inline-start-color", + "border-inline-end-color", + "border-block-start-color", + "border-block-end-color", + "border-top-color", + "border-right-color", + "border-bottom-color", + "border-left-color", + "background-color", + "background-image", + "--tw-gradient-position", + "--tw-gradient-stops", + "--tw-gradient-via-stops", + "--tw-gradient-from", + "--tw-gradient-from-position", + "--tw-gradient-via", + "--tw-gradient-via-position", + "--tw-gradient-to", + "--tw-gradient-to-position", + "mask-image", + "--tw-mask-top", + "--tw-mask-top-from-color", + "--tw-mask-top-from-position", + "--tw-mask-top-to-color", + "--tw-mask-top-to-position", + "--tw-mask-right", + "--tw-mask-right-from-color", + "--tw-mask-right-from-position", + "--tw-mask-right-to-color", + "--tw-mask-right-to-position", + "--tw-mask-bottom", + "--tw-mask-bottom-from-color", + "--tw-mask-bottom-from-position", + "--tw-mask-bottom-to-color", + "--tw-mask-bottom-to-position", + "--tw-mask-left", + "--tw-mask-left-from-color", + "--tw-mask-left-from-position", + "--tw-mask-left-to-color", + "--tw-mask-left-to-position", + "--tw-mask-linear", + "--tw-mask-linear-position", + "--tw-mask-linear-from-color", + "--tw-mask-linear-from-position", + "--tw-mask-linear-to-color", + "--tw-mask-linear-to-position", + "--tw-mask-radial", + "--tw-mask-radial-shape", + "--tw-mask-radial-size", + "--tw-mask-radial-position", + "--tw-mask-radial-from-color", + "--tw-mask-radial-from-position", + "--tw-mask-radial-to-color", + "--tw-mask-radial-to-position", + "--tw-mask-conic", + "--tw-mask-conic-position", + "--tw-mask-conic-from-color", + "--tw-mask-conic-from-position", + "--tw-mask-conic-to-color", + "--tw-mask-conic-to-position", + "box-decoration-break", + "background-size", + "background-attachment", + "background-clip", + "background-position", + "background-repeat", + "background-origin", + "mask-composite", + "mask-mode", + "mask-type", + "mask-size", + "mask-clip", + "mask-position", + "mask-repeat", + "mask-origin", + "fill", + "stroke", + "stroke-width", + "object-fit", + "object-position", + "padding", + "padding-inline", + "padding-block", + "padding-inline-start", + "padding-inline-end", + "padding-block-start", + "padding-block-end", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "text-align", + "text-indent", + "vertical-align", + "font-family", + "font-feature-settings", + "font-size", + "line-height", + "font-weight", + "letter-spacing", + "text-wrap", + "overflow-wrap", + "word-break", + "text-overflow", + "hyphens", + "white-space", + "tab-size", + "color", + "text-transform", + "font-style", + "font-stretch", + "font-variant-numeric", + "text-decoration-line", + "text-decoration-color", + "text-decoration-style", + "text-decoration-thickness", + "text-underline-offset", + "-webkit-font-smoothing", + "placeholder-color", + "caret-color", + "accent-color", + "color-scheme", + "opacity", + "background-blend-mode", + "mix-blend-mode", + "box-shadow", + "--tw-shadow", + "--tw-shadow-color", + "--tw-ring-shadow", + "--tw-ring-color", + "--tw-inset-shadow", + "--tw-inset-shadow-color", + "--tw-inset-ring-shadow", + "--tw-inset-ring-color", + "--tw-ring-offset-width", + "--tw-ring-offset-color", + "outline", + "outline-width", + "outline-offset", + "outline-color", + "--tw-blur", + "--tw-brightness", + "--tw-contrast", + "--tw-drop-shadow", + "--tw-grayscale", + "--tw-hue-rotate", + "--tw-invert", + "--tw-saturate", + "--tw-sepia", + "filter", + "--tw-backdrop-blur", + "--tw-backdrop-brightness", + "--tw-backdrop-contrast", + "--tw-backdrop-grayscale", + "--tw-backdrop-hue-rotate", + "--tw-backdrop-invert", + "--tw-backdrop-opacity", + "--tw-backdrop-saturate", + "--tw-backdrop-sepia", + "backdrop-filter", + "transition-property", + "transition-behavior", + "transition-delay", + "transition-duration", + "transition-timing-function", + "will-change", + "contain", + "content", + "forced-color-adjust", +} + +// twProseCSS returns the complete set of CSSRule nodes for the `prose` utility, +// matching the output of @tailwindcss/typography's default (base) configuration. +func proseCSS() []*AstNode { + p := func(sel string, decls ...*AstNode) *AstNode { + return styleRule(sel, decls...) + } + d := decl + + return []*AstNode{ + // Root + p("&", + d("color", "var(--tw-prose-body)"), + d("max-width", "65ch"), + d("font-size", "1rem"), + d("line-height", "1.75"), + // Gray theme variables (default) + d("--tw-prose-body", "#374151"), + d("--tw-prose-headings", "#111827"), + d("--tw-prose-lead", "#4b5563"), + d("--tw-prose-links", "#111827"), + d("--tw-prose-bold", "#111827"), + d("--tw-prose-counters", "#6b7280"), + d("--tw-prose-bullets", "#d1d5db"), + d("--tw-prose-hr", "#e5e7eb"), + d("--tw-prose-quotes", "#111827"), + d("--tw-prose-quote-borders", "#e5e7eb"), + d("--tw-prose-captions", "#6b7280"), + d("--tw-prose-kbd", "#111827"), + d("--tw-prose-kbd-shadows", "17 24 39"), + d("--tw-prose-code", "#111827"), + d("--tw-prose-pre-code", "#e5e7eb"), + d("--tw-prose-pre-bg", "#1f2937"), + d("--tw-prose-th-borders", "#d1d5db"), + d("--tw-prose-td-borders", "#e5e7eb"), + // Invert variables + d("--tw-prose-invert-body", "#d1d5db"), + d("--tw-prose-invert-headings", "#fff"), + d("--tw-prose-invert-lead", "#9ca3af"), + d("--tw-prose-invert-links", "#fff"), + d("--tw-prose-invert-bold", "#fff"), + d("--tw-prose-invert-counters", "#9ca3af"), + d("--tw-prose-invert-bullets", "#4b5563"), + d("--tw-prose-invert-hr", "#374151"), + d("--tw-prose-invert-quotes", "#f3f4f6"), + d("--tw-prose-invert-quote-borders", "#374151"), + d("--tw-prose-invert-captions", "#9ca3af"), + d("--tw-prose-invert-kbd", "#fff"), + d("--tw-prose-invert-kbd-shadows", "255 255 255"), + d("--tw-prose-invert-code", "#fff"), + d("--tw-prose-invert-pre-code", "#d1d5db"), + d("--tw-prose-invert-pre-bg", "rgb(0 0 0 / 50%)"), + d("--tw-prose-invert-th-borders", "#4b5563"), + d("--tw-prose-invert-td-borders", "#374151"), + ), + + // Lead text + p("& [class~=\"lead\"]", + d("color", "var(--tw-prose-lead)"), + d("font-size", "1.25em"), + d("line-height", "1.6"), + d("margin-top", "1.2em"), + d("margin-bottom", "1.2em"), + ), + + // Links + p("& a", + d("color", "var(--tw-prose-links)"), + d("text-decoration", "underline"), + d("font-weight", "500"), + ), + + // Strong + p("& strong", + d("color", "var(--tw-prose-bold)"), + d("font-weight", "600"), + ), + p("& a strong, & blockquote strong, & thead th strong", + d("color", "inherit"), + ), + + // Lists + p("& ol", + d("list-style-type", "decimal"), + d("margin-top", "1.25em"), + d("margin-bottom", "1.25em"), + d("padding-inline-start", "1.625em"), + ), + p("& ul", + d("list-style-type", "disc"), + d("margin-top", "1.25em"), + d("margin-bottom", "1.25em"), + d("padding-inline-start", "1.625em"), + ), + p("& li", + d("margin-top", "0.5em"), + d("margin-bottom", "0.5em"), + ), + p("& ol > li", + d("padding-inline-start", "0.375em"), + ), + p("& ul > li", + d("padding-inline-start", "0.375em"), + ), + p("& ol > li::marker", + d("font-weight", "400"), + d("color", "var(--tw-prose-counters)"), + ), + p("& ul > li::marker", + d("color", "var(--tw-prose-bullets)"), + ), + p("& > ul > li p", + d("margin-top", "0.75em"), + d("margin-bottom", "0.75em"), + ), + p("& > ul > li > p:first-child", + d("margin-top", "1.25em"), + ), + p("& > ul > li > p:last-child", + d("margin-bottom", "1.25em"), + ), + p("& > ol > li > p:first-child", + d("margin-top", "1.25em"), + ), + p("& > ol > li > p:last-child", + d("margin-bottom", "1.25em"), + ), + p("& ul ul, & ul ol, & ol ul, & ol ol", + d("margin-top", "0.75em"), + d("margin-bottom", "0.75em"), + ), + + // Definition lists + p("& dl", + d("margin-top", "1.25em"), + d("margin-bottom", "1.25em"), + ), + p("& dt", + d("color", "var(--tw-prose-headings)"), + d("font-weight", "600"), + d("margin-top", "1.25em"), + ), + p("& dd", + d("margin-top", "0.5em"), + d("padding-inline-start", "1.625em"), + ), + + // Paragraphs + p("& p", + d("margin-top", "1.25em"), + d("margin-bottom", "1.25em"), + ), + + // Headings + p("& h1", + d("color", "var(--tw-prose-headings)"), + d("font-weight", "800"), + d("font-size", "2.25em"), + d("margin-top", "0"), + d("margin-bottom", "0.8888889em"), + d("line-height", "1.1111111"), + ), + p("& h1 strong", + d("font-weight", "900"), + d("color", "inherit"), + ), + p("& h2", + d("color", "var(--tw-prose-headings)"), + d("font-weight", "700"), + d("font-size", "1.5em"), + d("margin-top", "2em"), + d("margin-bottom", "1em"), + d("line-height", "1.3333333"), + ), + p("& h2 strong", + d("font-weight", "800"), + d("color", "inherit"), + ), + p("& h3", + d("color", "var(--tw-prose-headings)"), + d("font-weight", "600"), + d("font-size", "1.25em"), + d("margin-top", "1.6em"), + d("margin-bottom", "0.6em"), + d("line-height", "1.6"), + ), + p("& h3 strong", + d("font-weight", "700"), + d("color", "inherit"), + ), + p("& h4", + d("color", "var(--tw-prose-headings)"), + d("font-weight", "600"), + d("margin-top", "1.5em"), + d("margin-bottom", "0.5em"), + d("line-height", "1.5"), + ), + p("& h4 strong", + d("font-weight", "700"), + d("color", "inherit"), + ), + + // Horizontal rule + p("& hr", + d("border-color", "var(--tw-prose-hr)"), + d("border-top-width", "1px"), + d("margin-top", "3em"), + d("margin-bottom", "3em"), + ), + p("& hr + *", + d("margin-top", "0"), + ), + p("& h2 + *", + d("margin-top", "0"), + ), + p("& h3 + *", + d("margin-top", "0"), + ), + p("& h4 + *", + d("margin-top", "0"), + ), + + // Blockquote + p("& blockquote", + d("font-weight", "500"), + d("font-style", "italic"), + d("color", "var(--tw-prose-quotes)"), + d("border-inline-start-width", "0.25rem"), + d("border-inline-start-color", "var(--tw-prose-quote-borders)"), + d("quotes", "\"\\201C\"\"\\201D\"\"\\2018\"\"\\2019\""), + d("margin-top", "1.6em"), + d("margin-bottom", "1.6em"), + d("padding-inline-start", "1em"), + ), + p("& blockquote p:first-of-type::before", + d("content", "open-quote"), + ), + p("& blockquote p:last-of-type::after", + d("content", "close-quote"), + ), + + // Images and media + p("& img", + d("margin-top", "2em"), + d("margin-bottom", "2em"), + ), + p("& picture", + d("display", "block"), + d("margin-top", "2em"), + d("margin-bottom", "2em"), + ), + p("& picture > img", + d("margin-top", "0"), + d("margin-bottom", "0"), + ), + p("& video", + d("margin-top", "2em"), + d("margin-bottom", "2em"), + ), + + // Figures + p("& figure", + d("margin-top", "2em"), + d("margin-bottom", "2em"), + ), + p("& figure > *", + d("margin-top", "0"), + d("margin-bottom", "0"), + ), + p("& figcaption", + d("color", "var(--tw-prose-captions)"), + d("font-size", "0.875em"), + d("line-height", "1.4285714"), + d("margin-top", "0.8571429em"), + ), + + // Keyboard + p("& kbd", + d("font-weight", "500"), + d("font-family", "inherit"), + d("color", "var(--tw-prose-kbd)"), + d("box-shadow", "0 0 0 1px rgb(var(--tw-prose-kbd-shadows) / 10%), 0 3px 0 rgb(var(--tw-prose-kbd-shadows) / 10%)"), + d("font-size", "0.875em"), + d("border-radius", "0.3125rem"), + d("padding-top", "0.1875em"), + d("padding-inline-end", "0.375em"), + d("padding-bottom", "0.1875em"), + d("padding-inline-start", "0.375em"), + ), + + // Code + p("& code", + d("color", "var(--tw-prose-code)"), + d("font-weight", "600"), + d("font-size", "0.875em"), + ), + p("& code::before", + d("content", "\"`\""), + ), + p("& code::after", + d("content", "\"`\""), + ), + p("& a code, & h1 code, & h2 code, & h3 code, & h4 code, & blockquote code, & thead th code", + d("color", "inherit"), + ), + + // Pre + p("& pre", + d("color", "var(--tw-prose-pre-code)"), + d("background-color", "var(--tw-prose-pre-bg)"), + d("overflow-x", "auto"), + d("font-weight", "400"), + d("font-size", "0.875em"), + d("line-height", "1.7142857"), + d("margin-top", "1.7142857em"), + d("margin-bottom", "1.7142857em"), + d("border-radius", "0.375rem"), + d("padding-top", "0.8571429em"), + d("padding-inline-end", "1.1428571em"), + d("padding-bottom", "0.8571429em"), + d("padding-inline-start", "1.1428571em"), + ), + p("& pre code", + d("background-color", "transparent"), + d("border-width", "0"), + d("border-radius", "0"), + d("padding", "0"), + d("font-weight", "inherit"), + d("color", "inherit"), + d("font-size", "inherit"), + d("font-family", "inherit"), + d("line-height", "inherit"), + ), + p("& pre code::before", + d("content", "none"), + ), + p("& pre code::after", + d("content", "none"), + ), + + // Tables + p("& table", + d("width", "100%"), + d("table-layout", "auto"), + d("margin-top", "2em"), + d("margin-bottom", "2em"), + d("font-size", "0.875em"), + d("line-height", "1.7142857"), + ), + p("& thead", + d("border-bottom-width", "1px"), + d("border-bottom-color", "var(--tw-prose-th-borders)"), + ), + p("& thead th", + d("color", "var(--tw-prose-headings)"), + d("font-weight", "600"), + d("vertical-align", "bottom"), + d("padding-inline-end", "0.5714286em"), + d("padding-bottom", "0.5714286em"), + d("padding-inline-start", "0.5714286em"), + ), + p("& thead th:first-child", + d("padding-inline-start", "0"), + ), + p("& thead th:last-child", + d("padding-inline-end", "0"), + ), + p("& tbody tr", + d("border-bottom-width", "1px"), + d("border-bottom-color", "var(--tw-prose-td-borders)"), + ), + p("& tbody tr:last-child", + d("border-bottom-width", "0"), + ), + p("& tbody td, & tfoot td", + d("vertical-align", "baseline"), + d("padding-top", "0.5714286em"), + d("padding-inline-end", "0.5714286em"), + d("padding-bottom", "0.5714286em"), + d("padding-inline-start", "0.5714286em"), + ), + p("& tbody td:first-child, & tfoot td:first-child", + d("padding-inline-start", "0"), + ), + p("& tbody td:last-child, & tfoot td:last-child", + d("padding-inline-end", "0"), + ), + p("& tfoot", + d("border-top-width", "1px"), + d("border-top-color", "var(--tw-prose-th-borders)"), + ), + p("& th, & td", + d("text-align", "start"), + ), + + // h2/h3 code sizes + p("& h2 code", + d("font-size", "0.875em"), + ), + p("& h3 code", + d("font-size", "0.9em"), + ), + + // First/last child margin reset + p("& > :first-child", + d("margin-top", "0"), + ), + p("& > :last-child", + d("margin-bottom", "0"), + ), + } +} + +// twProseInvertCSS returns CSSRule nodes for the `prose-invert` modifier. +func proseInvertCSS() []*AstNode { + vars := []string{ + "body", "headings", "lead", "links", "bold", "counters", "bullets", + "hr", "quotes", "quote-borders", "captions", "kbd", "kbd-shadows", + "code", "pre-code", "pre-bg", "th-borders", "td-borders", + } + var decls []*AstNode + for _, v := range vars { + decls = append(decls, decl("--tw-prose-"+v, "var(--tw-prose-invert-"+v+")")) + } + return []*AstNode{ + styleRule("&", decls...), + } +} + +// registerProse registers the non-core `prose`/`prose-invert` typography +// utilities (ported from @tailwindcss/typography's base output). -mta +func registerProse(c *utilCtx) { + c.utilities.static("prose", func(_ *Candidate) *utilResult { return uList(proseCSS()) }) + c.utilities.static("prose-invert", func(_ *Candidate) *utilResult { return uList(proseInvertCSS()) }) +} + +// Port of packages/tailwindcss/src/utils/replace-shadow-colors.ts +// +// The upstream walks the value AST; since every branch returns Skip/ReplaceStop +// (functions are never recursed into), only top-level value nodes matter, so +// this iterates them directly. + +var shadowKeywords = map[string]bool{"inset": true, "inherit": true, "initial": true, "revert": true, "unset": true} +var shadowLengthFns = map[string]bool{"calc": true, "clamp": true, "max": true, "min": true, "--spacing": true} +var shadowColorFns = map[string]bool{ + "color": true, "color-mix": true, "contrast-color": true, "device-cmyk": true, + "hsl": true, "hsla": true, "hwb": true, "lab": true, "lch": true, "light-dark": true, + "oklab": true, "oklch": true, "rgb": true, "rgba": true, "--alpha": true, +} +var reShadowLength = regexp.MustCompile(`^-?(\d+|\.\d+)(.*?)$`) + +func replaceShadowColors(input string, replacement func(color string) string) string { + replaceAst := func(node ValueNode) []ValueNode { + color := valueToCss([]ValueNode{node}) + return valueParse(replacement(color)) + } + + parts := segment(input, ",") + out := make([]string, len(parts)) + for pi, shadow := range parts { + shadow = strings.TrimSpace(shadow) + ast := valueParse(shadow) + + unknownIdx := -1 + unknowns := 0 + lengths := 0 + replaced := false + + for i := 0; i < len(ast); i++ { + switch n := ast[i].(type) { + case *ValueWord: + lw := strings.ToLower(n.Value) + if shadowKeywords[lw] { + continue + } + if reShadowLength.MatchString(lw) { + lengths++ + continue + } + if (len(n.Value) > 0 && n.Value[0] == '#') || isNamedColor(n.Value) { + repl := replaceAst(ast[i]) + ast = spliceValueNodes(ast, i, repl) + replaced = true + } + if replaced { + break + } + unknownIdx = i + unknowns++ + case *ValueFunction: + lf := strings.ToLower(n.Value) + if shadowColorFns[lf] { + repl := replaceAst(ast[i]) + ast = spliceValueNodes(ast, i, repl) + replaced = true + break + } + if shadowLengthFns[lf] { + lengths++ + continue + } + unknownIdx = i + unknowns++ + case *ValueSeparator: + continue + } + if replaced { + break + } + } + + if replaced { + out[pi] = valueToCss(ast) + continue + } + if lengths < 2 { + out[pi] = shadow + continue + } + if unknowns == 0 { + out[pi] = shadow + " " + replacement("currentcolor") + continue + } + if unknowns == 1 { + repl := replaceAst(ast[unknownIdx]) + ast = spliceValueNodes(ast, unknownIdx, repl) + replaced = true + } + if replaced { + out[pi] = valueToCss(ast) + } else { + out[pi] = shadow + } + } + + return strings.Join(out, ", ") +} + +func spliceValueNodes(nodes []ValueNode, idx int, repl []ValueNode) []ValueNode { + out := make([]ValueNode, 0, len(nodes)-1+len(repl)) + out = append(out, nodes[:idx]...) + out = append(out, repl...) + out = append(out, nodes[idx+1:]...) + return out +} + +func twScanFile(path string) ([]string, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + src := string(content) + // For HTML/template files, also extract tokens from raw content + // to catch class names inside Go template directives ({{ }}) + // which break the JS-oriented quote-based extraction. + if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".gohtml") || strings.HasSuffix(path, ".tmpl") { + return extractCandidatesHTML(src), nil + } + return extractCandidates(src), nil +} + +func extractCandidatesHTML(src string) []string { + seen := make(map[string]bool) + var results []string + add := func(candidates []string) { + for _, c := range candidates { + if !seen[c] { + seen[c] = true + results = append(results, c) + } + } + } + // Standard extraction from quoted strings + add(extractCandidates(src)) + // Also extract tokens from the entire raw content — this catches + // class names inside Go template blocks like {{if ...}}class{{end}} + // where embedded quotes break the string-based extraction. + add(extractTokens(src)) + return results +} + +func twScanFiles(dir string) ([]string, error) { + var candidates []string + seen := make(map[string]bool) + + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if !strings.HasSuffix(path, ".js") { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return nil + } + for _, c := range extractCandidates(string(content)) { + if !seen[c] { + seen[c] = true + candidates = append(candidates, c) + } + } + return nil + }) + if err != nil { + return nil, err + } + + sort.Strings(candidates) + return candidates, nil +} + +func extractCandidates(src string) []string { + var results []string + n := len(src) + i := 0 + // prevValue is true when the previous significant token can end an + // expression. It tells a `/` apart: division when it follows a value, + // a regex literal otherwise. Without this, a quote inside a regex (e.g. + // /it's/) reads as a string and desyncs the scanner, just like an + // apostrophe in a comment would. + prevValue := false + + for i < n { + ch := src[i] + + switch { + case ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r': + // Whitespace is insignificant; prevValue carries over. + i++ + + case ch == '/' && i+1 < n && (src[i+1] == '/' || src[i+1] == '*'): + // Comment — skip so quotes/apostrophes inside (e.g. "don't", + // "button's") don't desync the string scanner. A single stray + // apostrophe in a comment otherwise flips quote parity and swallows + // every class literal until the next quote. Insignificant, so + // prevValue carries over. Strings are matched after this, so a `//` + // inside a quoted URL is never reached here. + i, _ = skipComment(src, i, n) + + case ch == '"' || ch == '\'': + // String literal — where class names live. + i++ + start := i + for i < n && src[i] != ch { + if src[i] == '\\' && i+1 < n { + i += 2 + continue + } + i++ + } + results = append(results, extractTokens(src[start:i])...) + if i < n { + i++ + } + prevValue = true + + case ch == '`': + i++ + results = append(results, extractFromTemplate(src, &i, n)...) + prevValue = true + + case ch == '/': + // Not a comment (handled above): a regex literal unless it follows + // a value, in which case it's the division operator. + if prevValue { + i++ + prevValue = false + } else { + i = skipRegexLiteral(src, i, n) + prevValue = true + } + + case isIdentByte(ch): + start := i + for i < n && isIdentByte(src[i]) { + i++ + } + prevValue = !keywordExpectsRegex(src[start:i]) + + case ch == ')' || ch == ']': + prevValue = true + i++ + + default: + // Any other operator/punctuation ({ } ( = : ; , . ...): a `/` that + // follows is a regex, not division. + prevValue = false + i++ + } + } + + return results +} + +func extractFromTemplate(src string, pos *int, n int) []string { + var results []string + i := *pos + + for i < n { + if src[i] == '\\' && i+1 < n { + i += 2 + continue + } + if src[i] == '`' { + i++ + *pos = i + return results + } + if src[i] == '$' && i+1 < n && src[i+1] == '{' { + i += 2 + depth := 1 + // Same regex/division disambiguation as extractCandidates, scoped to + // this interpolation. Tracking prevValue (and skipping comments and + // regex literals) keeps quotes inside either from miscounting the + // braces that delimit the ${...} expression. + prevValue := false + for i < n && depth > 0 { + c := src[i] + switch { + case c == ' ' || c == '\t' || c == '\n' || c == '\r': + i++ + case c == '/' && i+1 < n && (src[i+1] == '/' || src[i+1] == '*'): + i, _ = skipComment(src, i, n) + case c == '{': + depth++ + i++ + prevValue = false + case c == '}': + depth-- + i++ + prevValue = false + case c == '"' || c == '\'': + i++ + start := i + for i < n && src[i] != c { + if src[i] == '\\' && i+1 < n { + i += 2 + continue + } + i++ + } + results = append(results, extractTokens(src[start:i])...) + if i < n { + i++ + } + prevValue = true + case c == '`': + i++ + results = append(results, extractFromTemplate(src, &i, n)...) + prevValue = true + case c == '/': + if prevValue { + i++ + prevValue = false + } else { + i = skipRegexLiteral(src, i, n) + prevValue = true + } + case isIdentByte(c): + start := i + for i < n && isIdentByte(src[i]) { + i++ + } + prevValue = !keywordExpectsRegex(src[start:i]) + case c == ')' || c == ']': + prevValue = true + i++ + default: + prevValue = false + i++ + } + } + continue + } + + // Accumulate template text content. + start := i + for i < n && src[i] != '`' && src[i] != '\\' && !(src[i] == '$' && i+1 < n && src[i+1] == '{') { + i++ + } + if i > start { + results = append(results, extractTokens(src[start:i])...) + } + } + + *pos = i + return results +} + +func extractTokens(s string) []string { + var tokens []string + n := len(s) + i := 0 + + for i < n { + // Skip non-candidate characters. + for i < n && !isCandidateChar(s[i]) { + i++ + } + if i >= n { + break + } + + start := i + for i < n && isCandidateChar(s[i]) { + // Handle bracket groups [...] + if s[i] == '[' { + i++ + for i < n && s[i] != ']' { + i++ + } + if i < n { + i++ + } + continue + } + i++ + } + + token := s[start:i] + if looksLikeTWCandidate(token) { + tokens = append(tokens, token) + } + } + + return tokens +} + +func isCandidateChar(ch byte) bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || + ch == '-' || ch == '_' || ch == '/' || ch == ':' || ch == '!' || ch == '[' || ch == ']' || + ch == '#' || ch == '.' || ch == '%' || ch == '(' || ch == ')' || ch == ',' +} + +func looksLikeTWCandidate(s string) bool { + if len(s) == 0 || len(s) > 200 { + return false + } + // Must start with a letter, ! or -. Also allow `[` to support bare + // arbitrary-property syntax like `[transition:opacity_300ms]` and arbitrary + // variants like `[&_td]:p-4`. + ch := s[0] + if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '!' || ch == '-' || ch == '[') { + return false + } + // Filter out things that are clearly not utilities. + if strings.ContainsAny(s, "{}()=<>&|+*~^") { + // Allow parens only inside brackets + if !strings.Contains(s, "[") { + return false + } + } + return true +} + +func isASCIILetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + +// isIdentByte reports whether b can appear in a JS identifier or number, used +// to consume barewords whole so a keyword can be told from a plain value. +func isIdentByte(b byte) bool { + return isASCIILetter(b) || (b >= '0' && b <= '9') || b == '_' || b == '$' +} + +// keywordExpectsRegex reports whether a `/` directly following this bareword is +// a regex literal (the word is a keyword that expects an expression next) +// rather than the division operator. +func keywordExpectsRegex(word string) bool { + switch word { + case "return", "typeof", "instanceof", "in", "of", "new", "delete", + "void", "do", "else", "case", "yield", "await", "throw": + return true + } + return false +} + +// skipComment skips a // line comment or /* */ block comment that starts at i +// (src[i] must be '/'). ok is false when i is not the start of a comment. +func skipComment(src string, i, n int) (int, bool) { + if i+1 >= n || src[i] != '/' { + return i, false + } + switch src[i+1] { + case '/': + i += 2 + for i < n && src[i] != '\n' { + i++ + } + return i, true + case '*': + i += 2 + for i+1 < n && !(src[i] == '*' && src[i+1] == '/') { + i++ + } + i += 2 // consume the closing */ + if i > n { + i = n + } + return i, true + } + return i, false +} + +// skipRegexLiteral skips a /.../flags regex literal whose opening slash is at i. +// Character classes [...] are honored so a `/` inside them doesn't end the +// literal; a newline aborts (a real regex can't span one) to bound any runaway +// from a mis-detected division. +func skipRegexLiteral(src string, i, n int) int { + i++ // opening / + inClass := false + for i < n { + c := src[i] + switch { + case c == '\\' && i+1 < n: + i += 2 + case c == '\n': + return i + case c == '[': + inClass = true + i++ + case c == ']': + inClass = false + i++ + case c == '/' && !inClass: + i++ // closing / + for i < n && isASCIILetter(src[i]) { + i++ // flags + } + return i + default: + i++ + } + } + return i +} + +// Port of packages/tailwindcss/src/utils/segment.ts +// +// Splits a string on a top-level occurrence of a single-character separator, +// ignoring separators that appear inside (), [], {} or quoted strings. Regex +// can't do balanced matching, so this is a tiny state machine — identical in +// behaviour to the upstream implementation. Operates on bytes; all structural +// characters are ASCII and UTF-8 continuation bytes are always >= 0x80, so +// multi-byte runes never collide with them. +func segment(input, separator string) []string { + if separator == "" { + return []string{input} + } + sep := separator[0] + + var stack []byte // expected closing brackets + var parts []string + lastPos := 0 + + for i := 0; i < len(input); i++ { + c := input[i] + + if len(stack) == 0 && c == sep { + parts = append(parts, input[lastPos:i]) + lastPos = i + 1 + continue + } + + switch c { + case '\\': + // Next character is escaped; skip it. + i++ + case '\'', '"': + // Consume the whole string literal. + for i++; i < len(input); i++ { + nc := input[i] + if nc == '\\' { + i++ + continue + } + if nc == c { + break + } + } + case '(': + stack = append(stack, ')') + case '[': + stack = append(stack, ']') + case '{': + stack = append(stack, '}') + case ')', ']', '}': + if len(stack) > 0 && c == stack[len(stack)-1] { + stack = stack[:len(stack)-1] + } + } + } + + parts = append(parts, input[lastPos:]) + return parts +} + +// Port of packages/tailwindcss/src/sort.ts + +type classOrderEntry struct { + class string + order *big.Int // nil for non-Tailwind classes +} + +func getClassOrder(ds *DesignSystem, classes []string) []classOrderEntry { + astNodes, nodeSorting := compileCandidates(classes, ds, nil, true) + + sorted := map[string]*big.Int{} + for _, c := range classes { + sorted[c] = nil + } + + idx := big.NewInt(0) + for _, node := range astNodes { + meta, ok := nodeSorting[node] + if !ok || meta.candidate == "" { + continue + } + if sorted[meta.candidate] == nil { + sorted[meta.candidate] = new(big.Int).Set(idx) + idx.Add(idx, big.NewInt(1)) + } + } + + out := make([]classOrderEntry, len(classes)) + for i, c := range classes { + out[i] = classOrderEntry{class: c, order: sorted[c]} + } + return out +} + +// Port of packages/tailwindcss/src/theme.ts +// +// The theme stores `--namespace-key` design tokens (from the default theme.css +// and the project's @theme block) and resolves utility values against them. +// Insertion order is preserved (the upstream code relies on JS Map order for +// deterministic emission and namespace iteration). + +type ThemeOptions int + +const ( + themeNone ThemeOptions = 0 + themeInline ThemeOptions = 1 << 0 + themeReference ThemeOptions = 1 << 1 + themeDefault ThemeOptions = 1 << 2 + themeStatic ThemeOptions = 1 << 3 + themeUsed ThemeOptions = 1 << 4 +) + +var ignoredThemeKeyMap = map[string][]string{ + "--font": {"--font-weight", "--font-size"}, + "--inset": {"--inset-shadow", "--inset-ring"}, + "--text": {"--text-color", "--text-decoration-color", "--text-decoration-thickness", "--text-indent", "--text-shadow", "--text-underline-offset"}, + "--grid-column": {"--grid-column-start", "--grid-column-end"}, + "--grid-row": {"--grid-row-start", "--grid-row-end"}, +} + +func isIgnoredThemeKey(themeKey, namespace string) bool { + for _, ig := range ignoredThemeKeyMap[namespace] { + if themeKey == ig || strings.HasPrefix(themeKey, ig+"-") { + return true + } + } + return false +} + +type themeValue struct { + value string + options ThemeOptions +} + +type Theme struct { + Prefix string + values map[string]*themeValue + order []string + keyframes []*AstNode +} + +func NewTheme() *Theme { + return &Theme{values: make(map[string]*themeValue)} +} + +func sptr(s string) *string { return &s } + +func (t *Theme) setVal(key string, v *themeValue) { + if _, ok := t.values[key]; !ok { + t.order = append(t.order, key) + } + t.values[key] = v +} + +func (t *Theme) delVal(key string) { + if _, ok := t.values[key]; !ok { + return + } + delete(t.values, key) + for i, k := range t.order { + if k == key { + t.order = append(t.order[:i], t.order[i+1:]...) + break + } + } +} + +func (t *Theme) Size() int { return len(t.values) } + +func (t *Theme) add(key, value string, options ThemeOptions) { + if strings.HasSuffix(key, "-*") { + if value != "initial" { + // Invalid usage upstream throws; we ignore to stay non-fatal. + return + } + if key == "--*" { + t.values = make(map[string]*themeValue) + t.order = nil + } else { + t.clearNamespace(key[:len(key)-2], themeNone) + } + } + + if options&themeDefault != 0 { + if existing, ok := t.values[key]; ok && existing.options&themeDefault == 0 { + return + } + } + + if value == "initial" { + t.delVal(key) + } else { + t.setVal(key, &themeValue{value: value, options: options}) + } +} + +func (t *Theme) keysInNamespaces(themeKeys []string) []string { + var keys []string + for _, namespace := range themeKeys { + prefix := namespace + "-" + for _, key := range t.order { + if !strings.HasPrefix(key, prefix) { + continue + } + if strings.Index(key[2:], "--") != -1 { + continue + } + if isIgnoredThemeKey(key, namespace) { + continue + } + keys = append(keys, key[len(prefix):]) + } + } + return keys +} + +func (t *Theme) Get(themeKeys []string) (string, bool) { + for _, key := range themeKeys { + if v, ok := t.values[key]; ok { + return v.value, true + } + } + return "", false +} + +func (t *Theme) Has(key string) bool { + _, ok := t.values[key] + return ok +} + +func (t *Theme) hasDefault(key string) bool { + return t.getOptions(key)&themeDefault == themeDefault +} + +func (t *Theme) getOptions(key string) ThemeOptions { + key = unescape(t.unprefixKey(key)) + if v, ok := t.values[key]; ok { + return v.options + } + return themeNone +} + +func (t *Theme) prefixKey(key string) string { + if t.Prefix == "" { + return key + } + return "--" + t.Prefix + "-" + key[2:] +} + +func (t *Theme) unprefixKey(key string) string { + if t.Prefix == "" { + return key + } + return "--" + key[3+len(t.Prefix):] +} + +func (t *Theme) clearNamespace(namespace string, clearOptions ThemeOptions) { + ignored := ignoredThemeKeyMap[namespace] + var toDelete []string +outer: + for _, key := range t.order { + if strings.HasPrefix(key, namespace) { + if clearOptions != themeNone { + options := t.getOptions(key) + if options&clearOptions != clearOptions { + continue + } + } + for _, ig := range ignored { + if strings.HasPrefix(key, ig) { + continue outer + } + } + toDelete = append(toDelete, key) + } + } + for _, key := range toDelete { + t.delVal(key) + } +} + +func (t *Theme) resolveKey(candidateValue *string, themeKeys []string) string { + for _, namespace := range themeKeys { + var themeKey string + if candidateValue != nil { + themeKey = namespace + "-" + *candidateValue + } else { + themeKey = namespace + } + + if _, ok := t.values[themeKey]; !ok { + if candidateValue != nil && strings.Contains(*candidateValue, ".") { + themeKey = namespace + "-" + strings.ReplaceAll(*candidateValue, ".", "_") + if _, ok := t.values[themeKey]; !ok { + continue + } + } else { + continue + } + } + + if isIgnoredThemeKey(themeKey, namespace) { + continue + } + return themeKey + } + return "" +} + +func (t *Theme) varFn(themeKey string) string { + v, ok := t.values[themeKey] + if !ok { + return "" + } + fallback := "" + if v.options&themeReference != 0 { + fallback = v.value + } + s := "var(" + escape(t.prefixKey(themeKey)) + if fallback != "" { + s += ", " + fallback + } + s += ")" + return s +} + +func (t *Theme) markUsedVariable(themeKey string) bool { + key := unescape(t.unprefixKey(themeKey)) + v, ok := t.values[key] + if !ok { + return false + } + wasUsed := v.options&themeUsed != 0 + v.options |= themeUsed + return !wasUsed +} + +func (t *Theme) resolve(candidateValue *string, themeKeys []string, options ThemeOptions) (string, bool) { + key := t.resolveKey(candidateValue, themeKeys) + if key == "" { + return "", false + } + v := t.values[key] + if (options|v.options)&themeInline != 0 { + return v.value, true + } + return t.varFn(key), true +} + +func (t *Theme) resolveValue(candidateValue *string, themeKeys []string) (string, bool) { + key := t.resolveKey(candidateValue, themeKeys) + if key == "" { + return "", false + } + return t.values[key].value, true +} + +func (t *Theme) resolveWith(candidateValue string, themeKeys []string, nestedKeys []string) (string, map[string]string, bool) { + key := t.resolveKey(&candidateValue, themeKeys) + if key == "" { + return "", nil, false + } + + extra := map[string]string{} + for _, name := range nestedKeys { + nestedKey := key + name + nv, ok := t.values[nestedKey] + if !ok { + continue + } + if nv.options&themeInline != 0 { + extra[name] = nv.value + } else { + extra[name] = t.varFn(nestedKey) + } + } + + v := t.values[key] + if v.options&themeInline != 0 { + return v.value, extra, true + } + return t.varFn(key), extra, true +} + +// ThemeNamespace is the flattened view of a single theme namespace. +type ThemeNamespace struct { + m map[string]string + order []string + hasNull bool + nullVal string +} + +func (n *ThemeNamespace) Get(key string) (string, bool) { + v, ok := n.m[key] + return v, ok +} + +func (n *ThemeNamespace) GetNull() (string, bool) { + return n.nullVal, n.hasNull +} + +func (n *ThemeNamespace) set(key, value string) { + if _, ok := n.m[key]; !ok { + n.order = append(n.order, key) + } + n.m[key] = value +} + +func (n *ThemeNamespace) Keys() []string { return n.order } + +// Values returns the namespace values in insertion order (including the null +// entry's value, if present, matching JS Map iteration order). +func (n *ThemeNamespace) Values() []string { + var out []string + if n.hasNull { + out = append(out, n.nullVal) + } + for _, k := range n.order { + out = append(out, n.m[k]) + } + return out +} + +func (t *Theme) namespace(namespace string) *ThemeNamespace { + ns := &ThemeNamespace{m: map[string]string{}} + prefix := namespace + "-" + for _, key := range t.order { + v := t.values[key] + switch { + case key == namespace: + ns.hasNull = true + ns.nullVal = v.value + case strings.HasPrefix(key, prefix+"-"): + // Preserve `--` prefix for sub-variables (e.g. --text-sm--line-height). + ns.set(key[len(namespace):], v.value) + case strings.HasPrefix(key, prefix): + ns.set(key[len(prefix):], v.value) + } + } + return ns +} + +func (t *Theme) addKeyframes(value *AstNode) { + t.keyframes = append(t.keyframes, value) +} + +func (t *Theme) getKeyframes() []*AstNode { + return t.keyframes +} + +// Port of packages/tailwindcss/src/utilities.ts +// +// The Utilities registry plus the helper builders (staticUtility, +// functionalUtility, colorUtility, spacingUtility) and the full utility +// catalog registered by createUtilities. +// +// Deviation: the IntelliSense suggestion layer (suggest/getCompletions) is +// stubbed to no-ops — it does not affect generated CSS, only IDE autocomplete. +// -mta + +type utilKind int + +const ( + utilStatic utilKind = iota + utilFunctional +) + +type UtilityOptions struct { + Types []string +} + +// utilResult models the TS tri-state return of a compile fn: +// +// nil -> undefined (skip this utility, try the next) +// {null:true} -> null (invalid; bail to fallback utilities if typed) +// {nodes:[...]} -> the produced AST nodes +type utilResult struct { + nodes []*AstNode + null bool +} + +func uNodes(nodes ...*AstNode) *utilResult { return &utilResult{nodes: nodes} } +func uList(nodes []*AstNode) *utilResult { return &utilResult{nodes: nodes} } +func uNull() *utilResult { return &utilResult{null: true} } + +type Utility struct { + kind utilKind + compileFn func(*Candidate) *utilResult + options *UtilityOptions +} + +type Utilities struct { + m map[string][]*Utility + order []string +} + +func NewUtilities() *Utilities { + return &Utilities{m: make(map[string][]*Utility)} +} + +func (u *Utilities) addUtility(name string, util *Utility) { + if _, ok := u.m[name]; !ok { + u.order = append(u.order, name) + } + u.m[name] = append(u.m[name], util) +} + +func (u *Utilities) static(name string, fn func(*Candidate) *utilResult) { + u.addUtility(name, &Utility{kind: utilStatic, compileFn: fn}) +} + +func (u *Utilities) functional(name string, fn func(*Candidate) *utilResult, options *UtilityOptions) { + u.addUtility(name, &Utility{kind: utilFunctional, compileFn: fn, options: options}) +} + +func (u *Utilities) has(name string, kind utilKind) bool { + fns, ok := u.m[name] + if !ok { + return false + } + for _, f := range fns { + if f.kind == kind { + return true + } + } + return false +} + +func (u *Utilities) get(name string) []*Utility { return u.m[name] } + +func (u *Utilities) keys(kind utilKind) []string { + var keys []string + for _, key := range u.order { + for _, f := range u.m[key] { + if f.kind == kind { + keys = append(keys, key) + break + } + } + } + return keys +} + +// ---- color/alpha helpers ------------------------------------------------ + +func withAlpha(value, alpha string) string { + if f, ok := jsParseNumber(alpha); ok { + alpha = jsNumberToString(f*100) + "%" + } + if alpha == "100%" { + return value + } + return "color-mix(in oklab, " + value + " " + alpha + ", transparent)" +} + +func replaceAlpha(value, alpha string) string { + if f, ok := jsParseNumber(alpha); ok { + alpha = jsNumberToString(f*100) + "%" + } + return "oklab(from " + value + " l a b / " + alpha + ")" +} + +// asColor resolves a color value plus an optional opacity modifier. Returns +// (value, true) or ("", false) when the modifier is an invalid opacity. +func asColor(value string, modifier *CandidateModifier, theme *Theme) (string, bool) { + if modifier == nil { + return value, true + } + if modifier.Kind == modArbitrary { + return withAlpha(value, modifier.Value), true + } + if alpha, ok := theme.resolve(&modifier.Value, []string{"--opacity"}, themeNone); ok && alpha != "" { + return withAlpha(value, alpha), true + } + if !isValidOpacityValue(modifier.Value) { + return "", false + } + return withAlpha(value, modifier.Value+"%"), true +} + +func resolveThemeColor(candidate *Candidate, theme *Theme, themeKeys []string) (string, bool) { + var value string + var ok bool + switch candidate.Value.Value { + case "inherit": + value, ok = "inherit", true + case "transparent": + value, ok = "transparent", true + case "current": + value, ok = "currentcolor", true + default: + value, ok = theme.resolve(&candidate.Value.Value, themeKeys, themeNone) + } + if !ok { + return "", false + } + return asColor(value, candidate.Modifier, theme) +} + +func property(ident, initialValue, syntax string) *AstNode { + syntaxStr := `"*"` + if syntax != "" { + syntaxStr = `"` + syntax + `"` + } + nodes := []*AstNode{decl("syntax", syntaxStr), decl("inherits", "false")} + if initialValue != "" { + nodes = append(nodes, decl("initial-value", initialValue)) + } + return atRule("@property", ident, nodes...) +} + +// ---- utility registration helpers -------------------------------------- + +// utilCtx carries the theme + registry through the catalog registration helpers +// (the upstream closures over `theme`/`utilities` inside createUtilities). +type utilCtx struct { + theme *Theme + utilities *Utilities +} + +// staticDecl is one entry of a static utility: either a property/value pair or +// a node-producing function. +type staticDecl struct { + prop string + val string + fn func() *AstNode +} + +func sd(prop, val string) staticDecl { return staticDecl{prop: prop, val: val} } +func sdFn(fn func() *AstNode) staticDecl { return staticDecl{fn: fn} } + +func (c *utilCtx) staticUtility(className string, decls []staticDecl) { + c.utilities.static(className, func(_ *Candidate) *utilResult { + nodes := make([]*AstNode, len(decls)) + for i, d := range decls { + if d.fn != nil { + nodes[i] = d.fn() + } else { + nodes[i] = decl(d.prop, d.val) + } + } + return uList(nodes) + }) +} + +// suggest is a no-op: IntelliSense suggestions don't affect generated CSS. +func (c *utilCtx) suggest(classRoot string, defns func() []any) {} + +type utilityDescription struct { + supportsNegative bool + supportsFractions bool + themeKeys []string + + // defaultValueSet distinguishes "undefined" (false) from an explicit + // default (true). When set, defaultValue==nil means null. + defaultValueSet bool + defaultValue *string + + staticValues map[string][]*AstNode + + handleBareValue func(*UtilityValue) (string, bool) + handleNegativeBareValue func(*UtilityValue) (string, bool) + handle func(value, dataType string) *utilResult +} + +func (c *utilCtx) functionalUtility(classRoot string, desc utilityDescription) { + make := func(negative bool) func(*Candidate) *utilResult { + return func(candidate *Candidate) *utilResult { + var value *string + dataType := "" + + if candidate.Value == nil { + if candidate.Modifier != nil { + return nil + } + if desc.defaultValueSet { + value = desc.defaultValue + } else { + if v, ok := c.theme.resolve(nil, desc.themeKeys, themeNone); ok { + value = &v + } + } + } else if candidate.Value.Kind == uvArbitrary { + if candidate.Modifier != nil { + return nil + } + v := candidate.Value.Value + value = &v + dataType = candidate.Value.DataType + } else { + key := candidate.Value.Value + if candidate.Value.Fraction != "" { + key = candidate.Value.Fraction + } + if v, ok := c.theme.resolve(&key, desc.themeKeys, themeNone); ok { + value = &v + } + + if value == nil && desc.supportsFractions && candidate.Value.Fraction != "" { + fparts := segment(candidate.Value.Fraction, "/") + if len(fparts) != 2 || !isPositiveInteger(fparts[0]) || !isPositiveInteger(fparts[1]) { + return nil + } + s := "calc(" + fparts[0] + " / " + fparts[1] + " * 100%)" + value = &s + } + + if value == nil && negative && desc.handleNegativeBareValue != nil { + v, ok := desc.handleNegativeBareValue(candidate.Value) + var vv *string + if ok { + vv = &v + } + includesSlash := vv != nil && strings.Contains(*vv, "/") + if !includesSlash && candidate.Modifier != nil { + return nil + } + if vv != nil { + return desc.handle(*vv, "") + } + } + + if value == nil && desc.handleBareValue != nil { + if v, ok := desc.handleBareValue(candidate.Value); ok { + value = &v + } + includesSlash := value != nil && strings.Contains(*value, "/") + if !includesSlash && candidate.Modifier != nil { + return nil + } + } + + if value == nil && !negative && desc.staticValues != nil && candidate.Modifier == nil { + if fb, ok := desc.staticValues[candidate.Value.Value]; ok { + return uList(cloneAstNodes(fb)) + } + } + } + + if value == nil { + return nil + } + + handleVal := *value + if negative { + handleVal = addWhitespaceAroundMathOperators("calc(" + *value + " * -1)") + } + return desc.handle(handleVal, dataType) + } + } + + if desc.supportsNegative { + c.utilities.functional("-"+classRoot, make(true), nil) + } + c.utilities.functional(classRoot, make(false), nil) +} + +type colorUtilityDescription struct { + themeKeys []string + handle func(value string) *utilResult +} + +func (c *utilCtx) colorUtility(classRoot string, desc colorUtilityDescription) { + c.utilities.functional(classRoot, func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + return nil + } + var value string + var ok bool + if candidate.Value.Kind == uvArbitrary { + value, ok = asColor(candidate.Value.Value, candidate.Modifier, c.theme) + } else { + value, ok = resolveThemeColor(candidate, c.theme, desc.themeKeys) + } + if !ok { + return nil + } + return desc.handle(value) + }, nil) +} + +func (c *utilCtx) spacingUtility(name string, themeKeys []string, handle func(value string) *utilResult, opts spacingOpts) { + if opts.supportsNegative { + c.utilities.static("-"+name+"-px", func(_ *Candidate) *utilResult { return handle("-1px") }) + } + c.utilities.static(name+"-px", func(_ *Candidate) *utilResult { return handle("1px") }) + + nullDefault := (*string)(nil) + c.functionalUtility(name, utilityDescription{ + themeKeys: themeKeys, + supportsFractions: opts.supportsFractions, + supportsNegative: opts.supportsNegative, + defaultValueSet: true, + defaultValue: nullDefault, + handleBareValue: func(v *UtilityValue) (string, bool) { + if _, ok := c.theme.resolve(nil, []string{"--spacing"}, themeNone); !ok { + return "", false + } + if !isValidSpacingMultiplier(v.Value) { + return "", false + } + return "--spacing(" + v.Value + ")", true + }, + handleNegativeBareValue: func(v *UtilityValue) (string, bool) { + if _, ok := c.theme.resolve(nil, []string{"--spacing"}, themeNone); !ok { + return "", false + } + if !isValidSpacingMultiplier(v.Value) { + return "", false + } + return "--spacing(-" + v.Value + ")", true + }, + handle: func(value, _ string) *utilResult { return handle(value) }, + staticValues: opts.staticValues, + }) +} + +type spacingOpts struct { + supportsNegative bool + supportsFractions bool + staticValues map[string][]*AstNode +} + +// createUtilities builds the full utility registry for a theme. +func createUtilities(theme *Theme) *Utilities { + c := &utilCtx{theme: theme, utilities: NewUtilities()} + registerUtilities(c) + registerProse(c) + return c.utilities +} + +// Port of the createUtilities() catalog from +// packages/tailwindcss/src/utilities.ts. Each registration mirrors the upstream +// staticUtility/functionalUtility/colorUtility/spacingUtility calls. suggest() +// calls are omitted (IntelliSense only). + +// bareInteger is the common handleBareValue that accepts a positive integer. +func bareInteger(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value, true +} + +func registerUtilities(c *utilCtx) { + d := decl + + c.staticUtility("sr-only", []staticDecl{ + sd("position", "absolute"), sd("width", "1px"), sd("height", "1px"), + sd("padding", "0"), sd("margin", "-1px"), sd("overflow", "hidden"), + sd("clip-path", "inset(50%)"), sd("white-space", "nowrap"), sd("border-width", "0"), + }) + c.staticUtility("not-sr-only", []staticDecl{ + sd("position", "static"), sd("width", "auto"), sd("height", "auto"), + sd("padding", "0"), sd("margin", "0"), sd("overflow", "visible"), + sd("clip-path", "none"), sd("white-space", "normal"), + }) + + c.staticUtility("pointer-events-none", []staticDecl{sd("pointer-events", "none")}) + c.staticUtility("pointer-events-auto", []staticDecl{sd("pointer-events", "auto")}) + + c.staticUtility("visible", []staticDecl{sd("visibility", "visible")}) + c.staticUtility("invisible", []staticDecl{sd("visibility", "hidden")}) + c.staticUtility("collapse", []staticDecl{sd("visibility", "collapse")}) + + c.staticUtility("static", []staticDecl{sd("position", "static")}) + c.staticUtility("fixed", []staticDecl{sd("position", "fixed")}) + c.staticUtility("absolute", []staticDecl{sd("position", "absolute")}) + c.staticUtility("relative", []staticDecl{sd("position", "relative")}) + c.staticUtility("sticky", []staticDecl{sd("position", "sticky")}) + + for _, pair := range [][2]string{ + {"inset", "inset"}, {"inset-x", "inset-inline"}, {"inset-y", "inset-block"}, + {"inset-s", "inset-inline-start"}, {"inset-e", "inset-inline-end"}, + {"inset-bs", "inset-block-start"}, {"inset-be", "inset-block-end"}, + {"top", "top"}, {"right", "right"}, {"bottom", "bottom"}, {"left", "left"}, + } { + name, prop := pair[0], pair[1] + c.staticUtility(name+"-auto", []staticDecl{sd(prop, "auto")}) + c.staticUtility(name+"-full", []staticDecl{sd(prop, "100%")}) + c.staticUtility("-"+name+"-full", []staticDecl{sd(prop, "-100%")}) + c.spacingUtility(name, []string{"--inset", "--spacing"}, + func(value string) *utilResult { return uNodes(d(prop, value)) }, + spacingOpts{supportsNegative: true, supportsFractions: true}) + } + + c.staticUtility("isolate", []staticDecl{sd("isolation", "isolate")}) + c.staticUtility("isolation-auto", []staticDecl{sd("isolation", "auto")}) + + c.functionalUtility("z", utilityDescription{ + supportsNegative: true, + handleBareValue: bareInteger, + themeKeys: []string{"--z-index"}, + handle: func(value, _ string) *utilResult { return uNodes(d("z-index", value)) }, + staticValues: map[string][]*AstNode{"auto": {d("z-index", "auto")}}, + }) + + c.functionalUtility("order", utilityDescription{ + supportsNegative: true, + handleBareValue: bareInteger, + themeKeys: []string{"--order"}, + handle: func(value, _ string) *utilResult { return uNodes(d("order", value)) }, + staticValues: map[string][]*AstNode{ + "first": {d("order", "-9999")}, + "last": {d("order", "9999")}, + }, + }) + + c.functionalUtility("col", utilityDescription{ + supportsNegative: true, + handleBareValue: bareInteger, + themeKeys: []string{"--grid-column"}, + handle: func(value, _ string) *utilResult { return uNodes(d("grid-column", value)) }, + staticValues: map[string][]*AstNode{"auto": {d("grid-column", "auto")}}, + }) + c.functionalUtility("col-span", utilityDescription{ + handleBareValue: bareInteger, + handle: func(value, _ string) *utilResult { + return uNodes(d("grid-column", "span "+value+" / span "+value)) + }, + staticValues: map[string][]*AstNode{"full": {d("grid-column", "1 / -1")}}, + }) + c.functionalUtility("col-start", utilityDescription{ + supportsNegative: true, + handleBareValue: bareInteger, + themeKeys: []string{"--grid-column-start"}, + handle: func(value, _ string) *utilResult { return uNodes(d("grid-column-start", value)) }, + staticValues: map[string][]*AstNode{"auto": {d("grid-column-start", "auto")}}, + }) + c.functionalUtility("col-end", utilityDescription{ + supportsNegative: true, + handleBareValue: bareInteger, + themeKeys: []string{"--grid-column-end"}, + handle: func(value, _ string) *utilResult { return uNodes(d("grid-column-end", value)) }, + staticValues: map[string][]*AstNode{"auto": {d("grid-column-end", "auto")}}, + }) + + c.functionalUtility("row", utilityDescription{ + supportsNegative: true, + handleBareValue: bareInteger, + themeKeys: []string{"--grid-row"}, + handle: func(value, _ string) *utilResult { return uNodes(d("grid-row", value)) }, + staticValues: map[string][]*AstNode{"auto": {d("grid-row", "auto")}}, + }) + c.functionalUtility("row-span", utilityDescription{ + handleBareValue: bareInteger, + handle: func(value, _ string) *utilResult { + return uNodes(d("grid-row", "span "+value+" / span "+value)) + }, + staticValues: map[string][]*AstNode{"full": {d("grid-row", "1 / -1")}}, + }) + c.functionalUtility("row-start", utilityDescription{ + supportsNegative: true, + handleBareValue: bareInteger, + themeKeys: []string{"--grid-row-start"}, + handle: func(value, _ string) *utilResult { return uNodes(d("grid-row-start", value)) }, + staticValues: map[string][]*AstNode{"auto": {d("grid-row-start", "auto")}}, + }) + c.functionalUtility("row-end", utilityDescription{ + supportsNegative: true, + handleBareValue: bareInteger, + themeKeys: []string{"--grid-row-end"}, + handle: func(value, _ string) *utilResult { return uNodes(d("grid-row-end", value)) }, + staticValues: map[string][]*AstNode{"auto": {d("grid-row-end", "auto")}}, + }) + + c.staticUtility("float-start", []staticDecl{sd("float", "inline-start")}) + c.staticUtility("float-end", []staticDecl{sd("float", "inline-end")}) + c.staticUtility("float-right", []staticDecl{sd("float", "right")}) + c.staticUtility("float-left", []staticDecl{sd("float", "left")}) + c.staticUtility("float-none", []staticDecl{sd("float", "none")}) + + c.staticUtility("clear-start", []staticDecl{sd("clear", "inline-start")}) + c.staticUtility("clear-end", []staticDecl{sd("clear", "inline-end")}) + c.staticUtility("clear-right", []staticDecl{sd("clear", "right")}) + c.staticUtility("clear-left", []staticDecl{sd("clear", "left")}) + c.staticUtility("clear-both", []staticDecl{sd("clear", "both")}) + c.staticUtility("clear-none", []staticDecl{sd("clear", "none")}) + + for _, pair := range [][2]string{ + {"m", "margin"}, {"mx", "margin-inline"}, {"my", "margin-block"}, + {"ms", "margin-inline-start"}, {"me", "margin-inline-end"}, + {"mbs", "margin-block-start"}, {"mbe", "margin-block-end"}, + {"mt", "margin-top"}, {"mr", "margin-right"}, {"mb", "margin-bottom"}, {"ml", "margin-left"}, + } { + namespace, prop := pair[0], pair[1] + c.staticUtility(namespace+"-auto", []staticDecl{sd(prop, "auto")}) + c.spacingUtility(namespace, []string{"--margin", "--spacing"}, + func(value string) *utilResult { return uNodes(d(prop, value)) }, + spacingOpts{supportsNegative: true}) + } + + c.staticUtility("box-border", []staticDecl{sd("box-sizing", "border-box")}) + c.staticUtility("box-content", []staticDecl{sd("box-sizing", "content-box")}) + + registerUtilities2(c) +} + +func registerUtilities2(c *utilCtx) { + d := decl + + c.functionalUtility("line-clamp", utilityDescription{ + themeKeys: []string{"--line-clamp"}, + handleBareValue: bareInteger, + handle: func(value, _ string) *utilResult { + return uNodes( + d("overflow", "hidden"), + d("display", "-webkit-box"), + d("-webkit-box-orient", "vertical"), + d("-webkit-line-clamp", value), + ) + }, + staticValues: map[string][]*AstNode{ + "none": { + d("overflow", "visible"), + d("display", "block"), + d("-webkit-box-orient", "horizontal"), + d("-webkit-line-clamp", "unset"), + }, + }, + }) + + for _, pair := range [][2]string{ + {"block", "block"}, {"inline-block", "inline-block"}, {"inline", "inline"}, + {"hidden", "none"}, {"inline-flex", "inline-flex"}, {"table", "table"}, + {"inline-table", "inline-table"}, {"table-caption", "table-caption"}, + {"table-cell", "table-cell"}, {"table-column", "table-column"}, + {"table-column-group", "table-column-group"}, {"table-footer-group", "table-footer-group"}, + {"table-header-group", "table-header-group"}, {"table-row-group", "table-row-group"}, + {"table-row", "table-row"}, {"flow-root", "flow-root"}, {"flex", "flex"}, + {"grid", "grid"}, {"inline-grid", "inline-grid"}, {"contents", "contents"}, + {"list-item", "list-item"}, + } { + c.staticUtility(pair[0], []staticDecl{sd("display", pair[1])}) + } + + c.staticUtility("field-sizing-content", []staticDecl{sd("field-sizing", "content")}) + c.staticUtility("field-sizing-fixed", []staticDecl{sd("field-sizing", "fixed")}) + + c.functionalUtility("aspect", utilityDescription{ + themeKeys: []string{"--aspect"}, + handleBareValue: func(v *UtilityValue) (string, bool) { + if v.Fraction == "" { + return "", false + } + fparts := segment(v.Fraction, "/") + if len(fparts) != 2 || !isValidSpacingMultiplier(fparts[0]) || !isValidSpacingMultiplier(fparts[1]) { + return "", false + } + return v.Fraction, true + }, + handle: func(value, _ string) *utilResult { return uNodes(d("aspect-ratio", value)) }, + staticValues: map[string][]*AstNode{ + "auto": {d("aspect-ratio", "auto")}, + "square": {d("aspect-ratio", "1 / 1")}, + }, + }) + + // size / w / h / min / max statics + for _, pair := range [][2]string{ + {"full", "100%"}, {"svw", "100svw"}, {"lvw", "100lvw"}, {"dvw", "100dvw"}, + {"svh", "100svh"}, {"lvh", "100lvh"}, {"dvh", "100dvh"}, + {"min", "min-content"}, {"max", "max-content"}, {"fit", "fit-content"}, + } { + key, value := pair[0], pair[1] + c.staticUtility("size-"+key, []staticDecl{sd("--tw-sort", "size"), sd("width", value), sd("height", value)}) + c.staticUtility("w-"+key, []staticDecl{sd("width", value)}) + c.staticUtility("h-"+key, []staticDecl{sd("height", value)}) + c.staticUtility("min-w-"+key, []staticDecl{sd("min-width", value)}) + c.staticUtility("min-h-"+key, []staticDecl{sd("min-height", value)}) + c.staticUtility("max-w-"+key, []staticDecl{sd("max-width", value)}) + c.staticUtility("max-h-"+key, []staticDecl{sd("max-height", value)}) + } + + c.staticUtility("size-auto", []staticDecl{sd("--tw-sort", "size"), sd("width", "auto"), sd("height", "auto")}) + c.staticUtility("w-auto", []staticDecl{sd("width", "auto")}) + c.staticUtility("h-auto", []staticDecl{sd("height", "auto")}) + c.staticUtility("min-w-auto", []staticDecl{sd("min-width", "auto")}) + c.staticUtility("min-h-auto", []staticDecl{sd("min-height", "auto")}) + + c.staticUtility("h-lh", []staticDecl{sd("height", "1lh")}) + c.staticUtility("min-h-lh", []staticDecl{sd("min-height", "1lh")}) + c.staticUtility("max-h-lh", []staticDecl{sd("max-height", "1lh")}) + + c.staticUtility("w-screen", []staticDecl{sd("width", "100vw")}) + c.staticUtility("min-w-screen", []staticDecl{sd("min-width", "100vw")}) + c.staticUtility("max-w-screen", []staticDecl{sd("max-width", "100vw")}) + c.staticUtility("h-screen", []staticDecl{sd("height", "100vh")}) + c.staticUtility("min-h-screen", []staticDecl{sd("min-height", "100vh")}) + c.staticUtility("max-h-screen", []staticDecl{sd("max-height", "100vh")}) + + c.staticUtility("max-w-none", []staticDecl{sd("max-width", "none")}) + c.staticUtility("max-h-none", []staticDecl{sd("max-height", "none")}) + + c.spacingUtility("size", []string{"--size", "--spacing"}, + func(value string) *utilResult { + return uNodes(d("--tw-sort", "size"), d("width", value), d("height", value)) + }, spacingOpts{supportsFractions: true}) + + for _, e := range []struct { + name string + namespaces []string + property string + }{ + {"w", []string{"--width", "--spacing", "--container"}, "width"}, + {"min-w", []string{"--min-width", "--spacing", "--container"}, "min-width"}, + {"max-w", []string{"--max-width", "--spacing", "--container"}, "max-width"}, + {"h", []string{"--height", "--spacing"}, "height"}, + {"min-h", []string{"--min-height", "--height", "--spacing"}, "min-height"}, + {"max-h", []string{"--max-height", "--height", "--spacing"}, "max-height"}, + } { + prop := e.property + c.spacingUtility(e.name, e.namespaces, + func(value string) *utilResult { return uNodes(d(prop, value)) }, + spacingOpts{supportsFractions: true}) + } + + // inline-size / block-size + for _, pair := range [][2]string{ + {"full", "100%"}, {"min", "min-content"}, {"max", "max-content"}, {"fit", "fit-content"}, + } { + key, value := pair[0], pair[1] + c.staticUtility("inline-"+key, []staticDecl{sd("inline-size", value)}) + c.staticUtility("block-"+key, []staticDecl{sd("block-size", value)}) + c.staticUtility("min-inline-"+key, []staticDecl{sd("min-inline-size", value)}) + c.staticUtility("min-block-"+key, []staticDecl{sd("min-block-size", value)}) + c.staticUtility("max-inline-"+key, []staticDecl{sd("max-inline-size", value)}) + c.staticUtility("max-block-"+key, []staticDecl{sd("max-block-size", value)}) + } + for _, pair := range [][2]string{{"svw", "100svw"}, {"lvw", "100lvw"}, {"dvw", "100dvw"}} { + key, value := pair[0], pair[1] + c.staticUtility("inline-"+key, []staticDecl{sd("inline-size", value)}) + c.staticUtility("min-inline-"+key, []staticDecl{sd("min-inline-size", value)}) + c.staticUtility("max-inline-"+key, []staticDecl{sd("max-inline-size", value)}) + } + for _, pair := range [][2]string{{"svh", "100svh"}, {"lvh", "100lvh"}, {"dvh", "100dvh"}} { + key, value := pair[0], pair[1] + c.staticUtility("block-"+key, []staticDecl{sd("block-size", value)}) + c.staticUtility("min-block-"+key, []staticDecl{sd("min-block-size", value)}) + c.staticUtility("max-block-"+key, []staticDecl{sd("max-block-size", value)}) + } + + c.staticUtility("inline-auto", []staticDecl{sd("inline-size", "auto")}) + c.staticUtility("block-auto", []staticDecl{sd("block-size", "auto")}) + c.staticUtility("min-inline-auto", []staticDecl{sd("min-inline-size", "auto")}) + c.staticUtility("min-block-auto", []staticDecl{sd("min-block-size", "auto")}) + + c.staticUtility("block-lh", []staticDecl{sd("block-size", "1lh")}) + c.staticUtility("min-block-lh", []staticDecl{sd("min-block-size", "1lh")}) + c.staticUtility("max-block-lh", []staticDecl{sd("max-block-size", "1lh")}) + + c.staticUtility("inline-screen", []staticDecl{sd("inline-size", "100vw")}) + c.staticUtility("min-inline-screen", []staticDecl{sd("min-inline-size", "100vw")}) + c.staticUtility("max-inline-screen", []staticDecl{sd("max-inline-size", "100vw")}) + c.staticUtility("block-screen", []staticDecl{sd("block-size", "100vh")}) + c.staticUtility("min-block-screen", []staticDecl{sd("min-block-size", "100vh")}) + c.staticUtility("max-block-screen", []staticDecl{sd("max-block-size", "100vh")}) + + c.staticUtility("max-inline-none", []staticDecl{sd("max-inline-size", "none")}) + c.staticUtility("max-block-none", []staticDecl{sd("max-block-size", "none")}) + + for _, e := range []struct { + name string + namespaces []string + property string + }{ + {"inline", []string{"--spacing", "--container"}, "inline-size"}, + {"min-inline", []string{"--spacing", "--container"}, "min-inline-size"}, + {"max-inline", []string{"--spacing", "--container"}, "max-inline-size"}, + {"block", []string{"--spacing"}, "block-size"}, + {"min-block", []string{"--spacing"}, "min-block-size"}, + {"max-block", []string{"--spacing"}, "max-block-size"}, + } { + prop := e.property + c.spacingUtility(e.name, e.namespaces, + func(value string) *utilResult { return uNodes(d(prop, value)) }, + spacingOpts{supportsFractions: true}) + } + + c.utilities.static("container", func(_ *Candidate) *utilResult { + breakpoints := c.theme.namespace("--breakpoint").Values() + sort.SliceStable(breakpoints, func(i, j int) bool { + return compareBreakpoints(breakpoints[i], breakpoints[j], "asc") < 0 + }) + decls := []*AstNode{d("--tw-sort", "--tw-container-component"), d("width", "100%")} + for _, bp := range breakpoints { + decls = append(decls, atRule("@media", "(width >= "+bp+")", d("max-width", bp))) + } + return uList(decls) + }) + + c.staticUtility("flex-auto", []staticDecl{sd("flex", "auto")}) + c.staticUtility("flex-initial", []staticDecl{sd("flex", "0 auto")}) + c.staticUtility("flex-none", []staticDecl{sd("flex", "none")}) + + c.utilities.functional("flex", func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + if candidate.Modifier != nil { + return nil + } + return uNodes(d("flex", candidate.Value.Value)) + } + if candidate.Value.Fraction != "" { + fparts := segment(candidate.Value.Fraction, "/") + if len(fparts) != 2 || !isPositiveInteger(fparts[0]) || !isPositiveInteger(fparts[1]) { + return nil + } + return uNodes(d("flex", "calc("+candidate.Value.Fraction+" * 100%)")) + } + if isPositiveInteger(candidate.Value.Value) { + if candidate.Modifier != nil { + return nil + } + return uNodes(d("flex", candidate.Value.Value)) + } + return nil + }, nil) + + c.functionalUtility("shrink", utilityDescription{ + defaultValueSet: true, + defaultValue: sptr("1"), + handleBareValue: bareInteger, + handle: func(value, _ string) *utilResult { return uNodes(d("flex-shrink", value)) }, + }) + c.functionalUtility("grow", utilityDescription{ + defaultValueSet: true, + defaultValue: sptr("1"), + handleBareValue: bareInteger, + handle: func(value, _ string) *utilResult { return uNodes(d("flex-grow", value)) }, + }) + + c.staticUtility("basis-auto", []staticDecl{sd("flex-basis", "auto")}) + c.staticUtility("basis-full", []staticDecl{sd("flex-basis", "100%")}) + c.spacingUtility("basis", []string{"--flex-basis", "--spacing", "--container"}, + func(value string) *utilResult { return uNodes(d("flex-basis", value)) }, + spacingOpts{supportsFractions: true}) + + c.staticUtility("table-auto", []staticDecl{sd("table-layout", "auto")}) + c.staticUtility("table-fixed", []staticDecl{sd("table-layout", "fixed")}) + c.staticUtility("caption-top", []staticDecl{sd("caption-side", "top")}) + c.staticUtility("caption-bottom", []staticDecl{sd("caption-side", "bottom")}) + c.staticUtility("border-collapse", []staticDecl{sd("border-collapse", "collapse")}) + c.staticUtility("border-separate", []staticDecl{sd("border-collapse", "separate")}) + + borderSpacingProperties := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-border-spacing-x", "0", "<length>"), + property("--tw-border-spacing-y", "0", "<length>"), + }) + } + c.spacingUtility("border-spacing", []string{"--border-spacing", "--spacing"}, func(value string) *utilResult { + return uNodes(borderSpacingProperties(), + d("--tw-border-spacing-x", value), d("--tw-border-spacing-y", value), + d("border-spacing", "var(--tw-border-spacing-x) var(--tw-border-spacing-y)")) + }, spacingOpts{}) + c.spacingUtility("border-spacing-x", []string{"--border-spacing", "--spacing"}, func(value string) *utilResult { + return uNodes(borderSpacingProperties(), + d("--tw-border-spacing-x", value), + d("border-spacing", "var(--tw-border-spacing-x) var(--tw-border-spacing-y)")) + }, spacingOpts{}) + c.spacingUtility("border-spacing-y", []string{"--border-spacing", "--spacing"}, func(value string) *utilResult { + return uNodes(borderSpacingProperties(), + d("--tw-border-spacing-y", value), + d("border-spacing", "var(--tw-border-spacing-x) var(--tw-border-spacing-y)")) + }, spacingOpts{}) + + registerUtilities3(c) +} + +func registerUtilities3(c *utilCtx) { + d := decl + + originStatics := func(prop string) map[string][]*AstNode { + return map[string][]*AstNode{ + "center": {d(prop, "center")}, + "top": {d(prop, "top")}, + "top-right": {d(prop, "100% 0")}, + "right": {d(prop, "100%")}, + "bottom-right": {d(prop, "100% 100%")}, + "bottom": {d(prop, "bottom")}, + "bottom-left": {d(prop, "0 100%")}, + "left": {d(prop, "0")}, + "top-left": {d(prop, "0 0")}, + } + } + + c.functionalUtility("origin", utilityDescription{ + themeKeys: []string{"--transform-origin"}, + handle: func(value, _ string) *utilResult { return uNodes(d("transform-origin", value)) }, + staticValues: originStatics("transform-origin"), + }) + c.functionalUtility("perspective-origin", utilityDescription{ + themeKeys: []string{"--perspective-origin"}, + handle: func(value, _ string) *utilResult { return uNodes(d("perspective-origin", value)) }, + staticValues: originStatics("perspective-origin"), + }) + + c.functionalUtility("perspective", utilityDescription{ + themeKeys: []string{"--perspective"}, + handle: func(value, _ string) *utilResult { return uNodes(d("perspective", value)) }, + staticValues: map[string][]*AstNode{"none": {d("perspective", "none")}}, + }) + + translateProperties := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-translate-x", "0", ""), + property("--tw-translate-y", "0", ""), + property("--tw-translate-z", "0", ""), + }) + } + + c.staticUtility("translate-none", []staticDecl{sd("translate", "none")}) + c.staticUtility("-translate-full", []staticDecl{ + sdFn(translateProperties), + sd("--tw-translate-x", "-100%"), sd("--tw-translate-y", "-100%"), + sd("translate", "var(--tw-translate-x) var(--tw-translate-y)"), + }) + c.staticUtility("translate-full", []staticDecl{ + sdFn(translateProperties), + sd("--tw-translate-x", "100%"), sd("--tw-translate-y", "100%"), + sd("translate", "var(--tw-translate-x) var(--tw-translate-y)"), + }) + + c.spacingUtility("translate", []string{"--translate", "--spacing"}, + func(value string) *utilResult { + return uNodes(translateProperties(), + d("--tw-translate-x", value), d("--tw-translate-y", value), + d("translate", "var(--tw-translate-x) var(--tw-translate-y)")) + }, spacingOpts{supportsNegative: true, supportsFractions: true}) + + for _, axis := range []string{"x", "y"} { + ax := axis + c.staticUtility("-translate-"+ax+"-full", []staticDecl{ + sdFn(translateProperties), + sd("--tw-translate-"+ax, "-100%"), + sd("translate", "var(--tw-translate-x) var(--tw-translate-y)"), + }) + c.staticUtility("translate-"+ax+"-full", []staticDecl{ + sdFn(translateProperties), + sd("--tw-translate-"+ax, "100%"), + sd("translate", "var(--tw-translate-x) var(--tw-translate-y)"), + }) + c.spacingUtility("translate-"+ax, []string{"--translate", "--spacing"}, + func(value string) *utilResult { + return uNodes(translateProperties(), + d("--tw-translate-"+ax, value), + d("translate", "var(--tw-translate-x) var(--tw-translate-y)")) + }, spacingOpts{supportsNegative: true, supportsFractions: true}) + } + + c.spacingUtility("translate-z", []string{"--translate", "--spacing"}, + func(value string) *utilResult { + return uNodes(translateProperties(), + d("--tw-translate-z", value), + d("translate", "var(--tw-translate-x) var(--tw-translate-y) var(--tw-translate-z)")) + }, spacingOpts{supportsNegative: true}) + + c.staticUtility("translate-3d", []staticDecl{ + sdFn(translateProperties), + sd("translate", "var(--tw-translate-x) var(--tw-translate-y) var(--tw-translate-z)"), + }) + + scaleProperties := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-scale-x", "1", ""), + property("--tw-scale-y", "1", ""), + property("--tw-scale-z", "1", ""), + }) + } + + c.staticUtility("scale-none", []staticDecl{sd("scale", "none")}) + + handleScale := func(negative bool) func(*Candidate) *utilResult { + return func(candidate *Candidate) *utilResult { + if candidate.Value == nil || candidate.Modifier != nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + if negative { + value = "calc(" + value + " * -1)" + } + return uNodes(d("scale", value)) + } + value, ok := c.theme.resolve(&candidate.Value.Value, []string{"--scale"}, themeNone) + if !ok && isPositiveInteger(candidate.Value.Value) { + value = candidate.Value.Value + "%" + ok = true + } + if !ok { + return nil + } + if negative { + value = "calc(" + value + " * -1)" + } + return uNodes(scaleProperties(), + d("--tw-scale-x", value), d("--tw-scale-y", value), d("--tw-scale-z", value), + d("scale", "var(--tw-scale-x) var(--tw-scale-y)")) + } + } + c.utilities.functional("-scale", handleScale(true), nil) + c.utilities.functional("scale", handleScale(false), nil) + + for _, axis := range []string{"x", "y", "z"} { + ax := axis + zSuffix := "" + if ax == "z" { + zSuffix = " var(--tw-scale-z)" + } + c.functionalUtility("scale-"+ax, utilityDescription{ + supportsNegative: true, + themeKeys: []string{"--scale"}, + handleBareValue: func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value + "%", true + }, + handle: func(value, _ string) *utilResult { + return uNodes(scaleProperties(), + d("--tw-scale-"+ax, value), + d("scale", "var(--tw-scale-x) var(--tw-scale-y)"+zSuffix)) + }, + }) + } + + c.staticUtility("scale-3d", []staticDecl{ + sdFn(scaleProperties), + sd("scale", "var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)"), + }) + + registerUtilities4(c) +} + +func registerUtilities4(c *utilCtx) { + d := decl + + c.staticUtility("rotate-none", []staticDecl{sd("rotate", "none")}) + + handleRotate := func(negative bool) func(*Candidate) *utilResult { + return func(candidate *Candidate) *utilResult { + if candidate.Value == nil || candidate.Modifier != nil { + return nil + } + var value string + if candidate.Value.Kind == uvArbitrary { + value = candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"angle", "vector"}) + } + if typ == "vector" { + return uNodes(d("rotate", value+" var(--tw-rotate)")) + } else if typ != "angle" { + if negative { + return uNodes(d("rotate", "calc("+value+" * -1)")) + } + return uNodes(d("rotate", value)) + } + } else { + v, ok := c.theme.resolve(&candidate.Value.Value, []string{"--rotate"}, themeNone) + if !ok && isPositiveInteger(candidate.Value.Value) { + v = candidate.Value.Value + "deg" + ok = true + } + if !ok { + return nil + } + value = v + } + if negative { + return uNodes(d("rotate", "calc("+value+" * -1)")) + } + return uNodes(d("rotate", value)) + } + } + c.utilities.functional("-rotate", handleRotate(true), nil) + c.utilities.functional("rotate", handleRotate(false), nil) + + transformValue := strings.Join([]string{ + "var(--tw-rotate-x,)", "var(--tw-rotate-y,)", "var(--tw-rotate-z,)", + "var(--tw-skew-x,)", "var(--tw-skew-y,)", + }, " ") + transformProperties := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-rotate-x", "", ""), property("--tw-rotate-y", "", ""), + property("--tw-rotate-z", "", ""), property("--tw-skew-x", "", ""), + property("--tw-skew-y", "", ""), + }) + } + + for _, axis := range []string{"x", "y", "z"} { + ax := axis + up := strings.ToUpper(ax) + c.functionalUtility("rotate-"+ax, utilityDescription{ + supportsNegative: true, + themeKeys: []string{"--rotate"}, + handleBareValue: func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value + "deg", true + }, + handle: func(value, _ string) *utilResult { + return uNodes(transformProperties(), + d("--tw-rotate-"+ax, "rotate"+up+"("+value+")"), + d("transform", transformValue)) + }, + }) + } + + skewBare := func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value + "deg", true + } + c.functionalUtility("skew", utilityDescription{ + supportsNegative: true, themeKeys: []string{"--skew"}, handleBareValue: skewBare, + handle: func(value, _ string) *utilResult { + return uNodes(transformProperties(), + d("--tw-skew-x", "skewX("+value+")"), d("--tw-skew-y", "skewY("+value+")"), + d("transform", transformValue)) + }, + }) + c.functionalUtility("skew-x", utilityDescription{ + supportsNegative: true, themeKeys: []string{"--skew"}, handleBareValue: skewBare, + handle: func(value, _ string) *utilResult { + return uNodes(transformProperties(), d("--tw-skew-x", "skewX("+value+")"), d("transform", transformValue)) + }, + }) + c.functionalUtility("skew-y", utilityDescription{ + supportsNegative: true, themeKeys: []string{"--skew"}, handleBareValue: skewBare, + handle: func(value, _ string) *utilResult { + return uNodes(transformProperties(), d("--tw-skew-y", "skewY("+value+")"), d("transform", transformValue)) + }, + }) + + c.utilities.functional("transform", func(candidate *Candidate) *utilResult { + if candidate.Modifier != nil { + return nil + } + value := "" + set := false + if candidate.Value == nil { + value = transformValue + set = true + } else if candidate.Value.Kind == uvArbitrary { + value = candidate.Value.Value + set = true + } + if !set { + return nil + } + return uNodes(transformProperties(), d("transform", value)) + }, nil) + + c.staticUtility("transform-cpu", []staticDecl{sd("transform", transformValue)}) + c.staticUtility("transform-gpu", []staticDecl{sd("transform", "translateZ(0) "+transformValue)}) + c.staticUtility("transform-none", []staticDecl{sd("transform", "none")}) + + c.functionalUtility("zoom", utilityDescription{ + handleBareValue: func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value + "%", true + }, + handle: func(value, _ string) *utilResult { return uNodes(d("zoom", value)) }, + }) + + c.staticUtility("transform-flat", []staticDecl{sd("transform-style", "flat")}) + c.staticUtility("transform-3d", []staticDecl{sd("transform-style", "preserve-3d")}) + c.staticUtility("transform-content", []staticDecl{sd("transform-box", "content-box")}) + c.staticUtility("transform-border", []staticDecl{sd("transform-box", "border-box")}) + c.staticUtility("transform-fill", []staticDecl{sd("transform-box", "fill-box")}) + c.staticUtility("transform-stroke", []staticDecl{sd("transform-box", "stroke-box")}) + c.staticUtility("transform-view", []staticDecl{sd("transform-box", "view-box")}) + c.staticUtility("backface-visible", []staticDecl{sd("backface-visibility", "visible")}) + c.staticUtility("backface-hidden", []staticDecl{sd("backface-visibility", "hidden")}) + + for _, value := range []string{ + "auto", "default", "pointer", "wait", "text", "move", "help", "not-allowed", "none", + "context-menu", "progress", "cell", "crosshair", "vertical-text", "alias", "copy", + "no-drop", "grab", "grabbing", "all-scroll", "col-resize", "row-resize", "n-resize", + "e-resize", "s-resize", "w-resize", "ne-resize", "nw-resize", "se-resize", "sw-resize", + "ew-resize", "ns-resize", "nesw-resize", "nwse-resize", "zoom-in", "zoom-out", + } { + c.staticUtility("cursor-"+value, []staticDecl{sd("cursor", value)}) + } + c.functionalUtility("cursor", utilityDescription{ + themeKeys: []string{"--cursor"}, + handle: func(value, _ string) *utilResult { return uNodes(d("cursor", value)) }, + }) + + for _, value := range []string{"auto", "none", "manipulation"} { + c.staticUtility("touch-"+value, []staticDecl{sd("touch-action", value)}) + } + touchProperties := func() *AstNode { + return atRoot([]*AstNode{property("--tw-pan-x", "", ""), property("--tw-pan-y", "", ""), property("--tw-pinch-zoom", "", "")}) + } + touchAction := "var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)" + for _, value := range []string{"x", "left", "right"} { + c.staticUtility("touch-pan-"+value, []staticDecl{sdFn(touchProperties), sd("--tw-pan-x", "pan-"+value), sd("touch-action", touchAction)}) + } + for _, value := range []string{"y", "up", "down"} { + c.staticUtility("touch-pan-"+value, []staticDecl{sdFn(touchProperties), sd("--tw-pan-y", "pan-"+value), sd("touch-action", touchAction)}) + } + c.staticUtility("touch-pinch-zoom", []staticDecl{sdFn(touchProperties), sd("--tw-pinch-zoom", "pinch-zoom"), sd("touch-action", touchAction)}) + + for _, value := range []string{"none", "text", "all", "auto"} { + c.staticUtility("select-"+value, []staticDecl{sd("-webkit-user-select", value), sd("user-select", value)}) + } + + c.staticUtility("resize-none", []staticDecl{sd("resize", "none")}) + c.staticUtility("resize-x", []staticDecl{sd("resize", "horizontal")}) + c.staticUtility("resize-y", []staticDecl{sd("resize", "vertical")}) + c.staticUtility("resize", []staticDecl{sd("resize", "both")}) + + c.staticUtility("snap-none", []staticDecl{sd("scroll-snap-type", "none")}) + snapProperties := func() *AstNode { + return atRoot([]*AstNode{property("--tw-scroll-snap-strictness", "proximity", "*")}) + } + for _, value := range []string{"x", "y", "both"} { + c.staticUtility("snap-"+value, []staticDecl{sdFn(snapProperties), sd("scroll-snap-type", value+" var(--tw-scroll-snap-strictness)")}) + } + c.staticUtility("snap-mandatory", []staticDecl{sdFn(snapProperties), sd("--tw-scroll-snap-strictness", "mandatory")}) + c.staticUtility("snap-proximity", []staticDecl{sdFn(snapProperties), sd("--tw-scroll-snap-strictness", "proximity")}) + c.staticUtility("snap-align-none", []staticDecl{sd("scroll-snap-align", "none")}) + c.staticUtility("snap-start", []staticDecl{sd("scroll-snap-align", "start")}) + c.staticUtility("snap-end", []staticDecl{sd("scroll-snap-align", "end")}) + c.staticUtility("snap-center", []staticDecl{sd("scroll-snap-align", "center")}) + c.staticUtility("snap-normal", []staticDecl{sd("scroll-snap-stop", "normal")}) + c.staticUtility("snap-always", []staticDecl{sd("scroll-snap-stop", "always")}) + + for _, pair := range [][2]string{ + {"scroll-m", "scroll-margin"}, {"scroll-mx", "scroll-margin-inline"}, {"scroll-my", "scroll-margin-block"}, + {"scroll-ms", "scroll-margin-inline-start"}, {"scroll-me", "scroll-margin-inline-end"}, + {"scroll-mbs", "scroll-margin-block-start"}, {"scroll-mbe", "scroll-margin-block-end"}, + {"scroll-mt", "scroll-margin-top"}, {"scroll-mr", "scroll-margin-right"}, + {"scroll-mb", "scroll-margin-bottom"}, {"scroll-ml", "scroll-margin-left"}, + } { + prop := pair[1] + c.spacingUtility(pair[0], []string{"--scroll-margin", "--spacing"}, + func(value string) *utilResult { return uNodes(d(prop, value)) }, spacingOpts{supportsNegative: true}) + } + for _, pair := range [][2]string{ + {"scroll-p", "scroll-padding"}, {"scroll-px", "scroll-padding-inline"}, {"scroll-py", "scroll-padding-block"}, + {"scroll-ps", "scroll-padding-inline-start"}, {"scroll-pe", "scroll-padding-inline-end"}, + {"scroll-pbs", "scroll-padding-block-start"}, {"scroll-pbe", "scroll-padding-block-end"}, + {"scroll-pt", "scroll-padding-top"}, {"scroll-pr", "scroll-padding-right"}, + {"scroll-pb", "scroll-padding-bottom"}, {"scroll-pl", "scroll-padding-left"}, + } { + prop := pair[1] + c.spacingUtility(pair[0], []string{"--scroll-padding", "--spacing"}, + func(value string) *utilResult { return uNodes(d(prop, value)) }, spacingOpts{}) + } + + c.staticUtility("list-inside", []staticDecl{sd("list-style-position", "inside")}) + c.staticUtility("list-outside", []staticDecl{sd("list-style-position", "outside")}) + c.functionalUtility("list", utilityDescription{ + themeKeys: []string{"--list-style-type"}, + handle: func(value, _ string) *utilResult { return uNodes(d("list-style-type", value)) }, + staticValues: map[string][]*AstNode{ + "none": {d("list-style-type", "none")}, "disc": {d("list-style-type", "disc")}, "decimal": {d("list-style-type", "decimal")}, + }, + }) + c.functionalUtility("list-image", utilityDescription{ + themeKeys: []string{"--list-style-image"}, + handle: func(value, _ string) *utilResult { return uNodes(d("list-style-image", value)) }, + staticValues: map[string][]*AstNode{"none": {d("list-style-image", "none")}}, + }) + + c.staticUtility("appearance-none", []staticDecl{sd("appearance", "none")}) + c.staticUtility("appearance-auto", []staticDecl{sd("appearance", "auto")}) + c.staticUtility("scheme-normal", []staticDecl{sd("color-scheme", "normal")}) + c.staticUtility("scheme-dark", []staticDecl{sd("color-scheme", "dark")}) + c.staticUtility("scheme-light", []staticDecl{sd("color-scheme", "light")}) + c.staticUtility("scheme-light-dark", []staticDecl{sd("color-scheme", "light dark")}) + c.staticUtility("scheme-only-dark", []staticDecl{sd("color-scheme", "only dark")}) + c.staticUtility("scheme-only-light", []staticDecl{sd("color-scheme", "only light")}) + + c.functionalUtility("columns", utilityDescription{ + themeKeys: []string{"--columns", "--container"}, + handleBareValue: bareInteger, + handle: func(value, _ string) *utilResult { return uNodes(d("columns", value)) }, + staticValues: map[string][]*AstNode{"auto": {d("columns", "auto")}}, + }) + + for _, value := range []string{"auto", "avoid", "all", "avoid-page", "page", "left", "right", "column"} { + c.staticUtility("break-before-"+value, []staticDecl{sd("break-before", value)}) + } + for _, value := range []string{"auto", "avoid", "avoid-page", "avoid-column"} { + c.staticUtility("break-inside-"+value, []staticDecl{sd("break-inside", value)}) + } + for _, value := range []string{"auto", "avoid", "all", "avoid-page", "page", "left", "right", "column"} { + c.staticUtility("break-after-"+value, []staticDecl{sd("break-after", value)}) + } + + c.staticUtility("grid-flow-row", []staticDecl{sd("grid-auto-flow", "row")}) + c.staticUtility("grid-flow-col", []staticDecl{sd("grid-auto-flow", "column")}) + c.staticUtility("grid-flow-dense", []staticDecl{sd("grid-auto-flow", "dense")}) + c.staticUtility("grid-flow-row-dense", []staticDecl{sd("grid-auto-flow", "row dense")}) + c.staticUtility("grid-flow-col-dense", []staticDecl{sd("grid-auto-flow", "column dense")}) + + autoTrackBare := func(v *UtilityValue) (string, bool) { + if _, ok := c.theme.resolve(nil, []string{"--spacing"}, themeNone); !ok { + return "", false + } + if !isValidSpacingMultiplier(v.Value) { + return "", false + } + return "--spacing(" + v.Value + ")", true + } + c.functionalUtility("auto-cols", utilityDescription{ + themeKeys: []string{"--grid-auto-columns"}, + handleBareValue: autoTrackBare, + handle: func(value, _ string) *utilResult { return uNodes(d("grid-auto-columns", value)) }, + staticValues: map[string][]*AstNode{ + "auto": {d("grid-auto-columns", "auto")}, "min": {d("grid-auto-columns", "min-content")}, + "max": {d("grid-auto-columns", "max-content")}, "fr": {d("grid-auto-columns", "minmax(0, 1fr)")}, + }, + }) + c.functionalUtility("auto-rows", utilityDescription{ + themeKeys: []string{"--grid-auto-rows"}, + handleBareValue: autoTrackBare, + handle: func(value, _ string) *utilResult { return uNodes(d("grid-auto-rows", value)) }, + staticValues: map[string][]*AstNode{ + "auto": {d("grid-auto-rows", "auto")}, "min": {d("grid-auto-rows", "min-content")}, + "max": {d("grid-auto-rows", "max-content")}, "fr": {d("grid-auto-rows", "minmax(0, 1fr)")}, + }, + }) + + registerUtilities5(c) +} + +func registerUtilities5(c *utilCtx) { + d := decl + + gridTemplateBare := func(v *UtilityValue) (string, bool) { + if !isStrictPositiveInteger(v.Value) { + return "", false + } + return "repeat(" + v.Value + ", minmax(0, 1fr))", true + } + c.functionalUtility("grid-cols", utilityDescription{ + themeKeys: []string{"--grid-template-columns"}, + handleBareValue: gridTemplateBare, + handle: func(value, _ string) *utilResult { return uNodes(d("grid-template-columns", value)) }, + staticValues: map[string][]*AstNode{ + "none": {d("grid-template-columns", "none")}, "subgrid": {d("grid-template-columns", "subgrid")}, + }, + }) + c.functionalUtility("grid-rows", utilityDescription{ + themeKeys: []string{"--grid-template-rows"}, + handleBareValue: gridTemplateBare, + handle: func(value, _ string) *utilResult { return uNodes(d("grid-template-rows", value)) }, + staticValues: map[string][]*AstNode{ + "none": {d("grid-template-rows", "none")}, "subgrid": {d("grid-template-rows", "subgrid")}, + }, + }) + + c.staticUtility("flex-row", []staticDecl{sd("flex-direction", "row")}) + c.staticUtility("flex-row-reverse", []staticDecl{sd("flex-direction", "row-reverse")}) + c.staticUtility("flex-col", []staticDecl{sd("flex-direction", "column")}) + c.staticUtility("flex-col-reverse", []staticDecl{sd("flex-direction", "column-reverse")}) + c.staticUtility("flex-wrap", []staticDecl{sd("flex-wrap", "wrap")}) + c.staticUtility("flex-nowrap", []staticDecl{sd("flex-wrap", "nowrap")}) + c.staticUtility("flex-wrap-reverse", []staticDecl{sd("flex-wrap", "wrap-reverse")}) + + statics := func(prop string, pairs [][2]string) { + for _, p := range pairs { + c.staticUtility(p[0], []staticDecl{sd(prop, p[1])}) + } + } + + statics("place-content", [][2]string{ + {"place-content-center", "center"}, {"place-content-start", "start"}, {"place-content-end", "end"}, + {"place-content-center-safe", "safe center"}, {"place-content-end-safe", "safe end"}, + {"place-content-between", "space-between"}, {"place-content-around", "space-around"}, + {"place-content-evenly", "space-evenly"}, {"place-content-baseline", "baseline"}, {"place-content-stretch", "stretch"}, + }) + statics("place-items", [][2]string{ + {"place-items-center", "center"}, {"place-items-start", "start"}, {"place-items-end", "end"}, + {"place-items-center-safe", "safe center"}, {"place-items-end-safe", "safe end"}, + {"place-items-baseline", "baseline"}, {"place-items-stretch", "stretch"}, + }) + statics("align-content", [][2]string{ + {"content-normal", "normal"}, {"content-center", "center"}, {"content-start", "flex-start"}, + {"content-end", "flex-end"}, {"content-center-safe", "safe center"}, {"content-end-safe", "safe flex-end"}, + {"content-between", "space-between"}, {"content-around", "space-around"}, {"content-evenly", "space-evenly"}, + {"content-baseline", "baseline"}, {"content-stretch", "stretch"}, + }) + statics("align-items", [][2]string{ + {"items-center", "center"}, {"items-start", "flex-start"}, {"items-end", "flex-end"}, + {"items-center-safe", "safe center"}, {"items-end-safe", "safe flex-end"}, + {"items-baseline", "baseline"}, {"items-baseline-last", "last baseline"}, {"items-stretch", "stretch"}, + }) + statics("justify-content", [][2]string{ + {"justify-normal", "normal"}, {"justify-center", "center"}, {"justify-start", "flex-start"}, + {"justify-end", "flex-end"}, {"justify-center-safe", "safe center"}, {"justify-end-safe", "safe flex-end"}, + {"justify-between", "space-between"}, {"justify-around", "space-around"}, {"justify-evenly", "space-evenly"}, + {"justify-baseline", "baseline"}, {"justify-stretch", "stretch"}, + }) + statics("justify-items", [][2]string{ + {"justify-items-normal", "normal"}, {"justify-items-center", "center"}, {"justify-items-start", "start"}, + {"justify-items-end", "end"}, {"justify-items-center-safe", "safe center"}, {"justify-items-end-safe", "safe end"}, + {"justify-items-stretch", "stretch"}, + }) + + c.spacingUtility("gap", []string{"--gap", "--spacing"}, func(value string) *utilResult { return uNodes(d("gap", value)) }, spacingOpts{}) + c.spacingUtility("gap-x", []string{"--gap", "--spacing"}, func(value string) *utilResult { return uNodes(d("column-gap", value)) }, spacingOpts{}) + c.spacingUtility("gap-y", []string{"--gap", "--spacing"}, func(value string) *utilResult { return uNodes(d("row-gap", value)) }, spacingOpts{}) + + spaceZero := func(value string) bool { + if value == "--spacing(0)" || value == "--spacing(-0)" { + return true + } + n, unit, ok := parseDimension(value) + if ok && n == 0 && (unit == "" || isLength(value)) { + return true + } + return false + } + c.spacingUtility("space-x", []string{"--space", "--spacing"}, func(value string) *utilResult { + zero := spaceZero(value) + ms, me := "calc("+value+" * var(--tw-space-x-reverse))", "calc("+value+" * calc(1 - var(--tw-space-x-reverse)))" + if zero { + ms, me = "0", "0" + } + return uNodes( + atRoot([]*AstNode{property("--tw-space-x-reverse", "0", "")}), + styleRule(":where(& > :not(:last-child))", + d("--tw-sort", "row-gap"), d("--tw-space-x-reverse", "0"), + d("margin-inline-start", ms), d("margin-inline-end", me)), + ) + }, spacingOpts{supportsNegative: true}) + c.spacingUtility("space-y", []string{"--space", "--spacing"}, func(value string) *utilResult { + zero := spaceZero(value) + ms, me := "calc("+value+" * var(--tw-space-y-reverse))", "calc("+value+" * calc(1 - var(--tw-space-y-reverse)))" + if zero { + ms, me = "0", "0" + } + return uNodes( + atRoot([]*AstNode{property("--tw-space-y-reverse", "0", "")}), + styleRule(":where(& > :not(:last-child))", + d("--tw-sort", "column-gap"), d("--tw-space-y-reverse", "0"), + d("margin-block-start", ms), d("margin-block-end", me)), + ) + }, spacingOpts{supportsNegative: true}) + + c.staticUtility("space-x-reverse", []staticDecl{ + sdFn(func() *AstNode { return atRoot([]*AstNode{property("--tw-space-x-reverse", "0", "")}) }), + sdFn(func() *AstNode { + return styleRule(":where(& > :not(:last-child))", d("--tw-sort", "row-gap"), d("--tw-space-x-reverse", "1")) + }), + }) + c.staticUtility("space-y-reverse", []staticDecl{ + sdFn(func() *AstNode { return atRoot([]*AstNode{property("--tw-space-y-reverse", "0", "")}) }), + sdFn(func() *AstNode { + return styleRule(":where(& > :not(:last-child))", d("--tw-sort", "column-gap"), d("--tw-space-y-reverse", "1")) + }), + }) + + c.staticUtility("accent-auto", []staticDecl{sd("accent-color", "auto")}) + c.colorUtility("accent", colorUtilityDescription{ + themeKeys: []string{"--accent-color", "--color"}, + handle: func(value string) *utilResult { return uNodes(d("accent-color", value)) }, + }) + c.colorUtility("caret", colorUtilityDescription{ + themeKeys: []string{"--caret-color", "--color"}, + handle: func(value string) *utilResult { return uNodes(d("caret-color", value)) }, + }) + c.colorUtility("divide", colorUtilityDescription{ + themeKeys: []string{"--divide-color", "--border-color", "--color"}, + handle: func(value string) *utilResult { + return uNodes(styleRule(":where(& > :not(:last-child))", + d("--tw-sort", "divide-color"), d("border-color", value))) + }, + }) + + statics("place-self", [][2]string{ + {"place-self-auto", "auto"}, {"place-self-start", "start"}, {"place-self-end", "end"}, + {"place-self-center", "center"}, {"place-self-end-safe", "safe end"}, {"place-self-center-safe", "safe center"}, + {"place-self-stretch", "stretch"}, + }) + statics("align-self", [][2]string{ + {"self-auto", "auto"}, {"self-start", "flex-start"}, {"self-end", "flex-end"}, {"self-center", "center"}, + {"self-end-safe", "safe flex-end"}, {"self-center-safe", "safe center"}, {"self-stretch", "stretch"}, + {"self-baseline", "baseline"}, {"self-baseline-last", "last baseline"}, + }) + statics("justify-self", [][2]string{ + {"justify-self-auto", "auto"}, {"justify-self-start", "flex-start"}, {"justify-self-end", "flex-end"}, + {"justify-self-center", "center"}, {"justify-self-end-safe", "safe flex-end"}, {"justify-self-center-safe", "safe center"}, + {"justify-self-stretch", "stretch"}, + }) + + for _, value := range []string{"auto", "hidden", "clip", "visible", "scroll"} { + c.staticUtility("overflow-"+value, []staticDecl{sd("overflow", value)}) + c.staticUtility("overflow-x-"+value, []staticDecl{sd("overflow-x", value)}) + c.staticUtility("overflow-y-"+value, []staticDecl{sd("overflow-y", value)}) + } + for _, value := range []string{"auto", "contain", "none"} { + c.staticUtility("overscroll-"+value, []staticDecl{sd("overscroll-behavior", value)}) + c.staticUtility("overscroll-x-"+value, []staticDecl{sd("overscroll-behavior-x", value)}) + c.staticUtility("overscroll-y-"+value, []staticDecl{sd("overscroll-behavior-y", value)}) + } + + c.staticUtility("scroll-auto", []staticDecl{sd("scroll-behavior", "auto")}) + c.staticUtility("scroll-smooth", []staticDecl{sd("scroll-behavior", "smooth")}) + + c.staticUtility("scrollbar-auto", []staticDecl{sd("scrollbar-width", "auto")}) + c.staticUtility("scrollbar-thin", []staticDecl{sd("scrollbar-width", "thin")}) + c.staticUtility("scrollbar-none", []staticDecl{sd("scrollbar-width", "none")}) + + scrollbarColorProperties := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-scrollbar-thumb", "#0000", "<color>"), + property("--tw-scrollbar-track", "#0000", "<color>"), + }) + } + c.colorUtility("scrollbar-thumb", colorUtilityDescription{ + themeKeys: []string{"--color"}, + handle: func(value string) *utilResult { + return uNodes(scrollbarColorProperties(), d("--tw-scrollbar-thumb", value), + d("scrollbar-color", "var(--tw-scrollbar-thumb) var(--tw-scrollbar-track)")) + }, + }) + c.colorUtility("scrollbar-track", colorUtilityDescription{ + themeKeys: []string{"--color"}, + handle: func(value string) *utilResult { + return uNodes(scrollbarColorProperties(), d("--tw-scrollbar-track", value), + d("scrollbar-color", "var(--tw-scrollbar-thumb) var(--tw-scrollbar-track)")) + }, + }) + + registerUtilities6(c) +} + +func registerUtilities6(c *utilCtx) { + d := decl + + c.staticUtility("scrollbar-gutter-auto", []staticDecl{sd("scrollbar-gutter", "auto")}) + c.staticUtility("scrollbar-gutter-stable", []staticDecl{sd("scrollbar-gutter", "stable")}) + c.staticUtility("scrollbar-gutter-both", []staticDecl{sd("scrollbar-gutter", "stable both-edges")}) + + c.staticUtility("truncate", []staticDecl{sd("overflow", "hidden"), sd("text-overflow", "ellipsis"), sd("white-space", "nowrap")}) + c.staticUtility("text-ellipsis", []staticDecl{sd("text-overflow", "ellipsis")}) + c.staticUtility("text-clip", []staticDecl{sd("text-overflow", "clip")}) + + c.staticUtility("hyphens-none", []staticDecl{sd("-webkit-hyphens", "none"), sd("hyphens", "none")}) + c.staticUtility("hyphens-manual", []staticDecl{sd("-webkit-hyphens", "manual"), sd("hyphens", "manual")}) + c.staticUtility("hyphens-auto", []staticDecl{sd("-webkit-hyphens", "auto"), sd("hyphens", "auto")}) + + c.staticUtility("whitespace-normal", []staticDecl{sd("white-space", "normal")}) + c.staticUtility("whitespace-nowrap", []staticDecl{sd("white-space", "nowrap")}) + c.staticUtility("whitespace-pre", []staticDecl{sd("white-space", "pre")}) + c.staticUtility("whitespace-pre-line", []staticDecl{sd("white-space", "pre-line")}) + c.staticUtility("whitespace-pre-wrap", []staticDecl{sd("white-space", "pre-wrap")}) + c.staticUtility("whitespace-break-spaces", []staticDecl{sd("white-space", "break-spaces")}) + + c.functionalUtility("tab", utilityDescription{ + handleBareValue: bareInteger, + handle: func(value, _ string) *utilResult { return uNodes(d("tab-size", value)) }, + }) + + c.staticUtility("text-wrap", []staticDecl{sd("text-wrap", "wrap")}) + c.staticUtility("text-nowrap", []staticDecl{sd("text-wrap", "nowrap")}) + c.staticUtility("text-balance", []staticDecl{sd("text-wrap", "balance")}) + c.staticUtility("text-pretty", []staticDecl{sd("text-wrap", "pretty")}) + c.staticUtility("break-normal", []staticDecl{sd("overflow-wrap", "normal"), sd("word-break", "normal")}) + c.staticUtility("break-all", []staticDecl{sd("word-break", "break-all")}) + c.staticUtility("break-keep", []staticDecl{sd("word-break", "keep-all")}) + c.staticUtility("wrap-anywhere", []staticDecl{sd("overflow-wrap", "anywhere")}) + c.staticUtility("wrap-break-word", []staticDecl{sd("overflow-wrap", "break-word")}) + c.staticUtility("wrap-normal", []staticDecl{sd("overflow-wrap", "normal")}) + + for _, e := range []struct { + root string + props []string + }{ + {"rounded", []string{"border-radius"}}, + {"rounded-s", []string{"border-start-start-radius", "border-end-start-radius"}}, + {"rounded-e", []string{"border-start-end-radius", "border-end-end-radius"}}, + {"rounded-t", []string{"border-top-left-radius", "border-top-right-radius"}}, + {"rounded-r", []string{"border-top-right-radius", "border-bottom-right-radius"}}, + {"rounded-b", []string{"border-bottom-right-radius", "border-bottom-left-radius"}}, + {"rounded-l", []string{"border-top-left-radius", "border-bottom-left-radius"}}, + {"rounded-ss", []string{"border-start-start-radius"}}, + {"rounded-se", []string{"border-start-end-radius"}}, + {"rounded-ee", []string{"border-end-end-radius"}}, + {"rounded-es", []string{"border-end-start-radius"}}, + {"rounded-tl", []string{"border-top-left-radius"}}, + {"rounded-tr", []string{"border-top-right-radius"}}, + {"rounded-br", []string{"border-bottom-right-radius"}}, + {"rounded-bl", []string{"border-bottom-left-radius"}}, + } { + props := e.props + mk := func(v string) []*AstNode { + nodes := make([]*AstNode, len(props)) + for i, p := range props { + nodes[i] = d(p, v) + } + return nodes + } + c.functionalUtility(e.root, utilityDescription{ + themeKeys: []string{"--radius"}, + handle: func(value, _ string) *utilResult { return uList(mk(value)) }, + staticValues: map[string][]*AstNode{ + "none": mk("0"), + "full": mk("calc(infinity * 1px)"), + }, + }) + } + + c.staticUtility("border-solid", []staticDecl{sd("--tw-border-style", "solid"), sd("border-style", "solid")}) + c.staticUtility("border-dashed", []staticDecl{sd("--tw-border-style", "dashed"), sd("border-style", "dashed")}) + c.staticUtility("border-dotted", []staticDecl{sd("--tw-border-style", "dotted"), sd("border-style", "dotted")}) + c.staticUtility("border-double", []staticDecl{sd("--tw-border-style", "double"), sd("border-style", "double")}) + c.staticUtility("border-hidden", []staticDecl{sd("--tw-border-style", "hidden"), sd("border-style", "hidden")}) + c.staticUtility("border-none", []staticDecl{sd("--tw-border-style", "none"), sd("border-style", "none")}) + + registerUtilities7(c) +} + +func registerUtilities7(c *utilCtx) { + d := decl + + borderProperties := func() *AstNode { + return atRoot([]*AstNode{property("--tw-border-style", "solid", "")}) + } + + borderSideUtility := func(classRoot string, width, color func(string) []*AstNode) { + c.utilities.functional(classRoot, func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + if candidate.Modifier != nil { + return nil + } + value, ok := c.theme.Get([]string{"--default-border-width"}) + if !ok { + value = "1px" + } + decls := width(value) + if decls == nil { + return nil + } + return uList(append([]*AstNode{borderProperties()}, decls...)) + } + + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color", "line-width", "length"}) + } + switch typ { + case "line-width", "length": + if candidate.Modifier != nil { + return nil + } + decls := width(value) + if decls == nil { + return nil + } + return uList(append([]*AstNode{borderProperties()}, decls...)) + default: + cv, ok := asColor(value, candidate.Modifier, c.theme) + if !ok { + return nil + } + return uList(color(cv)) + } + } + + if v, ok := resolveThemeColor(candidate, c.theme, []string{"--border-color", "--color"}); ok { + return uList(color(v)) + } + + if candidate.Modifier != nil { + return nil + } + if v, ok := c.theme.resolve(&candidate.Value.Value, []string{"--border-width"}, themeNone); ok { + decls := width(v) + if decls == nil { + return nil + } + return uList(append([]*AstNode{borderProperties()}, decls...)) + } + if isPositiveInteger(candidate.Value.Value) { + decls := width(candidate.Value.Value + "px") + if decls == nil { + return nil + } + return uList(append([]*AstNode{borderProperties()}, decls...)) + } + return nil + }, nil) + } + + type bs struct { + root string + styleProp string + widthProp string + colorProps []string // properties to set for color + } + for _, e := range []bs{ + {"border", "border-style", "border-width", []string{"border-color"}}, + {"border-x", "border-inline-style", "border-inline-width", []string{"border-inline-color"}}, + {"border-y", "border-block-style", "border-block-width", []string{"border-block-color"}}, + {"border-s", "border-inline-start-style", "border-inline-start-width", []string{"border-inline-start-color"}}, + {"border-e", "border-inline-end-style", "border-inline-end-width", []string{"border-inline-end-color"}}, + {"border-bs", "border-block-start-style", "border-block-start-width", []string{"border-block-start-color"}}, + {"border-be", "border-block-end-style", "border-block-end-width", []string{"border-block-end-color"}}, + {"border-t", "border-top-style", "border-top-width", []string{"border-top-color"}}, + {"border-r", "border-right-style", "border-right-width", []string{"border-right-color"}}, + {"border-b", "border-bottom-style", "border-bottom-width", []string{"border-bottom-color"}}, + {"border-l", "border-left-style", "border-left-width", []string{"border-left-color"}}, + } { + e := e + width := func(value string) []*AstNode { + return []*AstNode{d(e.styleProp, "var(--tw-border-style)"), d(e.widthProp, value)} + } + color := func(value string) []*AstNode { + nodes := make([]*AstNode, len(e.colorProps)) + for i, p := range e.colorProps { + nodes[i] = d(p, value) + } + return nodes + } + borderSideUtility(e.root, width, color) + } + + defaultBorderWidth := func() *string { + v, ok := c.theme.Get([]string{"--default-border-width"}) + if !ok { + v = "1px" + } + return &v + } + + c.functionalUtility("divide-x", utilityDescription{ + defaultValueSet: true, + defaultValue: defaultBorderWidth(), + themeKeys: []string{"--divide-width", "--border-width"}, + handleBareValue: func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value + "px", true + }, + handle: func(value, _ string) *utilResult { + return uNodes( + atRoot([]*AstNode{property("--tw-divide-x-reverse", "0", "")}), + styleRule(":where(& > :not(:last-child))", + d("--tw-sort", "divide-x-width"), borderProperties(), d("--tw-divide-x-reverse", "0"), + d("border-inline-style", "var(--tw-border-style)"), + d("border-inline-start-width", "calc("+value+" * var(--tw-divide-x-reverse))"), + d("border-inline-end-width", "calc("+value+" * calc(1 - var(--tw-divide-x-reverse)))")), + ) + }, + }) + c.functionalUtility("divide-y", utilityDescription{ + defaultValueSet: true, + defaultValue: defaultBorderWidth(), + themeKeys: []string{"--divide-width", "--border-width"}, + handleBareValue: func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value + "px", true + }, + handle: func(value, _ string) *utilResult { + return uNodes( + atRoot([]*AstNode{property("--tw-divide-y-reverse", "0", "")}), + styleRule(":where(& > :not(:last-child))", + d("--tw-sort", "divide-y-width"), borderProperties(), d("--tw-divide-y-reverse", "0"), + d("border-bottom-style", "var(--tw-border-style)"), d("border-top-style", "var(--tw-border-style)"), + d("border-top-width", "calc("+value+" * var(--tw-divide-y-reverse))"), + d("border-bottom-width", "calc("+value+" * calc(1 - var(--tw-divide-y-reverse)))")), + ) + }, + }) + + c.staticUtility("divide-x-reverse", []staticDecl{ + sdFn(func() *AstNode { return atRoot([]*AstNode{property("--tw-divide-x-reverse", "0", "")}) }), + sdFn(func() *AstNode { + return styleRule(":where(& > :not(:last-child))", d("--tw-divide-x-reverse", "1")) + }), + }) + c.staticUtility("divide-y-reverse", []staticDecl{ + sdFn(func() *AstNode { return atRoot([]*AstNode{property("--tw-divide-y-reverse", "0", "")}) }), + sdFn(func() *AstNode { + return styleRule(":where(& > :not(:last-child))", d("--tw-divide-y-reverse", "1")) + }), + }) + for _, value := range []string{"solid", "dashed", "dotted", "double", "none"} { + v := value + c.staticUtility("divide-"+v, []staticDecl{ + sdFn(func() *AstNode { + return styleRule(":where(& > :not(:last-child))", + d("--tw-sort", "divide-style"), d("--tw-border-style", v), d("border-style", v)) + }), + }) + } + + c.staticUtility("bg-auto", []staticDecl{sd("background-size", "auto")}) + c.staticUtility("bg-cover", []staticDecl{sd("background-size", "cover")}) + c.staticUtility("bg-contain", []staticDecl{sd("background-size", "contain")}) + c.functionalUtility("bg-size", utilityDescription{ + handle: func(value, _ string) *utilResult { + if value == "" { + return nil + } + return uNodes(d("background-size", value)) + }, + }) + + c.staticUtility("bg-fixed", []staticDecl{sd("background-attachment", "fixed")}) + c.staticUtility("bg-local", []staticDecl{sd("background-attachment", "local")}) + c.staticUtility("bg-scroll", []staticDecl{sd("background-attachment", "scroll")}) + + c.staticUtility("bg-top", []staticDecl{sd("background-position", "top")}) + c.staticUtility("bg-top-left", []staticDecl{sd("background-position", "left top")}) + c.staticUtility("bg-top-right", []staticDecl{sd("background-position", "right top")}) + c.staticUtility("bg-bottom", []staticDecl{sd("background-position", "bottom")}) + c.staticUtility("bg-bottom-left", []staticDecl{sd("background-position", "left bottom")}) + c.staticUtility("bg-bottom-right", []staticDecl{sd("background-position", "right bottom")}) + c.staticUtility("bg-left", []staticDecl{sd("background-position", "left")}) + c.staticUtility("bg-right", []staticDecl{sd("background-position", "right")}) + c.staticUtility("bg-center", []staticDecl{sd("background-position", "center")}) + c.functionalUtility("bg-position", utilityDescription{ + handle: func(value, _ string) *utilResult { + if value == "" { + return nil + } + return uNodes(d("background-position", value)) + }, + }) + + c.staticUtility("bg-repeat", []staticDecl{sd("background-repeat", "repeat")}) + c.staticUtility("bg-no-repeat", []staticDecl{sd("background-repeat", "no-repeat")}) + c.staticUtility("bg-repeat-x", []staticDecl{sd("background-repeat", "repeat-x")}) + c.staticUtility("bg-repeat-y", []staticDecl{sd("background-repeat", "repeat-y")}) + c.staticUtility("bg-repeat-round", []staticDecl{sd("background-repeat", "round")}) + c.staticUtility("bg-repeat-space", []staticDecl{sd("background-repeat", "space")}) + + c.staticUtility("bg-none", []staticDecl{sd("background-image", "none")}) + + registerUtilities8(c) +} + +func registerUtilities8(c *utilCtx) { + d := decl + theme := c.theme + + linearGradientDirections := map[string]string{ + "to-t": "to top", "to-tr": "to top right", "to-r": "to right", "to-br": "to bottom right", + "to-b": "to bottom", "to-bl": "to bottom left", "to-l": "to left", "to-tl": "to top left", + } + + resolveInterpolationModifier := func(modifier *CandidateModifier) string { + method := "in oklab" + if modifier != nil { + if modifier.Kind == modNamed { + switch modifier.Value { + case "longer", "shorter", "increasing", "decreasing": + method = "in oklch " + modifier.Value + " hue" + default: + method = "in " + modifier.Value + } + } else { + method = modifier.Value + } + } + return method + } + + handleBgLinear := func(negative bool) func(*Candidate) *utilResult { + return func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + if candidate.Modifier != nil { + return nil + } + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"angle"}) + } + if typ == "angle" { + if negative { + value = "calc(" + value + " * -1)" + } + return uNodes(d("--tw-gradient-position", value), + d("background-image", "linear-gradient(var(--tw-gradient-stops,"+value+"))")) + } + if negative { + return nil + } + return uNodes(d("--tw-gradient-position", value), + d("background-image", "linear-gradient(var(--tw-gradient-stops,"+value+"))")) + } + + value := candidate.Value.Value + if !negative { + if dir, ok := linearGradientDirections[value]; ok { + value = dir + } else if isPositiveInteger(value) { + value = value + "deg" + } else { + return nil + } + } else if isPositiveInteger(value) { + value = "calc(" + value + "deg * -1)" + } else { + return nil + } + + interp := resolveInterpolationModifier(candidate.Modifier) + return uNodes( + d("--tw-gradient-position", value), + rule("@supports (background-image: linear-gradient(in lab, red, red))", + d("--tw-gradient-position", value+" "+interp)), + d("background-image", "linear-gradient(var(--tw-gradient-stops))"), + ) + } + } + c.utilities.functional("-bg-linear", handleBgLinear(true), nil) + c.utilities.functional("bg-linear", handleBgLinear(false), nil) + + handleBgConic := func(negative bool) func(*Candidate) *utilResult { + return func(candidate *Candidate) *utilResult { + if candidate.Value != nil && candidate.Value.Kind == uvArbitrary { + if candidate.Modifier != nil { + return nil + } + value := candidate.Value.Value + return uNodes(d("--tw-gradient-position", value), + d("background-image", "conic-gradient(var(--tw-gradient-stops,"+value+"))")) + } + interp := resolveInterpolationModifier(candidate.Modifier) + if candidate.Value == nil { + return uNodes(d("--tw-gradient-position", interp), + d("background-image", "conic-gradient(var(--tw-gradient-stops))")) + } + value := candidate.Value.Value + if !isPositiveInteger(value) { + return nil + } + if negative { + value = "calc(" + value + "deg * -1)" + } else { + value = value + "deg" + } + return uNodes(d("--tw-gradient-position", "from "+value+" "+interp), + d("background-image", "conic-gradient(var(--tw-gradient-stops))")) + } + } + c.utilities.functional("-bg-conic", handleBgConic(true), nil) + c.utilities.functional("bg-conic", handleBgConic(false), nil) + + c.utilities.functional("bg-radial", func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + interp := resolveInterpolationModifier(candidate.Modifier) + return uNodes(d("--tw-gradient-position", interp), + d("background-image", "radial-gradient(var(--tw-gradient-stops))")) + } + if candidate.Value.Kind == uvArbitrary { + if candidate.Modifier != nil { + return nil + } + value := candidate.Value.Value + return uNodes(d("--tw-gradient-position", value), + d("background-image", "radial-gradient(var(--tw-gradient-stops,"+value+"))")) + } + return nil + }, nil) + + c.utilities.functional("bg", func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"image", "color", "percentage", "position", "bg-size", "length", "url"}) + } + switch typ { + case "percentage", "position": + if candidate.Modifier != nil { + return nil + } + return uNodes(d("background-position", value)) + case "bg-size", "length", "size": + if candidate.Modifier != nil { + return nil + } + return uNodes(d("background-size", value)) + case "image", "url": + if candidate.Modifier != nil { + return nil + } + return uNodes(d("background-image", value)) + default: + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(d("background-color", cv)) + } + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--background-color", "--color"}); ok { + return uNodes(d("background-color", v)) + } + if candidate.Modifier != nil { + return nil + } + if v, ok := theme.resolve(&candidate.Value.Value, []string{"--background-image"}, themeNone); ok { + return uNodes(d("background-image", v)) + } + return nil + }, nil) + + gradientStopProperties := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-gradient-position", "", ""), + property("--tw-gradient-from", "#0000", "<color>"), + property("--tw-gradient-via", "#0000", "<color>"), + property("--tw-gradient-to", "#0000", "<color>"), + property("--tw-gradient-stops", "", ""), + property("--tw-gradient-via-stops", "", ""), + property("--tw-gradient-from-position", "0%", "<length-percentage>"), + property("--tw-gradient-via-position", "50%", "<length-percentage>"), + property("--tw-gradient-to-position", "100%", "<length-percentage>"), + }) + } + + gradientStopUtility := func(classRoot string, color, position func(string) []*AstNode) { + c.utilities.functional(classRoot, func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color", "length", "percentage"}) + } + switch typ { + case "length", "percentage": + if candidate.Modifier != nil { + return nil + } + return uList(position(value)) + default: + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uList(color(cv)) + } + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--background-color", "--color"}); ok { + return uList(color(v)) + } + if candidate.Modifier != nil { + return nil + } + if v, ok := theme.resolve(&candidate.Value.Value, []string{"--gradient-color-stop-positions"}, themeNone); ok { + return uList(position(v)) + } else if strings.HasSuffix(candidate.Value.Value, "%") && isPositiveInteger(candidate.Value.Value[:len(candidate.Value.Value)-1]) { + return uList(position(candidate.Value.Value)) + } + return nil + }, nil) + } + + gradientStopUtility("from", + func(value string) []*AstNode { + return []*AstNode{gradientStopProperties(), d("--tw-sort", "--tw-gradient-from"), d("--tw-gradient-from", value), + d("--tw-gradient-stops", "var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))")} + }, + func(value string) []*AstNode { + return []*AstNode{gradientStopProperties(), d("--tw-gradient-from-position", value)} + }) + c.staticUtility("via-none", []staticDecl{sd("--tw-gradient-via-stops", "initial")}) + gradientStopUtility("via", + func(value string) []*AstNode { + return []*AstNode{gradientStopProperties(), d("--tw-sort", "--tw-gradient-via"), d("--tw-gradient-via", value), + d("--tw-gradient-via-stops", "var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position)"), + d("--tw-gradient-stops", "var(--tw-gradient-via-stops)")} + }, + func(value string) []*AstNode { + return []*AstNode{gradientStopProperties(), d("--tw-gradient-via-position", value)} + }) + gradientStopUtility("to", + func(value string) []*AstNode { + return []*AstNode{gradientStopProperties(), d("--tw-sort", "--tw-gradient-to"), d("--tw-gradient-to", value), + d("--tw-gradient-stops", "var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))")} + }, + func(value string) []*AstNode { + return []*AstNode{gradientStopProperties(), d("--tw-gradient-to-position", value)} + }) + + registerUtilities9(c) +} + +func registerUtilities9(c *utilCtx) { + d := decl + + c.staticUtility("mask-none", []staticDecl{sd("mask-image", "none")}) + + c.utilities.functional("mask", func(candidate *Candidate) *utilResult { + if candidate.Value == nil || candidate.Modifier != nil { + return nil + } + if candidate.Value.Kind != uvArbitrary { + return nil + } + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"image", "percentage", "position", "bg-size", "length", "url"}) + } + switch typ { + case "percentage", "position": + return uNodes(d("mask-position", value)) + case "bg-size", "length", "size": + return uNodes(d("mask-size", value)) + default: + return uNodes(d("mask-image", value)) + } + }, nil) + + c.staticUtility("mask-add", []staticDecl{sd("mask-composite", "add")}) + c.staticUtility("mask-subtract", []staticDecl{sd("mask-composite", "subtract")}) + c.staticUtility("mask-intersect", []staticDecl{sd("mask-composite", "intersect")}) + c.staticUtility("mask-exclude", []staticDecl{sd("mask-composite", "exclude")}) + + c.staticUtility("mask-alpha", []staticDecl{sd("mask-mode", "alpha")}) + c.staticUtility("mask-luminance", []staticDecl{sd("mask-mode", "luminance")}) + c.staticUtility("mask-match", []staticDecl{sd("mask-mode", "match-source")}) + + c.staticUtility("mask-type-alpha", []staticDecl{sd("mask-type", "alpha")}) + c.staticUtility("mask-type-luminance", []staticDecl{sd("mask-type", "luminance")}) + + c.staticUtility("mask-auto", []staticDecl{sd("mask-size", "auto")}) + c.staticUtility("mask-cover", []staticDecl{sd("mask-size", "cover")}) + c.staticUtility("mask-contain", []staticDecl{sd("mask-size", "contain")}) + c.functionalUtility("mask-size", utilityDescription{ + handle: func(value, _ string) *utilResult { + if value == "" { + return nil + } + return uNodes(d("mask-size", value)) + }, + }) + + c.staticUtility("mask-top", []staticDecl{sd("mask-position", "top")}) + c.staticUtility("mask-top-left", []staticDecl{sd("mask-position", "left top")}) + c.staticUtility("mask-top-right", []staticDecl{sd("mask-position", "right top")}) + c.staticUtility("mask-bottom", []staticDecl{sd("mask-position", "bottom")}) + c.staticUtility("mask-bottom-left", []staticDecl{sd("mask-position", "left bottom")}) + c.staticUtility("mask-bottom-right", []staticDecl{sd("mask-position", "right bottom")}) + c.staticUtility("mask-left", []staticDecl{sd("mask-position", "left")}) + c.staticUtility("mask-right", []staticDecl{sd("mask-position", "right")}) + c.staticUtility("mask-center", []staticDecl{sd("mask-position", "center")}) + c.functionalUtility("mask-position", utilityDescription{ + handle: func(value, _ string) *utilResult { + if value == "" { + return nil + } + return uNodes(d("mask-position", value)) + }, + }) + + c.staticUtility("mask-repeat", []staticDecl{sd("mask-repeat", "repeat")}) + c.staticUtility("mask-no-repeat", []staticDecl{sd("mask-repeat", "no-repeat")}) + c.staticUtility("mask-repeat-x", []staticDecl{sd("mask-repeat", "repeat-x")}) + c.staticUtility("mask-repeat-y", []staticDecl{sd("mask-repeat", "repeat-y")}) + c.staticUtility("mask-repeat-round", []staticDecl{sd("mask-repeat", "round")}) + c.staticUtility("mask-repeat-space", []staticDecl{sd("mask-repeat", "space")}) + + c.staticUtility("mask-clip-border", []staticDecl{sd("mask-clip", "border-box")}) + c.staticUtility("mask-clip-padding", []staticDecl{sd("mask-clip", "padding-box")}) + c.staticUtility("mask-clip-content", []staticDecl{sd("mask-clip", "content-box")}) + c.staticUtility("mask-clip-fill", []staticDecl{sd("mask-clip", "fill-box")}) + c.staticUtility("mask-clip-stroke", []staticDecl{sd("mask-clip", "stroke-box")}) + c.staticUtility("mask-clip-view", []staticDecl{sd("mask-clip", "view-box")}) + c.staticUtility("mask-no-clip", []staticDecl{sd("mask-clip", "no-clip")}) + + c.staticUtility("mask-origin-border", []staticDecl{sd("mask-origin", "border-box")}) + c.staticUtility("mask-origin-padding", []staticDecl{sd("mask-origin", "padding-box")}) + c.staticUtility("mask-origin-content", []staticDecl{sd("mask-origin", "content-box")}) + c.staticUtility("mask-origin-fill", []staticDecl{sd("mask-origin", "fill-box")}) + c.staticUtility("mask-origin-stroke", []staticDecl{sd("mask-origin", "stroke-box")}) + c.staticUtility("mask-origin-view", []staticDecl{sd("mask-origin", "view-box")}) + + registerUtilities10(c) +} + +// registerUtilities10: mask-image gradients (edge / linear / radial / conic). + +func registerUtilities10(c *utilCtx) { + d := decl + theme := c.theme + + maskImage := func() *AstNode { + return d("mask-image", "var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)") + } + maskComposite := func() *AstNode { return d("mask-composite", "intersect") } + maskPropertiesGradient := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-mask-linear", "linear-gradient(#fff, #fff)", ""), + property("--tw-mask-radial", "linear-gradient(#fff, #fff)", ""), + property("--tw-mask-conic", "linear-gradient(#fff, #fff)", ""), + }) + } + + maskStopUtility := func(classRoot string, colorFn, positionFn func(string) []*AstNode) { + c.utilities.functional(classRoot, func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"length", "percentage", "color"}) + } + switch typ { + case "color": + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uList(colorFn(cv)) + case "percentage": + if candidate.Modifier != nil { + return nil + } + if len(value) == 0 || !isPositiveInteger(value[:len(value)-1]) { + return nil + } + return uList(positionFn(value)) + default: + if candidate.Modifier != nil { + return nil + } + return uList(positionFn(value)) + } + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--background-color", "--color"}); ok { + return uList(colorFn(v)) + } + if candidate.Modifier != nil { + return nil + } + typ := inferDataType(candidate.Value.Value, []string{"number", "percentage"}) + switch typ { + case "number": + if _, ok := theme.resolve(nil, []string{"--spacing"}, themeNone); !ok { + return nil + } + if !isValidSpacingMultiplier(candidate.Value.Value) { + return nil + } + return uList(positionFn("--spacing(" + candidate.Value.Value + ")")) + case "percentage": + v := candidate.Value.Value + if len(v) == 0 || !isPositiveInteger(v[:len(v)-1]) { + return nil + } + return uList(positionFn(v)) + } + return nil + }, nil) + } + + // --- Edge masks --- + maskPropertiesEdge := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-mask-left", "linear-gradient(#fff, #fff)", ""), + property("--tw-mask-right", "linear-gradient(#fff, #fff)", ""), + property("--tw-mask-bottom", "linear-gradient(#fff, #fff)", ""), + property("--tw-mask-top", "linear-gradient(#fff, #fff)", ""), + }) + } + maskEdgeUtility := func(name, stop string, top, right, bottom, left bool) { + edges := []struct { + name string + on bool + }{{"top", top}, {"right", right}, {"bottom", bottom}, {"left", left}} + build := func(value, kind string) []*AstNode { + nodes := []*AstNode{ + maskPropertiesGradient(), maskPropertiesEdge(), maskImage(), maskComposite(), + d("--tw-mask-linear", "var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top)"), + } + for _, e := range edges { + if !e.on { + continue + } + nodes = append(nodes, + d("--tw-mask-"+e.name, "linear-gradient(to "+e.name+", var(--tw-mask-"+e.name+"-from-color) var(--tw-mask-"+e.name+"-from-position), var(--tw-mask-"+e.name+"-to-color) var(--tw-mask-"+e.name+"-to-position))"), + atRoot([]*AstNode{ + property("--tw-mask-"+e.name+"-from-position", "0%", ""), + property("--tw-mask-"+e.name+"-to-position", "100%", ""), + property("--tw-mask-"+e.name+"-from-color", "black", ""), + property("--tw-mask-"+e.name+"-to-color", "transparent", ""), + }), + d("--tw-mask-"+e.name+"-"+stop+"-"+kind, value), + ) + } + return nodes + } + maskStopUtility(name, + func(value string) []*AstNode { return build(value, "color") }, + func(value string) []*AstNode { return build(value, "position") }) + } + + maskEdgeUtility("mask-x-from", "from", false, true, false, true) + maskEdgeUtility("mask-x-to", "to", false, true, false, true) + maskEdgeUtility("mask-y-from", "from", true, false, true, false) + maskEdgeUtility("mask-y-to", "to", true, false, true, false) + maskEdgeUtility("mask-t-from", "from", true, false, false, false) + maskEdgeUtility("mask-t-to", "to", true, false, false, false) + maskEdgeUtility("mask-r-from", "from", false, true, false, false) + maskEdgeUtility("mask-r-to", "to", false, true, false, false) + maskEdgeUtility("mask-b-from", "from", false, false, true, false) + maskEdgeUtility("mask-b-to", "to", false, false, true, false) + maskEdgeUtility("mask-l-from", "from", false, false, false, true) + maskEdgeUtility("mask-l-to", "to", false, false, false, true) + + // --- Linear masks --- + maskPropertiesLinear := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-mask-linear-position", "0deg", ""), + property("--tw-mask-linear-from-position", "0%", ""), + property("--tw-mask-linear-to-position", "100%", ""), + property("--tw-mask-linear-from-color", "black", ""), + property("--tw-mask-linear-to-color", "transparent", ""), + }) + } + degBare := func(neg bool) func(*UtilityValue) (string, bool) { + return func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + if v.Value == "0" { + return "0deg", true + } + if v.Value == "1" { + if neg { + return "-1deg", true + } + return "1deg", true + } + if neg { + return "calc(1deg * -" + v.Value + ")", true + } + return "calc(1deg * " + v.Value + ")", true + } + } + c.functionalUtility("mask-linear", utilityDescription{ + defaultValueSet: true, + defaultValue: nil, + supportsNegative: true, + handleBareValue: degBare(false), + handleNegativeBareValue: degBare(true), + handle: func(value, _ string) *utilResult { + return uNodes(maskPropertiesGradient(), maskPropertiesLinear(), maskImage(), maskComposite(), + d("--tw-mask-linear", "linear-gradient(var(--tw-mask-linear-stops, var(--tw-mask-linear-position)))"), + d("--tw-mask-linear-position", value)) + }, + }) + linearStops := "var(--tw-mask-linear-position), var(--tw-mask-linear-from-color) var(--tw-mask-linear-from-position), var(--tw-mask-linear-to-color) var(--tw-mask-linear-to-position)" + maskStopUtility("mask-linear-from", + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesLinear(), maskImage(), maskComposite(), d("--tw-mask-linear-stops", linearStops), d("--tw-mask-linear", "linear-gradient(var(--tw-mask-linear-stops))"), d("--tw-mask-linear-from-color", v)} + }, + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesLinear(), maskImage(), maskComposite(), d("--tw-mask-linear-stops", linearStops), d("--tw-mask-linear", "linear-gradient(var(--tw-mask-linear-stops))"), d("--tw-mask-linear-from-position", v)} + }) + maskStopUtility("mask-linear-to", + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesLinear(), maskImage(), maskComposite(), d("--tw-mask-linear-stops", linearStops), d("--tw-mask-linear", "linear-gradient(var(--tw-mask-linear-stops))"), d("--tw-mask-linear-to-color", v)} + }, + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesLinear(), maskImage(), maskComposite(), d("--tw-mask-linear-stops", linearStops), d("--tw-mask-linear", "linear-gradient(var(--tw-mask-linear-stops))"), d("--tw-mask-linear-to-position", v)} + }) + + // --- Radial masks --- + maskPropertiesRadial := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-mask-radial-from-position", "0%", ""), + property("--tw-mask-radial-to-position", "100%", ""), + property("--tw-mask-radial-from-color", "black", ""), + property("--tw-mask-radial-to-color", "transparent", ""), + property("--tw-mask-radial-shape", "ellipse", ""), + property("--tw-mask-radial-size", "farthest-corner", ""), + property("--tw-mask-radial-position", "center", ""), + }) + } + c.staticUtility("mask-circle", []staticDecl{sd("--tw-mask-radial-shape", "circle")}) + c.staticUtility("mask-ellipse", []staticDecl{sd("--tw-mask-radial-shape", "ellipse")}) + c.staticUtility("mask-radial-closest-side", []staticDecl{sd("--tw-mask-radial-size", "closest-side")}) + c.staticUtility("mask-radial-farthest-side", []staticDecl{sd("--tw-mask-radial-size", "farthest-side")}) + c.staticUtility("mask-radial-closest-corner", []staticDecl{sd("--tw-mask-radial-size", "closest-corner")}) + c.staticUtility("mask-radial-farthest-corner", []staticDecl{sd("--tw-mask-radial-size", "farthest-corner")}) + for _, p := range [][2]string{ + {"mask-radial-at-top", "top"}, {"mask-radial-at-top-left", "top left"}, {"mask-radial-at-top-right", "top right"}, + {"mask-radial-at-bottom", "bottom"}, {"mask-radial-at-bottom-left", "bottom left"}, {"mask-radial-at-bottom-right", "bottom right"}, + {"mask-radial-at-left", "left"}, {"mask-radial-at-right", "right"}, {"mask-radial-at-center", "center"}, + } { + c.staticUtility(p[0], []staticDecl{sd("--tw-mask-radial-position", p[1])}) + } + c.functionalUtility("mask-radial-at", utilityDescription{ + defaultValueSet: true, defaultValue: nil, + handle: func(value, _ string) *utilResult { return uNodes(d("--tw-mask-radial-position", value)) }, + }) + c.functionalUtility("mask-radial", utilityDescription{ + defaultValueSet: true, defaultValue: nil, + handle: func(value, _ string) *utilResult { + return uNodes(maskPropertiesGradient(), maskPropertiesRadial(), maskImage(), maskComposite(), + d("--tw-mask-radial", "radial-gradient(var(--tw-mask-radial-stops, var(--tw-mask-radial-size)))"), + d("--tw-mask-radial-size", value)) + }, + }) + radialStops := "var(--tw-mask-radial-shape) var(--tw-mask-radial-size) at var(--tw-mask-radial-position), var(--tw-mask-radial-from-color) var(--tw-mask-radial-from-position), var(--tw-mask-radial-to-color) var(--tw-mask-radial-to-position)" + maskStopUtility("mask-radial-from", + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesRadial(), maskImage(), maskComposite(), d("--tw-mask-radial-stops", radialStops), d("--tw-mask-radial", "radial-gradient(var(--tw-mask-radial-stops))"), d("--tw-mask-radial-from-color", v)} + }, + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesRadial(), maskImage(), maskComposite(), d("--tw-mask-radial-stops", radialStops), d("--tw-mask-radial", "radial-gradient(var(--tw-mask-radial-stops))"), d("--tw-mask-radial-from-position", v)} + }) + maskStopUtility("mask-radial-to", + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesRadial(), maskImage(), maskComposite(), d("--tw-mask-radial-stops", radialStops), d("--tw-mask-radial", "radial-gradient(var(--tw-mask-radial-stops))"), d("--tw-mask-radial-to-color", v)} + }, + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesRadial(), maskImage(), maskComposite(), d("--tw-mask-radial-stops", radialStops), d("--tw-mask-radial", "radial-gradient(var(--tw-mask-radial-stops))"), d("--tw-mask-radial-to-position", v)} + }) + + // --- Conic masks --- + maskPropertiesConic := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-mask-conic-position", "0deg", ""), + property("--tw-mask-conic-from-position", "0%", ""), + property("--tw-mask-conic-to-position", "100%", ""), + property("--tw-mask-conic-from-color", "black", ""), + property("--tw-mask-conic-to-color", "transparent", ""), + }) + } + c.functionalUtility("mask-conic", utilityDescription{ + defaultValueSet: true, + defaultValue: nil, + supportsNegative: true, + handleBareValue: degBare(false), + handleNegativeBareValue: degBare(true), + handle: func(value, _ string) *utilResult { + return uNodes(maskPropertiesGradient(), maskPropertiesConic(), maskImage(), maskComposite(), + d("--tw-mask-conic", "conic-gradient(var(--tw-mask-conic-stops, var(--tw-mask-conic-position)))"), + d("--tw-mask-conic-position", value)) + }, + }) + conicStops := "from var(--tw-mask-conic-position), var(--tw-mask-conic-from-color) var(--tw-mask-conic-from-position), var(--tw-mask-conic-to-color) var(--tw-mask-conic-to-position)" + maskStopUtility("mask-conic-from", + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesConic(), maskImage(), maskComposite(), d("--tw-mask-conic-stops", conicStops), d("--tw-mask-conic", "conic-gradient(var(--tw-mask-conic-stops))"), d("--tw-mask-conic-from-color", v)} + }, + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesConic(), maskImage(), maskComposite(), d("--tw-mask-conic-stops", conicStops), d("--tw-mask-conic", "conic-gradient(var(--tw-mask-conic-stops))"), d("--tw-mask-conic-from-position", v)} + }) + maskStopUtility("mask-conic-to", + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesConic(), maskImage(), maskComposite(), d("--tw-mask-conic-stops", conicStops), d("--tw-mask-conic", "conic-gradient(var(--tw-mask-conic-stops))"), d("--tw-mask-conic-to-color", v)} + }, + func(v string) []*AstNode { + return []*AstNode{maskPropertiesGradient(), maskPropertiesConic(), maskImage(), maskComposite(), d("--tw-mask-conic-stops", conicStops), d("--tw-mask-conic", "conic-gradient(var(--tw-mask-conic-stops))"), d("--tw-mask-conic-to-position", v)} + }) + + registerUtilities11(c) +} + +// registerUtilities11: box-decoration, bg-clip/origin, blend modes, fill, +// stroke, object, padding, text-align, indent, vertical-align, font, +// text-transform/style/decoration-line, font-stretch, placeholder, decoration. + +func registerUtilities11(c *utilCtx) { + d := decl + theme := c.theme + + c.staticUtility("box-decoration-slice", []staticDecl{sd("-webkit-box-decoration-break", "slice"), sd("box-decoration-break", "slice")}) + c.staticUtility("box-decoration-clone", []staticDecl{sd("-webkit-box-decoration-break", "clone"), sd("box-decoration-break", "clone")}) + + c.staticUtility("bg-clip-text", []staticDecl{sd("background-clip", "text")}) + c.staticUtility("bg-clip-border", []staticDecl{sd("background-clip", "border-box")}) + c.staticUtility("bg-clip-padding", []staticDecl{sd("background-clip", "padding-box")}) + c.staticUtility("bg-clip-content", []staticDecl{sd("background-clip", "content-box")}) + c.staticUtility("bg-origin-border", []staticDecl{sd("background-origin", "border-box")}) + c.staticUtility("bg-origin-padding", []staticDecl{sd("background-origin", "padding-box")}) + c.staticUtility("bg-origin-content", []staticDecl{sd("background-origin", "content-box")}) + + for _, value := range []string{ + "normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", + "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", + "saturation", "color", "luminosity", + } { + c.staticUtility("bg-blend-"+value, []staticDecl{sd("background-blend-mode", value)}) + c.staticUtility("mix-blend-"+value, []staticDecl{sd("mix-blend-mode", value)}) + } + c.staticUtility("mix-blend-plus-darker", []staticDecl{sd("mix-blend-mode", "plus-darker")}) + c.staticUtility("mix-blend-plus-lighter", []staticDecl{sd("mix-blend-mode", "plus-lighter")}) + + c.staticUtility("fill-none", []staticDecl{sd("fill", "none")}) + c.utilities.functional("fill", func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + v, ok := asColor(candidate.Value.Value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(d("fill", v)) + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--fill", "--color"}); ok { + return uNodes(d("fill", v)) + } + return nil + }, nil) + + c.staticUtility("stroke-none", []staticDecl{sd("stroke", "none")}) + c.utilities.functional("stroke", func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color", "number", "length", "percentage"}) + } + switch typ { + case "number", "length", "percentage": + if candidate.Modifier != nil { + return nil + } + return uNodes(d("stroke-width", value)) + default: + v, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(d("stroke", v)) + } + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--stroke", "--color"}); ok { + return uNodes(d("stroke", v)) + } + vv := candidate.Value.Value + if v, ok := theme.resolve(&vv, []string{"--stroke-width"}, themeNone); ok { + return uNodes(d("stroke-width", v)) + } else if isPositiveInteger(vv) { + return uNodes(d("stroke-width", vv)) + } + return nil + }, nil) + + c.staticUtility("object-contain", []staticDecl{sd("object-fit", "contain")}) + c.staticUtility("object-cover", []staticDecl{sd("object-fit", "cover")}) + c.staticUtility("object-fill", []staticDecl{sd("object-fit", "fill")}) + c.staticUtility("object-none", []staticDecl{sd("object-fit", "none")}) + c.staticUtility("object-scale-down", []staticDecl{sd("object-fit", "scale-down")}) + c.functionalUtility("object", utilityDescription{ + themeKeys: []string{"--object-position"}, + handle: func(value, _ string) *utilResult { return uNodes(d("object-position", value)) }, + staticValues: map[string][]*AstNode{ + "top": {d("object-position", "top")}, "top-left": {d("object-position", "left top")}, + "top-right": {d("object-position", "right top")}, "bottom": {d("object-position", "bottom")}, + "bottom-left": {d("object-position", "left bottom")}, "bottom-right": {d("object-position", "right bottom")}, + "left": {d("object-position", "left")}, "right": {d("object-position", "right")}, "center": {d("object-position", "center")}, + }, + }) + + for _, pair := range [][2]string{ + {"p", "padding"}, {"px", "padding-inline"}, {"py", "padding-block"}, + {"ps", "padding-inline-start"}, {"pe", "padding-inline-end"}, + {"pbs", "padding-block-start"}, {"pbe", "padding-block-end"}, + {"pt", "padding-top"}, {"pr", "padding-right"}, {"pb", "padding-bottom"}, {"pl", "padding-left"}, + } { + prop := pair[1] + c.spacingUtility(pair[0], []string{"--padding", "--spacing"}, + func(value string) *utilResult { return uNodes(d(prop, value)) }, spacingOpts{}) + } + + c.staticUtility("text-left", []staticDecl{sd("text-align", "left")}) + c.staticUtility("text-center", []staticDecl{sd("text-align", "center")}) + c.staticUtility("text-right", []staticDecl{sd("text-align", "right")}) + c.staticUtility("text-justify", []staticDecl{sd("text-align", "justify")}) + c.staticUtility("text-start", []staticDecl{sd("text-align", "start")}) + c.staticUtility("text-end", []staticDecl{sd("text-align", "end")}) + + c.spacingUtility("indent", []string{"--text-indent", "--spacing"}, + func(value string) *utilResult { return uNodes(d("text-indent", value)) }, spacingOpts{supportsNegative: true}) + + c.staticUtility("align-baseline", []staticDecl{sd("vertical-align", "baseline")}) + c.staticUtility("align-top", []staticDecl{sd("vertical-align", "top")}) + c.staticUtility("align-middle", []staticDecl{sd("vertical-align", "middle")}) + c.staticUtility("align-bottom", []staticDecl{sd("vertical-align", "bottom")}) + c.staticUtility("align-text-top", []staticDecl{sd("vertical-align", "text-top")}) + c.staticUtility("align-text-bottom", []staticDecl{sd("vertical-align", "text-bottom")}) + c.staticUtility("align-sub", []staticDecl{sd("vertical-align", "sub")}) + c.staticUtility("align-super", []staticDecl{sd("vertical-align", "super")}) + c.functionalUtility("align", utilityDescription{ + handle: func(value, _ string) *utilResult { return uNodes(d("vertical-align", value)) }, + }) + + c.utilities.functional("font", func(candidate *Candidate) *utilResult { + if candidate.Value == nil || candidate.Modifier != nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"number", "generic-name", "family-name"}) + } + switch typ { + case "generic-name", "family-name": + return uNodes(d("font-family", value)) + default: + return uNodes(atRoot([]*AstNode{property("--tw-font-weight", "", "")}), + d("--tw-font-weight", value), d("font-weight", value)) + } + } + if families, extra, ok := theme.resolveWith(candidate.Value.Value, []string{"--font"}, + []string{"--font-feature-settings", "--font-variation-settings"}); ok { + nodes := []*AstNode{d("font-family", families)} + if v, has := extra["--font-feature-settings"]; has { + nodes = append(nodes, d("font-feature-settings", v)) + } + if v, has := extra["--font-variation-settings"]; has { + nodes = append(nodes, d("font-variation-settings", v)) + } + return uList(nodes) + } + vv := candidate.Value.Value + if v, ok := theme.resolve(&vv, []string{"--font-weight"}, themeNone); ok { + return uNodes(atRoot([]*AstNode{property("--tw-font-weight", "", "")}), + d("--tw-font-weight", v), d("font-weight", v)) + } + return nil + }, nil) + + c.functionalUtility("font-features", utilityDescription{ + handle: func(value, _ string) *utilResult { return uNodes(d("font-feature-settings", value)) }, + }) + + c.staticUtility("uppercase", []staticDecl{sd("text-transform", "uppercase")}) + c.staticUtility("lowercase", []staticDecl{sd("text-transform", "lowercase")}) + c.staticUtility("capitalize", []staticDecl{sd("text-transform", "capitalize")}) + c.staticUtility("normal-case", []staticDecl{sd("text-transform", "none")}) + c.staticUtility("italic", []staticDecl{sd("font-style", "italic")}) + c.staticUtility("not-italic", []staticDecl{sd("font-style", "normal")}) + c.staticUtility("underline", []staticDecl{sd("text-decoration-line", "underline")}) + c.staticUtility("overline", []staticDecl{sd("text-decoration-line", "overline")}) + c.staticUtility("line-through", []staticDecl{sd("text-decoration-line", "line-through")}) + c.staticUtility("no-underline", []staticDecl{sd("text-decoration-line", "none")}) + + for _, pair := range [][2]string{ + {"font-stretch-normal", "normal"}, {"font-stretch-ultra-condensed", "ultra-condensed"}, + {"font-stretch-extra-condensed", "extra-condensed"}, {"font-stretch-condensed", "condensed"}, + {"font-stretch-semi-condensed", "semi-condensed"}, {"font-stretch-semi-expanded", "semi-expanded"}, + {"font-stretch-expanded", "expanded"}, {"font-stretch-extra-expanded", "extra-expanded"}, + {"font-stretch-ultra-expanded", "ultra-expanded"}, + } { + c.staticUtility(pair[0], []staticDecl{sd("font-stretch", pair[1])}) + } + c.functionalUtility("font-stretch", utilityDescription{ + handleBareValue: func(v *UtilityValue) (string, bool) { + if !strings.HasSuffix(v.Value, "%") { + return "", false + } + numStr := v.Value[:len(v.Value)-1] + if !isPositiveInteger(numStr) { + return "", false + } + n, ok := jsParseNumber(numStr) + if !ok || n < 50 || n > 200 { + return "", false + } + return v.Value, true + }, + handle: func(value, _ string) *utilResult { return uNodes(d("font-stretch", value)) }, + }) + + c.colorUtility("placeholder", colorUtilityDescription{ + themeKeys: []string{"--placeholder-color", "--color"}, + handle: func(value string) *utilResult { + return uNodes(styleRule("&::placeholder", d("--tw-sort", "placeholder-color"), d("color", value))) + }, + }) + + c.staticUtility("decoration-solid", []staticDecl{sd("text-decoration-style", "solid")}) + c.staticUtility("decoration-double", []staticDecl{sd("text-decoration-style", "double")}) + c.staticUtility("decoration-dotted", []staticDecl{sd("text-decoration-style", "dotted")}) + c.staticUtility("decoration-dashed", []staticDecl{sd("text-decoration-style", "dashed")}) + c.staticUtility("decoration-wavy", []staticDecl{sd("text-decoration-style", "wavy")}) + c.staticUtility("decoration-auto", []staticDecl{sd("text-decoration-thickness", "auto")}) + c.staticUtility("decoration-from-font", []staticDecl{sd("text-decoration-thickness", "from-font")}) + + registerUtilities12(c) +} + +// registerUtilities12: text-decoration (functional) + animation. + +func registerUtilities12(c *utilCtx) { + d := decl + theme := c.theme + + c.utilities.functional("decoration", func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color", "length", "percentage"}) + } + switch typ { + case "length", "percentage": + if candidate.Modifier != nil { + return nil + } + return uNodes(d("text-decoration-thickness", value)) + default: + v, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(d("text-decoration-color", v)) + } + } + vv := candidate.Value.Value + if v, ok := theme.resolve(&vv, []string{"--text-decoration-thickness"}, themeNone); ok { + if candidate.Modifier != nil { + return nil + } + return uNodes(d("text-decoration-thickness", v)) + } + if isPositiveInteger(vv) { + if candidate.Modifier != nil { + return nil + } + return uNodes(d("text-decoration-thickness", vv+"px")) + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--text-decoration-color", "--color"}); ok { + return uNodes(d("text-decoration-color", v)) + } + return nil + }, nil) + + c.functionalUtility("animate", utilityDescription{ + themeKeys: []string{"--animate"}, + handle: func(value, _ string) *utilResult { return uNodes(d("animation", value)) }, + staticValues: map[string][]*AstNode{"none": {d("animation", "none")}}, + }) + + registerUtilities13(c) +} + +func alphaReplacedDropShadowProperties(prop, value string, alpha *string, varInjector func(string) string, prefix string) []*AstNode { + requiresFallback := false + parts := segment(value, ",") + replacedParts := make([]string, len(parts)) + for i, v := range parts { + replacedParts[i] = "drop-shadow(" + replaceShadowColors(v, func(color string) string { + if alpha == nil { + return varInjector(color) + } + if strings.HasPrefix(color, "current") { + return varInjector(withAlpha(color, *alpha)) + } + if strings.HasPrefix(color, "var(") || strings.HasPrefix(*alpha, "var(") { + requiresFallback = true + } + return varInjector(replaceAlpha(color, *alpha)) + }) + ")" + } + replacedValue := strings.Join(replacedParts, " ") + if requiresFallback { + fb := make([]string, len(parts)) + for i, v := range parts { + fb[i] = "drop-shadow(" + replaceShadowColors(v, varInjector) + ")" + } + return []*AstNode{ + decl(prop, prefix+strings.Join(fb, " ")), + rule("@supports (color: lab(from red l a b))", decl(prop, prefix+replacedValue)), + } + } + return []*AstNode{decl(prop, prefix+replacedValue)} +} + +// registerUtilities13: filter / backdrop-filter and all individual filters. +func registerUtilities13(c *utilCtx) { + d := decl + theme := c.theme + + cssFilterValue := "var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)" + cssBackdropFilterValue := "var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)" + + filterProperties := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-blur", "", ""), property("--tw-brightness", "", ""), property("--tw-contrast", "", ""), + property("--tw-grayscale", "", ""), property("--tw-hue-rotate", "", ""), property("--tw-invert", "", ""), + property("--tw-opacity", "", ""), property("--tw-saturate", "", ""), property("--tw-sepia", "", ""), + property("--tw-drop-shadow", "", ""), property("--tw-drop-shadow-color", "", ""), + property("--tw-drop-shadow-alpha", "100%", "<percentage>"), property("--tw-drop-shadow-size", "", ""), + }) + } + backdropFilterProperties := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-backdrop-blur", "", ""), property("--tw-backdrop-brightness", "", ""), + property("--tw-backdrop-contrast", "", ""), property("--tw-backdrop-grayscale", "", ""), + property("--tw-backdrop-hue-rotate", "", ""), property("--tw-backdrop-invert", "", ""), + property("--tw-backdrop-opacity", "", ""), property("--tw-backdrop-saturate", "", ""), + property("--tw-backdrop-sepia", "", ""), + }) + } + + c.utilities.functional("filter", func(candidate *Candidate) *utilResult { + if candidate.Modifier != nil { + return nil + } + if candidate.Value == nil { + return uNodes(filterProperties(), d("filter", cssFilterValue)) + } + if candidate.Value.Kind == uvArbitrary { + return uNodes(d("filter", candidate.Value.Value)) + } + if candidate.Value.Value == "none" { + return uNodes(d("filter", "none")) + } + return nil + }, nil) + + c.utilities.functional("backdrop-filter", func(candidate *Candidate) *utilResult { + if candidate.Modifier != nil { + return nil + } + if candidate.Value == nil { + return uNodes(backdropFilterProperties(), d("-webkit-backdrop-filter", cssBackdropFilterValue), d("backdrop-filter", cssBackdropFilterValue)) + } + if candidate.Value.Kind == uvArbitrary { + return uNodes(d("-webkit-backdrop-filter", candidate.Value.Value), d("backdrop-filter", candidate.Value.Value)) + } + if candidate.Value.Value == "none" { + return uNodes(d("-webkit-backdrop-filter", "none"), d("backdrop-filter", "none")) + } + return nil + }, nil) + + pctBare := func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value + "%", true + } + degBareF := func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value + "deg", true + } + + // blur + c.functionalUtility("blur", utilityDescription{ + themeKeys: []string{"--blur"}, + handle: func(value, _ string) *utilResult { + return uNodes(filterProperties(), d("--tw-blur", "blur("+value+")"), d("filter", cssFilterValue)) + }, + staticValues: map[string][]*AstNode{"none": {filterProperties(), d("--tw-blur", " "), d("filter", cssFilterValue)}}, + }) + c.functionalUtility("backdrop-blur", utilityDescription{ + themeKeys: []string{"--backdrop-blur", "--blur"}, + handle: func(value, _ string) *utilResult { + return uNodes(backdropFilterProperties(), d("--tw-backdrop-blur", "blur("+value+")"), + d("-webkit-backdrop-filter", cssBackdropFilterValue), d("backdrop-filter", cssBackdropFilterValue)) + }, + staticValues: map[string][]*AstNode{"none": {backdropFilterProperties(), d("--tw-backdrop-blur", " "), + d("-webkit-backdrop-filter", cssBackdropFilterValue), d("backdrop-filter", cssBackdropFilterValue)}}, + }) + + // simple filter factory (filter side) + simpleFilter := func(name, themeKey, fn string, defaultPct bool) { + desc := utilityDescription{ + themeKeys: []string{themeKey}, + handleBareValue: pctBare, + handle: func(value, _ string) *utilResult { + return uNodes(filterProperties(), d("--tw-"+name, fn+"("+value+")"), d("filter", cssFilterValue)) + }, + } + if defaultPct { + desc.defaultValueSet = true + desc.defaultValue = sptr("100%") + } + c.functionalUtility(name, desc) + } + simpleBackdrop := func(name, twName, themeKeyA, themeKeyB, fn string, defaultPct bool, bare func(*UtilityValue) (string, bool)) { + desc := utilityDescription{ + themeKeys: []string{themeKeyA, themeKeyB}, + handleBareValue: bare, + handle: func(value, _ string) *utilResult { + return uNodes(backdropFilterProperties(), d("--tw-"+twName, fn+"("+value+")"), + d("-webkit-backdrop-filter", cssBackdropFilterValue), d("backdrop-filter", cssBackdropFilterValue)) + }, + } + if defaultPct { + desc.defaultValueSet = true + desc.defaultValue = sptr("100%") + } + c.functionalUtility(name, desc) + } + + simpleFilter("brightness", "--brightness", "brightness", false) + simpleBackdrop("backdrop-brightness", "backdrop-brightness", "--backdrop-brightness", "--brightness", "brightness", false, pctBare) + simpleFilter("contrast", "--contrast", "contrast", false) + simpleBackdrop("backdrop-contrast", "backdrop-contrast", "--backdrop-contrast", "--contrast", "contrast", false, pctBare) + simpleFilter("grayscale", "--grayscale", "grayscale", true) + simpleBackdrop("backdrop-grayscale", "backdrop-grayscale", "--backdrop-grayscale", "--grayscale", "grayscale", true, pctBare) + + // hue-rotate (supportsNegative, deg) + c.functionalUtility("hue-rotate", utilityDescription{ + supportsNegative: true, themeKeys: []string{"--hue-rotate"}, handleBareValue: degBareF, + handle: func(value, _ string) *utilResult { + return uNodes(filterProperties(), d("--tw-hue-rotate", "hue-rotate("+value+")"), d("filter", cssFilterValue)) + }, + }) + c.functionalUtility("backdrop-hue-rotate", utilityDescription{ + supportsNegative: true, themeKeys: []string{"--backdrop-hue-rotate", "--hue-rotate"}, handleBareValue: degBareF, + handle: func(value, _ string) *utilResult { + return uNodes(backdropFilterProperties(), d("--tw-backdrop-hue-rotate", "hue-rotate("+value+")"), + d("-webkit-backdrop-filter", cssBackdropFilterValue), d("backdrop-filter", cssBackdropFilterValue)) + }, + }) + + simpleFilter("invert", "--invert", "invert", true) + simpleBackdrop("backdrop-invert", "backdrop-invert", "--backdrop-invert", "--invert", "invert", true, pctBare) + simpleFilter("saturate", "--saturate", "saturate", false) + simpleBackdrop("backdrop-saturate", "backdrop-saturate", "--backdrop-saturate", "--saturate", "saturate", false, pctBare) + simpleFilter("sepia", "--sepia", "sepia", true) + simpleBackdrop("backdrop-sepia", "backdrop-sepia", "--backdrop-sepia", "--sepia", "sepia", true, pctBare) + + c.staticUtility("drop-shadow-none", []staticDecl{sdFn(filterProperties), sd("--tw-drop-shadow", " "), sd("filter", cssFilterValue)}) + + varInjector := func(color string) string { return "var(--tw-drop-shadow-color, " + color + ")" } + c.utilities.functional("drop-shadow", func(candidate *Candidate) *utilResult { + var alphaPtr *string + if candidate.Modifier != nil { + if candidate.Modifier.Kind == modArbitrary { + v := candidate.Modifier.Value + alphaPtr = &v + } else if isPositiveInteger(candidate.Modifier.Value) { + v := candidate.Modifier.Value + "%" + alphaPtr = &v + } + } + alphaDecl := func() *AstNode { + if alphaPtr != nil { + return d("--tw-drop-shadow-alpha", *alphaPtr) + } + return &AstNode{Kind: nDeclaration, Property: "--tw-drop-shadow-alpha", Undefined: true} + } + + if candidate.Value == nil { + value, okGet := theme.Get([]string{"--drop-shadow"}) + resolved, okRes := theme.resolve(nil, []string{"--drop-shadow"}, themeNone) + if !okGet || !okRes { + return nil + } + nodes := []*AstNode{filterProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedDropShadowProperties("--tw-drop-shadow-size", value, alphaPtr, varInjector, "")...) + nodes = append(nodes, d("--tw-drop-shadow", dropShadowJoin(resolved)), d("filter", cssFilterValue)) + return uList(nodes) + } + + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color"}) + } + if typ == "color" { + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(filterProperties(), + d("--tw-drop-shadow-color", withAlpha(cv, "var(--tw-drop-shadow-alpha)")), + d("--tw-drop-shadow", "var(--tw-drop-shadow-size)")) + } + if candidate.Modifier != nil && alphaPtr == nil { + return nil + } + nodes := []*AstNode{filterProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedDropShadowProperties("--tw-drop-shadow-size", value, alphaPtr, varInjector, "")...) + nodes = append(nodes, d("--tw-drop-shadow", "var(--tw-drop-shadow-size)"), d("filter", cssFilterValue)) + return uList(nodes) + } + + cv := candidate.Value.Value + value, okGet := theme.Get([]string{"--drop-shadow-" + cv}) + resolved, okRes := theme.resolve(&cv, []string{"--drop-shadow"}, themeNone) + if okGet && okRes { + if candidate.Modifier != nil && alphaPtr == nil { + return nil + } + nodes := []*AstNode{filterProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedDropShadowProperties("--tw-drop-shadow-size", value, alphaPtr, varInjector, "")...) + if alphaPtr != nil { + nodes = append(nodes, d("--tw-drop-shadow", "var(--tw-drop-shadow-size)"), d("filter", cssFilterValue)) + } else { + nodes = append(nodes, d("--tw-drop-shadow", dropShadowJoin(resolved)), d("filter", cssFilterValue)) + } + return uList(nodes) + } + + if v, ok := resolveThemeColor(candidate, theme, []string{"--drop-shadow-color", "--color"}); ok { + if v == "inherit" { + return uNodes(filterProperties(), d("--tw-drop-shadow-color", "inherit"), d("--tw-drop-shadow", "var(--tw-drop-shadow-size)")) + } + return uNodes(filterProperties(), d("--tw-drop-shadow-color", withAlpha(v, "var(--tw-drop-shadow-alpha)")), d("--tw-drop-shadow", "var(--tw-drop-shadow-size)")) + } + return nil + }, nil) + + c.functionalUtility("backdrop-opacity", utilityDescription{ + themeKeys: []string{"--backdrop-opacity", "--opacity"}, + handleBareValue: func(v *UtilityValue) (string, bool) { + if !isValidOpacityValue(v.Value) { + return "", false + } + return v.Value + "%", true + }, + handle: func(value, _ string) *utilResult { + return uNodes(backdropFilterProperties(), d("--tw-backdrop-opacity", "opacity("+value+")"), + d("-webkit-backdrop-filter", cssBackdropFilterValue), d("backdrop-filter", cssBackdropFilterValue)) + }, + }) + + registerUtilities14(c) +} + +func dropShadowJoin(resolved string) string { + parts := segment(resolved, ",") + for i, p := range parts { + parts[i] = "drop-shadow(" + p + ")" + } + return strings.Join(parts, " ") +} + +// registerUtilities14: transition/delay/duration/ease, will-change, content, +// contain, forced-color-adjust, leading, tracking, antialiasing, +// font-variant-numeric. + +func registerUtilities14(c *utilCtx) { + d := decl + theme := c.theme + + dtf := "ease" + if v, ok := theme.resolve(nil, []string{"--default-transition-timing-function"}, themeNone); ok { + dtf = v + } + defaultTimingFunction := "var(--tw-ease, " + dtf + ")" + dd := "0s" + if v, ok := theme.resolve(nil, []string{"--default-transition-duration"}, themeNone); ok { + dd = v + } + defaultDuration := "var(--tw-duration, " + dd + ")" + + transitionDefault := "color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events" + tdecls := func(prop string) []*AstNode { + return []*AstNode{d("transition-property", prop), d("transition-timing-function", defaultTimingFunction), d("transition-duration", defaultDuration)} + } + c.functionalUtility("transition", utilityDescription{ + defaultValueSet: true, defaultValue: sptr(transitionDefault), + themeKeys: []string{"--transition-property"}, + handle: func(value, _ string) *utilResult { return uList(tdecls(value)) }, + staticValues: map[string][]*AstNode{ + "none": {d("transition-property", "none")}, + "all": tdecls("all"), + "colors": tdecls("color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to"), + "opacity": tdecls("opacity"), + "shadow": tdecls("box-shadow"), + "transform": tdecls("transform, translate, scale, rotate"), + }, + }) + c.staticUtility("transition-discrete", []staticDecl{sd("transition-behavior", "allow-discrete")}) + c.staticUtility("transition-normal", []staticDecl{sd("transition-behavior", "normal")}) + + c.functionalUtility("delay", utilityDescription{ + handleBareValue: func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value + "ms", true + }, + themeKeys: []string{"--transition-delay"}, + handle: func(value, _ string) *utilResult { return uNodes(d("transition-delay", value)) }, + }) + + transitionDurationProperty := func() *AstNode { return atRoot([]*AstNode{property("--tw-duration", "", "")}) } + c.staticUtility("duration-initial", []staticDecl{sdFn(transitionDurationProperty), sd("--tw-duration", "initial")}) + c.utilities.functional("duration", func(candidate *Candidate) *utilResult { + if candidate.Modifier != nil || candidate.Value == nil { + return nil + } + var value string + ok := false + if candidate.Value.Kind == uvArbitrary { + value, ok = candidate.Value.Value, true + } else { + key := candidate.Value.Value + if candidate.Value.Fraction != "" { + key = candidate.Value.Fraction + } + value, ok = theme.resolve(&key, []string{"--transition-duration"}, themeNone) + if !ok && isPositiveInteger(candidate.Value.Value) { + value, ok = candidate.Value.Value+"ms", true + } + } + if !ok { + return nil + } + return uNodes(transitionDurationProperty(), d("--tw-duration", value), d("transition-duration", value)) + }, nil) + + transitionTimingFunctionProperty := func() *AstNode { return atRoot([]*AstNode{property("--tw-ease", "", "")}) } + c.functionalUtility("ease", utilityDescription{ + themeKeys: []string{"--ease"}, + handle: func(value, _ string) *utilResult { + return uNodes(transitionTimingFunctionProperty(), d("--tw-ease", value), d("transition-timing-function", value)) + }, + staticValues: map[string][]*AstNode{ + "initial": {transitionTimingFunctionProperty(), d("--tw-ease", "initial")}, + "linear": {transitionTimingFunctionProperty(), d("--tw-ease", "linear"), d("transition-timing-function", "linear")}, + }, + }) + + c.staticUtility("will-change-auto", []staticDecl{sd("will-change", "auto")}) + c.staticUtility("will-change-scroll", []staticDecl{sd("will-change", "scroll-position")}) + c.staticUtility("will-change-contents", []staticDecl{sd("will-change", "contents")}) + c.staticUtility("will-change-transform", []staticDecl{sd("will-change", "transform")}) + c.functionalUtility("will-change", utilityDescription{ + handle: func(value, _ string) *utilResult { return uNodes(d("will-change", value)) }, + }) + + c.staticUtility("content-none", []staticDecl{sd("--tw-content", "none"), sd("content", "none")}) + c.functionalUtility("content", utilityDescription{ + themeKeys: []string{"--content"}, + handle: func(value, _ string) *utilResult { + return uNodes(atRoot([]*AstNode{property("--tw-content", `""`, "")}), d("--tw-content", value), d("content", "var(--tw-content)")) + }, + }) + + cssContainValue := "var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)" + cssContainProperties := func() *AstNode { + return atRoot([]*AstNode{property("--tw-contain-size", "", ""), property("--tw-contain-layout", "", ""), property("--tw-contain-paint", "", ""), property("--tw-contain-style", "", "")}) + } + c.staticUtility("contain-none", []staticDecl{sd("contain", "none")}) + c.staticUtility("contain-content", []staticDecl{sd("contain", "content")}) + c.staticUtility("contain-strict", []staticDecl{sd("contain", "strict")}) + for _, pair := range [][2]string{ + {"contain-size", "size"}, {"contain-inline-size", "inline-size"}, {"contain-layout", "layout"}, + {"contain-paint", "paint"}, {"contain-style", "style"}, + } { + twVar := "--tw-contain-" + strings.TrimPrefix(pair[0], "contain-") + if pair[0] == "contain-inline-size" { + twVar = "--tw-contain-size" + } + c.staticUtility(pair[0], []staticDecl{sdFn(cssContainProperties), sd(twVar, pair[1]), sd("contain", cssContainValue)}) + } + c.functionalUtility("contain", utilityDescription{ + handle: func(value, _ string) *utilResult { return uNodes(d("contain", value)) }, + }) + + c.staticUtility("forced-color-adjust-none", []staticDecl{sd("forced-color-adjust", "none")}) + c.staticUtility("forced-color-adjust-auto", []staticDecl{sd("forced-color-adjust", "auto")}) + + c.spacingUtility("leading", []string{"--leading", "--spacing"}, + func(value string) *utilResult { + return uNodes(atRoot([]*AstNode{property("--tw-leading", "", "")}), d("--tw-leading", value), d("line-height", value)) + }, spacingOpts{staticValues: map[string][]*AstNode{ + "none": {atRoot([]*AstNode{property("--tw-leading", "", "")}), d("--tw-leading", "1"), d("line-height", "1")}, + }}) + + c.functionalUtility("tracking", utilityDescription{ + supportsNegative: true, + themeKeys: []string{"--tracking"}, + handle: func(value, _ string) *utilResult { + return uNodes(atRoot([]*AstNode{property("--tw-tracking", "", "")}), d("--tw-tracking", value), d("letter-spacing", value)) + }, + }) + + c.staticUtility("antialiased", []staticDecl{sd("-webkit-font-smoothing", "antialiased"), sd("-moz-osx-font-smoothing", "grayscale")}) + c.staticUtility("subpixel-antialiased", []staticDecl{sd("-webkit-font-smoothing", "auto"), sd("-moz-osx-font-smoothing", "auto")}) + + cssFVN := "var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)" + fvnProps := func() *AstNode { + return atRoot([]*AstNode{property("--tw-ordinal", "", ""), property("--tw-slashed-zero", "", ""), property("--tw-numeric-figure", "", ""), property("--tw-numeric-spacing", "", ""), property("--tw-numeric-fraction", "", "")}) + } + c.staticUtility("normal-nums", []staticDecl{sd("font-variant-numeric", "normal")}) + for _, e := range []struct{ name, twVar, val string }{ + {"ordinal", "--tw-ordinal", "ordinal"}, {"slashed-zero", "--tw-slashed-zero", "slashed-zero"}, + {"lining-nums", "--tw-numeric-figure", "lining-nums"}, {"oldstyle-nums", "--tw-numeric-figure", "oldstyle-nums"}, + {"proportional-nums", "--tw-numeric-spacing", "proportional-nums"}, {"tabular-nums", "--tw-numeric-spacing", "tabular-nums"}, + {"diagonal-fractions", "--tw-numeric-fraction", "diagonal-fractions"}, {"stacked-fractions", "--tw-numeric-fraction", "stacked-fractions"}, + } { + e := e + c.staticUtility(e.name, []staticDecl{sdFn(fvnProps), sd(e.twVar, e.val), sd("font-variant-numeric", cssFVN)}) + } + + registerUtilities15(c) +} + +func alphaReplacedShadowProperties(prop, value string, alpha *string, varInjector func(string) string, prefix string) []*AstNode { + requiresFallback := false + replacedValue := replaceShadowColors(value, func(color string) string { + if alpha == nil { + return varInjector(color) + } + if strings.HasPrefix(color, "current") { + return varInjector(withAlpha(color, *alpha)) + } + if strings.HasPrefix(color, "var(") || strings.HasPrefix(*alpha, "var(") { + requiresFallback = true + } + return varInjector(replaceAlpha(color, *alpha)) + }) + applyPrefix := func(x string) string { + if prefix == "" { + return x + } + parts := segment(x, ",") + for i, v := range parts { + parts[i] = strings.TrimSpace(prefix) + " " + strings.TrimSpace(v) + } + return strings.Join(parts, ", ") + } + if requiresFallback { + return []*AstNode{ + decl(prop, applyPrefix(replaceShadowColors(value, varInjector))), + rule("@supports (color: lab(from red l a b))", decl(prop, applyPrefix(replacedValue))), + } + } + return []*AstNode{decl(prop, applyPrefix(replacedValue))} +} + +// shadowAlpha extracts an opacity modifier as an alpha (*string), matching the +// shadow/text-shadow/drop-shadow modifier handling. +func shadowAlpha(modifier *CandidateModifier) *string { + if modifier == nil { + return nil + } + if modifier.Kind == modArbitrary { + v := modifier.Value + return &v + } + if isPositiveInteger(modifier.Value) { + v := modifier.Value + "%" + return &v + } + return nil +} + +// registerUtilities15: outline, opacity, underline-offset, text, text-shadow. +func registerUtilities15(c *utilCtx) { + d := decl + theme := c.theme + + outlineProperties := func() *AstNode { return atRoot([]*AstNode{property("--tw-outline-style", "solid", "")}) } + c.utilities.static("outline-hidden", func(_ *Candidate) *utilResult { + return uNodes(d("--tw-outline-style", "none"), d("outline-style", "none"), + atRule("@media", "(forced-colors: active)", d("outline", "2px solid transparent"), d("outline-offset", "2px"))) + }) + c.staticUtility("outline-none", []staticDecl{sd("--tw-outline-style", "none"), sd("outline-style", "none")}) + c.staticUtility("outline-solid", []staticDecl{sd("--tw-outline-style", "solid"), sd("outline-style", "solid")}) + c.staticUtility("outline-dashed", []staticDecl{sd("--tw-outline-style", "dashed"), sd("outline-style", "dashed")}) + c.staticUtility("outline-dotted", []staticDecl{sd("--tw-outline-style", "dotted"), sd("outline-style", "dotted")}) + c.staticUtility("outline-double", []staticDecl{sd("--tw-outline-style", "double"), sd("outline-style", "double")}) + + c.utilities.functional("outline", func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + if candidate.Modifier != nil { + return nil + } + value, ok := theme.Get([]string{"--default-outline-width"}) + if !ok { + value = "1px" + } + return uNodes(outlineProperties(), d("outline-style", "var(--tw-outline-style)"), d("outline-width", value)) + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color", "length", "number", "percentage"}) + } + switch typ { + case "length", "number", "percentage": + if candidate.Modifier != nil { + return nil + } + return uNodes(outlineProperties(), d("outline-style", "var(--tw-outline-style)"), d("outline-width", value)) + default: + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(d("outline-color", cv)) + } + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--outline-color", "--color"}); ok { + return uNodes(d("outline-color", v)) + } + if candidate.Modifier != nil { + return nil + } + vv := candidate.Value.Value + if v, ok := theme.resolve(&vv, []string{"--outline-width"}, themeNone); ok { + return uNodes(outlineProperties(), d("outline-style", "var(--tw-outline-style)"), d("outline-width", v)) + } else if isPositiveInteger(vv) { + return uNodes(outlineProperties(), d("outline-style", "var(--tw-outline-style)"), d("outline-width", vv+"px")) + } + return nil + }, nil) + + c.functionalUtility("outline-offset", utilityDescription{ + supportsNegative: true, + themeKeys: []string{"--outline-offset"}, + handleBareValue: func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value + "px", true + }, + handle: func(value, _ string) *utilResult { return uNodes(d("outline-offset", value)) }, + }) + + c.functionalUtility("opacity", utilityDescription{ + themeKeys: []string{"--opacity"}, + handleBareValue: func(v *UtilityValue) (string, bool) { + if !isValidOpacityValue(v.Value) { + return "", false + } + return v.Value + "%", true + }, + handle: func(value, _ string) *utilResult { return uNodes(d("opacity", value)) }, + }) + + c.functionalUtility("underline-offset", utilityDescription{ + supportsNegative: true, + themeKeys: []string{"--text-underline-offset"}, + handleBareValue: func(v *UtilityValue) (string, bool) { + if !isPositiveInteger(v.Value) { + return "", false + } + return v.Value + "px", true + }, + handle: func(value, _ string) *utilResult { return uNodes(d("text-underline-offset", value)) }, + staticValues: map[string][]*AstNode{"auto": {d("text-underline-offset", "auto")}}, + }) + + // resolveTextModifier resolves a `/<modifier>` on text-* into a line-height. + resolveTextModifier := func(modifier *CandidateModifier) (string, bool) { + var mod string + mok := false + if modifier.Kind == modArbitrary { + mod, mok = modifier.Value, true + } else if v, ok := theme.resolve(&modifier.Value, []string{"--leading"}, themeNone); ok { + mod, mok = v, true + } + if !mok && isValidSpacingMultiplier(modifier.Value) { + if _, ok := theme.resolve(nil, []string{"--spacing"}, themeNone); !ok { + return "", false + } + mod, mok = "--spacing("+modifier.Value+")", true + } + if !mok && modifier.Value == "none" { + mod, mok = "1", true + } + return mod, mok + } + + c.utilities.functional("text", func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color", "length", "percentage", "absolute-size", "relative-size"}) + } + switch typ { + case "size", "length", "percentage", "absolute-size", "relative-size": + if candidate.Modifier != nil { + mod, ok := resolveTextModifier(candidate.Modifier) + if !ok { + return nil + } + return uNodes(d("font-size", value), d("line-height", mod)) + } + return uNodes(d("font-size", value)) + default: + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(d("color", cv)) + } + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--text-color", "--color"}); ok { + return uNodes(d("color", v)) + } + if fontSize, options, ok := theme.resolveWith(candidate.Value.Value, []string{"--text"}, + []string{"--line-height", "--letter-spacing", "--font-weight"}); ok { + if candidate.Modifier != nil { + mod, mok := resolveTextModifier(candidate.Modifier) + if !mok { + return nil + } + return uNodes(d("font-size", fontSize), d("line-height", mod)) + } + nodes := []*AstNode{d("font-size", fontSize)} + if lh, has := options["--line-height"]; has { + nodes = append(nodes, d("line-height", "var(--tw-leading, "+lh+")")) + } + if ls, has := options["--letter-spacing"]; has { + nodes = append(nodes, d("letter-spacing", "var(--tw-tracking, "+ls+")")) + } + if fw, has := options["--font-weight"]; has { + nodes = append(nodes, d("font-weight", "var(--tw-font-weight, "+fw+")")) + } + return uList(nodes) + } + return nil + }, nil) + + textShadowProperties := func() *AstNode { + return atRoot([]*AstNode{property("--tw-text-shadow-color", "", ""), property("--tw-text-shadow-alpha", "100%", "<percentage>")}) + } + c.staticUtility("text-shadow-initial", []staticDecl{sdFn(textShadowProperties), sd("--tw-text-shadow-color", "initial")}) + tsInjector := func(color string) string { return "var(--tw-text-shadow-color, " + color + ")" } + c.utilities.functional("text-shadow", func(candidate *Candidate) *utilResult { + alpha := shadowAlpha(candidate.Modifier) + alphaDecl := func() *AstNode { + if alpha != nil { + return d("--tw-text-shadow-alpha", *alpha) + } + return &AstNode{Kind: nDeclaration, Property: "--tw-text-shadow-alpha", Undefined: true} + } + if candidate.Value == nil { + value, ok := theme.Get([]string{"--text-shadow"}) + if !ok { + return nil + } + nodes := []*AstNode{textShadowProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedShadowProperties("text-shadow", value, alpha, tsInjector, "")...) + return uList(nodes) + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color"}) + } + if typ == "color" { + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(textShadowProperties(), d("--tw-text-shadow-color", withAlpha(cv, "var(--tw-text-shadow-alpha)"))) + } + nodes := []*AstNode{textShadowProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedShadowProperties("text-shadow", value, alpha, tsInjector, "")...) + return uList(nodes) + } + switch candidate.Value.Value { + case "none": + if candidate.Modifier != nil { + return nil + } + return uNodes(textShadowProperties(), d("text-shadow", "none")) + case "inherit": + if candidate.Modifier != nil { + return nil + } + return uNodes(textShadowProperties(), d("--tw-text-shadow-color", "inherit")) + } + if value, ok := theme.Get([]string{"--text-shadow-" + candidate.Value.Value}); ok { + nodes := []*AstNode{textShadowProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedShadowProperties("text-shadow", value, alpha, tsInjector, "")...) + return uList(nodes) + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--text-shadow-color", "--color"}); ok { + return uNodes(textShadowProperties(), d("--tw-text-shadow-color", withAlpha(v, "var(--tw-text-shadow-alpha)"))) + } + return nil + }, nil) + + registerUtilities16(c) +} + +func undecl(p string) *AstNode { return &AstNode{Kind: nDeclaration, Property: p, Undefined: true} } + +// registerUtilities16: box-shadow, inset-shadow, ring, inset-ring, ring-offset. +func registerUtilities16(c *utilCtx) { + d := decl + theme := c.theme + + cssBoxShadowValue := "var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)" + nullShadow := "0 0 #0000" + boxShadowProperties := func() *AstNode { + return atRoot([]*AstNode{ + property("--tw-shadow", nullShadow, ""), property("--tw-shadow-color", "", ""), + property("--tw-shadow-alpha", "100%", "<percentage>"), + property("--tw-inset-shadow", nullShadow, ""), property("--tw-inset-shadow-color", "", ""), + property("--tw-inset-shadow-alpha", "100%", "<percentage>"), + property("--tw-ring-color", "", ""), property("--tw-ring-shadow", nullShadow, ""), + property("--tw-inset-ring-color", "", ""), property("--tw-inset-ring-shadow", nullShadow, ""), + property("--tw-ring-inset", "", ""), property("--tw-ring-offset-width", "0px", "<length>"), + property("--tw-ring-offset-color", "#fff", ""), property("--tw-ring-offset-shadow", nullShadow, ""), + }) + } + + c.staticUtility("shadow-initial", []staticDecl{sdFn(boxShadowProperties), sd("--tw-shadow-color", "initial")}) + + shadowInj := func(color string) string { return "var(--tw-shadow-color, " + color + ")" } + c.utilities.functional("shadow", func(candidate *Candidate) *utilResult { + alpha := shadowAlpha(candidate.Modifier) + alphaDecl := func() *AstNode { + if alpha != nil { + return d("--tw-shadow-alpha", *alpha) + } + return undecl("--tw-shadow-alpha") + } + if candidate.Value == nil { + value, ok := theme.Get([]string{"--shadow"}) + if !ok { + return nil + } + nodes := []*AstNode{boxShadowProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedShadowProperties("--tw-shadow", value, alpha, shadowInj, "")...) + return uList(append(nodes, d("box-shadow", cssBoxShadowValue))) + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color"}) + } + if typ == "color" { + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(boxShadowProperties(), d("--tw-shadow-color", withAlpha(cv, "var(--tw-shadow-alpha)"))) + } + nodes := []*AstNode{boxShadowProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedShadowProperties("--tw-shadow", value, alpha, shadowInj, "")...) + return uList(append(nodes, d("box-shadow", cssBoxShadowValue))) + } + switch candidate.Value.Value { + case "none": + if candidate.Modifier != nil { + return nil + } + return uNodes(boxShadowProperties(), d("--tw-shadow", nullShadow), d("box-shadow", cssBoxShadowValue)) + case "inherit": + if candidate.Modifier != nil { + return nil + } + return uNodes(boxShadowProperties(), d("--tw-shadow-color", "inherit")) + } + if value, ok := theme.Get([]string{"--shadow-" + candidate.Value.Value}); ok { + nodes := []*AstNode{boxShadowProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedShadowProperties("--tw-shadow", value, alpha, shadowInj, "")...) + return uList(append(nodes, d("box-shadow", cssBoxShadowValue))) + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--box-shadow-color", "--color"}); ok { + return uNodes(boxShadowProperties(), d("--tw-shadow-color", withAlpha(v, "var(--tw-shadow-alpha)"))) + } + return nil + }, nil) + + c.staticUtility("inset-shadow-initial", []staticDecl{sdFn(boxShadowProperties), sd("--tw-inset-shadow-color", "initial")}) + insetShadowInj := func(color string) string { return "var(--tw-inset-shadow-color, " + color + ")" } + c.utilities.functional("inset-shadow", func(candidate *Candidate) *utilResult { + alpha := shadowAlpha(candidate.Modifier) + alphaDecl := func() *AstNode { + if alpha != nil { + return d("--tw-inset-shadow-alpha", *alpha) + } + return undecl("--tw-inset-shadow-alpha") + } + if candidate.Value == nil { + value, ok := theme.Get([]string{"--inset-shadow"}) + if !ok { + return nil + } + nodes := []*AstNode{boxShadowProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedShadowProperties("--tw-inset-shadow", value, alpha, insetShadowInj, "")...) + return uList(append(nodes, d("box-shadow", cssBoxShadowValue))) + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color"}) + } + if typ == "color" { + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(boxShadowProperties(), d("--tw-inset-shadow-color", withAlpha(cv, "var(--tw-inset-shadow-alpha)"))) + } + nodes := []*AstNode{boxShadowProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedShadowProperties("--tw-inset-shadow", value, alpha, insetShadowInj, "inset")...) + return uList(append(nodes, d("box-shadow", cssBoxShadowValue))) + } + switch candidate.Value.Value { + case "none": + if candidate.Modifier != nil { + return nil + } + return uNodes(boxShadowProperties(), d("--tw-inset-shadow", "inset "+nullShadow), d("box-shadow", cssBoxShadowValue)) + case "inherit": + if candidate.Modifier != nil { + return nil + } + return uNodes(boxShadowProperties(), d("--tw-inset-shadow-color", "inherit")) + } + if value, ok := theme.Get([]string{"--inset-shadow-" + candidate.Value.Value}); ok { + nodes := []*AstNode{boxShadowProperties(), alphaDecl()} + nodes = append(nodes, alphaReplacedShadowProperties("--tw-inset-shadow", value, alpha, insetShadowInj, "")...) + return uList(append(nodes, d("box-shadow", cssBoxShadowValue))) + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--box-shadow-color", "--color"}); ok { + return uNodes(boxShadowProperties(), d("--tw-inset-shadow-color", withAlpha(v, "var(--tw-inset-shadow-alpha)"))) + } + return nil + }, nil) + + c.staticUtility("ring-inset", []staticDecl{sdFn(boxShadowProperties), sd("--tw-ring-inset", "inset")}) + + defaultRingColor := "currentcolor" + if v, ok := theme.Get([]string{"--default-ring-color"}); ok { + defaultRingColor = v + } + ringShadowValue := func(value string) string { + return "var(--tw-ring-inset,) 0 0 0 calc(" + value + " + var(--tw-ring-offset-width)) var(--tw-ring-color, " + defaultRingColor + ")" + } + c.utilities.functional("ring", func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + if candidate.Modifier != nil { + return nil + } + value, ok := theme.Get([]string{"--default-ring-width"}) + if !ok { + value = "1px" + } + return uNodes(boxShadowProperties(), d("--tw-ring-shadow", ringShadowValue(value)), d("box-shadow", cssBoxShadowValue)) + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color", "length"}) + } + if typ == "length" { + if candidate.Modifier != nil { + return nil + } + return uNodes(boxShadowProperties(), d("--tw-ring-shadow", ringShadowValue(value)), d("box-shadow", cssBoxShadowValue)) + } + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(d("--tw-ring-color", cv)) + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--ring-color", "--color"}); ok { + return uNodes(d("--tw-ring-color", v)) + } + if candidate.Modifier != nil { + return nil + } + vv := candidate.Value.Value + value, ok := theme.resolve(&vv, []string{"--ring-width"}, themeNone) + if !ok && isPositiveInteger(vv) { + value, ok = vv+"px", true + } + if ok { + return uNodes(boxShadowProperties(), d("--tw-ring-shadow", ringShadowValue(value)), d("box-shadow", cssBoxShadowValue)) + } + return nil + }, nil) + + insetRingShadowValue := func(value string) string { + return "inset 0 0 0 " + value + " var(--tw-inset-ring-color, currentcolor)" + } + c.utilities.functional("inset-ring", func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + if candidate.Modifier != nil { + return nil + } + return uNodes(boxShadowProperties(), d("--tw-inset-ring-shadow", insetRingShadowValue("1px")), d("box-shadow", cssBoxShadowValue)) + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color", "length"}) + } + if typ == "length" { + if candidate.Modifier != nil { + return nil + } + return uNodes(boxShadowProperties(), d("--tw-inset-ring-shadow", insetRingShadowValue(value)), d("box-shadow", cssBoxShadowValue)) + } + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(d("--tw-inset-ring-color", cv)) + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--ring-color", "--color"}); ok { + return uNodes(d("--tw-inset-ring-color", v)) + } + if candidate.Modifier != nil { + return nil + } + vv := candidate.Value.Value + value, ok := theme.resolve(&vv, []string{"--ring-width"}, themeNone) + if !ok && isPositiveInteger(vv) { + value, ok = vv+"px", true + } + if ok { + return uNodes(boxShadowProperties(), d("--tw-inset-ring-shadow", insetRingShadowValue(value)), d("box-shadow", cssBoxShadowValue)) + } + return nil + }, nil) + + ringOffsetShadowValue := "var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)" + c.utilities.functional("ring-offset", func(candidate *Candidate) *utilResult { + if candidate.Value == nil { + return nil + } + if candidate.Value.Kind == uvArbitrary { + value := candidate.Value.Value + typ := candidate.Value.DataType + if typ == "" { + typ = inferDataType(value, []string{"color", "length"}) + } + if typ == "length" { + if candidate.Modifier != nil { + return nil + } + return uNodes(d("--tw-ring-offset-width", value), d("--tw-ring-offset-shadow", ringOffsetShadowValue)) + } + cv, ok := asColor(value, candidate.Modifier, theme) + if !ok { + return nil + } + return uNodes(d("--tw-ring-offset-color", cv)) + } + vv := candidate.Value.Value + if v, ok := theme.resolve(&vv, []string{"--ring-offset-width"}, themeNone); ok { + if candidate.Modifier != nil { + return nil + } + return uNodes(d("--tw-ring-offset-width", v), d("--tw-ring-offset-shadow", ringOffsetShadowValue)) + } else if isPositiveInteger(vv) { + if candidate.Modifier != nil { + return nil + } + return uNodes(d("--tw-ring-offset-width", vv+"px"), d("--tw-ring-offset-shadow", ringOffsetShadowValue)) + } + if v, ok := resolveThemeColor(candidate, theme, []string{"--ring-offset-color", "--color"}); ok { + return uNodes(d("--tw-ring-offset-color", v)) + } + return nil + }, nil) + + registerUtilities17(c) +} + +// registerUtilities17: @container (container-type). +func registerUtilities17(c *utilCtx) { + d := decl + c.utilities.functional("@container", func(candidate *Candidate) *utilResult { + value := "" + ok := false + if candidate.Value == nil { + value, ok = "inline-size", true + } else if candidate.Value.Kind == uvArbitrary { + value, ok = candidate.Value.Value, true + } else if candidate.Value.Kind == uvNamed && candidate.Value.Value == "normal" { + value, ok = "normal", true + } else if candidate.Value.Kind == uvNamed && candidate.Value.Value == "size" { + value, ok = "size", true + } + if !ok { + return nil + } + if candidate.Modifier != nil { + return uNodes(d("container-type", value), d("container-name", candidate.Modifier.Value)) + } + return uNodes(d("container-type", value)) + }, nil) +} + +// Port of packages/tailwindcss/src/value-parser.ts +// +// Parses a CSS value into a small AST of words, function calls, and +// separators. Used by arbitrary-value decoding and the theme()/calc() helpers. +// Nodes are pointers so passes (e.g. underscore decoding) can mutate them. + +type ValueNode interface{ valueNode() } + +type ValueWord struct{ Value string } +type ValueFunction struct { + Value string + Nodes []ValueNode +} +type ValueSeparator struct{ Value string } + +func (*ValueWord) valueNode() {} +func (*ValueFunction) valueNode() {} +func (*ValueSeparator) valueNode() {} + +func valueToCss(ast []ValueNode) string { + var b strings.Builder + writeValueNodes(&b, ast) + return b.String() +} + +func writeValueNodes(b *strings.Builder, ast []ValueNode) { + for _, node := range ast { + switch n := node.(type) { + case *ValueWord: + b.WriteString(n.Value) + case *ValueSeparator: + b.WriteString(n.Value) + case *ValueFunction: + b.WriteString(n.Value) + b.WriteByte('(') + writeValueNodes(b, n.Nodes) + b.WriteByte(')') + } + } +} + +func isValueSeparatorByte(c byte) bool { + switch c { + case ':', ',', '=', '>', '<', '\n', ' ', '\t': + return true + } + return false +} + +func valueParse(input string) []ValueNode { + input = strings.ReplaceAll(input, "\r\n", "\n") + + var ast []ValueNode + var stack []*ValueFunction + var parent *ValueFunction + var buf []byte + + push := func(n ValueNode) { + if parent != nil { + parent.Nodes = append(parent.Nodes, n) + } else { + ast = append(ast, n) + } + } + flushWord := func() { + if len(buf) > 0 { + push(&ValueWord{Value: string(buf)}) + buf = buf[:0] + } + } + + for i := 0; i < len(input); i++ { + c := input[i] + switch { + case c == '\\': + // Escaped character: consume this and the next byte. + if i+1 < len(input) { + buf = append(buf, input[i], input[i+1]) + i++ + } else { + buf = append(buf, c) + } + + case c == '/': + // `/` is its own word (e.g. theme(colors.red.500/10)). + flushWord() + push(&ValueWord{Value: "/"}) + + case isValueSeparatorByte(c): + flushWord() + start := i + end := i + 1 + for ; end < len(input); end++ { + if !isValueSeparatorByte(input[end]) { + break + } + } + i = end - 1 + push(&ValueSeparator{Value: input[start:end]}) + + case c == '\'' || c == '"': + start := i + for j := i + 1; j < len(input); j++ { + p := input[j] + if p == '\\' { + j++ + continue + } + if p == c { + i = j + break + } + } + buf = append(buf, input[start:i+1]...) + + case c == '(': + n := &ValueFunction{Value: string(buf)} + buf = buf[:0] + push(n) + stack = append(stack, n) + parent = n + + case c == ')': + var tail *ValueFunction + if len(stack) > 0 { + tail = stack[len(stack)-1] + stack = stack[:len(stack)-1] + } + if len(buf) > 0 { + if tail != nil { + tail.Nodes = append(tail.Nodes, &ValueWord{Value: string(buf)}) + } + buf = buf[:0] + } + if len(stack) > 0 { + parent = stack[len(stack)-1] + } else { + parent = nil + } + + default: + buf = append(buf, c) + } + } + + if len(buf) > 0 { + ast = append(ast, &ValueWord{Value: string(buf)}) + } + + return ast +} + +// Port of packages/tailwindcss/src/variants.ts +// +// The Variants registry plus createVariants (the full variant catalog). +// suggest()/completions are stubbed (IntelliSense only). substituteAtVariant +// returns nothing (the Features bitset is not modeled). + +type Compounds int + +const ( + CompoundsNever Compounds = 0 + CompoundsAtRules Compounds = 1 << 0 + CompoundsStyleRules Compounds = 1 << 1 +) + +type variantApplyFn func(r *AstNode, v *Variant) bool + +type variantInfo struct { + kind variantKind + order int + applyFn variantApplyFn + compoundsWith Compounds + compounds Compounds +} + +type vOpts struct { + compounds Compounds + hasCompounds bool + order int + hasOrder bool +} + +type Variants struct { + variants map[string]*variantInfo + order []string + compareFns map[int]func(a, z *Variant) int + groupOrder *int + lastOrder int +} + +func NewVariants() *Variants { + return &Variants{variants: map[string]*variantInfo{}, compareFns: map[int]func(a, z *Variant) int{}} +} + +func (v *Variants) nextOrder() int { + if v.groupOrder != nil { + return *v.groupOrder + } + return v.lastOrder + 1 +} + +func (v *Variants) set(name string, kind variantKind, fn variantApplyFn, compoundsWith, compounds Compounds, o vOpts) { + if existing, ok := v.variants[name]; ok { + existing.kind = kind + existing.applyFn = fn + existing.compounds = compounds + return + } + order := o.order + if !o.hasOrder { + v.lastOrder = v.nextOrder() + order = v.lastOrder + } + v.variants[name] = &variantInfo{kind: kind, order: order, applyFn: fn, compoundsWith: compoundsWith, compounds: compounds} + v.order = append(v.order, name) +} + +func compoundsOrDefault(o vOpts) Compounds { + if o.hasCompounds { + return o.compounds + } + return CompoundsStyleRules +} + +func (v *Variants) static(name string, fn variantApplyFn, o vOpts) { + v.set(name, varStatic, fn, CompoundsNever, compoundsOrDefault(o), o) +} + +func (v *Variants) functional(name string, fn variantApplyFn, o vOpts) { + v.set(name, varFunctional, fn, CompoundsNever, compoundsOrDefault(o), o) +} + +func (v *Variants) compound(name string, compoundsWith Compounds, fn variantApplyFn, o vOpts) { + v.set(name, varCompound, fn, compoundsWith, compoundsOrDefault(o), o) +} + +func (v *Variants) group(fn func(), compareFn func(a, z *Variant) int) { + o := v.nextOrder() + v.groupOrder = &o + if compareFn != nil { + v.compareFns[o] = compareFn + } + fn() + v.groupOrder = nil +} + +func (v *Variants) has(name string) bool { _, ok := v.variants[name]; return ok } + +func (v *Variants) get(name string) *variantInfo { return v.variants[name] } + +func (v *Variants) kind(name string) variantKind { + if info, ok := v.variants[name]; ok { + return info.kind + } + return varStatic +} + +func (v *Variants) keys() []string { return v.order } + +func (v *Variants) compoundsWith(parent string, child *Variant) bool { + parentInfo, ok := v.variants[parent] + if !ok { + return false + } + var childCompounds Compounds + if child.Kind == varArbitrary { + childCompounds = compoundsForSelectors([]string{child.Selector}) + } else { + ci, ok := v.variants[child.Root] + if !ok { + return false + } + childCompounds = ci.compounds + } + if parentInfo.kind != varCompound { + return false + } + if childCompounds == CompoundsNever { + return false + } + if parentInfo.compoundsWith == CompoundsNever { + return false + } + if parentInfo.compoundsWith&childCompounds == 0 { + return false + } + return true +} + +func (v *Variants) compare(a, z *Variant) int { + if a == z { + return 0 + } + if a == nil { + return -1 + } + if z == nil { + return 1 + } + + if a.Kind == varArbitrary && z.Kind == varArbitrary { + if a.Selector < z.Selector { + return -1 + } + return 1 + } else if a.Kind == varArbitrary { + return 1 + } else if z.Kind == varArbitrary { + return -1 + } + + aOrder := v.variants[a.Root].order + zOrder := v.variants[z.Root].order + if aOrder != zOrder { + return aOrder - zOrder + } + + if a.Kind == varCompound && z.Kind == varCompound { + order := v.compare(a.Variant, z.Variant) + if order != 0 { + return order + } + if a.Modifier != nil && z.Modifier != nil { + if a.Modifier.Value < z.Modifier.Value { + return -1 + } + return 1 + } else if a.Modifier != nil { + return 1 + } else if z.Modifier != nil { + return -1 + } + return 0 + } + + if fn, ok := v.compareFns[aOrder]; ok { + return fn(a, z) + } + + if a.Root != z.Root { + if a.Root < z.Root { + return -1 + } + return 1 + } + + aValue := a.Value + zValue := z.Value + if aValue == nil { + return -1 + } + if zValue == nil { + return 1 + } + if aValue.Kind == vvArbitrary && zValue.Kind != vvArbitrary { + return 1 + } + if aValue.Kind != vvArbitrary && zValue.Kind == vvArbitrary { + return -1 + } + if aValue.Value < zValue.Value { + return -1 + } + return 1 +} + +// fromAst registers a variant whose body comes from CSS (@custom-variant). +func (v *Variants) fromAst(name string, ast []*AstNode, ds *DesignSystem) { + var selectors []string + usesAtVariant := false + astCopy := ast + walkAst(&astCopy, func(node *AstNode, _ *VisitContext) WalkResult { + if node.Kind == nRule { + selectors = append(selectors, node.Selector) + } else if node.Kind == nAtRule && node.Name == "@variant" { + usesAtVariant = true + } else if node.Kind == nAtRule && node.Name != "@slot" { + selectors = append(selectors, node.Name+" "+node.Params) + } + return WContinue + }) + v.static(name, func(r *AstNode, _ *Variant) bool { + body := cloneAstNodes(ast) + if usesAtVariant { + substituteAtVariant(body, ds) + } + substituteAtSlot(body, r.Nodes) + r.Nodes = body + return true + }, vOpts{compounds: compoundsForSelectors(selectors), hasCompounds: true}) +} + +func compoundsForSelectors(selectors []string) Compounds { + compounds := CompoundsNever + for _, sel := range selectors { + if len(sel) > 0 && sel[0] == '@' { + if !strings.HasPrefix(sel, "@media") && !strings.HasPrefix(sel, "@supports") && !strings.HasPrefix(sel, "@container") { + return CompoundsNever + } + compounds |= CompoundsAtRules + continue + } + if strings.Contains(sel, "::") { + return CompoundsNever + } + compounds |= CompoundsStyleRules + } + return compounds +} + +func addStaticVariant(variants *Variants, name string, selectors []string, o vOpts) { + if !o.hasCompounds { + o.compounds = compoundsForSelectors(selectors) + o.hasCompounds = true + } + sels := selectors + variants.static(name, func(r *AstNode, _ *Variant) bool { + orig := r.Nodes + newNodes := make([]*AstNode, len(sels)) + for i, sel := range sels { + newNodes[i] = rule(sel, orig...) + } + r.Nodes = newNodes + return true + }, o) +} + +func createVariants(theme *Theme) *Variants { + variants := NewVariants() + + addStaticVariant(variants, "*", []string{":is(& > *)"}, vOpts{compounds: CompoundsNever, hasCompounds: true}) + addStaticVariant(variants, "**", []string{":is(& *)"}, vOpts{compounds: CompoundsNever, hasCompounds: true}) + + registerCompoundVariants(variants, theme) + registerPseudoVariants(variants) + registerFunctionalVariants(variants, theme) + registerBreakpointVariants(variants, theme) + registerMediaVariants(variants) + + return variants +} + +// ---- helpers shared by the variant catalog ------------------------------ + +func negateSelector(selector string) (string, bool) { + if strings.Contains(selector, "::") { + return "", false + } + parts := segment(selector, ",") + for i, sel := range parts { + parts[i] = strings.ReplaceAll(sel, "&", "*") + } + return "&:not(" + strings.Join(parts, ", ") + ")", true +} + +var conditionalRules = []string{"@media", "@supports", "@container"} + +func negateConditions(ruleName string, conditions []string) []string { + out := make([]string, len(conditions)) + for i, condition := range conditions { + if ruleName == "@container" { + ast := valueParse(strings.TrimSpace(condition)) + switch { + case len(ast) >= 1 && isValueFn(ast[0]): + out[i] = "not " + condition + case len(ast) >= 3 && isValueWordVal(ast[0], "not") && isValueFn(ast[2]): + ast = ast[2:] + out[i] = valueToCss(ast) + case len(ast) >= 5 && isValueWord(ast[0]) && isValueWordVal(ast[2], "not") && isValueFn(ast[4]): + ast = append(ast[:2], ast[4:]...) + out[i] = valueToCss(ast) + case len(ast) >= 3 && isValueWord(ast[0]) && !isValueWordVal(ast[0], "not") && isValueFn(ast[2]): + rest := append([]ValueNode{&ValueSeparator{Value: " "}, &ValueWord{Value: "not"}}, ast[1:]...) + ast = append([]ValueNode{ast[0]}, rest...) + out[i] = valueToCss(ast) + default: + out[i] = "not " + condition + } + } else { + condition = strings.TrimSpace(condition) + parts := segment(condition, " ") + if parts[0] == "not" { + out[i] = strings.Join(parts[1:], " ") + } else { + out[i] = "not " + condition + } + } + } + return out +} + +func isValueFn(n ValueNode) bool { _, ok := n.(*ValueFunction); return ok } +func isValueWord(n ValueNode) bool { _, ok := n.(*ValueWord); return ok } +func isValueWordVal(n ValueNode, val string) bool { + w, ok := n.(*ValueWord) + return ok && w.Value == val +} + +func negateAtRule(node *AstNode) *AstNode { + for _, ruleName := range conditionalRules { + if ruleName != node.Name { + continue + } + conditions := segment(node.Params, ",") + if len(conditions) > 1 { + return nil + } + conditions = negateConditions(node.Name, conditions) + return atRule(node.Name, strings.Join(conditions, ", ")) + } + return nil +} + +var reSupportsFn = regexp.MustCompile(`^[\w-]*\s*\(`) +var reSupportsBool = regexp.MustCompile(`\b(and|or|not)\b`) + +func quoteAttributeValue(input string) string { + if strings.Contains(input, "=") { + segs := segment(input, "=") + attribute := segs[0] + value := strings.TrimSpace(strings.Join(segs[1:], "=")) + if len(value) > 0 && (value[0] == '\'' || value[0] == '"') { + return input + } + if len(value) > 1 { + tc := value[len(value)-1] + if value[len(value)-2] == ' ' && (tc == 'i' || tc == 'I' || tc == 's' || tc == 'S') { + return attribute + "=\"" + value[:len(value)-2] + "\" " + string(tc) + } + } + return attribute + "=\"" + value + "\"" + } + return input +} + +func substituteAtSlot(ast []*AstNode, nodes []*AstNode) { + astCopy := ast + walkAst(&astCopy, func(node *AstNode, _ *VisitContext) WalkResult { + if node.Kind == nAtRule && node.Name == "@slot" { + return WReplaceSkip(nodes...) + } + if node.Kind == nAtRule && (node.Name == "@keyframes" || node.Name == "@property") { + *node = *atRoot([]*AstNode{atRule(node.Name, node.Params, node.Nodes...)}) + return WSkip + } + return WContinue + }) +} + +func substituteAtVariant(ast []*AstNode, ds *DesignSystem) { + astCopy := ast + walkAst(&astCopy, func(variantNode *AstNode, _ *VisitContext) WalkResult { + if variantNode.Kind != nAtRule || variantNode.Name != "@variant" { + return WContinue + } + var nodes []*AstNode + compoundVariants := segment(variantNode.Params, ",") + for idx, compoundVariant := range compoundVariants { + var childNodes []*AstNode + if idx == len(compoundVariants)-1 { + childNodes = variantNode.Nodes + } else { + childNodes = cloneAstNodes(variantNode.Nodes) + } + node := styleRule("&", childNodes...) + stacked := segment(compoundVariant, ":") + for i := len(stacked) - 1; i >= 0; i-- { + name := strings.TrimSpace(stacked[i]) + if name == "" { + return WContinue + } + variantAst := ds.parseVariant(name) + if variantAst == nil { + return WContinue + } + if !applyVariant(node, variantAst, ds.variants, 0) { + return WContinue + } + } + nodes = append(nodes, node) + } + return WReplace(nodes...) + }) +} + +func registerCompoundVariants(variants *Variants, theme *Theme) { + prefixDot := func(base string) string { + if theme.Prefix != "" { + return ":where(." + theme.Prefix + "\\:" + base + } + return ":where(." + base + } + + variants.compound("not", CompoundsStyleRules|CompoundsAtRules, func(ruleNode *AstNode, variant *Variant) bool { + if variant.Variant.Kind == varArbitrary && variant.Variant.Relative { + return false + } + if variant.Modifier != nil { + return false + } + didApply := false + list := []*AstNode{ruleNode} + walkAst(&list, func(node *AstNode, ctx *VisitContext) WalkResult { + if node.Kind != nRule && node.Kind != nAtRule { + return WContinue + } + if len(node.Nodes) > 0 { + return WContinue + } + var atRules, styleRules []*AstNode + path := ctx.Path() + path = append(path, node) + for _, p := range path { + if p.Kind == nAtRule { + atRules = append(atRules, p) + } else if p.Kind == nRule { + styleRules = append(styleRules, p) + } + } + if len(atRules) > 1 { + return WStop + } + if len(styleRules) > 1 { + return WStop + } + var rules []*AstNode + for _, sr := range styleRules { + sel, ok := negateSelector(sr.Selector) + if !ok { + didApply = false + return WStop + } + rules = append(rules, styleRule(sel)) + } + for _, ar := range atRules { + neg := negateAtRule(ar) + if neg == nil { + didApply = false + return WStop + } + rules = append(rules, neg) + } + *ruleNode = *styleRule("&", rules...) + didApply = true + return WSkip + }) + if ruleNode.Kind == nRule && ruleNode.Selector == "&" && len(ruleNode.Nodes) == 1 { + *ruleNode = *ruleNode.Nodes[0] + } + return didApply + }, vOpts{}) + + groupPeer := func(name, suffix string) { + variants.compound(name, CompoundsStyleRules, func(ruleNode *AstNode, variant *Variant) bool { + if variant.Variant.Kind == varArbitrary && variant.Variant.Relative { + return false + } + var variantSelector string + if variant.Modifier != nil { + variantSelector = prefixDot(name+"\\/"+variant.Modifier.Value) + ")" + } else { + variantSelector = prefixDot(name) + ")" + } + didApply := false + list := []*AstNode{ruleNode} + walkAst(&list, func(node *AstNode, ctx *VisitContext) WalkResult { + if node.Kind != nRule { + return WContinue + } + for _, parent := range ctx.Path() { + if parent.Kind != nRule { + continue + } + didApply = false + return WStop + } + selector := strings.ReplaceAll(node.Selector, "&", variantSelector) + if len(segment(selector, ",")) > 1 { + selector = ":is(" + selector + ")" + } + node.Selector = "&:is(" + selector + suffix + ")" + didApply = true + return WContinue + }) + return didApply + }, vOpts{}) + } + groupPeer("group", " *") + groupPeer("peer", " ~ *") + + variants.compound("in", CompoundsStyleRules, func(ruleNode *AstNode, variant *Variant) bool { + if variant.Modifier != nil { + return false + } + didApply := false + list := []*AstNode{ruleNode} + walkAst(&list, func(node *AstNode, ctx *VisitContext) WalkResult { + if node.Kind != nRule { + return WContinue + } + for _, parent := range ctx.Path() { + if parent.Kind != nRule { + continue + } + didApply = false + return WStop + } + node.Selector = ":where(" + strings.ReplaceAll(node.Selector, "&", "*") + ") &" + didApply = true + return WContinue + }) + return didApply + }, vOpts{}) + + variants.compound("has", CompoundsStyleRules, func(ruleNode *AstNode, variant *Variant) bool { + if variant.Modifier != nil { + return false + } + didApply := false + list := []*AstNode{ruleNode} + walkAst(&list, func(node *AstNode, ctx *VisitContext) WalkResult { + if node.Kind != nRule { + return WContinue + } + for _, parent := range ctx.Path() { + if parent.Kind != nRule { + continue + } + didApply = false + return WStop + } + node.Selector = "&:has(" + strings.ReplaceAll(node.Selector, "&", "*") + ")" + didApply = true + return WContinue + }) + return didApply + }, vOpts{}) +} + +func registerPseudoVariants(variants *Variants) { + sv := func(name string, selectors ...string) { addStaticVariant(variants, name, selectors, vOpts{}) } + + sv("first-letter", "&::first-letter") + sv("first-line", "&::first-line") + sv("marker", "& *::marker", "&::marker", "& *::-webkit-details-marker", "&::-webkit-details-marker") + sv("selection", "& *::selection", "&::selection") + sv("file", "&::file-selector-button") + sv("placeholder", "&::placeholder") + sv("backdrop", "&::backdrop") + sv("details-content", "&::details-content") + + contentProps := func() *AstNode { + return atRoot([]*AstNode{atRule("@property", "--tw-content", + decl("syntax", `"*"`), decl("initial-value", `""`), decl("inherits", "false"))}) + } + pseudoContent := func(name, sel string) { + variants.static(name, func(v *AstNode, _ *Variant) bool { + inner := append([]*AstNode{contentProps(), decl("content", "var(--tw-content)")}, v.Nodes...) + v.Nodes = []*AstNode{styleRule(sel, inner...)} + return true + }, vOpts{compounds: CompoundsNever, hasCompounds: true}) + } + pseudoContent("before", "&::before") + pseudoContent("after", "&::after") + + sv("first", "&:first-child") + sv("last", "&:last-child") + sv("only", "&:only-child") + sv("odd", "&:nth-child(odd)") + sv("even", "&:nth-child(even)") + sv("first-of-type", "&:first-of-type") + sv("last-of-type", "&:last-of-type") + sv("only-of-type", "&:only-of-type") + + sv("visited", "&:visited") + sv("target", "&:target") + sv("open", "&:is([open], :popover-open, :open)") + + sv("default", "&:default") + sv("checked", "&:checked") + sv("indeterminate", "&:indeterminate") + sv("placeholder-shown", "&:placeholder-shown") + sv("autofill", "&:autofill") + sv("optional", "&:optional") + sv("required", "&:required") + sv("valid", "&:valid") + sv("invalid", "&:invalid") + sv("user-valid", "&:user-valid") + sv("user-invalid", "&:user-invalid") + sv("in-range", "&:in-range") + sv("out-of-range", "&:out-of-range") + sv("read-only", "&:read-only") + + sv("empty", "&:empty") + + sv("focus-within", "&:focus-within") + variants.static("hover", func(r *AstNode, _ *Variant) bool { + r.Nodes = []*AstNode{styleRule("&:hover", atRule("@media", "(hover: hover)", r.Nodes...))} + return true + }, vOpts{}) + sv("focus", "&:focus") + sv("focus-visible", "&:focus-visible") + sv("active", "&:active") + sv("enabled", "&:enabled") + sv("disabled", "&:disabled") + sv("inert", "&:is([inert], [inert] *)") +} + +func registerFunctionalVariants(variants *Variants, theme *Theme) { + variants.functional("aria", func(r *AstNode, variant *Variant) bool { + if variant.Value == nil || variant.Modifier != nil { + return false + } + if variant.Value.Kind == vvArbitrary { + r.Nodes = []*AstNode{styleRule("&[aria-"+quoteAttributeValue(variant.Value.Value)+"]", r.Nodes...)} + } else { + r.Nodes = []*AstNode{styleRule("&[aria-"+variant.Value.Value+"=\"true\"]", r.Nodes...)} + } + return true + }, vOpts{}) + + variants.functional("data", func(r *AstNode, variant *Variant) bool { + if variant.Value == nil || variant.Modifier != nil { + return false + } + r.Nodes = []*AstNode{styleRule("&[data-"+quoteAttributeValue(variant.Value.Value)+"]", r.Nodes...)} + return true + }, vOpts{}) + + nthVariant := func(name, pseudo string) { + variants.functional(name, func(r *AstNode, variant *Variant) bool { + if variant.Value == nil || variant.Modifier != nil { + return false + } + if variant.Value.Kind == vvNamed && !isPositiveInteger(variant.Value.Value) { + return false + } + r.Nodes = []*AstNode{styleRule("&:"+pseudo+"("+variant.Value.Value+")", r.Nodes...)} + return true + }, vOpts{}) + } + nthVariant("nth", "nth-child") + nthVariant("nth-last", "nth-last-child") + nthVariant("nth-of-type", "nth-of-type") + nthVariant("nth-last-of-type", "nth-last-of-type") + + variants.functional("supports", func(r *AstNode, variant *Variant) bool { + if variant.Value == nil || variant.Modifier != nil { + return false + } + value := variant.Value.Value + if value == "" { + return false + } + if reSupportsFn.MatchString(value) { + query := reSupportsBool.ReplaceAllString(value, " $1 ") + r.Nodes = []*AstNode{atRule("@supports", query, r.Nodes...)} + return true + } + if !strings.Contains(value, ":") { + value = value + ": var(--tw)" + } + if value[0] != '(' || value[len(value)-1] != ')' { + value = "(" + value + ")" + } + r.Nodes = []*AstNode{atRule("@supports", value, r.Nodes...)} + return true + }, vOpts{compounds: CompoundsAtRules, hasCompounds: true}) +} + +func registerBreakpointVariants(variants *Variants, theme *Theme) { + compareBP := func(a, z *Variant, direction string, lookup func(*Variant) (string, bool)) int { + if a == z { + return 0 + } + av, aok := lookup(a) + if !aok { + if direction == "asc" { + return -1 + } + return 1 + } + zv, zok := lookup(z) + if !zok { + if direction == "asc" { + return 1 + } + return -1 + } + return compareBreakpoints(av, zv, direction) + } + + breakpoints := theme.namespace("--breakpoint") + resolvedBreakpoints := func(variant *Variant) (string, bool) { + switch variant.Kind { + case varStatic: + return theme.resolveValue(sptr(variant.Root), []string{"--breakpoint"}) + case varFunctional: + if variant.Value == nil || variant.Modifier != nil { + return "", false + } + var value string + var ok bool + if variant.Value.Kind == vvArbitrary { + value, ok = variant.Value.Value, true + } else { + value, ok = theme.resolveValue(sptr(variant.Value.Value), []string{"--breakpoint"}) + } + if !ok || value == "" || strings.Contains(value, "var(") { + return "", false + } + return value, true + } + return "", false + } + + variants.group(func() { + variants.functional("max", func(r *AstNode, variant *Variant) bool { + if variant.Modifier != nil { + return false + } + value, ok := resolvedBreakpoints(variant) + if !ok { + return false + } + r.Nodes = []*AstNode{atRule("@media", "(width < "+value+")", r.Nodes...)} + return true + }, vOpts{compounds: CompoundsAtRules, hasCompounds: true}) + }, func(a, z *Variant) int { return compareBP(a, z, "desc", resolvedBreakpoints) }) + + variants.group(func() { + for _, key := range breakpoints.Keys() { + value, _ := breakpoints.Get(key) + val := value + variants.static(key, func(r *AstNode, _ *Variant) bool { + r.Nodes = []*AstNode{atRule("@media", "(width >= "+val+")", r.Nodes...)} + return true + }, vOpts{compounds: CompoundsAtRules, hasCompounds: true}) + } + variants.functional("min", func(r *AstNode, variant *Variant) bool { + if variant.Modifier != nil { + return false + } + value, ok := resolvedBreakpoints(variant) + if !ok { + return false + } + r.Nodes = []*AstNode{atRule("@media", "(width >= "+value+")", r.Nodes...)} + return true + }, vOpts{compounds: CompoundsAtRules, hasCompounds: true}) + }, func(a, z *Variant) int { return compareBP(a, z, "asc", resolvedBreakpoints) }) + + resolvedWidths := func(variant *Variant) (string, bool) { + if variant.Kind == varFunctional { + if variant.Value == nil { + return "", false + } + var value string + var ok bool + if variant.Value.Kind == vvArbitrary { + value, ok = variant.Value.Value, true + } else { + value, ok = theme.resolveValue(sptr(variant.Value.Value), []string{"--container"}) + } + if !ok || value == "" || strings.Contains(value, "var(") { + return "", false + } + return value, true + } + return "", false + } + + variants.group(func() { + variants.functional("@max", func(r *AstNode, variant *Variant) bool { + value, ok := resolvedWidths(variant) + if !ok { + return false + } + params := "(width < " + value + ")" + if variant.Modifier != nil { + params = variant.Modifier.Value + " " + params + } + r.Nodes = []*AstNode{atRule("@container", params, r.Nodes...)} + return true + }, vOpts{compounds: CompoundsAtRules, hasCompounds: true}) + }, func(a, z *Variant) int { return compareBP(a, z, "desc", resolvedWidths) }) + + variants.group(func() { + atFn := func(r *AstNode, variant *Variant) bool { + value, ok := resolvedWidths(variant) + if !ok { + return false + } + params := "(width >= " + value + ")" + if variant.Modifier != nil { + params = variant.Modifier.Value + " " + params + } + r.Nodes = []*AstNode{atRule("@container", params, r.Nodes...)} + return true + } + variants.functional("@", atFn, vOpts{compounds: CompoundsAtRules, hasCompounds: true}) + variants.functional("@min", atFn, vOpts{compounds: CompoundsAtRules, hasCompounds: true}) + }, func(a, z *Variant) int { return compareBP(a, z, "asc", resolvedWidths) }) +} + +func registerMediaVariants(variants *Variants) { + sv := func(name string, selectors ...string) { addStaticVariant(variants, name, selectors, vOpts{}) } + + sv("motion-safe", "@media (prefers-reduced-motion: no-preference)") + sv("motion-reduce", "@media (prefers-reduced-motion: reduce)") + sv("contrast-more", "@media (prefers-contrast: more)") + sv("contrast-less", "@media (prefers-contrast: less)") + sv("portrait", "@media (orientation: portrait)") + sv("landscape", "@media (orientation: landscape)") + sv("ltr", `&:where(:dir(ltr), [dir="ltr"], [dir="ltr"] *)`) + sv("rtl", `&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *)`) + sv("dark", "@media (prefers-color-scheme: dark)") + sv("starting", "@starting-style") + sv("print", "@media print") + sv("forced-colors", "@media (forced-colors: active)") + sv("inverted-colors", "@media (inverted-colors: inverted)") + sv("pointer-none", "@media (pointer: none)") + sv("pointer-coarse", "@media (pointer: coarse)") + sv("pointer-fine", "@media (pointer: fine)") + sv("any-pointer-none", "@media (any-pointer: none)") + sv("any-pointer-coarse", "@media (any-pointer: coarse)") + sv("any-pointer-fine", "@media (any-pointer: fine)") + sv("noscript", "@media (scripting: none)") +} + +// Port of packages/tailwindcss/src/walk.ts +// +// Depth-first AST traversal with enter/exit hooks that may continue, skip +// children, stop, or replace the current node. + +type walkKind int + +const ( + wkContinue walkKind = iota + wkSkip + wkStop + wkReplace + wkReplaceSkip + wkReplaceStop +) + +type WalkResult struct { + kind walkKind + nodes []*AstNode +} + +var ( + WContinue = WalkResult{kind: wkContinue} + WSkip = WalkResult{kind: wkSkip} + WStop = WalkResult{kind: wkStop} +) + +func WReplace(nodes ...*AstNode) WalkResult { return WalkResult{kind: wkReplace, nodes: nodes} } +func WReplaceSkip(nodes ...*AstNode) WalkResult { return WalkResult{kind: wkReplaceSkip, nodes: nodes} } +func WReplaceStop(nodes ...*AstNode) WalkResult { return WalkResult{kind: wkReplaceStop, nodes: nodes} } + +type VisitContext struct { + Parent *AstNode + Depth int + Index int + Siblings []*AstNode + ancestor []*AstNode +} + +func (c *VisitContext) Path() []*AstNode { + return append([]*AstNode{}, c.ancestor...) +} + +func spliceNodes(nodes *[]*AstNode, idx int, repl []*AstNode) { + s := *nodes + out := make([]*AstNode, 0, len(s)-1+len(repl)) + out = append(out, s[:idx]...) + out = append(out, repl...) + out = append(out, s[idx+1:]...) + *nodes = out +} + +type walkFn func(node *AstNode, ctx *VisitContext) WalkResult + +func walkAst(nodes *[]*AstNode, enter walkFn) { + walkAstImpl(nodes, nil, 0, nil, enter, nil) +} + +func walkAstEnterExit(nodes *[]*AstNode, enter, exit walkFn) { + walkAstImpl(nodes, nil, 0, nil, enter, exit) +} + +func walkAstImpl(nodes *[]*AstNode, parent *AstNode, depth int, ancestors []*AstNode, enter, exit walkFn) bool { + i := 0 + for i < len(*nodes) { + node := (*nodes)[i] + ctx := &VisitContext{Parent: parent, Depth: depth, Index: i, Siblings: *nodes, ancestor: ancestors} + + res := WContinue + if enter != nil { + res = enter(node, ctx) + } + + switch res.kind { + case wkStop: + return false + case wkReplaceStop: + spliceNodes(nodes, i, res.nodes) + return false + case wkReplace: + spliceNodes(nodes, i, res.nodes) + continue + case wkReplaceSkip: + spliceNodes(nodes, i, res.nodes) + i += len(res.nodes) + continue + case wkSkip: + case wkContinue: + if ch := nodeChildren(node); ch != nil && len(*ch) > 0 { + childAnc := append(append([]*AstNode{}, ancestors...), node) + if !walkAstImpl(ch, node, depth+1, childAnc, enter, exit) { + return false + } + } + } + + if exit != nil { + res2 := exit(node, ctx) + switch res2.kind { + case wkStop: + return false + case wkReplaceStop: + spliceNodes(nodes, i, res2.nodes) + return false + case wkReplace, wkReplaceSkip: + spliceNodes(nodes, i, res2.nodes) + i += len(res2.nodes) + continue + } + } + + i++ + } + return true +} diff --git a/bundler/tailwind_test.go b/bundler/tailwind_test.go new file mode 100644 index 00000000..e2c107bd --- /dev/null +++ b/bundler/tailwind_test.go @@ -0,0 +1,217 @@ +package bundler + +import ( + "strings" + "testing" +) + +func twTestCompile(t *testing.T, candidates ...string) string { + t.Helper() + css, _, err := twCompile(`@import "tailwindcss";`, ".", candidates) + if err != nil { + t.Fatalf("twCompile error: %v", err) + } + return css +} + +// Edge cases that the modifier/fraction rewrite fixes. +func TestEngineEdgeCases(t *testing.T) { + cases := []struct { + candidate string + contains string + }{ + // Improper fraction read as a fraction (not an opacity modifier). + {"aspect-16/9", "aspect-ratio: 16/9"}, + // Proper fraction. + {"w-1/2", "width: calc(1 / 2 * 100%)"}, + // Arbitrary opacity modifier decoded (not "[0.5]%"). + {"bg-white/[0.5]", "color-mix(in oklab, var(--color-white) 50%, transparent)"}, + // Bare spacing. + {"m-4", "margin: calc(var(--spacing) * 4)"}, + {"p-4", "padding: calc(var(--spacing) * 4)"}, + // text-{size}/{leading}: font-size AND line-height (the dropped-modifier fix). + {"text-sm/6", "line-height: calc(var(--spacing) * 6)"}, + // Theme color via @theme namespace. + {"bg-red-500", "background-color: var(--color-red-500)"}, + } + for _, c := range cases { + css := twTestCompile(t, c.candidate) + if !strings.Contains(css, c.contains) { + t.Errorf("compile(%q): expected to contain %q\n---\n%s", c.candidate, c.contains, css) + } + } +} + +func TestSegmentTopLevel(t *testing.T) { + cases := []struct { + in string + sep string + want []string + }{ + {"a:b:c", ":", []string{"a", "b", "c"}}, + {"var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)", ",", + []string{"var(--a, 0 0 1px rgb(0, 0, 0))", " 0 0 1px rgb(0, 0, 0)"}}, + {"display:grid", ":", []string{"display", "grid"}}, + {"[display:grid]", ":", []string{"[display:grid]"}}, + {"red-500/50", "/", []string{"red-500", "50"}}, + {"calc(1/2)/3", "/", []string{"calc(1/2)", "3"}}, + } + for _, c := range cases { + got := segment(c.in, c.sep) + if len(got) != len(c.want) { + t.Errorf("segment(%q,%q) = %v, want %v", c.in, c.sep, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("segment(%q,%q)[%d] = %q, want %q", c.in, c.sep, i, got[i], c.want[i]) + } + } + } +} + +func TestEscapeIdentifier(t *testing.T) { + cases := []struct{ in, want string }{ + {"flex", "flex"}, + {"hover:bg-red-500", `hover\:bg-red-500`}, + {"w-1/2", `w-1\/2`}, + {"bg-[#fff]", `bg-\[\#fff\]`}, + {"2xl", `\32 xl`}, + } + for _, c := range cases { + if got := escape(c.in); got != c.want { + t.Errorf("escape(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestDecodeArbitraryValue(t *testing.T) { + cases := []struct{ in, want string }{ + {"100%_!important", "100% !important"}, + {"calc(100dvh_-_5rem)", "calc(100dvh - 5rem)"}, + {`url(/a_b.png)`, "url(/a_b.png)"}, + {`var(--my_var)`, "var(--my_var)"}, + {"calc(var(--spacing)*4_+_env(safe-area-inset-bottom,0px))", + "calc(var(--spacing) * 4 + env(safe-area-inset-bottom,0px))"}, + } + for _, c := range cases { + if got := decodeArbitraryValue(c.in); got != c.want { + t.Errorf("decodeArbitraryValue(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestInferDataType(t *testing.T) { + cases := []struct { + value string + types []string + want string + }{ + {"#ff0000", []string{dtColor, dtLength}, dtColor}, + {"10rem", []string{dtColor, dtLength}, dtLength}, + {"50%", []string{dtLength, dtPercentage}, dtPercentage}, + {"16/9", []string{dtRatio, dtColor}, dtRatio}, + {"var(--x)", []string{dtColor, dtLength}, ""}, + {"calc(1px+2px)", []string{dtLength}, dtLength}, + {"red", []string{dtColor}, dtColor}, + } + for _, c := range cases { + if got := inferDataType(c.value, c.types); got != c.want { + t.Errorf("inferDataType(%q,%v) = %q, want %q", c.value, c.types, got, c.want) + } + } +} + +func TestNumericPredicates(t *testing.T) { + if !isValidSpacingMultiplier("0.5") { + t.Error("0.5 should be a valid spacing multiplier") + } + if isValidSpacingMultiplier("0.3") { + t.Error("0.3 should NOT be a valid spacing multiplier") + } + if !isPositiveInteger("3") || isPositiveInteger("3.0") || isPositiveInteger("03") { + t.Error("isPositiveInteger canonical-form check failed") + } +} + +// Apostrophes inside JS comments (e.g. "don't", "button's") must not desync the +// quote-based class scanner. Before comments were skipped, a stray apostrophe +// flipped quote parity and swallowed every class literal until the next quote — +// silently dropping singly-used utilities like the lineup card's switch and +// drag styles. +func TestExtractCandidatesSkipsComments(t *testing.T) { + src := "// we don't need reactivity here\n" + + "const cur = locked ? \"cursor-not-allowed\" : \"cursor-move\";\n" + + "/* the button's thumb: it's offset */\n" + + "const thumb = on ? \"translate-x-3\" : \"\";\n" + + "const tpl = `<input class=\"sr-only\"/>`;\n" + + got := map[string]bool{} + for _, c := range extractCandidates(src) { + got[c] = true + } + for _, want := range []string{"cursor-not-allowed", "cursor-move", "translate-x-3", "sr-only"} { + if !got[want] { + t.Errorf("candidate %q was not extracted past comment apostrophes", want) + } + } +} + +// scan returns the set of candidates extracted from src. +func scan(src string) map[string]bool { + got := map[string]bool{} + for _, c := range extractCandidates(src) { + got[c] = true + } + return got +} + +// Quotes inside a regex literal must not be read as a string (which would +// desync the scanner and drop following class literals), and a division `/` +// must not be mistaken for a regex (which would swallow the code after it). +func TestExtractCandidatesRegexLiterals(t *testing.T) { + cases := []struct { + name string + src string + want []string + }{ + { + "apostrophe in regex", + `const r = /it's/; const c = "cursor-move";`, + []string{"cursor-move"}, + }, + { + "regex after return keyword", + `function f(){ return /a"b/; } const c = "p-4";`, + []string{"p-4"}, + }, + { + "char class containing slash and quote", + `const r = /[/']/g; const c = "block";`, + []string{"block"}, + }, + { + "division is not a regex (string between divisions survives)", + `const w = a / 2; cls = "text-lg"; const h = b / 4; cls2 = "m-2";`, + []string{"text-lg", "m-2"}, + }, + { + "regex inside a template interpolation, class after it", + "const t = html`<a class=${x.replace(/'/g, \"\")}>${y ? \"p-5\" : \"p-6\"}</a>`;", + []string{"p-5", "p-6"}, + }, + { + "regex with braces in template interpolation keeps ${} balanced", + "const t = html`<a class=${s.match(/[{}]/) ? \"flex\" : \"hidden\"}></a>`;", + []string{"flex", "hidden"}, + }, + } + for _, tc := range cases { + got := scan(tc.src) + for _, w := range tc.want { + if !got[w] { + t.Errorf("%s: candidate %q not extracted (src=%q)", tc.name, w, tc.src) + } + } + } +} diff --git a/bundler/tw_preflight.css b/bundler/tw_preflight.css new file mode 100644 index 00000000..753e79ef --- /dev/null +++ b/bundler/tw_preflight.css @@ -0,0 +1,393 @@ +/* + 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) + 2. Remove default margins and padding + 3. Reset all borders. +*/ + +*, +::after, +::before, +::backdrop, +::file-selector-button { + box-sizing: border-box; /* 1 */ + margin: 0; /* 2 */ + padding: 0; /* 2 */ + border: 0 solid; /* 3 */ +} + +/* + 1. Use a consistent sensible line-height in all browsers. + 2. Prevent adjustments of font size after orientation changes in iOS. + 3. Use a more readable tab size. + 4. Use the user's configured `sans` font-family by default. + 5. Use the user's configured `sans` font-feature-settings by default. + 6. Use the user's configured `sans` font-variation-settings by default. + 7. Disable tap highlights on iOS. +*/ + +html, +:host { + line-height: 1.5; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + tab-size: 4; /* 3 */ + font-family: --theme( + --default-font-family, + ui-sans-serif, + system-ui, + sans-serif, + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol', + 'Noto Color Emoji' + ); /* 4 */ + font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */ + font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */ + -webkit-tap-highlight-color: transparent; /* 7 */ +} + +/* + 1. Add the correct height in Firefox. + 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) + 3. Reset the default border style to a 1px solid border. +*/ + +hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ + border-top-width: 1px; /* 3 */ +} + +/* + Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* + Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* + Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; +} + +/* + Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* + 1. Use the user's configured `mono` font-family by default. + 2. Use the user's configured `mono` font-feature-settings by default. + 3. Use the user's configured `mono` font-variation-settings by default. + 4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: --theme( + --default-mono-font-family, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + 'Liberation Mono', + 'Courier New', + monospace + ); /* 1 */ + font-feature-settings: --theme(--default-mono-font-feature-settings, normal); /* 2 */ + font-variation-settings: --theme(--default-mono-font-variation-settings, normal); /* 3 */ + font-size: 1em; /* 4 */ +} + +/* + Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* + Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* + 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) + 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) + 3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ + border-collapse: collapse; /* 3 */ +} + +/* + Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* + Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* + Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* + Make lists unstyled by default. +*/ + +ol, +ul, +menu { + list-style: none; +} + +/* + 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) + 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; /* 1 */ + vertical-align: middle; /* 2 */ +} + +/* + Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* + 1. Inherit font styles in all browsers. + 2. Remove border radius in all browsers. + 3. Remove background color in all browsers. + 4. Ensure consistent opacity for disabled states in all browsers. +*/ + +button, +input, +select, +optgroup, +textarea, +::file-selector-button { + font: inherit; /* 1 */ + font-feature-settings: inherit; /* 1 */ + font-variation-settings: inherit; /* 1 */ + letter-spacing: inherit; /* 1 */ + color: inherit; /* 1 */ + border-radius: 0; /* 2 */ + background-color: transparent; /* 3 */ + opacity: 1; /* 4 */ +} + +/* + Restore default font weight. +*/ + +:where(select:is([multiple], [size])) optgroup { + font-weight: bolder; +} + +/* + Restore indentation. +*/ + +:where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; +} + +/* + Restore space after button. +*/ + +::file-selector-button { + margin-inline-end: 4px; +} + +/* + Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +*/ + +::placeholder { + opacity: 1; +} + +/* + Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not + crash when using `color-mix(…)` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194) +*/ + +@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or + (contain-intrinsic-size: 1px) /* Safari 17+ */ { + ::placeholder { + color: color-mix(in oklab, currentcolor 50%, transparent); + } +} + +/* + Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* + Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* + 1. Ensure date/time inputs have the same height when empty in iOS Safari. + 2. Ensure text alignment can be changed on date/time inputs in iOS Safari. +*/ + +::-webkit-date-and-time-value { + min-height: 1lh; /* 1 */ + text-align: inherit; /* 2 */ +} + +/* + Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`. +*/ + +::-webkit-datetime-edit { + display: inline-flex; +} + +/* + Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers. +*/ + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-datetime-edit, +::-webkit-datetime-edit-year-field, +::-webkit-datetime-edit-month-field, +::-webkit-datetime-edit-day-field, +::-webkit-datetime-edit-hour-field, +::-webkit-datetime-edit-minute-field, +::-webkit-datetime-edit-second-field, +::-webkit-datetime-edit-millisecond-field, +::-webkit-datetime-edit-meridiem-field { + padding-block: 0; +} + +/* + Center dropdown marker shown on inputs with paired `<datalist>`s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499) +*/ + +::-webkit-calendar-picker-indicator { + line-height: 1; +} + +/* + Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* + Correct the inability to style the border radius in iOS Safari. +*/ + +button, +input:where([type='button'], [type='reset'], [type='submit']), +::file-selector-button { + appearance: button; +} + +/* + Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* + Make elements with the HTML hidden attribute stay hidden by default. +*/ + +[hidden]:where(:not([hidden='until-found'])) { + display: none !important; +} diff --git a/bundler/tw_theme.css b/bundler/tw_theme.css new file mode 100644 index 00000000..502f5c75 --- /dev/null +++ b/bundler/tw_theme.css @@ -0,0 +1,510 @@ +@theme default { + --font-sans: + ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; + --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + + --color-red-50: oklch(97.1% 0.013 17.38); + --color-red-100: oklch(93.6% 0.032 17.717); + --color-red-200: oklch(88.5% 0.062 18.334); + --color-red-300: oklch(80.8% 0.114 19.571); + --color-red-400: oklch(70.4% 0.191 22.216); + --color-red-500: oklch(63.7% 0.237 25.331); + --color-red-600: oklch(57.7% 0.245 27.325); + --color-red-700: oklch(50.5% 0.213 27.518); + --color-red-800: oklch(44.4% 0.177 26.899); + --color-red-900: oklch(39.6% 0.141 25.723); + --color-red-950: oklch(25.8% 0.092 26.042); + + --color-orange-50: oklch(98% 0.016 73.684); + --color-orange-100: oklch(95.4% 0.038 75.164); + --color-orange-200: oklch(90.1% 0.076 70.697); + --color-orange-300: oklch(83.7% 0.128 66.29); + --color-orange-400: oklch(75% 0.183 55.934); + --color-orange-500: oklch(70.5% 0.213 47.604); + --color-orange-600: oklch(64.6% 0.222 41.116); + --color-orange-700: oklch(55.3% 0.195 38.402); + --color-orange-800: oklch(47% 0.157 37.304); + --color-orange-900: oklch(40.8% 0.123 38.172); + --color-orange-950: oklch(26.6% 0.079 36.259); + + --color-amber-50: oklch(98.7% 0.022 95.277); + --color-amber-100: oklch(96.2% 0.059 95.617); + --color-amber-200: oklch(92.4% 0.12 95.746); + --color-amber-300: oklch(87.9% 0.169 91.605); + --color-amber-400: oklch(82.8% 0.189 84.429); + --color-amber-500: oklch(76.9% 0.188 70.08); + --color-amber-600: oklch(66.6% 0.179 58.318); + --color-amber-700: oklch(55.5% 0.163 48.998); + --color-amber-800: oklch(47.3% 0.137 46.201); + --color-amber-900: oklch(41.4% 0.112 45.904); + --color-amber-950: oklch(27.9% 0.077 45.635); + + --color-yellow-50: oklch(98.7% 0.026 102.212); + --color-yellow-100: oklch(97.3% 0.071 103.193); + --color-yellow-200: oklch(94.5% 0.129 101.54); + --color-yellow-300: oklch(90.5% 0.182 98.111); + --color-yellow-400: oklch(85.2% 0.199 91.936); + --color-yellow-500: oklch(79.5% 0.184 86.047); + --color-yellow-600: oklch(68.1% 0.162 75.834); + --color-yellow-700: oklch(55.4% 0.135 66.442); + --color-yellow-800: oklch(47.6% 0.114 61.907); + --color-yellow-900: oklch(42.1% 0.095 57.708); + --color-yellow-950: oklch(28.6% 0.066 53.813); + + --color-lime-50: oklch(98.6% 0.031 120.757); + --color-lime-100: oklch(96.7% 0.067 122.328); + --color-lime-200: oklch(93.8% 0.127 124.321); + --color-lime-300: oklch(89.7% 0.196 126.665); + --color-lime-400: oklch(84.1% 0.238 128.85); + --color-lime-500: oklch(76.8% 0.233 130.85); + --color-lime-600: oklch(64.8% 0.2 131.684); + --color-lime-700: oklch(53.2% 0.157 131.589); + --color-lime-800: oklch(45.3% 0.124 130.933); + --color-lime-900: oklch(40.5% 0.101 131.063); + --color-lime-950: oklch(27.4% 0.072 132.109); + + --color-green-50: oklch(98.2% 0.018 155.826); + --color-green-100: oklch(96.2% 0.044 156.743); + --color-green-200: oklch(92.5% 0.084 155.995); + --color-green-300: oklch(87.1% 0.15 154.449); + --color-green-400: oklch(79.2% 0.209 151.711); + --color-green-500: oklch(72.3% 0.219 149.579); + --color-green-600: oklch(62.7% 0.194 149.214); + --color-green-700: oklch(52.7% 0.154 150.069); + --color-green-800: oklch(44.8% 0.119 151.328); + --color-green-900: oklch(39.3% 0.095 152.535); + --color-green-950: oklch(26.6% 0.065 152.934); + + --color-emerald-50: oklch(97.9% 0.021 166.113); + --color-emerald-100: oklch(95% 0.052 163.051); + --color-emerald-200: oklch(90.5% 0.093 164.15); + --color-emerald-300: oklch(84.5% 0.143 164.978); + --color-emerald-400: oklch(76.5% 0.177 163.223); + --color-emerald-500: oklch(69.6% 0.17 162.48); + --color-emerald-600: oklch(59.6% 0.145 163.225); + --color-emerald-700: oklch(50.8% 0.118 165.612); + --color-emerald-800: oklch(43.2% 0.095 166.913); + --color-emerald-900: oklch(37.8% 0.077 168.94); + --color-emerald-950: oklch(26.2% 0.051 172.552); + + --color-teal-50: oklch(98.4% 0.014 180.72); + --color-teal-100: oklch(95.3% 0.051 180.801); + --color-teal-200: oklch(91% 0.096 180.426); + --color-teal-300: oklch(85.5% 0.138 181.071); + --color-teal-400: oklch(77.7% 0.152 181.912); + --color-teal-500: oklch(70.4% 0.14 182.503); + --color-teal-600: oklch(60% 0.118 184.704); + --color-teal-700: oklch(51.1% 0.096 186.391); + --color-teal-800: oklch(43.7% 0.078 188.216); + --color-teal-900: oklch(38.6% 0.063 188.416); + --color-teal-950: oklch(27.7% 0.046 192.524); + + --color-cyan-50: oklch(98.4% 0.019 200.873); + --color-cyan-100: oklch(95.6% 0.045 203.388); + --color-cyan-200: oklch(91.7% 0.08 205.041); + --color-cyan-300: oklch(86.5% 0.127 207.078); + --color-cyan-400: oklch(78.9% 0.154 211.53); + --color-cyan-500: oklch(71.5% 0.143 215.221); + --color-cyan-600: oklch(60.9% 0.126 221.723); + --color-cyan-700: oklch(52% 0.105 223.128); + --color-cyan-800: oklch(45% 0.085 224.283); + --color-cyan-900: oklch(39.8% 0.07 227.392); + --color-cyan-950: oklch(30.2% 0.056 229.695); + + --color-sky-50: oklch(97.7% 0.013 236.62); + --color-sky-100: oklch(95.1% 0.026 236.824); + --color-sky-200: oklch(90.1% 0.058 230.902); + --color-sky-300: oklch(82.8% 0.111 230.318); + --color-sky-400: oklch(74.6% 0.16 232.661); + --color-sky-500: oklch(68.5% 0.169 237.323); + --color-sky-600: oklch(58.8% 0.158 241.966); + --color-sky-700: oklch(50% 0.134 242.749); + --color-sky-800: oklch(44.3% 0.11 240.79); + --color-sky-900: oklch(39.1% 0.09 240.876); + --color-sky-950: oklch(29.3% 0.066 243.157); + + --color-blue-50: oklch(97% 0.014 254.604); + --color-blue-100: oklch(93.2% 0.032 255.585); + --color-blue-200: oklch(88.2% 0.059 254.128); + --color-blue-300: oklch(80.9% 0.105 251.813); + --color-blue-400: oklch(70.7% 0.165 254.624); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-blue-600: oklch(54.6% 0.245 262.881); + --color-blue-700: oklch(48.8% 0.243 264.376); + --color-blue-800: oklch(42.4% 0.199 265.638); + --color-blue-900: oklch(37.9% 0.146 265.522); + --color-blue-950: oklch(28.2% 0.091 267.935); + + --color-indigo-50: oklch(96.2% 0.018 272.314); + --color-indigo-100: oklch(93% 0.034 272.788); + --color-indigo-200: oklch(87% 0.065 274.039); + --color-indigo-300: oklch(78.5% 0.115 274.713); + --color-indigo-400: oklch(67.3% 0.182 276.935); + --color-indigo-500: oklch(58.5% 0.233 277.117); + --color-indigo-600: oklch(51.1% 0.262 276.966); + --color-indigo-700: oklch(45.7% 0.24 277.023); + --color-indigo-800: oklch(39.8% 0.195 277.366); + --color-indigo-900: oklch(35.9% 0.144 278.697); + --color-indigo-950: oklch(25.7% 0.09 281.288); + + --color-violet-50: oklch(96.9% 0.016 293.756); + --color-violet-100: oklch(94.3% 0.029 294.588); + --color-violet-200: oklch(89.4% 0.057 293.283); + --color-violet-300: oklch(81.1% 0.111 293.571); + --color-violet-400: oklch(70.2% 0.183 293.541); + --color-violet-500: oklch(60.6% 0.25 292.717); + --color-violet-600: oklch(54.1% 0.281 293.009); + --color-violet-700: oklch(49.1% 0.27 292.581); + --color-violet-800: oklch(43.2% 0.232 292.759); + --color-violet-900: oklch(38% 0.189 293.745); + --color-violet-950: oklch(28.3% 0.141 291.089); + + --color-purple-50: oklch(97.7% 0.014 308.299); + --color-purple-100: oklch(94.6% 0.033 307.174); + --color-purple-200: oklch(90.2% 0.063 306.703); + --color-purple-300: oklch(82.7% 0.119 306.383); + --color-purple-400: oklch(71.4% 0.203 305.504); + --color-purple-500: oklch(62.7% 0.265 303.9); + --color-purple-600: oklch(55.8% 0.288 302.321); + --color-purple-700: oklch(49.6% 0.265 301.924); + --color-purple-800: oklch(43.8% 0.218 303.724); + --color-purple-900: oklch(38.1% 0.176 304.987); + --color-purple-950: oklch(29.1% 0.149 302.717); + + --color-fuchsia-50: oklch(97.7% 0.017 320.058); + --color-fuchsia-100: oklch(95.2% 0.037 318.852); + --color-fuchsia-200: oklch(90.3% 0.076 319.62); + --color-fuchsia-300: oklch(83.3% 0.145 321.434); + --color-fuchsia-400: oklch(74% 0.238 322.16); + --color-fuchsia-500: oklch(66.7% 0.295 322.15); + --color-fuchsia-600: oklch(59.1% 0.293 322.896); + --color-fuchsia-700: oklch(51.8% 0.253 323.949); + --color-fuchsia-800: oklch(45.2% 0.211 324.591); + --color-fuchsia-900: oklch(40.1% 0.17 325.612); + --color-fuchsia-950: oklch(29.3% 0.136 325.661); + + --color-pink-50: oklch(97.1% 0.014 343.198); + --color-pink-100: oklch(94.8% 0.028 342.258); + --color-pink-200: oklch(89.9% 0.061 343.231); + --color-pink-300: oklch(82.3% 0.12 346.018); + --color-pink-400: oklch(71.8% 0.202 349.761); + --color-pink-500: oklch(65.6% 0.241 354.308); + --color-pink-600: oklch(59.2% 0.249 0.584); + --color-pink-700: oklch(52.5% 0.223 3.958); + --color-pink-800: oklch(45.9% 0.187 3.815); + --color-pink-900: oklch(40.8% 0.153 2.432); + --color-pink-950: oklch(28.4% 0.109 3.907); + + --color-rose-50: oklch(96.9% 0.015 12.422); + --color-rose-100: oklch(94.1% 0.03 12.58); + --color-rose-200: oklch(89.2% 0.058 10.001); + --color-rose-300: oklch(81% 0.117 11.638); + --color-rose-400: oklch(71.2% 0.194 13.428); + --color-rose-500: oklch(64.5% 0.246 16.439); + --color-rose-600: oklch(58.6% 0.253 17.585); + --color-rose-700: oklch(51.4% 0.222 16.935); + --color-rose-800: oklch(45.5% 0.188 13.697); + --color-rose-900: oklch(41% 0.159 10.272); + --color-rose-950: oklch(27.1% 0.105 12.094); + + --color-slate-50: oklch(98.4% 0.003 247.858); + --color-slate-100: oklch(96.8% 0.007 247.896); + --color-slate-200: oklch(92.9% 0.013 255.508); + --color-slate-300: oklch(86.9% 0.022 252.894); + --color-slate-400: oklch(70.4% 0.04 256.788); + --color-slate-500: oklch(55.4% 0.046 257.417); + --color-slate-600: oklch(44.6% 0.043 257.281); + --color-slate-700: oklch(37.2% 0.044 257.287); + --color-slate-800: oklch(27.9% 0.041 260.031); + --color-slate-900: oklch(20.8% 0.042 265.755); + --color-slate-950: oklch(12.9% 0.042 264.695); + + --color-gray-50: oklch(98.5% 0.002 247.839); + --color-gray-100: oklch(96.7% 0.003 264.542); + --color-gray-200: oklch(92.8% 0.006 264.531); + --color-gray-300: oklch(87.2% 0.01 258.338); + --color-gray-400: oklch(70.7% 0.022 261.325); + --color-gray-500: oklch(55.1% 0.027 264.364); + --color-gray-600: oklch(44.6% 0.03 256.802); + --color-gray-700: oklch(37.3% 0.034 259.733); + --color-gray-800: oklch(27.8% 0.033 256.848); + --color-gray-900: oklch(21% 0.034 264.665); + --color-gray-950: oklch(13% 0.028 261.692); + + --color-zinc-50: oklch(98.5% 0 0); + --color-zinc-100: oklch(96.7% 0.001 286.375); + --color-zinc-200: oklch(92% 0.004 286.32); + --color-zinc-300: oklch(87.1% 0.006 286.286); + --color-zinc-400: oklch(70.5% 0.015 286.067); + --color-zinc-500: oklch(55.2% 0.016 285.938); + --color-zinc-600: oklch(44.2% 0.017 285.786); + --color-zinc-700: oklch(37% 0.013 285.805); + --color-zinc-800: oklch(27.4% 0.006 286.033); + --color-zinc-900: oklch(21% 0.006 285.885); + --color-zinc-950: oklch(14.1% 0.005 285.823); + + --color-neutral-50: oklch(98.5% 0 0); + --color-neutral-100: oklch(97% 0 0); + --color-neutral-200: oklch(92.2% 0 0); + --color-neutral-300: oklch(87% 0 0); + --color-neutral-400: oklch(70.8% 0 0); + --color-neutral-500: oklch(55.6% 0 0); + --color-neutral-600: oklch(43.9% 0 0); + --color-neutral-700: oklch(37.1% 0 0); + --color-neutral-800: oklch(26.9% 0 0); + --color-neutral-900: oklch(20.5% 0 0); + --color-neutral-950: oklch(14.5% 0 0); + + --color-stone-50: oklch(98.5% 0.001 106.423); + --color-stone-100: oklch(97% 0.001 106.424); + --color-stone-200: oklch(92.3% 0.003 48.717); + --color-stone-300: oklch(86.9% 0.005 56.366); + --color-stone-400: oklch(70.9% 0.01 56.259); + --color-stone-500: oklch(55.3% 0.013 58.071); + --color-stone-600: oklch(44.4% 0.011 73.639); + --color-stone-700: oklch(37.4% 0.01 67.558); + --color-stone-800: oklch(26.8% 0.007 34.298); + --color-stone-900: oklch(21.6% 0.006 56.043); + --color-stone-950: oklch(14.7% 0.004 49.25); + + --color-mauve-50: oklch(98.5% 0 0); + --color-mauve-100: oklch(96% 0.003 325.6); + --color-mauve-200: oklch(92.2% 0.005 325.62); + --color-mauve-300: oklch(86.5% 0.012 325.68); + --color-mauve-400: oklch(71.1% 0.019 323.02); + --color-mauve-500: oklch(54.2% 0.034 322.5); + --color-mauve-600: oklch(43.5% 0.029 321.78); + --color-mauve-700: oklch(36.4% 0.029 323.89); + --color-mauve-800: oklch(26.3% 0.024 320.12); + --color-mauve-900: oklch(21.2% 0.019 322.12); + --color-mauve-950: oklch(14.5% 0.008 326); + + --color-olive-50: oklch(98.8% 0.003 106.5); + --color-olive-100: oklch(96.6% 0.005 106.5); + --color-olive-200: oklch(93% 0.007 106.5); + --color-olive-300: oklch(88% 0.011 106.6); + --color-olive-400: oklch(73.7% 0.021 106.9); + --color-olive-500: oklch(58% 0.031 107.3); + --color-olive-600: oklch(46.6% 0.025 107.3); + --color-olive-700: oklch(39.4% 0.023 107.4); + --color-olive-800: oklch(28.6% 0.016 107.4); + --color-olive-900: oklch(22.8% 0.013 107.4); + --color-olive-950: oklch(15.3% 0.006 107.1); + + --color-mist-50: oklch(98.7% 0.002 197.1); + --color-mist-100: oklch(96.3% 0.002 197.1); + --color-mist-200: oklch(92.5% 0.005 214.3); + --color-mist-300: oklch(87.2% 0.007 219.6); + --color-mist-400: oklch(72.3% 0.014 214.4); + --color-mist-500: oklch(56% 0.021 213.5); + --color-mist-600: oklch(45% 0.017 213.2); + --color-mist-700: oklch(37.8% 0.015 216); + --color-mist-800: oklch(27.5% 0.011 216.9); + --color-mist-900: oklch(21.8% 0.008 223.9); + --color-mist-950: oklch(14.8% 0.004 228.8); + + --color-taupe-50: oklch(98.6% 0.002 67.8); + --color-taupe-100: oklch(96% 0.002 17.2); + --color-taupe-200: oklch(92.2% 0.005 34.3); + --color-taupe-300: oklch(86.8% 0.007 39.5); + --color-taupe-400: oklch(71.4% 0.014 41.2); + --color-taupe-500: oklch(54.7% 0.021 43.1); + --color-taupe-600: oklch(43.8% 0.017 39.3); + --color-taupe-700: oklch(36.7% 0.016 35.7); + --color-taupe-800: oklch(26.8% 0.011 36.5); + --color-taupe-900: oklch(21.4% 0.009 43.1); + --color-taupe-950: oklch(14.7% 0.004 49.3); + + --color-black: #000; + --color-white: #fff; + + --spacing: 0.25rem; + + --breakpoint-sm: 40rem; + --breakpoint-md: 48rem; + --breakpoint-lg: 64rem; + --breakpoint-xl: 80rem; + --breakpoint-2xl: 96rem; + + --container-3xs: 16rem; + --container-2xs: 18rem; + --container-xs: 20rem; + --container-sm: 24rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-xl: 36rem; + --container-2xl: 42rem; + --container-3xl: 48rem; + --container-4xl: 56rem; + --container-5xl: 64rem; + --container-6xl: 72rem; + --container-7xl: 80rem; + + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; + --text-6xl: 3.75rem; + --text-6xl--line-height: 1; + --text-7xl: 4.5rem; + --text-7xl--line-height: 1; + --text-8xl: 6rem; + --text-8xl--line-height: 1; + --text-9xl: 8rem; + --text-9xl--line-height: 1; + + --font-weight-thin: 100; + --font-weight-extralight: 200; + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --font-weight-extrabold: 800; + --font-weight-black: 900; + + --tracking-tighter: -0.05em; + --tracking-tight: -0.025em; + --tracking-normal: 0em; + --tracking-wide: 0.025em; + --tracking-wider: 0.05em; + --tracking-widest: 0.1em; + + --leading-tight: 1.25; + --leading-snug: 1.375; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --leading-loose: 2; + + --radius-xs: 0.125rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-3xl: 1.5rem; + --radius-4xl: 2rem; + + --shadow-2xs: 0 1px rgb(0 0 0 / 0.05); + --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + + --inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05); + --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05); + --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05); + + --drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05); + --drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15); + --drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12); + --drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15); + --drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1); + --drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15); + + --text-shadow-2xs: 0px 1px 0px rgb(0 0 0 / 0.15); + --text-shadow-xs: 0px 1px 1px rgb(0 0 0 / 0.2); + --text-shadow-sm: + 0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), 0px 2px 2px rgb(0 0 0 / 0.075); + --text-shadow-md: + 0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), 0px 2px 4px rgb(0 0 0 / 0.1); + --text-shadow-lg: + 0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), 0px 4px 8px rgb(0 0 0 / 0.1); + + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + + --animate-spin: spin 1s linear infinite; + --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; + --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-bounce: bounce 1s infinite; + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + @keyframes ping { + 75%, + 100% { + transform: scale(2); + opacity: 0; + } + } + + @keyframes pulse { + 50% { + opacity: 0.5; + } + } + + @keyframes bounce { + 0%, + 100% { + transform: translateY(-25%); + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + } + + 50% { + transform: none; + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } + } + + --blur-xs: 4px; + --blur-sm: 8px; + --blur-md: 12px; + --blur-lg: 16px; + --blur-xl: 24px; + --blur-2xl: 40px; + --blur-3xl: 64px; + + --perspective-dramatic: 100px; + --perspective-near: 300px; + --perspective-normal: 500px; + --perspective-midrange: 800px; + --perspective-distant: 1200px; + + --aspect-video: 16 / 9; + + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: --theme(--font-sans, initial); + --default-font-feature-settings: --theme(--font-sans--font-feature-settings, initial); + --default-font-variation-settings: --theme(--font-sans--font-variation-settings, initial); + --default-mono-font-family: --theme(--font-mono, initial); + --default-mono-font-feature-settings: --theme(--font-mono--font-feature-settings, initial); + --default-mono-font-variation-settings: --theme(--font-mono--font-variation-settings, initial); +} + +/* Deprecated */ +@theme default inline reference { + --blur: 8px; + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05); + --drop-shadow: 0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06); + --radius: 0.25rem; + --max-width-prose: 65ch; +} diff --git a/bundler/vendor_plugins.go b/bundler/vendor_plugins.go new file mode 100644 index 00000000..16a08ffb --- /dev/null +++ b/bundler/vendor_plugins.go @@ -0,0 +1,111 @@ +package bundler + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + esbuild "github.com/evanw/esbuild/pkg/api" +) + +// VendorManifest mirrors frontend/vendor/vendor.json. +type VendorManifest struct { + Entrypoints map[string]string `json:"entrypoints"` +} + +// loadVendorManifest reads frontend/vendor/vendor.json — the single place that +// declares the exact entrypoint file for every bundled vendored package. +func loadVendorManifest(vendorDir string) (map[string]string, error) { + data, err := os.ReadFile(filepath.Join(vendorDir, "vendor.json")) + if err != nil { + return nil, err + } + var m VendorManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parsing vendor.json: %w", err) + } + return m.Entrypoints, nil +} + +// vendorManifestPlugin resolves bare imports from the vendor manifest: a package +// listed in vendor.json resolves to its pinned entrypoint file, bypassing the +// package's own `exports` map (which mis-resolves — e.g. solid-js's bare core to +// its SSR build, where effects are no-ops). Subpath imports of a vendored package +// (e.g. pdfjs-dist/build/pdf.worker.min.mjs) resolve to the real file via +// NodePaths; anything not vendored is left external for the import map. +func vendorManifestPlugin(vendorDir string, entrypoints map[string]string) esbuild.Plugin { + abs := make(map[string]string, len(entrypoints)) + for spec, rel := range entrypoints { + p, _ := filepath.Abs(filepath.Join(vendorDir, filepath.FromSlash(rel))) + abs[spec] = filepath.ToSlash(p) + } + return esbuild.Plugin{ + Name: "vendor-manifest", + Setup: func(build esbuild.PluginBuild) { + // Bare specifiers only (relative/absolute/entry/?suffixed are skipped). + build.OnResolve(esbuild.OnResolveOptions{Filter: `^[^./]`}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) { + if args.Kind == esbuild.ResolveEntryPoint || filepath.IsAbs(args.Path) || strings.HasPrefix(args.Path, ".") || strings.ContainsRune(args.Path, '?') { + return esbuild.OnResolveResult{}, nil + } + if target, ok := abs[args.Path]; ok { + return esbuild.OnResolveResult{Path: target}, nil // pinned entrypoint + } + if vendorHasPackage(vendorDir, args.Path) { + return esbuild.OnResolveResult{}, nil // subpath of a vendored pkg -> NodePaths + } + return esbuild.OnResolveResult{External: true}, nil // not vendored -> import map + }) + }, + } +} + +// vendorHasPackage reports whether a bare specifier maps to a package directory +// under vendorDir, honouring @scope/name. +func vendorHasPackage(vendorDir, spec string) bool { + parts := strings.Split(spec, "/") + pkg := parts[0] + if strings.HasPrefix(pkg, "@") && len(parts) > 1 { + pkg = pkg + "/" + parts[1] + } + info, err := os.Stat(filepath.Join(vendorDir, filepath.FromSlash(pkg))) + return err == nil && info.IsDir() +} + +// assetURLPlugin implements a generic `import url from "<path>?url"`: the +// referenced file is emitted as a build asset (via esbuild's file loader, +// controlled by BuildOptions.AssetNames/PublicPath) and the import resolves to +// its served URL. Used for resources that must stay separate files rather than +// be inlined — e.g. the pdfjs Web Worker, which relies on import.meta.url and so +// can't run from a Blob. The asset path lives in the app code that needs it, not +// in the bundler. +func assetURLPlugin() esbuild.Plugin { + const ns = "asset-url" + return esbuild.Plugin{ + Name: "asset-url", + Setup: func(build esbuild.PluginBuild) { + build.OnResolve(esbuild.OnResolveOptions{Filter: `\?url$`}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) { + real := strings.TrimSuffix(args.Path, "?url") + r := build.Resolve(real, esbuild.ResolveOptions{ + ResolveDir: args.ResolveDir, + Importer: args.Importer, + Kind: args.Kind, + }) + if len(r.Errors) > 0 { + return esbuild.OnResolveResult{}, fmt.Errorf("asset-url: cannot resolve %q: %s", real, r.Errors[0].Text) + } + return esbuild.OnResolveResult{Path: r.Path, Namespace: ns}, nil + }) + build.OnLoad(esbuild.OnLoadOptions{Filter: `.*`, Namespace: ns}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) { + data, err := os.ReadFile(args.Path) + if err != nil { + return esbuild.OnLoadResult{}, err + } + contents := string(data) + loader := esbuild.LoaderFile + return esbuild.OnLoadResult{Contents: &contents, Loader: loader}, nil + }) + }, + } +} diff --git a/chrono/chrono.go b/chrono/chrono.go new file mode 100644 index 00000000..720df402 --- /dev/null +++ b/chrono/chrono.go @@ -0,0 +1,391 @@ +// This package provides functionality for reading, writing, and converting time +// Time is confusing and sucks because of timezones, format strings, daylight savings, etc. +// In reality we only care about the UTC representation of a datetime. +// +// Ex: +// If we say "something happened *now*", `now` is an absolute measurement. +// You don't to care about format strings, daylight savings, or timezones to store it. +// +// For this reason, we store and manipulate ALL date/time values as UTC, and only +// convert to the approprate timezone when presenting the output to the user. +// The offset and format we use to display to the user can be provided + +// !IMPORTANTE! +// IF ANY TIMEZONE CONVERSION FAILS DUE TO A MALFORMED TIMEZONE STRING, +// THE OUTPUT WILL DEFAULT TO UTC. IT IS THE PROGRAMMER'S RESPONSIBILITY +// TO PREVENT USERS FROM INPUTTING A RAW TIMEZONE STRING. +// +// INSTEAD, USE A STRUCTURED SELECT FORM INPUT THAT CAN ONLY RETURN +// A VALID TIMEZONE STRING, OR CONVERT IT FROM ANOTHER INPUT SUCH AS A 'STATE' ABBREVIATION. +package chrono + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + _ "time/tzdata" +) + +// In addition to the constants provided by the time package (https://pkg.go.dev/time#Layout), +// here are some more useful formatting constants. +const ( + MDYDateTime12Hour = "2006/01/02 03:04:05 PM" + MDYDateTime12HourMinute = "2006/01/02 03:04 PM" + DayMonDYDateTime12HourMinute = "Mon, Jan 02 2006 3:04 PM" + MDYDateOnly = "01/02/2006" + MDYDateOnlyShort = "01/02/06" + HTMLDateTime = "2006-01-02T15:04" + TimeOnly12Hour = "03:04 PM" + MonthYear = "January 2006" +) + +// @TODO Possibly refactor below functions to wrap around this one (although maybe there are too many func calls) +func FormatWithTz(utcTime time.Time, timezone string, format string) string { + loc, locErr := time.LoadLocation(timezone) + if locErr != nil { + loc, _ = time.LoadLocation("UTC") + } + + return utcTime.In(loc).Format(format) +} + +// Take in a datetime-local from HTML form. The timezone is set on the users identity. +// Returns time.Time struct in **UTC** +func HTMLDatetimeLocalToTime(datetimelocal string, timezone string) time.Time { + loc, locErr := time.LoadLocation(timezone) + if locErr != nil { + loc, _ = time.LoadLocation("UTC") + } + + t, _ := time.ParseInLocation(HTMLDateTime, datetimelocal, loc) + + return t.UTC() +} + +// Take in a date from an HTML form. The timezone of this input depends on what the user set on their identity. +// Returns time.Time struct in **UTC** +func HTMLDateToTime(date string, timezone string) time.Time { + loc, locErr := time.LoadLocation(timezone) + if locErr != nil { + loc, _ = time.LoadLocation("UTC") + } + + t, _ := time.ParseInLocation(time.DateOnly, date, loc) + + return t.UTC() +} + +// Convert a time with timezone 'UTC' to the input timezone +// and output as a formatted string. +func TimeToString(utcTime time.Time, timezone string) string { + loc, locErr := time.LoadLocation(timezone) + if locErr != nil { + loc, _ = time.LoadLocation("UTC") + } + + return utcTime.In(loc).Format(TimeOnly12Hour) +} + +// Convert a datetime with timezone 'UTC' to the input timezone +// and output as a formatted string. +func DateTimeToString(utcTime time.Time, timezone string) string { + loc, locErr := time.LoadLocation(timezone) + if locErr != nil { + loc, _ = time.LoadLocation("UTC") + } + + return utcTime.In(loc).Format(MDYDateTime12HourMinute) +} + +// Convert a date with timezone 'UTC' to the input timezone +// and output as a formatted string. +func DateToString(utcTime time.Time, timezone string) string { + loc, locErr := time.LoadLocation(timezone) + if locErr != nil { + loc, _ = time.LoadLocation("UTC") + } + + return utcTime.In(loc).Format(MDYDateOnlyShort) +} + +func TimeSinceToString(utcTime time.Time, timezone string) string { + result := TimeElapsedToString(utcTime, time.Now(), timezone) + if result == "Never" || result == "Just now" { + return result + } + return result + " ago" +} + +// Convert datetimes with timezone 'UTC' to the input timezone +// and output the time that has elapsed since the two input datetimes. +func TimeElapsedToString(utcTimeStart time.Time, utcTimeEnd time.Time, timezone string) string { + if utcTimeStart.IsZero() || utcTimeEnd.IsZero() { + return "Never" + } + + loc, locErr := time.LoadLocation(timezone) + if locErr != nil { + loc, _ = time.LoadLocation("UTC") + } + + diff := utcTimeEnd.Sub(utcTimeStart.In(loc)) + + seconds := int(diff.Seconds()) + minutes := int(diff.Minutes()) + hours := int(diff.Hours()) + days := int(diff.Hours() / 24) + weeks := days / 7 + months := days / 30 + years := days / 365 + + if years > 0 { + if years == 1 { + return "1 year" + } + return fmt.Sprintf("%d years", years) + } else if months > 0 { + if months == 1 { + return "1 month" + } + return fmt.Sprintf("%d months", months) + } else if weeks > 0 { + if weeks == 1 { + return "1 week" + } + return fmt.Sprintf("%d weeks", weeks) + } else if days > 0 { + if days == 1 { + return "1 day" + } + return fmt.Sprintf("%d days", days) + } else if hours > 0 { + if hours == 1 { + return "1 hour" + } + return fmt.Sprintf("%d hours", hours) + } else if minutes > 0 { + if minutes == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", minutes) + } else { + if seconds <= 1 { + return "Just now" + } + return fmt.Sprintf("%d seconds", seconds) + } +} + +// Convert a date with timezone 'UTC' to input timezone +// and output as formatting string, suitable for use in HTML forms +func DateToHTMLString(utcTime time.Time, timezone string) string { + loc, locErr := time.LoadLocation(timezone) + if locErr != nil { + loc, _ = time.LoadLocation("UTC") + } + + return utcTime.In(loc).Format(time.DateOnly) +} + +func DatetimeToHTMLString(utcTime time.Time, timezone string) string { + loc, locErr := time.LoadLocation(timezone) + if locErr != nil { + loc, _ = time.LoadLocation("UTC") + } + + return utcTime.In(loc).Format(HTMLDateTime) +} + +var stateTimezones = map[string]string{ + "AL": "America/Chicago", // Alabama + "AK": "America/Anchorage", // Alaska (main timezone) + "AZ": "America/Phoenix", // Arizona + "AR": "America/Chicago", // Arkansas + "CA": "America/Los_Angeles", // California + "CO": "America/Denver", // Colorado + "CT": "America/New_York", // Connecticut + "DE": "America/New_York", // Delaware + "FL": "America/New_York", // Florida (most of state) + "GA": "America/New_York", // Georgia + "HI": "Pacific/Honolulu", // Hawaii + "ID": "America/Boise", // Idaho (most of state) + "IL": "America/Chicago", // Illinois + "IN": "America/New_York", // Indiana (most of state) + "IA": "America/Chicago", // Iowa + "KS": "America/Chicago", // Kansas (most of state) + "KY": "America/New_York", // Kentucky (most of state) + "LA": "America/Chicago", // Louisiana + "ME": "America/New_York", // Maine + "MD": "America/New_York", // Maryland + "MA": "America/New_York", // Massachusetts + "MI": "America/Detroit", // Michigan (most of state) + "MN": "America/Chicago", // Minnesota + "MS": "America/Chicago", // Mississippi + "MO": "America/Chicago", // Missouri + "MT": "America/Denver", // Montana + "NE": "America/Chicago", // Nebraska (most of state) + "NV": "America/Los_Angeles", // Nevada (most of state) + "NH": "America/New_York", // New Hampshire + "NJ": "America/New_York", // New Jersey + "NM": "America/Denver", // New Mexico + "NY": "America/New_York", // New York + "NC": "America/New_York", // North Carolina + "ND": "America/Chicago", // North Dakota (most of state) + "OH": "America/New_York", // Ohio + "OK": "America/Chicago", // Oklahoma + "OR": "America/Los_Angeles", // Oregon (most of state) + "PA": "America/New_York", // Pennsylvania + "RI": "America/New_York", // Rhode Island + "SC": "America/New_York", // South Carolina + "SD": "America/Chicago", // South Dakota (most of state) + "TN": "America/Chicago", // Tennessee (most of state) + "TX": "America/Chicago", // Texas (most of state) + "UT": "America/Denver", // Utah + "VT": "America/New_York", // Vermont + "VA": "America/New_York", // Virginia + "WA": "America/Los_Angeles", // Washington + "WV": "America/New_York", // West Virginia + "WI": "America/Chicago", // Wisconsin + "WY": "America/Denver", // Wyoming +} + +// Convert 2 character US state abbreviation to valid timezone +func StateCodeToTimezone(stateCode string) (string, error) { + // Convert input to uppercase to handle case variations + stateCode = strings.ToUpper(stateCode) + + // Look up timezone + timezone, exists := stateTimezones[stateCode] + if !exists { + return "", fmt.Errorf("invalid or unknown state code: %s", stateCode) + } + + return timezone, nil +} + +// ConvertTimeToMilliseconds converts a time string in HH:MM:SS.MS format to milliseconds. +// Returns an error if the input format is invalid. +func ConvertTimeStringToMilliseconds(timeStr string) (int64, error) { + // Split on colon first + mainParts := strings.Split(timeStr, ":") + if len(mainParts) != 3 { + return 0, errors.New("invalid time format, expected HH:MM:SS.MS") + } + + // Split the last part on decimal point for seconds and milliseconds + secMsParts := strings.Split(mainParts[2], ".") + if len(secMsParts) != 2 { + return 0, errors.New("invalid time format, expected decimal point for milliseconds") + } + + hours, err := strconv.ParseInt(mainParts[0], 10, 64) + if err != nil || hours < 0 { + return 0, errors.New("invalid hours value") + } + + minutes, err := strconv.ParseInt(mainParts[1], 10, 64) + if err != nil || minutes < 0 || minutes > 59 { + return 0, errors.New("invalid minutes value") + } + + seconds, err := strconv.ParseInt(secMsParts[0], 10, 64) + if err != nil || seconds < 0 || seconds > 59 { + return 0, errors.New("invalid seconds value") + } + + // Pad milliseconds to ensure 3 digits (e.g., "7" becomes "700") + msStr := secMsParts[1] + if len(msStr) > 3 { + return 0, errors.New("milliseconds must be 3 digits or less") + } + msStr = msStr + strings.Repeat("0", 3-len(msStr)) + milliseconds, err := strconv.ParseInt(msStr, 10, 64) + if err != nil || milliseconds < 0 || milliseconds > 999 { + return 0, errors.New("invalid milliseconds value") + } + + totalMs := hours*3600*1000 + minutes*60*1000 + seconds*1000 + milliseconds + return totalMs, nil +} + +// ConvertMillisecondsToTime converts milliseconds to a time string in HH:MM:SS.MS format. +// Returns an error if the input is negative. +func ConvertMillisecondsToTimeString(milliseconds int64) (string, error) { + if milliseconds < 0 { + return "", errors.New("milliseconds cannot be negative") + } + + hours := milliseconds / (3600 * 1000) + milliseconds %= 3600 * 1000 + minutes := milliseconds / (60 * 1000) + milliseconds %= 60 * 1000 + seconds := milliseconds / 1000 + milliseconds %= 1000 + + return fmt.Sprintf("%02d:%02d:%02d.%03d", hours, minutes, seconds, milliseconds), nil +} + +func ConvertMillisecondsToTimeStringNoErr(milliseconds int64) string { + out, _ := ConvertMillisecondsToTimeString(milliseconds) + return out +} + +// formatTimeSince returns a human-readable "time since" string +func TimeSince(t time.Time) string { + if t.IsZero() { + return "Never" + } + + duration := time.Since(t) + + if duration < time.Minute { + return "Just now" + } + + if duration < time.Hour { + minutes := int(duration.Minutes()) + if minutes == 1 { + return "1 minute ago" + } + return makeReadableDuration(minutes, "minute") + } + + if duration < 24*time.Hour { + hours := int(duration.Hours()) + if hours == 1 { + return "1 hour ago" + } + return makeReadableDuration(hours, "hour") + } + + if duration < 30*24*time.Hour { + days := int(duration.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return makeReadableDuration(days, "day") + } + + if duration < 365*24*time.Hour { + months := int(duration.Hours() / 24 / 30) + if months == 1 { + return "1 month ago" + } + return makeReadableDuration(months, "month") + } + + years := int(duration.Hours() / 24 / 365) + if years == 1 { + return "1 year ago" + } + return makeReadableDuration(years, "year") +} + +func makeReadableDuration(value int, unit string) string { + if value == 1 { + return "1 " + unit + " ago" + } + return fmt.Sprintf("%d %ss ago", value, unit) +} diff --git a/chrono/chrono_test.go b/chrono/chrono_test.go new file mode 100644 index 00000000..22720e7f --- /dev/null +++ b/chrono/chrono_test.go @@ -0,0 +1,86 @@ +package chrono + +import ( + "testing" + "time" +) + +// This tests the TimeElapsedToString function and the TimeSinceToString function that implements it +func TestTimeElapsedToString(t *testing.T) { + tests := []struct { + startInput time.Time + endInput time.Time + expected string + }{ + {time.Time{}, + time.Date(2025, time.June, 27, 11, 0, 0, 0, time.UTC), + "Never"}, + {time.Date(2025, time.June, 27, 11, 0, 0, 0, time.UTC), + time.Time{}, + "Never"}, + {time.Date(2025, time.June, 27, 11, 0, 0, 0, time.UTC), + time.Date(2025, time.June, 27, 11, 0, 0, 999999999, time.UTC), + "Just now"}, + {time.Date(2025, time.June, 27, 11, 0, 0, 0, time.UTC), + time.Date(2025, time.June, 27, 11, 0, 0, 1, time.UTC), + "Just now"}, + {time.Date(2025, time.June, 27, 11, 0, 0, 0, time.UTC), + time.Date(2025, time.June, 27, 11, 1, 0, 1, time.UTC), + "1 minute"}, + {time.Date(2025, time.June, 27, 11, 0, 0, 0, time.UTC), + time.Date(2025, time.June, 27, 11, 3, 0, 1, time.UTC), + "3 minutes"}, + {time.Date(2025, time.June, 27, 11, 0, 0, 0, time.UTC), + time.Date(2025, time.June, 27, 12, 0, 0, 1, time.UTC), + "1 hour"}, + {time.Date(2025, time.June, 27, 11, 0, 0, 0, time.UTC), + time.Date(2025, time.June, 27, 13, 0, 0, 1, time.UTC), + "2 hours"}, + {time.Date(2025, time.June, 27, 11, 0, 0, 0, time.UTC), + time.Date(2025, time.June, 30, 11, 0, 0, 1, time.UTC), + "3 days"}, + {time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.January, 8, 1, 0, 0, 1, time.UTC), + "1 week"}, + {time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.January, 15, 1, 0, 0, 1, time.UTC), + "2 weeks"}, + {time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.February, 7, 0, 0, 0, 1, time.UTC), + "1 month"}, + {time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.March, 15, 0, 0, 0, 1, time.UTC), + "2 months"}, + {time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + time.Date(2026, time.January, 1, 0, 0, 0, 1, time.UTC), + "1 year"}, + {time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC), + time.Date(2025, time.January, 1, 0, 0, 0, 1, time.UTC), + "5 years"}, + } + + for _, tt := range tests { + result := TimeElapsedToString(tt.startInput, tt.endInput, "UTC") + if result != tt.expected { + t.Errorf("TimeElapsedToString(%q, %q, \"UTC\") = %q; want %q", tt.startInput, tt.endInput, result, tt.expected) + } + } + + sinceTests := []struct { + timeInput time.Time + expected string + }{ + {time.Now().Add(-1 * time.Minute), "1 minute ago"}, + {time.Now().Add(-2 * time.Hour), "2 hours ago"}, + {time.Now().Add(-3 * time.Hour * 24), "3 days ago"}, + {time.Now(), "Just now"}, + {time.Now().Add(-5 * 7 * 24 * time.Hour), "1 month ago"}, + } + + for _, tt := range sinceTests { + result := TimeSinceToString(tt.timeInput, "UTC") + if result != tt.expected { + t.Errorf("TimeSinceToString(%q, \"UTC\") = %q; want %q", tt.timeInput, result, tt.expected) + } + } +} diff --git a/cmd/bundle/main.go b/cmd/bundle/main.go new file mode 100644 index 00000000..27f214e2 --- /dev/null +++ b/cmd/bundle/main.go @@ -0,0 +1,18 @@ +package main + +// Thin CLI wrapper around kjol/bundler. The bundler wires its own Go-native +// Solid JSX compiler (see bundler.Build), so this wrapper carries no build logic. + +import ( + "fmt" + "os" + + "kjol/bundler" +) + +func main() { + if err := bundler.Build(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/loc/main.go b/cmd/loc/main.go new file mode 100644 index 00000000..48ec83dd --- /dev/null +++ b/cmd/loc/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/hhatto/gocloc" +) + +func main() { + out, err := exec.Command("git", "ls-files").Output() + if err != nil { + fmt.Fprintf(os.Stderr, "git ls-files: %v\n", err) + os.Exit(1) + } + + allFiles := strings.Split(strings.TrimSpace(string(out)), "\n") + + var files []string + for _, f := range allFiles { + f = strings.TrimSpace(f) + if f == "" { + continue + } + norm := filepath.ToSlash(f) + if strings.HasPrefix(norm, "vendor/") || strings.Contains(norm, "/vendor/") { + continue + } + files = append(files, f) + } + + opts := gocloc.NewClocOptions() + langs := gocloc.NewDefinedLanguages() + + // gocloc maps "TypeScript" instead of "ts" in its Exts table + extAliases := map[string]string{ + "ts": "TypeScript", + } + + total := gocloc.NewLanguage("TOTAL", []string{}, [][]string{{"", ""}}) + languages := make(map[string]*gocloc.Language) + clocFiles := make(map[string]*gocloc.ClocFile) + + for _, file := range files { + file = strings.TrimSpace(file) + if file == "" { + continue + } + + ext := filepath.Ext(file) + if ext == "" { + continue + } + ext = ext[1:] + + langName, ok := gocloc.Exts[ext] + if !ok { + langName, ok = extAliases[ext] + if !ok { + continue + } + } + + def := langs.Langs[langName] + if def == nil { + continue + } + + cf := gocloc.AnalyzeFile(file, def, opts) + cf.Lang = langName + clocFiles[file] = cf + + if _, exists := languages[langName]; !exists { + languages[langName] = gocloc.NewLanguage(def.Name, []string{}, [][]string{{"", ""}}) + } + lang := languages[langName] + lang.Files = append(lang.Files, file) + lang.Code += cf.Code + lang.Comments += cf.Comments + lang.Blanks += cf.Blanks + + total.Code += cf.Code + total.Comments += cf.Comments + total.Blanks += cf.Blanks + } + + type row struct { + Name string + Files int + Code int32 + Comments int32 + Blanks int32 + } + + var rows []row + for name, lang := range languages { + rows = append(rows, row{ + Name: name, + Files: len(lang.Files), + Code: lang.Code, + Comments: lang.Comments, + Blanks: lang.Blanks, + }) + } + sort.Slice(rows, func(i, j int) bool { + return rows[i].Code > rows[j].Code + }) + + divider := "-------------------------------------------------------------------------------" + fmt.Println(divider) + fmt.Printf("%-25s %10s %10s %10s %10s\n", "Language", "Files", "Code", "Comment", "Blank") + fmt.Println(divider) + for _, r := range rows { + fmt.Printf("%-25s %10d %10d %10d %10d\n", r.Name, r.Files, r.Code, r.Comments, r.Blanks) + } + fmt.Println(divider) + fmt.Printf("%-25s %10d %10d %10d %10d\n", + "Total", + len(clocFiles), + total.Code, + total.Comments, + total.Blanks, + ) + fmt.Println(divider) +} diff --git a/cmd/migrate/database.go b/cmd/migrate/database.go new file mode 100644 index 00000000..7b4c8376 --- /dev/null +++ b/cmd/migrate/database.go @@ -0,0 +1,380 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "hash/crc32" + "os" + "strconv" + "strings" + + "github.com/lib/pq" +) + +const ( + nilVersion int = -1 + migrationsTable = "schema_migrations" + advisoryLockIDSalt uint = 1486364155 +) + +type migrateDB struct { + conn *sql.Conn + db *sql.DB + schemaName string + dbName string + lockID string + isLocked bool +} + +func openDatabase(connStr string, schemaName string) (*migrateDB, error) { + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + conn, err := db.Conn(context.Background()) + if err != nil { + db.Close() + return nil, fmt.Errorf("failed to acquire connection: %w", err) + } + + mdb := &migrateDB{ + conn: conn, + db: db, + } + + // get actual database name + if err := conn.QueryRowContext(context.Background(), "SELECT current_database()").Scan(&mdb.dbName); err != nil { + mdb.close() + return nil, fmt.Errorf("failed to get database name: %w", err) + } + + // determine schema name + if schemaName != "" { + mdb.schemaName = schemaName + } else { + if err := conn.QueryRowContext(context.Background(), "SELECT current_schema()").Scan(&mdb.schemaName); err != nil { + mdb.close() + return nil, fmt.Errorf("failed to get schema name: %w", err) + } + } + + mdb.lockID = generateAdvisoryLockID(mdb.dbName, mdb.schemaName) + + // ensure the schema_migrations table exists + if err := mdb.ensureVersionTable(); err != nil { + mdb.close() + return nil, err + } + + return mdb, nil +} + +func (d *migrateDB) close() error { + var connErr, dbErr error + if d.conn != nil { + connErr = d.conn.Close() + } + if d.db != nil { + dbErr = d.db.Close() + } + if connErr != nil { + return connErr + } + return dbErr +} + +func (d *migrateDB) ensureVersionTable() error { + if err := d.lock(); err != nil { + return err + } + defer d.unlock() + + // check if table already exists + var count int + query := `SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2 LIMIT 1` + if err := d.conn.QueryRowContext(context.Background(), query, d.schemaName, migrationsTable).Scan(&count); err != nil { + return fmt.Errorf("failed to check for migrations table: %w", err) + } + + if count > 0 { + return nil + } + + // create the table + createQuery := `CREATE TABLE IF NOT EXISTS ` + + pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(migrationsTable) + + ` (version bigint NOT NULL PRIMARY KEY, dirty boolean NOT NULL)` + + if _, err := d.conn.ExecContext(context.Background(), createQuery); err != nil { + return fmt.Errorf("failed to create migrations table: %w", err) + } + + return nil +} + +// generateAdvisoryLockID replicates golang-migrate's lock ID generation. +// Internally: strings.Join(append([]string{schemaName, tableName}, databaseName), "\x00") +// Result: "schemaName\x00tableName\x00databaseName" +// Then: CRC32(result) * 1486364155 +func generateAdvisoryLockID(dbName, schemaName string) string { + combined := strings.Join([]string{schemaName, migrationsTable, dbName}, "\x00") + sum := crc32.ChecksumIEEE([]byte(combined)) + sum = sum * uint32(advisoryLockIDSalt) + return fmt.Sprint(sum) +} + +func (d *migrateDB) lock() error { + if d.isLocked { + return fmt.Errorf("database already locked") + } + + query := `SELECT pg_advisory_lock($1)` + if _, err := d.conn.ExecContext(context.Background(), query, d.lockID); err != nil { + return fmt.Errorf("failed to acquire advisory lock: %w", err) + } + + d.isLocked = true + return nil +} + +func (d *migrateDB) unlock() error { + if !d.isLocked { + return nil + } + + query := `SELECT pg_advisory_unlock($1)` + if _, err := d.conn.ExecContext(context.Background(), query, d.lockID); err != nil { + return fmt.Errorf("failed to release advisory lock: %w", err) + } + + d.isLocked = false + return nil +} + +func (d *migrateDB) version() (int, bool, error) { + query := `SELECT version, dirty FROM ` + + pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(migrationsTable) + + ` LIMIT 1` + + var version int + var dirty bool + err := d.conn.QueryRowContext(context.Background(), query).Scan(&version, &dirty) + + switch { + case err == sql.ErrNoRows: + return nilVersion, false, nil + case err != nil: + if e, ok := err.(*pq.Error); ok { + if e.Code.Name() == "undefined_table" { + return nilVersion, false, nil + } + } + return 0, false, fmt.Errorf("failed to get migration version: %w", err) + default: + return version, dirty, nil + } +} + +func (d *migrateDB) setVersion(version int, dirty bool) error { + tx, err := d.conn.BeginTx(context.Background(), &sql.TxOptions{}) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + + truncateQuery := `TRUNCATE ` + + pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(migrationsTable) + + if _, err := tx.Exec(truncateQuery); err != nil { + tx.Rollback() + return fmt.Errorf("failed to truncate migrations table: %w", err) + } + + // re-write the schema version for nil dirty versions to prevent + // empty schema version for failed down migration on the first migration + if version >= 0 || (version == nilVersion && dirty) { + insertQuery := `INSERT INTO ` + + pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(migrationsTable) + + ` (version, dirty) VALUES ($1, $2)` + + if _, err := tx.Exec(insertQuery, version, dirty); err != nil { + tx.Rollback() + return fmt.Errorf("failed to insert migration version: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit version update: %w", err) + } + + return nil +} + +func (d *migrateDB) run(filePath string) error { + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read migration file %s: %w", filePath, err) + } + + query := string(content) + if strings.TrimSpace(query) == "" { + return nil + } + + if _, err := d.conn.ExecContext(context.Background(), query); err != nil { + if pgErr, ok := err.(*pq.Error); ok { + message := fmt.Sprintf("migration failed: %s", pgErr.Message) + if pgErr.Position != "" { + if pos, parseErr := strconv.ParseUint(pgErr.Position, 10, 64); parseErr == nil { + line, col, ok := computeLineFromPos(query, int(pos)) + if ok { + message = fmt.Sprintf("%s (line %d, column %d)", message, line, col) + } + } + } + if pgErr.Detail != "" { + message = fmt.Sprintf("%s, %s", message, pgErr.Detail) + } + return fmt.Errorf("%s", message) + } + return fmt.Errorf("migration failed: %w", err) + } + + return nil +} + +func (d *migrateDB) drop() error { + // Drop tables. + tableQuery := `SELECT table_name FROM information_schema.tables WHERE table_schema=$1 AND table_type='BASE TABLE'` + rows, err := d.conn.QueryContext(context.Background(), tableQuery, d.schemaName) + if err != nil { + return fmt.Errorf("failed to query tables: %w", err) + } + defer rows.Close() + + var tableNames []string + for rows.Next() { + var tableName string + if err := rows.Scan(&tableName); err != nil { + return fmt.Errorf("failed to scan table name: %w", err) + } + if len(tableName) > 0 { + tableNames = append(tableNames, tableName) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("failed to iterate tables: %w", err) + } + + for _, t := range tableNames { + dropQuery := `DROP TABLE IF EXISTS ` + pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(t) + ` CASCADE` + if _, err := d.conn.ExecContext(context.Background(), dropQuery); err != nil { + return fmt.Errorf("failed to drop table %s: %w", t, err) + } + } + + // Drop views. + viewQuery := `SELECT table_name FROM information_schema.views WHERE table_schema=$1` + viewRows, err := d.conn.QueryContext(context.Background(), viewQuery, d.schemaName) + if err != nil { + return fmt.Errorf("failed to query views: %w", err) + } + defer viewRows.Close() + + var viewNames []string + for viewRows.Next() { + var viewName string + if err := viewRows.Scan(&viewName); err != nil { + return fmt.Errorf("failed to scan view name: %w", err) + } + viewNames = append(viewNames, viewName) + } + if err := viewRows.Err(); err != nil { + return fmt.Errorf("failed to iterate views: %w", err) + } + + for _, v := range viewNames { + dropQuery := `DROP VIEW IF EXISTS ` + pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(v) + ` CASCADE` + if _, err := d.conn.ExecContext(context.Background(), dropQuery); err != nil { + return fmt.Errorf("failed to drop view %s: %w", v, err) + } + } + + // Drop custom types (enums, composites, domains, ranges). typtype 'b' (base) + // already excludes Postgres' auto-generated array types, so no further + // filtering by name is needed. + typeQuery := ` + SELECT t.typname + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = $1 + AND t.typtype IN ('e', 'c', 'd', 'r') + AND NOT EXISTS ( + SELECT 1 FROM pg_class c + WHERE c.reltype = t.oid AND c.relkind IN ('r', 'v', 'm', 'f', 'p') + ) + ` + typeRows, err := d.conn.QueryContext(context.Background(), typeQuery, d.schemaName) + if err != nil { + return fmt.Errorf("failed to query types: %w", err) + } + defer typeRows.Close() + + var typeNames []string + for typeRows.Next() { + var typeName string + if err := typeRows.Scan(&typeName); err != nil { + return fmt.Errorf("failed to scan type name: %w", err) + } + typeNames = append(typeNames, typeName) + } + if err := typeRows.Err(); err != nil { + return fmt.Errorf("failed to iterate types: %w", err) + } + + for _, tn := range typeNames { + dropQuery := `DROP TYPE IF EXISTS ` + pq.QuoteIdentifier(d.schemaName) + `.` + pq.QuoteIdentifier(tn) + ` CASCADE` + if _, err := d.conn.ExecContext(context.Background(), dropQuery); err != nil { + return fmt.Errorf("failed to drop type %s: %w", tn, err) + } + } + + return nil +} + +func computeLineFromPos(s string, pos int) (line uint, col uint, ok bool) { + s = strings.ReplaceAll(s, "\r\n", "\n") + runes := []rune(s) + if pos > len(runes) { + return 0, 0, false + } + sel := runes[:pos] + line = uint(runesCount(sel, '\n') + 1) + col = uint(pos - 1 - runesLastIndex(sel, '\n')) + return line, col, true +} + +func runesCount(input []rune, target rune) int { + var count int + for _, r := range input { + if r == target { + count++ + } + } + return count +} + +func runesLastIndex(input []rune, target rune) int { + for i := len(input) - 1; i >= 0; i-- { + if input[i] == target { + return i + } + } + return -1 +} diff --git a/cmd/migrate/engine.go b/cmd/migrate/engine.go new file mode 100644 index 00000000..8b6dede8 --- /dev/null +++ b/cmd/migrate/engine.go @@ -0,0 +1,260 @@ +package main + +import ( + "errors" + "fmt" + "os" +) + +var ( + errNoChange = errors.New("no change") +) + +type engine struct { + source *migrationSource + db *migrateDB +} + +func newEngine(migrationsDir string, connStr string, schemaName string) (*engine, error) { + source, err := newMigrationSource(migrationsDir) + if err != nil { + return nil, err + } + + db, err := openDatabase(connStr, schemaName) + if err != nil { + return nil, err + } + + return &engine{source: source, db: db}, nil +} + +func (e *engine) close() error { + return e.db.close() +} + +func (e *engine) up() error { + if err := e.db.lock(); err != nil { + return err + } + defer e.db.unlock() + + curVersion, dirty, err := e.db.version() + if err != nil { + return err + } + if dirty { + return fmt.Errorf("Dirty database version %d. Fix and force version.", curVersion) + } + + // determine where to start + var version uint + if curVersion == nilVersion { + first, err := e.source.first() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return errNoChange + } + return err + } + version = first + } else { + next, err := e.source.next(uint(curVersion)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return errNoChange + } + return err + } + version = next + } + + // apply from starting version forward + for { + if err := e.applyUp(version); err != nil { + return err + } + + next, err := e.source.next(version) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil // done, applied all + } + return err + } + version = next + } +} + +func (e *engine) down() error { + if err := e.db.lock(); err != nil { + return err + } + defer e.db.unlock() + + curVersion, dirty, err := e.db.version() + if err != nil { + return err + } + if dirty { + return fmt.Errorf("Dirty database version %d. Fix and force version.", curVersion) + } + if curVersion == nilVersion { + return errNoChange + } + + // determine target version after rollback + targetVersion := nilVersion + prev, err := e.source.prev(uint(curVersion)) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + // at the first migration, target is nilVersion (-1) + } else { + targetVersion = int(prev) + } + + return e.applyDown(uint(curVersion), targetVersion) +} + +func (e *engine) goTo(target uint) error { + if err := e.db.lock(); err != nil { + return err + } + defer e.db.unlock() + + curVersion, dirty, err := e.db.version() + if err != nil { + return err + } + if dirty { + return fmt.Errorf("Dirty database version %d. Fix and force version.", curVersion) + } + + if !e.source.versionExists(target) { + return fmt.Errorf("version %d not found in migration source", target) + } + + intTarget := int(target) + if intTarget == curVersion { + return errNoChange + } + + if intTarget > curVersion { + // going up + var version uint + if curVersion == nilVersion { + first, err := e.source.first() + if err != nil { + return err + } + version = first + } else { + next, err := e.source.next(uint(curVersion)) + if err != nil { + return err + } + version = next + } + + for { + if err := e.applyUp(version); err != nil { + return err + } + if version == target { + return nil + } + next, err := e.source.next(version) + if err != nil { + return err + } + version = next + } + } + + // going down + version := uint(curVersion) + for int(version) > intTarget { + prev, err := e.source.prev(version) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // we're at the first version, can't go lower + return fmt.Errorf("cannot migrate down from version %d to %d", version, target) + } + return err + } + if err := e.applyDown(version, int(prev)); err != nil { + return err + } + version = prev + } + + return nil +} + +func (e *engine) force(version int) error { + if err := e.db.lock(); err != nil { + return err + } + defer e.db.unlock() + + return e.db.setVersion(version, false) +} + +func (e *engine) dropAll() error { + if err := e.db.lock(); err != nil { + return err + } + defer e.db.unlock() + + return e.db.drop() +} + +// applyUp applies a single up migration for the given version. +func (e *engine) applyUp(version uint) error { + // mark dirty before execution + if err := e.db.setVersion(int(version), true); err != nil { + return err + } + + filePath, err := e.source.readUp(version) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // no up file for this version, just set clean + return e.db.setVersion(int(version), false) + } + return err + } + + if err := e.db.run(filePath); err != nil { + return err + } + + // mark clean after successful execution + return e.db.setVersion(int(version), false) +} + +// applyDown applies a single down migration from fromVersion, setting targetVersion afterward. +func (e *engine) applyDown(fromVersion uint, targetVersion int) error { + // mark dirty with target version before execution + if err := e.db.setVersion(targetVersion, true); err != nil { + return err + } + + filePath, err := e.source.readDown(fromVersion) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // no down file for this version, just set clean + return e.db.setVersion(targetVersion, false) + } + return err + } + + if err := e.db.run(filePath); err != nil { + return err + } + + // mark clean after successful execution + return e.db.setVersion(targetVersion, false) +} diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 00000000..95d34801 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,273 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "kjol/config" + "kjol/dbutil" + + _ "time/tzdata" + + _ "github.com/lib/pq" +) + +func invalidInput() { + fmt.Println("Usage: [up, down, drop, goto {V}, new {migration name}]") + os.Exit(1) +} + +const ( + defaultTimeFormat = "20060102150405" + defaultTimezone = "UTC" +) + +var ( + errInvalidSequenceWidth = errors.New("Digits must be positive") + errIncompatibleSeqAndFormat = errors.New("The seq and format options are mutually exclusive") + errInvalidTimeFormat = errors.New("Time format may not be empty") +) + +func createFile(filename string) error { + // create exclusive (fails if file already exists) + // os.Create() specifies 0666 as the FileMode, so we're doing the same + f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666) + + if err != nil { + return err + } + + return f.Close() +} + +func nextSeqVersion(matches []string, seqDigits int) (string, error) { + if seqDigits <= 0 { + return "", errInvalidSequenceWidth + } + + nextSeq := uint64(1) + + if len(matches) > 0 { + filename := matches[len(matches)-1] + matchSeqStr := filepath.Base(filename) + idx := strings.Index(matchSeqStr, "_") + + if idx < 1 { // Using 1 instead of 0 since there should be at least 1 digit + return "", fmt.Errorf("Malformed migration filename: %s", filename) + } + + var err error + matchSeqStr = matchSeqStr[0:idx] + nextSeq, err = strconv.ParseUint(matchSeqStr, 10, 64) + + if err != nil { + return "", err + } + + nextSeq++ + } + + version := fmt.Sprintf("%0[2]*[1]d", nextSeq, seqDigits) + + if len(version) > seqDigits { + return "", fmt.Errorf("Next sequence number %s too large. At most %d digits are allowed", version, seqDigits) + } + + return version, nil +} + +func timeVersion(startTime time.Time, format string) (version string, err error) { + switch format { + case "": + err = errInvalidTimeFormat + case "unix": + version = strconv.FormatInt(startTime.Unix(), 10) + case "unixNano": + version = strconv.FormatInt(startTime.UnixNano(), 10) + default: + version = startTime.Format(format) + } + + return +} + +func newCmd(dir string, startTime time.Time, format string, name string, ext string, seq bool, seqDigits int, print bool) error { + if seq && format != defaultTimeFormat { + return errIncompatibleSeqAndFormat + } + + var version string + var err error + + dir = filepath.Clean(dir) + ext = "." + strings.TrimPrefix(ext, ".") + + if seq { + matches, err := filepath.Glob(filepath.Join(dir, "*"+ext)) + + if err != nil { + return err + } + + version, err = nextSeqVersion(matches, seqDigits) + + if err != nil { + return err + } + } else { + version, err = timeVersion(startTime, format) + + if err != nil { + return err + } + } + + versionGlob := filepath.Join(dir, version+"_*"+ext) + matches, err := filepath.Glob(versionGlob) + + if err != nil { + return err + } + + if len(matches) > 0 { + return fmt.Errorf("duplicate migration version: %s", version) + } + + if err = os.MkdirAll(dir, os.ModePerm); err != nil { + return err + } + + for _, direction := range []string{"up", "down"} { + basename := fmt.Sprintf("%s_%s.%s%s", version, name, direction, ext) + filename := filepath.Join(dir, basename) + + if err = createFile(filename); err != nil { + return err + } + + if print { + absPath, _ := filepath.Abs(filename) + log.Println(absPath) + } + } + + return nil +} + +func main() { + envfile := flag.String("env-file", "", "Load environment variables from this file instead of the implicit ./.env. Real environment variables always take precedence.") + dir := flag.String("dir", "./migrations", "Directory containing migration files.") + + flag.Parse() + + var dbc struct { + Username string `env:"DATABASE_USERNAME"` + Password string `env:"DATABASE_PASSWORD"` + Host string `env:"DATABASE_HOST"` + Port int `env:"DATABASE_PORT"` + Name string `env:"DATABASE_NAME"` + Schema string `env:"DATABASE_SCHEMA"` + SSLMode string `env:"DATABASE_SSL_MODE"` + } + if err := config.Load(*envfile, &dbc); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + + connectionString := dbutil.BuildConnectionString(dbutil.ConnConfig{ + Username: dbc.Username, + Password: dbc.Password, + Host: dbc.Host, + Port: dbc.Port, + Name: dbc.Name, + Schema: dbc.Schema, + SSLMode: dbc.SSLMode, + }) + + // parse CLI args and do actions + args := flag.Args() + + if len(args) < 1 { + invalidInput() + } + + // handle "new" command early — it doesn't need a database connection + if args[0] == "new" { + if len(args) < 2 { + fmt.Println("Please provide a name for the new migration.") + os.Exit(1) + } + newCmd(*dir, time.Now(), defaultTimeFormat, args[1], "sql", true, 7, true) + return + } + + eng, err := newEngine(*dir, connectionString, dbc.Schema) + if err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + defer eng.close() + + migrateNum := 0 + + if len(args) >= 2 { + var parseErr error + migrateNum, parseErr = strconv.Atoi(args[1]) + if parseErr != nil { + fmt.Println("Please provide a valid migration number.") + os.Exit(1) + } + } + + switch args[0] { + case "up": + if err := eng.up(); err != nil { + if errors.Is(err, errNoChange) { + fmt.Println("No change") + os.Exit(0) + } + fmt.Println(err.Error()) + os.Exit(1) + } + fmt.Println("Database successfully migrated to latest version") + + case "down": + if err := eng.down(); err != nil { + if errors.Is(err, errNoChange) { + fmt.Println("No change") + os.Exit(0) + } + fmt.Println(err.Error()) + os.Exit(1) + } + fmt.Println("Database successfully migrated to previous version") + + case "goto": + if err := eng.goTo(uint(migrateNum)); err != nil { + if errors.Is(err, errNoChange) { + fmt.Println("No change") + os.Exit(0) + } + fmt.Println(err.Error()) + os.Exit(1) + } + fmt.Printf("Database successfully migrated to version `%d`\n", migrateNum) + + case "drop": + if err := eng.dropAll(); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + fmt.Println("Database tables, views, and types successfully dropped") + + default: + invalidInput() + } +} diff --git a/cmd/migrate/source.go b/cmd/migrate/source.go new file mode 100644 index 00000000..4a3142f8 --- /dev/null +++ b/cmd/migrate/source.go @@ -0,0 +1,151 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" +) + +type direction string + +const ( + dirUp direction = "up" + dirDown direction = "down" +) + +type migrationFile struct { + Version uint + Identifier string + Direction direction + Filename string // e.g. "0000001_INITIAL_CREATE.up.sql" +} + +type migrationSource struct { + dir string + index []uint // sorted unique version numbers + migrations map[uint]map[direction]*migrationFile // version -> direction -> file +} + +// matches: 0000001_INITIAL_CREATE.up.sql +var migrationRegex = regexp.MustCompile(`^([0-9]+)_(.*)\.(up|down)\.(.*)$`) + +func newMigrationSource(dir string) (*migrationSource, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("failed to read migrations directory: %w", err) + } + + s := &migrationSource{ + dir: dir, + migrations: make(map[uint]map[direction]*migrationFile), + } + + versionSet := make(map[uint]struct{}) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + m := migrationRegex.FindStringSubmatch(name) + if len(m) != 5 { + // skip non-matching filenames (same as library) + continue + } + + versionUint64, err := strconv.ParseUint(m[1], 10, 64) + if err != nil { + continue + } + + version := uint(versionUint64) + dir := direction(m[3]) + + mf := &migrationFile{ + Version: version, + Identifier: m[2], + Direction: dir, + Filename: name, + } + + if _, ok := s.migrations[version]; !ok { + s.migrations[version] = make(map[direction]*migrationFile) + } + + if _, exists := s.migrations[version][dir]; exists { + return nil, fmt.Errorf("duplicate migration version %d direction %s", version, dir) + } + + s.migrations[version][dir] = mf + versionSet[version] = struct{}{} + } + + // build sorted index + s.index = make([]uint, 0, len(versionSet)) + for v := range versionSet { + s.index = append(s.index, v) + } + sort.Slice(s.index, func(i, j int) bool { + return s.index[i] < s.index[j] + }) + + return s, nil +} + +func (s *migrationSource) first() (uint, error) { + if len(s.index) == 0 { + return 0, os.ErrNotExist + } + return s.index[0], nil +} + +func (s *migrationSource) next(version uint) (uint, error) { + pos := sort.Search(len(s.index), func(i int) bool { + return s.index[i] > version + }) + if pos >= len(s.index) { + return 0, os.ErrNotExist + } + return s.index[pos], nil +} + +func (s *migrationSource) prev(version uint) (uint, error) { + pos := sort.Search(len(s.index), func(i int) bool { + return s.index[i] >= version + }) + // pos is the index of version (or where it would be inserted) + // we want the one before it + if pos <= 0 { + return 0, os.ErrNotExist + } + return s.index[pos-1], nil +} + +func (s *migrationSource) readUp(version uint) (string, error) { + if dirs, ok := s.migrations[version]; ok { + if mf, ok := dirs[dirUp]; ok { + return filepath.Join(s.dir, mf.Filename), nil + } + } + return "", os.ErrNotExist +} + +func (s *migrationSource) readDown(version uint) (string, error) { + if dirs, ok := s.migrations[version]; ok { + if mf, ok := dirs[dirDown]; ok { + return filepath.Join(s.dir, mf.Filename), nil + } + } + return "", os.ErrNotExist +} + +func (s *migrationSource) versionExists(version uint) bool { + pos := sort.Search(len(s.index), func(i int) bool { + return s.index[i] >= version + }) + return pos < len(s.index) && s.index[pos] == version +} diff --git a/cmd/passgen/main.go b/cmd/passgen/main.go new file mode 100644 index 00000000..3842d871 --- /dev/null +++ b/cmd/passgen/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "log" + "os" + + "kjol/appenv" + "kjol/security" +) + +func main() { + if len(os.Args) == 2 { + passHash, _ := security.HashPassword(os.Args[1]) + + println(passHash) + } else { + println("Please input a password as first program argument") + } + + if appenv.Environment == appenv.EnvTypeDevelopment { + log.Printf("log message") + } +} diff --git a/cmd/typecheck/main.go b/cmd/typecheck/main.go new file mode 100644 index 00000000..5e4700ad --- /dev/null +++ b/cmd/typecheck/main.go @@ -0,0 +1,150 @@ +package main + +// Frontend TypeScript checker. Runs tsc in noEmit mode using +// tsconfig.json. Requires node on PATH; downloads the pinned TypeScript +// release on first run (no npm). +// +// go run ./cmd/typecheck +// go run ./cmd/typecheck -p tsconfig.json + +import ( + "archive/tar" + "compress/gzip" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const typescriptVersion = "5.8.3" + +func main() { + tsconfig := flag.String("p", "tsconfig.json", "path to tsconfig.json") + flag.Parse() + + if err := runTypecheck(*tsconfig); err != nil { + fmt.Fprintf(os.Stderr, "Typecheck failed: %v\n", err) + os.Exit(1) + } +} + +func runTypecheck(tsconfig string) error { + node, err := exec.LookPath("node") + if err != nil { + return fmt.Errorf("node not found on PATH (required to run tsc): %w", err) + } + + tsc, err := ensureTypeScript() + if err != nil { + return err + } + + if _, err := os.Stat(tsconfig); err != nil { + return fmt.Errorf("tsconfig not found: %s", tsconfig) + } + + fmt.Printf("Typechecking with TypeScript %s...\n", typescriptVersion) + cmd := exec.Command(node, tsc, "--noEmit", "-p", tsconfig) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + + fmt.Println("No type errors.") + return nil +} + +func ensureTypeScript() (string, error) { + root, err := os.Getwd() + if err != nil { + return "", err + } + + cacheDir := filepath.Join(root, "tools", ".cache", "typescript", typescriptVersion) + tscPath := filepath.Join(cacheDir, "package", "lib", "tsc.js") + if _, err := os.Stat(tscPath); err == nil { + return tscPath, nil + } + + fmt.Printf("Downloading TypeScript %s...\n", typescriptVersion) + if err := downloadTypeScript(cacheDir); err != nil { + return "", err + } + + if _, err := os.Stat(tscPath); err != nil { + return "", fmt.Errorf("tsc not found after download: %s", tscPath) + } + return tscPath, nil +} + +func downloadTypeScript(destDir string) error { + url := fmt.Sprintf("https://registry.npmjs.org/typescript/-/typescript-%s.tgz", typescriptVersion) + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("download typescript: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download typescript: HTTP %s", resp.Status) + } + + if err := os.MkdirAll(destDir, 0o755); err != nil { + return err + } + return extractTGZ(resp.Body, destDir) +} + +func extractTGZ(r io.Reader, destDir string) error { + gz, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("read typescript archive: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + cleanDest := filepath.Clean(destDir) + + for { + hdr, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("read typescript archive: %w", err) + } + + target := filepath.Join(destDir, filepath.FromSlash(hdr.Name)) + cleanTarget := filepath.Clean(target) + if cleanTarget != cleanDest && !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) { + return fmt.Errorf("invalid archive path: %s", hdr.Name) + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0o777|0o600) + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + } + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 00000000..3925abef --- /dev/null +++ b/config/config.go @@ -0,0 +1,42 @@ +// Package config provides a generic environment-variable + .env configuration +// loader. Each application defines its own configuration struct (with +// `env:"..."` tags) and calls Load to populate it. The framework owns only the +// loading mechanism, never the schema — so the two apps can have completely +// different configuration structs while sharing this loader. +package config + +import ( + "os" + + "github.com/caarlos0/env/v11" + "github.com/joho/godotenv" +) + +// Load populates dst from the process environment. A ".env" file in the working +// directory is loaded implicitly when present; passing a non-empty overrideFile +// (e.g. from a --env-file flag) loads that file instead. In either case godotenv +// never overwrites a variable already set in the real environment, so actual +// environment variables always take precedence over the file. +// +// Load returns an error rather than exiting; callers (typically a thin app-side +// wrapper) decide how to handle failure. +func Load[T any](overrideFile string, dst *T) error { + switch { + case overrideFile != "": + // Explicit override: the file is required, so a missing/unreadable file + // is an error. + if err := godotenv.Load(overrideFile); err != nil { + return err + } + default: + // Implicitly load ".env" when it exists. Its absence is not an error — + // the process environment may already carry everything. + if _, err := os.Stat(".env"); err == nil { + if err := godotenv.Load(".env"); err != nil { + return err + } + } + } + + return env.Parse(dst) +} diff --git a/csv/csv.go b/csv/csv.go new file mode 100644 index 00000000..bee4e3ab --- /dev/null +++ b/csv/csv.go @@ -0,0 +1,123 @@ +package csv + +import ( + "encoding/csv" + "errors" + "fmt" + "net/http" + "reflect" + "strings" +) + +func WriteCSVtoHTTP(w http.ResponseWriter, csv string, filename string) { + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", "attachment; filename="+filename+".csv") + + w.Write([]byte(csv)) +} + +func MakeCSV(headers []string, records [][]string) (string, error) { + if len(records) > 0 { + if len(headers) != len(records[0]) { + return "", errors.New("data contains more columns then specified by headers") + } + } + + // Write to CSV + var csvBuilder strings.Builder + writer := csv.NewWriter(&csvBuilder) + + // Write headers + if err := writer.Write(headers); err != nil { + return "", fmt.Errorf("failed to write headers: %v", err) + } + + // Write records + for _, record := range records { + if err := writer.Write(record); err != nil { + return "", fmt.Errorf("failed to write record: %v", err) + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + return "", fmt.Errorf("csv writer error: %v", err) + } + + return csvBuilder.String(), nil +} + +func StructToCSV(data any) (string, error) { + val := reflect.ValueOf(data) + if val.Kind() != reflect.Slice { + return "", fmt.Errorf("input must be a slice of structs") + } + + if val.Len() == 0 { + return "", nil + } + + var headers []string + var records [][]string + + // Get the first element to determine struct fields + first := val.Index(0) + if first.Kind() == reflect.Ptr { + first = first.Elem() + } + + if first.Kind() != reflect.Struct { + return "", fmt.Errorf("slice elements must be structs or pointers to structs") + } + + // Get field names as headers + t := first.Type() + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + // Use json tag if present, otherwise use field name + tag := field.Tag.Get("json") + if tag != "" && tag != "-" { + headers = append(headers, tag) + } else { + headers = append(headers, field.Name) + } + } + + // Convert each struct to a record + for i := 0; i < val.Len(); i++ { + item := val.Index(i) + if item.Kind() == reflect.Ptr { + item = item.Elem() + } + + var record []string + for j := 0; j < item.NumField(); j++ { + fieldVal := item.Field(j) + record = append(record, fmt.Sprintf("%v", fieldVal.Interface())) + } + records = append(records, record) + } + + // Write to CSV + var csvBuilder strings.Builder + writer := csv.NewWriter(&csvBuilder) + + // Write headers + if err := writer.Write(headers); err != nil { + return "", fmt.Errorf("failed to write headers: %v", err) + } + + // Write records + for _, record := range records { + if err := writer.Write(record); err != nil { + return "", fmt.Errorf("failed to write record: %v", err) + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + return "", fmt.Errorf("csv writer error: %v", err) + } + + return csvBuilder.String(), nil +} diff --git a/dbutil/automapper.go b/dbutil/automapper.go new file mode 100644 index 00000000..08afa446 --- /dev/null +++ b/dbutil/automapper.go @@ -0,0 +1,554 @@ +// automapper.go scans sql.Rows into Go structs using column name conventions. +// +// The standard library's sql.Rows.Scan requires you to pass a pointer for +// every column in the result set, in order, which is tedious and fragile. +// The automapper eliminates that by reflecting on the destination struct, +// building a map from column names to field index paths, and wiring up the +// scan targets automatically. +// +// # Column-to-field mapping +// +// The mapper inspects the destination struct and applies these rules in order: +// +// 1. A field with a `db:"col"` tag maps to the column named "col". When the +// field lives inside a nested model struct, the column name is prefixed: +// "prefix.col". +// +// 2. An anonymous (embedded) struct without a `db` tag is flattened into its +// parent. Its fields are mapped as if they were declared directly on the +// parent. +// +// 3. A named struct field without a `db` tag, whose type contains at least +// one `db`-tagged field, is treated as a nested model. Its prefix is taken +// from the field's `alias` tag if present, otherwise from the snake_case +// of the field name. For example, a field named CreatedBy of type AppUser +// gets the prefix "created_by", so its ID column maps to "created_by.id". +// +// 4. Everything else (unexported fields, fields without `db` tags whose types +// have no `db`-tagged fields) is skipped. +// +// The mapping is computed once per destination type and cached with a +// sync.RWMutex for concurrent safety. +// +// # How it connects to the query builder +// +// The builder's Cols method generates SELECT expressions with aliases that +// match this mapping convention. For a table reference like +// T[models.AppUser]("au"), Cols produces: +// +// au.id AS "app_user.id", au.username AS "app_user.username", ... +// +// When the automapper sees "app_user.id" in the result columns, it looks up +// that key and finds the field path into the AppUser nested struct in the +// destination DTO. This is what lets a single scan call populate a multi-model +// DTO from a JOIN query. +// +// For single-table queries where no prefix is needed, use ColsFlat instead. +// It produces bare expressions like "au.id, au.username, ..." which map +// directly to `db` tags without a prefix. +// +// # LEFT JOIN nil detection +// +// When a destination struct has a pointer-to-struct field (e.g. *AppUser), +// the mapper tracks it as a potential LEFT JOIN result. Before scanning, it +// allocates the pointed-to struct so the driver has somewhere to write values. +// After scanning, if every field in that struct is still its zero value, the +// mapper nils the pointer back out. This gives you a clean nil when the LEFT +// JOIN matched no rows, instead of a struct full of zero values. +// +// # Public API +// +// - ScanOne scans a single row into a struct pointer. Returns sql.ErrNoRows +// if no row is available. +// - ScanAll scans all remaining rows into a slice of structs. +// - QueryOne and QueryAll are convenience wrappers that execute a query and +// scan in one call. +// - QueryScalar scans a single scalar value (int, string, etc.) without +// struct mapping. +// - Columns generates the aliased SELECT expressions for a model type. +// - DebugMapping returns the full column-to-field-path map for a type, +// useful for troubleshooting mismatches. + +package dbutil + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strings" + "sync" + "unicode" +) + +// Querier is the interface for types that can execute SQL queries. +// *sql.DB, *sql.Tx, and *sql.Conn all implement this. +type Querier interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) +} + +var ( + cacheMu sync.RWMutex + mappingCache = make(map[reflect.Type]typeMapping) +) + +type typeMapping struct { + columns map[string][]int // column name -> field index path + ptrStructs [][]int // field index paths of pointer-to-struct fields +} + +// ScanOne scans the next row from rows into dest. +// dest must be a pointer to a struct. Does not close rows. +func ScanOne(rows *sql.Rows, dest any) error { + dv := reflect.ValueOf(dest) + if dv.Kind() != reflect.Ptr || dv.Elem().Kind() != reflect.Struct { + return fmt.Errorf("automapper: dest must be a pointer to a struct, got %T", dest) + } + + columns, err := rows.Columns() + if err != nil { + return err + } + + tm := getMapping(dv.Elem().Type()) + + if !rows.Next() { + if err := rows.Err(); err != nil { + return err + } + return sql.ErrNoRows + } + + return scanRow(rows, columns, tm, dv.Elem()) +} + +// ScanAll scans all remaining rows into dest. +// dest must be a pointer to a slice of structs (or pointer-to-structs). +// Does not close rows. +func ScanAll(rows *sql.Rows, dest any) error { + dv := reflect.ValueOf(dest) + if dv.Kind() != reflect.Ptr || dv.Elem().Kind() != reflect.Slice { + return fmt.Errorf("automapper: dest must be a pointer to a slice, got %T", dest) + } + + sliceVal := dv.Elem() + elemType := sliceVal.Type().Elem() + isPtr := elemType.Kind() == reflect.Ptr + if isPtr { + elemType = elemType.Elem() + } + if elemType.Kind() != reflect.Struct { + return fmt.Errorf("automapper: slice element must be a struct or *struct, got %s", elemType.Kind()) + } + + columns, err := rows.Columns() + if err != nil { + return err + } + + tm := getMapping(elemType) + + for rows.Next() { + elem := reflect.New(elemType).Elem() + if err := scanRow(rows, columns, tm, elem); err != nil { + return err + } + if isPtr { + ptr := reflect.New(elemType) + ptr.Elem().Set(elem) + sliceVal.Set(reflect.Append(sliceVal, ptr)) + } else { + sliceVal.Set(reflect.Append(sliceVal, elem)) + } + } + + return rows.Err() +} + +// QueryOne executes query and scans a single row into dest. +// dest must be a pointer to a struct. +func QueryOne(ctx context.Context, db Querier, dest any, query string, args ...any) error { + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + return ScanOne(rows, dest) +} + +// QueryAll executes query and scans all rows into dest. +// dest must be a pointer to a slice of structs. +func QueryAll(ctx context.Context, db Querier, dest any, query string, args ...any) error { + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + return ScanAll(rows, dest) +} + +// QueryScalar executes query and scans a single scalar value. +func QueryScalar[T any](ctx context.Context, db Querier, query string, args ...any) (T, error) { + var result T + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return result, err + } + defer rows.Close() + + if !rows.Next() { + if err := rows.Err(); err != nil { + return result, err + } + return result, sql.ErrNoRows + } + err = rows.Scan(&result) + return result, err +} + +// Columns generates SELECT column expressions for a model struct, aliased for +// the automapper to route into nested destination structs. +// +// For a type like model.AppUser with db tags, and tableAlias "au": +// +// Columns(model.AppUser{}, "au") +// -> au.id AS "app_user.id", au.username AS "app_user.username", ... +// +// The mapping prefix defaults to the snake_case of the type name. Override it +// with a third argument to match an `alias` tag on the destination field: +// +// Columns(model.AppUser{}, "cb", "created_by") +// -> cb.id AS "created_by.id", cb.username AS "created_by.username", ... +func Columns(model any, tableAlias string, mappingPrefix ...string) string { + t := reflect.TypeOf(model) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + prefix := toSnakeCase(t.Name()) + if len(mappingPrefix) > 0 && mappingPrefix[0] != "" { + prefix = mappingPrefix[0] + } + cols := collectColumns(t, tableAlias, prefix) + return strings.Join(cols, ", ") +} + +// DebugMapping returns the column-to-field mapping for a given struct type. +// Useful for verifying that your SQL column aliases match the expected mapping. +func DebugMapping(dest any) map[string]string { + t := reflect.TypeOf(dest) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + tm := getMapping(t) + result := make(map[string]string, len(tm.columns)) + for col, idx := range tm.columns { + result[col] = fieldPathString(t, idx) + } + return result +} + +// internal + +func scanRow(rows *sql.Rows, columns []string, tm typeMapping, dest reflect.Value) error { + // Allocate pointer-to-struct fields so we can scan into them + for _, idx := range tm.ptrStructs { + f := dest.FieldByIndex(idx) + if f.IsNil() { + f.Set(reflect.New(f.Type().Elem())) + } + } + + // Build scan targets + targets := make([]any, len(columns)) + for i, col := range columns { + if idx, ok := tm.columns[col]; ok { + f := fieldByIndex(dest, idx) + if isInsidePtrStruct(idx, tm.ptrStructs) && f.Kind() != reflect.Ptr { + // Non-pointer field inside a LEFT JOIN struct. + // database/sql cannot scan NULL into non-pointer types, + // so wrap in a nullSafeScanner that absorbs NULLs. + targets[i] = &nullSafeScanner{field: f} + } else { + targets[i] = f.Addr().Interface() + } + } else { + // Unmapped column -- discard + targets[i] = new(sql.RawBytes) + } + } + + if err := rows.Scan(targets...); err != nil { + return err + } + + // For LEFT JOINs: nil out pointer-to-struct fields where every column + // scanned to its zero value (the entire joined row was NULL). + for _, idx := range tm.ptrStructs { + f := dest.FieldByIndex(idx) + if f.Elem().IsZero() { + f.Set(reflect.Zero(f.Type())) + } + } + + return nil +} + +// nullSafeScanner implements sql.Scanner for non-pointer struct fields that +// live inside a LEFT JOIN (pointer-to-struct) target. When the LEFT JOIN +// produces no match, every column is NULL. database/sql can't scan NULL into +// non-pointer types like string or int32, so this wrapper absorbs NULLs +// (leaving the field at its zero value) and delegates non-NULL values to the +// field's own Scanner or to database/sql's built-in conversion. +type nullSafeScanner struct { + field reflect.Value +} + +func (n *nullSafeScanner) Scan(src any) error { + if src == nil { + return nil + } + // If the field's address implements sql.Scanner, delegate to it. + addr := n.field.Addr().Interface() + if scanner, ok := addr.(sql.Scanner); ok { + return scanner.Scan(src) + } + // Otherwise, use reflect to assign compatible types directly. + sv := reflect.ValueOf(src) + ft := n.field.Type() + if sv.Type().AssignableTo(ft) { + n.field.Set(sv) + return nil + } + if sv.Type().ConvertibleTo(ft) { + n.field.Set(sv.Convert(ft)) + return nil + } + return fmt.Errorf("automapper: cannot convert %T to %s", src, ft) +} + +// isInsidePtrStruct reports whether the field at idx is a descendant of any +// pointer-to-struct field tracked for LEFT JOIN nil detection. +func isInsidePtrStruct(idx []int, ptrStructs [][]int) bool { + for _, ps := range ptrStructs { + if len(idx) > len(ps) { + match := true + for i, v := range ps { + if idx[i] != v { + match = false + break + } + } + if match { + return true + } + } + } + return false +} + +// fieldByIndex walks a field index path, dereferencing pointers along the way. +func fieldByIndex(v reflect.Value, index []int) reflect.Value { + for _, i := range index { + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + v = v.Field(i) + } + return v +} + +func getMapping(t reflect.Type) typeMapping { + cacheMu.RLock() + if tm, ok := mappingCache[t]; ok { + cacheMu.RUnlock() + return tm + } + cacheMu.RUnlock() + + cacheMu.Lock() + defer cacheMu.Unlock() + + // Double-check after write lock + if tm, ok := mappingCache[t]; ok { + return tm + } + + tm := typeMapping{columns: make(map[string][]int)} + buildColumns(t, "", nil, tm.columns) + tm.ptrStructs = findPtrStructs(t, nil) + mappingCache[t] = tm + return tm +} + +// buildColumns recursively maps column names -> field index paths. +// +// Mapping rules: +// 1. Field has `db:"col"` tag -> maps to "col" (or "prefix.col" when nested) +// 2. Anonymous (embedded) struct without `db` tag -> flattened into parent +// 3. Named struct field without `db` tag, whose type has db-tagged fields -> +// nested model. Prefix comes from `alias` tag or snake_case of field name. +// 4. Everything else is skipped. +func buildColumns(t reflect.Type, prefix string, parent []int, columns map[string][]int) { + for i := range t.NumField() { + field := t.Field(i) + if !field.IsExported() { + continue + } + + idx := appendIndex(parent, i) + + // Rule 1: leaf column + if dbTag := field.Tag.Get("db"); dbTag != "" && dbTag != "-" { + col := dbTag + if prefix != "" { + col = prefix + "." + dbTag + } + columns[col] = idx + continue + } + + // Struct field without db tag -- check for nested model or embedding + ft := derefType(field.Type) + if ft.Kind() != reflect.Struct || !isModelStruct(ft) { + continue + } + + if field.Anonymous { + // Rule 2: embedded -- flatten + buildColumns(ft, prefix, idx, columns) + } else { + // Rule 3: named nested model + nestedPrefix := field.Tag.Get("alias") + if nestedPrefix == "" { + nestedPrefix = toSnakeCase(field.Name) + } + if prefix != "" { + nestedPrefix = prefix + "." + nestedPrefix + } + buildColumns(ft, nestedPrefix, idx, columns) + } + } +} + +// findPtrStructs returns field index paths of all pointer-to-struct fields +// whose pointed-to type is a model struct. Used for LEFT JOIN nil detection. +func findPtrStructs(t reflect.Type, parent []int) [][]int { + var result [][]int + for i := range t.NumField() { + field := t.Field(i) + if !field.IsExported() { + continue + } + + idx := appendIndex(parent, i) + + if field.Type.Kind() == reflect.Ptr { + inner := field.Type.Elem() + if inner.Kind() == reflect.Struct && isModelStruct(inner) { + result = append(result, idx) + result = append(result, findPtrStructs(inner, idx)...) + continue + } + } + + // Recurse into non-pointer nested model structs + ft := derefType(field.Type) + if field.Tag.Get("db") == "" && ft.Kind() == reflect.Struct && isModelStruct(ft) { + result = append(result, findPtrStructs(ft, idx)...) + } + } + return result +} + +// isModelStruct reports whether t (or any of its embedded structs) contains +// at least one field with a "db" tag. This distinguishes model structs +// (e.g. model.AppUser) from value types (e.g. time.Time, uuid.UUID). +func isModelStruct(t reflect.Type) bool { + for i := range t.NumField() { + f := t.Field(i) + if tag := f.Tag.Get("db"); tag != "" && tag != "-" { + return true + } + if f.Anonymous { + ft := derefType(f.Type) + if ft.Kind() == reflect.Struct && isModelStruct(ft) { + return true + } + } + } + return false +} + +func collectColumns(t reflect.Type, tableAlias, prefix string) []string { + var cols []string + for i := range t.NumField() { + f := t.Field(i) + if !f.IsExported() { + continue + } + if dbTag := f.Tag.Get("db"); dbTag != "" && dbTag != "-" { + if tableAlias != "" { + cols = append(cols, fmt.Sprintf(`%s.%s AS "%s.%s"`, tableAlias, dbTag, prefix, dbTag)) + } else { + cols = append(cols, fmt.Sprintf(`%s AS "%s.%s"`, dbTag, prefix, dbTag)) + } + continue + } + // Flatten embedded structs + if f.Anonymous { + ft := derefType(f.Type) + if ft.Kind() == reflect.Struct { + cols = append(cols, collectColumns(ft, tableAlias, prefix)...) + } + } + } + return cols +} + +func fieldPathString(t reflect.Type, index []int) string { + var parts []string + for _, i := range index { + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + f := t.Field(i) + parts = append(parts, f.Name) + t = f.Type + } + return strings.Join(parts, ".") +} + +func derefType(t reflect.Type) reflect.Type { + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + return t +} + +func appendIndex(parent []int, i int) []int { + idx := make([]int, len(parent)+1) + copy(idx, parent) + idx[len(parent)] = i + return idx +} + +// toSnakeCase converts CamelCase/PascalCase to snake_case. +// Handles consecutive uppercase correctly: "IPAddr" -> "ip_addr", "OrgUserDTO" -> "org_user_dto". +func toSnakeCase(s string) string { + var b strings.Builder + runes := []rune(s) + for i, r := range runes { + if unicode.IsUpper(r) { + if i > 0 { + prev := runes[i-1] + if !unicode.IsUpper(prev) || (i+1 < len(runes) && unicode.IsLower(runes[i+1])) { + b.WriteRune('_') + } + } + b.WriteRune(unicode.ToLower(r)) + } else { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/dbutil/automapper_test.go b/dbutil/automapper_test.go new file mode 100644 index 00000000..78b5d9a6 --- /dev/null +++ b/dbutil/automapper_test.go @@ -0,0 +1,507 @@ +package dbutil + +import ( + "reflect" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" +) + +// buildMapping (flat struct) + +func TestBuildMappingFlat(t *testing.T) { + tm := getMapping(reflect.TypeOf(testUser{})) + + want := map[string]bool{ + "id": true, "username": true, "email": true, "password": true, + "first_name": true, "last_name": true, + "login_count": true, "created": true, "active": true, + } + for col := range want { + if _, ok := tm.columns[col]; !ok { + t.Errorf("missing mapping for column %q", col) + } + } + if len(tm.columns) != len(want) { + t.Errorf("column count = %d, want %d", len(tm.columns), len(want)) + } +} + +// buildMapping (nested struct / DTO) + +func TestBuildMappingNested(t *testing.T) { + type DTO struct { + Membership testMembership + User testUser + } + + tm := getMapping(reflect.TypeOf(DTO{})) + + if _, ok := tm.columns["membership.id"]; !ok { + t.Error("missing membership.id") + } + if _, ok := tm.columns["membership.user_id"]; !ok { + t.Error("missing membership.user_id") + } + + if _, ok := tm.columns["user.id"]; !ok { + t.Error("missing user.id") + } + if _, ok := tm.columns["user.username"]; !ok { + t.Error("missing user.username") + } + + // No unprefixed columns should exist + if _, ok := tm.columns["id"]; ok { + t.Error("should not have unprefixed 'id'") + } +} + +// buildMapping (alias tag) + +func TestBuildMappingAlias(t *testing.T) { + type DTO struct { + User testUser + CreatedBy testUser `alias:"created_by"` + } + + tm := getMapping(reflect.TypeOf(DTO{})) + + if _, ok := tm.columns["user.id"]; !ok { + t.Error("missing user.id") + } + if _, ok := tm.columns["created_by.id"]; !ok { + t.Error("missing created_by.id") + } + if _, ok := tm.columns["created_by.username"]; !ok { + t.Error("missing created_by.username") + } +} + +// buildMapping (pointer-to-struct for LEFT JOINs) + +func TestBuildMappingPointerStruct(t *testing.T) { + type DTO struct { + Session testSession + User *testUser `alias:"test_user"` + } + + tm := getMapping(reflect.TypeOf(DTO{})) + + if _, ok := tm.columns["session.id"]; !ok { + t.Error("missing session.id") + } + if _, ok := tm.columns["test_user.id"]; !ok { + t.Error("missing test_user.id (via alias tag)") + } + if len(tm.ptrStructs) != 1 { + t.Errorf("ptrStructs len = %d, want 1", len(tm.ptrStructs)) + } +} + +// buildMapping (embedded struct) + +func TestBuildMappingEmbedded(t *testing.T) { + type Base struct { + ID uuid.UUID `db:"id"` + Created time.Time `db:"created"` + } + type Extended struct { + Base + Name string `db:"name"` + } + + tm := getMapping(reflect.TypeOf(Extended{})) + + if _, ok := tm.columns["id"]; !ok { + t.Error("missing flattened id from embedded Base") + } + if _, ok := tm.columns["created"]; !ok { + t.Error("missing flattened created from embedded Base") + } + if _, ok := tm.columns["name"]; !ok { + t.Error("missing name") + } +} + +// isModelStruct + +func TestIsModelStruct(t *testing.T) { + if !isModelStruct(reflect.TypeOf(testUser{})) { + t.Error("testUser should be a model struct") + } + if isModelStruct(reflect.TypeOf(time.Time{})) { + t.Error("time.Time should not be a model struct") + } + if isModelStruct(reflect.TypeOf(struct{ X int }{})) { + t.Error("anonymous struct without db tags should not be a model struct") + } +} + +// DebugMapping + +func TestDebugMapping(t *testing.T) { + type DTO struct { + User testUser + } + m := DebugMapping(DTO{}) + + if path, ok := m["user.id"]; !ok || path != "User.ID" { + t.Errorf("user.id mapping = %q, ok = %v", path, ok) + } + if path, ok := m["user.username"]; !ok || path != "User.Username" { + t.Errorf("user.username mapping = %q, ok = %v", path, ok) + } +} + +// ScanOne (flat struct via sqlmock) + +func TestScanOneFlat(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + + id := uuid.New() + now := time.Now().Truncate(time.Second) + + rows := sqlmock.NewRows([]string{"id", "username", "email", "password", "first_name", "last_name", "login_count", "created", "active"}). + AddRow(id, "alice", "alice@test.com", "hash", "Alice", "Smith", int32(5), now, true) + + mock.ExpectQuery("SELECT").WillReturnRows(rows) + + sqlRows, err := db.Query("SELECT anything") + if err != nil { + t.Fatal(err) + } + defer sqlRows.Close() + + var user testUser + err = ScanOne(sqlRows, &user) + if err != nil { + t.Fatal(err) + } + + if user.ID != id { + t.Errorf("ID = %v, want %v", user.ID, id) + } + if user.Username != "alice" { + t.Errorf("Username = %q", user.Username) + } + if user.Email != "alice@test.com" { + t.Errorf("Email = %q", user.Email) + } + if user.FirstName != "Alice" { + t.Errorf("FirstName = %q", user.FirstName) + } + if user.LoginCount != 5 { + t.Errorf("LoginCount = %d", user.LoginCount) + } + if user.Active != true { + t.Errorf("Active = %v", user.Active) + } +} + +// ScanAll (multiple rows) + +func TestScanAllFlat(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + + id1, id2 := uuid.New(), uuid.New() + now := time.Now().Truncate(time.Second) + + rows := sqlmock.NewRows([]string{"id", "key", "user_id", "org_id", "created", "user_agent", "revoked"}). + AddRow(id1, "key1", uuid.New(), nil, now, "Mozilla", false). + AddRow(id2, "key2", uuid.New(), nil, now, "Chrome", true) + + mock.ExpectQuery("SELECT").WillReturnRows(rows) + + sqlRows, err := db.Query("SELECT anything") + if err != nil { + t.Fatal(err) + } + defer sqlRows.Close() + + var sessions []testSession + err = ScanAll(sqlRows, &sessions) + if err != nil { + t.Fatal(err) + } + + if len(sessions) != 2 { + t.Fatalf("len = %d, want 2", len(sessions)) + } + if sessions[0].Key != "key1" { + t.Errorf("[0].Key = %q", sessions[0].Key) + } + if sessions[1].Revoked != true { + t.Errorf("[1].Revoked = %v", sessions[1].Revoked) + } +} + +// ScanOne (nested DTO with prefixed columns) + +func TestScanOneNested(t *testing.T) { + type DTO struct { + Membership testMembership + User testUser + } + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + + mID, uID, orgID := uuid.New(), uuid.New(), uuid.New() + now := time.Now().Truncate(time.Second) + + cols := []string{ + "membership.id", "membership.user_id", "membership.org_id", + "membership.created_by", "membership.joined", + "membership.login_count", + "user.id", "user.username", "user.email", + } + rows := sqlmock.NewRows(cols). + AddRow( + mID, uID, orgID, + uuid.New(), now, + int32(10), + uID, "alice", "alice@test.com", + ) + + mock.ExpectQuery("SELECT").WillReturnRows(rows) + + sqlRows, err := db.Query("SELECT anything") + if err != nil { + t.Fatal(err) + } + defer sqlRows.Close() + + var dto DTO + err = ScanOne(sqlRows, &dto) + if err != nil { + t.Fatal(err) + } + + if dto.Membership.ID != mID { + t.Errorf("Membership.ID = %v, want %v", dto.Membership.ID, mID) + } + if dto.Membership.OrgID != orgID { + t.Errorf("Membership.OrgID = %v, want %v", dto.Membership.OrgID, orgID) + } + if dto.User.Username != "alice" { + t.Errorf("User.Username = %q", dto.User.Username) + } + if dto.Membership.LoginCount != 10 { + t.Errorf("Membership.LoginCount = %v, want 10", dto.Membership.LoginCount) + } +} + +// ScanOne (alias tag) + +func TestScanOneAlias(t *testing.T) { + type DTO struct { + User testUser + CreatedBy testUser `alias:"created_by"` + } + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + + userID, cbID := uuid.New(), uuid.New() + + cols := []string{"user.id", "user.username", "created_by.id", "created_by.username"} + rows := sqlmock.NewRows(cols). + AddRow(userID, "alice", cbID, "bob") + + mock.ExpectQuery("SELECT").WillReturnRows(rows) + + sqlRows, err := db.Query("SELECT anything") + if err != nil { + t.Fatal(err) + } + defer sqlRows.Close() + + var dto DTO + err = ScanOne(sqlRows, &dto) + if err != nil { + t.Fatal(err) + } + + if dto.User.ID != userID { + t.Errorf("User.ID = %v, want %v", dto.User.ID, userID) + } + if dto.User.Username != "alice" { + t.Errorf("User.Username = %q", dto.User.Username) + } + if dto.CreatedBy.ID != cbID { + t.Errorf("CreatedBy.ID = %v, want %v", dto.CreatedBy.ID, cbID) + } + if dto.CreatedBy.Username != "bob" { + t.Errorf("CreatedBy.Username = %q", dto.CreatedBy.Username) + } +} + +// Pointer-to-struct nil detection (LEFT JOIN) + +func TestScanOnePtrStructNilDetection(t *testing.T) { + type DTO struct { + Session testSession + User *testUser `alias:"test_user"` + } + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + + sessID := uuid.New() + now := time.Now().Truncate(time.Second) + + cols := []string{ + "session.id", "session.key", "session.user_id", "session.org_id", + "session.created", "session.user_agent", "session.revoked", + // All user columns are NULL (LEFT JOIN miss) + "test_user.id", "test_user.username", + } + rows := sqlmock.NewRows(cols). + AddRow( + sessID, "key1", uuid.New(), nil, + now, "Mozilla", false, + // NULL user + uuid.Nil, "", + ) + + mock.ExpectQuery("SELECT").WillReturnRows(rows) + + sqlRows, err := db.Query("SELECT anything") + if err != nil { + t.Fatal(err) + } + defer sqlRows.Close() + + var dto DTO + err = ScanOne(sqlRows, &dto) + if err != nil { + t.Fatal(err) + } + + if dto.Session.ID != sessID { + t.Errorf("Session.ID = %v, want %v", dto.Session.ID, sessID) + } + if dto.User != nil { + t.Errorf("User should be nil for LEFT JOIN miss, got %+v", dto.User) + } +} + +func TestScanOnePtrStructNonNil(t *testing.T) { + type DTO struct { + Session testSession + User *testUser `alias:"test_user"` + } + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + + sessID, userID := uuid.New(), uuid.New() + now := time.Now().Truncate(time.Second) + + cols := []string{ + "session.id", "session.key", "session.user_id", "session.org_id", + "session.created", "session.user_agent", "session.revoked", + "test_user.id", "test_user.username", + } + rows := sqlmock.NewRows(cols). + AddRow( + sessID, "key1", userID, nil, + now, "Mozilla", false, + userID, "alice", + ) + + mock.ExpectQuery("SELECT").WillReturnRows(rows) + + sqlRows, err := db.Query("SELECT anything") + if err != nil { + t.Fatal(err) + } + defer sqlRows.Close() + + var dto DTO + err = ScanOne(sqlRows, &dto) + if err != nil { + t.Fatal(err) + } + + if dto.User == nil { + t.Fatal("User should not be nil") + } + if dto.User.ID != userID { + t.Errorf("User.ID = %v, want %v", dto.User.ID, userID) + } + if dto.User.Username != "alice" { + t.Errorf("User.Username = %q", dto.User.Username) + } +} + +// ScanOne: sql.ErrNoRows + +func TestScanOneNoRows(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + + rows := sqlmock.NewRows([]string{"id", "username"}) + mock.ExpectQuery("SELECT").WillReturnRows(rows) + + sqlRows, err := db.Query("SELECT anything") + if err != nil { + t.Fatal(err) + } + defer sqlRows.Close() + + type Small struct { + ID string `db:"id"` + Username string `db:"username"` + } + var s Small + err = ScanOne(sqlRows, &s) + if err == nil { + t.Error("expected error for no rows") + } +} + +// Columns function + +func TestColumnsFunction(t *testing.T) { + result := Columns(testSession{}, "s") + if !containsSubstr(result, `s.id AS "test_session.id"`) { + t.Errorf("missing s.id alias: %s", result) + } + if !containsSubstr(result, `s.key AS "test_session.key"`) { + t.Errorf("missing s.key alias: %s", result) + } +} + +func TestColumnsWithPrefix(t *testing.T) { + result := Columns(testUser{}, "cb", "created_by") + if !containsSubstr(result, `cb.id AS "created_by.id"`) { + t.Errorf("missing custom prefix alias: %s", result) + } +} diff --git a/dbutil/builder.go b/dbutil/builder.go new file mode 100644 index 00000000..38b96baf --- /dev/null +++ b/dbutil/builder.go @@ -0,0 +1,1270 @@ +// Package dbutil provides a SQL query builder for PostgreSQL. +// +// The builder generates parameterized SQL strings and argument slices from +// composable Go values. It is not an ORM. It does not manage connections, +// transactions, or migrations. It pairs with the automapper in automapper.go +// to scan query results into structs. +// +// # Naming conventions +// +// The public API uses short names because they appear repeatedly in query +// construction code: +// +// - T (Table) creates a typed table reference from a model struct. +// - M (Model) is the addressable zero-value of the model struct living on +// the table reference. It exists solely so you can take field pointers +// for F and FieldNames. It does not hold real data. +// - F (Field) resolves a pointer to a field on M into a Col. +// - C (Column) creates a Col from a raw column name string. +// +// # Table references +// +// Every query starts by binding a Go model type to a SQL table alias with T: +// +// au := T[models.AppUser]("au") +// +// T looks up the table name from models.Tables, allocates an addressable +// zero-value of the model (stored in au.M), and builds a mapping from struct +// field byte offsets to their "db" tag values. The type parameter gives you +// compile-time safety; the alias is the SQL alias used in the generated query. +// Omit the alias for single-table statements: +// +// it := T[models.Identity]() // uses the bare table name "identity" +// +// For tables not registered in models.Tables (CTEs, subquery aliases, etc.), +// use TName: +// +// cte := TName("recent_logins", "rl") +// +// # Referencing columns +// +// F takes a pointer to a field on the table's zero-value M and resolves it to +// the column name from its "db" tag. This gives you IDE autocomplete and +// compile-time breakage when a field is renamed or removed: +// +// au.F(&au.M.ID) // Col representing "au.id" +// au.F(&au.M.FirstName) // Col representing "au.first_name" +// +// Under the hood F computes the pointer's byte offset relative to au.M and +// looks it up in a cached offset-to-column map. +// +// C is still available as a raw-string fallback for expressions that don't +// correspond to a single struct field: +// +// au.C("id") // same as au.F(&au.M.ID), but no compile-time checking +// +// FieldNames does the same resolution as F but returns bare column name +// strings instead of Col values. Use it with SetColumns and Columns: +// +// au.FieldNames(&au.M.FirstName, &au.M.LastName) // []string{"first_name", "last_name"} +// +// # Building conditions +// +// Col methods produce Cond values that carry a SQL fragment and bound args: +// +// au.F(&au.M.ID).Eq(userID) // "au.id = ?" +// au.F(&au.M.LastName).Like("%smith%") // "au.last_name LIKE ?" +// au.F(&au.M.ID).In(ids) // "au.id IN (?, ?, ...)" +// +// Conditions compose with And, Or, and Not: +// +// cond := au.F(&au.M.Email).IsNotNull().And(au.F(&au.M.LoginCount).Gt(0)) +// +// EqCol compares two columns without a bound parameter (useful for joins): +// +// au.F(&au.M.ID).EqCol(ou.F(&ou.M.AppUserID)) +// +// # SELECT +// +// Select(au.ColsFlat()). +// From(au). +// Where(au.F(&au.M.ID).Eq(userID)). +// QueryRow(ctx, db, &user) +// +// ColsFlat generates unaliased column expressions (au.id, au.email, ...) for +// single-table queries. Cols generates aliased expressions +// (au.id AS "app_user.id", ...) for multi-table queries where the automapper +// needs prefixes to route columns into nested destination structs. +// +// Joins, ordering, grouping, limit, and offset chain as expected: +// +// Select(ou.Cols(), au.Cols()). +// From(ou). +// InnerJoin(au, au.F(&au.M.ID).EqCol(ou.F(&ou.M.AppUserID))). +// Where(ou.F(&ou.M.OrgID).Eq(orgID)). +// OrderBy(au.F(&au.M.LastName).Asc()). +// Limit(25). +// Offset(50). +// Query(ctx, db, &results) +// +// When joining the same model twice, use MapAs to set a distinct automapper +// prefix so the scanner can tell the two apart: +// +// cb := T[models.AppUser]("cb").MapAs("created_by") +// +// # INSERT +// +// InsertInto(it). +// Columns(it.FieldNames(&it.M.Key, &it.M.AppUserID, &it.M.Timezone)...). +// Model(identity). +// Exec(ctx, db) +// +// If Columns is omitted and Model is provided, all "db"-tagged fields are +// inserted. Values can be passed directly with Values() instead of Model(). +// +// # UPDATE +// +// Update(au). +// SetColumns(au.FieldNames(&au.M.FirstName, &au.M.LastName)...). +// Model(user). +// Where(au.F(&au.M.ID).Eq(user.ID)). +// Exec(ctx, db) +// +// Set can also be called for individual column/value pairs: +// +// Update(au).Set("login_count", newCount).Where(...).Exec(ctx, db) +// +// # DELETE +// +// DeleteFrom(it). +// Where(it.F(&it.M.Key).Eq(key)). +// Exec(ctx, db) +// +// # Execution +// +// Build returns the final SQL string (with $1, $2, ... placeholders) and the +// argument slice. Query, QueryRow, QueryScalarTo, and Exec are convenience +// methods that call Build and then execute against a Querier or Execer. +// + +// //////////////////////////////////////////////////////////////////////////// +// +// BEHIND THE SCENES POINTER MAGIC: +// +// # How F resolves field pointers to column names +// +// When T[M] is called, it allocates a zero-value of the model struct with +// new(M) and stores the pointer in the M field. It also walks the struct's +// reflect.Type and records every db-tagged field's byte offset (from +// reflect.StructField.Offset) alongside its "db" tag value into a map: +// +// fieldMap[0] = "id" // ID is at byte offset 0 +// fieldMap[16] = "username" // Username is at byte offset 16 +// fieldMap[32] = "email" // Email is at byte offset 32 +// ... +// +// When you call au.F(&au.M.ID), F receives a pointer to the ID field within +// that same heap-allocated struct. It subtracts the base address of the struct +// from the field's address to recover the byte offset: +// +// offset = reflect.ValueOf(&au.M.ID).Pointer() - reflect.ValueOf(au.M).Pointer() +// +// That offset is looked up in fieldMap to get the column name "id", which is +// then combined with the table alias to produce the Col expression "au.id". +// +// This works because Go guarantees that struct fields sit at fixed offsets +// from the start of the struct, and those offsets are the same for every +// instance of that type. The reflect package exposes them without needing +// an unsafe import. +// +// If the pointer does not fall within the struct (e.g. you pass a pointer to +// an unrelated variable), the offset will not exist in the map and F panics. +// +// # Field offset cache +// +// Walking a struct's reflect.Type to collect field offsets is not free, but +// the result is the same for every instance of a given type. The offset map +// is computed once and stored in a package-level cache keyed by reflect.Type: +// +// var fieldCache = map[reflect.Type]map[uintptr]string +// +// The cache is protected by a sync.RWMutex using a double-check pattern. +// On the hot path (the type has been seen before), buildFieldOffsetMap takes +// a read lock, finds the map, and returns it. On the cold path (first time +// seeing a type), it upgrades to a write lock, checks again in case another +// goroutine populated it in the meantime, and only then does the reflect +// walk. This is the same pattern used by the automapper's mappingCache in +// automapper.go. +// +// Each call to T[M] receives a reference to the shared cached map rather +// than its own copy, so there is no per-table-reference allocation cost +// beyond the first time a model type is used. +// +// See examples.go for full working examples of each pattern. +package dbutil + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strings" + "sync" +) + +// AllColumns is a convenience constant for use with Returning(). +const AllColumns = "*" + +type Execer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +// TableExpr is satisfied by both TableRef and Tbl[M]. +type TableExpr interface { + tableRef() TableRef +} + +// TableRef + +type TableRef struct { + tableName string + alias string + modelType reflect.Type + mapPrefix string +} + +func (t TableRef) tableRef() TableRef { return t } + +// fieldInfo stores the column name and Go type for a struct field. +type fieldInfo struct { + colName string + fieldType reflect.Type +} + +// Tbl[M] wraps TableRef and adds type-safe field references. + +type Tbl[M any] struct { + TableRef + M *M + fieldMap map[uintptr]fieldInfo +} + +func T[M any](alias ...string) Tbl[M] { + var zero M + t := reflect.TypeOf(zero) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + tableName := tableNameFor(t) + if tableName == "" { + panic(fmt.Sprintf("builder: no table registered for type %s", t.Name())) + } + a := "" + if len(alias) > 0 { + a = alias[0] + } + tbl := Tbl[M]{ + TableRef: TableRef{ + tableName: tableName, + alias: a, + modelType: t, + mapPrefix: toSnakeCase(t.Name()), + }, + } + tbl.M = new(M) + tbl.fieldMap = buildFieldOffsetMap(t) + return tbl +} + +func (t Tbl[M]) tableRef() TableRef { return t.TableRef } + +// F resolves a pointer to a field on t.M to a Col using byte offset math. +func (t Tbl[M]) F(fieldPtr any) Col { + ptr := reflect.ValueOf(fieldPtr).Pointer() + base := reflect.ValueOf(t.M).Pointer() + offset := ptr - base + info, ok := t.fieldMap[offset] + if !ok { + panic(fmt.Sprintf("builder: field pointer offset %d not found in %T", offset, *t.M)) + } + return Col{expr: t.ref() + "." + info.colName, fieldType: info.fieldType} +} + +// FieldNames resolves multiple field pointers to their db column names. +func (t Tbl[M]) FieldNames(fieldPtrs ...any) []string { + base := reflect.ValueOf(t.M).Pointer() + names := make([]string, len(fieldPtrs)) + for i, fp := range fieldPtrs { + ptr := reflect.ValueOf(fp).Pointer() + offset := ptr - base + info, ok := t.fieldMap[offset] + if !ok { + panic(fmt.Sprintf("builder: field pointer offset %d not found in %T", offset, *t.M)) + } + names[i] = info.colName + } + return names +} + +// As returns a copy with a new alias. +func (t Tbl[M]) As(alias string) Tbl[M] { + t.TableRef = t.TableRef.As(alias) + return t +} + +// MapAs returns a copy with a new automapper prefix. +func (t Tbl[M]) MapAs(prefix string) Tbl[M] { + t.TableRef = t.TableRef.MapAs(prefix) + return t +} + +// Field offset cache + +var ( + fieldCacheMu sync.RWMutex + fieldCache = make(map[reflect.Type]map[uintptr]fieldInfo) +) + +func buildFieldOffsetMap(t reflect.Type) map[uintptr]fieldInfo { + fieldCacheMu.RLock() + if m, ok := fieldCache[t]; ok { + fieldCacheMu.RUnlock() + return m + } + fieldCacheMu.RUnlock() + + fieldCacheMu.Lock() + defer fieldCacheMu.Unlock() + + if m, ok := fieldCache[t]; ok { + return m + } + + m := make(map[uintptr]fieldInfo) + walkFieldOffsets(t, 0, m) + fieldCache[t] = m + return m +} + +func walkFieldOffsets(t reflect.Type, base uintptr, m map[uintptr]fieldInfo) { + for i := range t.NumField() { + f := t.Field(i) + if !f.IsExported() { + continue + } + if dbTag := f.Tag.Get("db"); dbTag != "" && dbTag != "-" { + ft := f.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + m[base+f.Offset] = fieldInfo{colName: dbTag, fieldType: ft} + continue + } + if f.Anonymous { + ft := derefType(f.Type) + if ft.Kind() == reflect.Struct { + walkFieldOffsets(ft, base+f.Offset, m) + } + } + } +} + +func TName(tableName string, alias ...string) TableRef { + a := "" + if len(alias) > 0 { + a = alias[0] + } + return TableRef{tableName: tableName, alias: a} +} + +func (t TableRef) As(alias string) TableRef { + t.alias = alias + return t +} + +// MapAs overrides the automapper column prefix. +// Use when joining the same table twice with different roles: +// +// cb := T[models.AppUser]("cb").MapAs("created_by") +func (t TableRef) MapAs(prefix string) TableRef { + t.mapPrefix = prefix + return t +} + +func (t TableRef) C(name string) Col { + return Col{expr: t.ref() + "." + name} +} + +// C creates a Col from a bare name, not tied to any table. +// Use for computed aliases in ORDER BY / GROUP BY (e.g. C("points")). +func C(name string) Col { + return Col{expr: name} +} + +// Cols returns automapper-compatible aliased columns for JOIN queries. +// +// T(models.AppUser{}, "au").Cols() +// -> au.id AS "app_user.id", au.username AS "app_user.username", ... +func (t TableRef) Cols() string { + if t.modelType == nil { + panic("builder: TableRef has no model type, cannot generate columns") + } + model := reflect.New(t.modelType).Interface() + return Columns(model, t.ref(), t.mapPrefix) +} + +// ColsFlat returns unaliased column expressions for single-table queries. +// +// T(models.AppUser{}, "au").ColsFlat() +// -> au.id, au.username, au.email, ... +func (t TableRef) ColsFlat() string { + if t.modelType == nil { + panic("builder: TableRef has no model type, cannot generate columns") + } + cols := collectColumnsFlat(t.modelType, t.ref()) + return strings.Join(cols, ", ") +} + +func (t TableRef) AllColNames() []string { + if t.modelType == nil { + panic("builder: TableRef has no model type") + } + return allDBColumns(t.modelType) +} + +func (t TableRef) ref() string { + if t.alias != "" { + return t.alias + } + return t.tableName +} + +func (t TableRef) fromExpr() string { + if t.alias != "" { + return t.tableName + " " + t.alias + } + return t.tableName +} + +// Col + +type Col struct { + expr string + alias string // set by RawCol; empty for normal columns + fieldType reflect.Type // set by F(); nil for raw/computed columns +} + +// String returns the column expression for use in SELECT lists. +// For RawCol columns, this includes the AS "alias" suffix. +func (c Col) String() string { + if c.alias != "" { + return c.expr + ` AS "` + c.alias + `"` + } + return c.expr +} + +// checkType validates that val's type matches the column's field type. +// Panics on mismatch. Skips check if fieldType is nil (raw/computed columns). +func (c Col) checkType(val any) { + if c.fieldType == nil || val == nil { + return + } + valType := reflect.TypeOf(val) + if valType != c.fieldType { + panic(fmt.Sprintf( + "dbutil: type mismatch for column %s: expected %s, got %s (%v)", + c.expr, c.fieldType, valType, val, + )) + } +} + +func (c Col) Eq(val any) Cond { + c.checkType(val) + return Cond{fragment: c.expr + " = ?", args: []any{val}} +} +func (c Col) Neq(val any) Cond { + c.checkType(val) + return Cond{fragment: c.expr + " <> ?", args: []any{val}} +} +func (c Col) Gt(val any) Cond { + c.checkType(val) + return Cond{fragment: c.expr + " > ?", args: []any{val}} +} +func (c Col) GtEq(val any) Cond { + c.checkType(val) + return Cond{fragment: c.expr + " >= ?", args: []any{val}} +} +func (c Col) Lt(val any) Cond { + c.checkType(val) + return Cond{fragment: c.expr + " < ?", args: []any{val}} +} +func (c Col) LtEq(val any) Cond { + c.checkType(val) + return Cond{fragment: c.expr + " <= ?", args: []any{val}} +} +func (c Col) Like(val any) Cond { return Cond{fragment: c.expr + " LIKE ?", args: []any{val}} } +func (c Col) ILike(val any) Cond { return Cond{fragment: c.expr + " ILIKE ?", args: []any{val}} } +func (c Col) IsNull() Cond { return Cond{fragment: c.expr + " IS NULL"} } +func (c Col) IsNotNull() Cond { return Cond{fragment: c.expr + " IS NOT NULL"} } +func (c Col) EqCol(other Col) Cond { return Cond{fragment: c.expr + " = " + other.expr} } +func (c Col) GtCol(other Col) Cond { return Cond{fragment: c.expr + " > " + other.expr} } +func (c Col) LtCol(other Col) Cond { return Cond{fragment: c.expr + " < " + other.expr} } + +func (c Col) Between(low, high any) Cond { + c.checkType(low) + c.checkType(high) + return Cond{fragment: c.expr + " BETWEEN ? AND ?", args: []any{low, high}} +} + +// In accepts individual values or a single slice argument. +func (c Col) In(vals ...any) Cond { + if len(vals) == 1 { + rv := reflect.ValueOf(vals[0]) + if rv.Kind() == reflect.Slice { + expanded := make([]any, rv.Len()) + for i := range rv.Len() { + expanded[i] = rv.Index(i).Interface() + } + vals = expanded + } + } + for _, v := range vals { + c.checkType(v) + } + placeholders := make([]string, len(vals)) + for i := range vals { + placeholders[i] = "?" + } + return Cond{ + fragment: c.expr + " IN (" + strings.Join(placeholders, ", ") + ")", + args: vals, + } +} + +func (c Col) InQuery(sub *SelectBuilder) Cond { + subSQL, subArgs := sub.toSQL() + return Cond{ + fragment: c.expr + " IN (" + subSQL + ")", + args: subArgs, + } +} + +func (c Col) Asc() OrderExpr { return OrderExpr{expr: c.expr + " ASC"} } +func (c Col) Desc() OrderExpr { return OrderExpr{expr: c.expr + " DESC"} } + +func Lower(c Col) Col { return Col{expr: "LOWER(" + c.expr + ")"} } +func Sum(c Col) Col { return Col{expr: "SUM(" + c.expr + ")"} } +func Max(c Col) Col { return Col{expr: "MAX(" + c.expr + ")"} } +func Min(c Col) Col { return Col{expr: "MIN(" + c.expr + ")"} } +func Count(c Col) Col { return Col{expr: "COUNT(" + c.expr + ")"} } +func CountExpr(expr string) Col { return Col{expr: "COUNT(" + expr + ")"} } +func CountDistinct(c Col) Col { return Col{expr: "COUNT(DISTINCT " + c.expr + ")"} } +func Round(c Col) Col { return Col{expr: "ROUND(" + c.expr + ")"} } + +// CountDistinctRow produces COUNT(DISTINCT ROW(col1, col2, ...)). +func CountDistinctRow(cols ...Col) Col { + parts := make([]string, len(cols)) + for i, c := range cols { + parts[i] = c.expr + } + return Col{expr: "COUNT(DISTINCT ROW(" + strings.Join(parts, ", ") + "))"} +} + +// Arithmetic operations on columns. +func (c Col) Mul(other Col) Col { return Col{expr: "(" + c.expr + " * " + other.expr + ")"} } +func (c Col) Div(other Col) Col { return Col{expr: "(" + c.expr + " / " + other.expr + ")"} } +func (c Col) Add(other Col) Col { return Col{expr: "(" + c.expr + " + " + other.expr + ")"} } +func (c Col) Sub(other Col) Col { return Col{expr: "(" + c.expr + " - " + other.expr + ")"} } + +// NumLit creates a Col from a numeric literal. +func NumLit(val any) Col { return Col{expr: fmt.Sprintf("%v", val)} } + +// BoolAnd produces (col1 AND col2) as a boolean expression column. +func BoolAnd(a, b Col) Col { return Col{expr: "(" + a.expr + " AND " + b.expr + ")"} } + +// IsTrue converts a boolean Col expression into a Cond for use in WHERE/WHEN clauses. +func (c Col) IsTrue() Cond { return Cond{fragment: c.expr} } + +// CaseCol builds a SQL CASE expression. Usage: +// +// CaseCol().When(cond, result).Else(fallback).End() +func CaseCol() *CaseBuilder { return &CaseBuilder{} } + +// CaseBuilder constructs a SQL CASE WHEN ... THEN ... ELSE ... END expression. +type CaseBuilder struct { + whens []struct { + cond Cond + result Col + } + elseCol *Col +} + +func (cb *CaseBuilder) When(cond Cond, result Col) *CaseBuilder { + cb.whens = append(cb.whens, struct { + cond Cond + result Col + }{cond, result}) + return cb +} + +func (cb *CaseBuilder) Else(c Col) *CaseBuilder { + cb.elseCol = &c + return cb +} + +func (cb *CaseBuilder) End() Col { + var b strings.Builder + b.WriteString("CASE") + var args []any + for _, w := range cb.whens { + b.WriteString(" WHEN ") + b.WriteString(w.cond.fragment) + args = append(args, w.cond.args...) + b.WriteString(" THEN ") + b.WriteString(w.result.expr) + } + if cb.elseCol != nil { + b.WriteString(" ELSE ") + b.WriteString(cb.elseCol.expr) + } + b.WriteString(" END") + // CASE args are baked into the expression since Col doesn't carry args. + // For parameterized WHEN conditions, use RawCol instead. + _ = args + return Col{expr: b.String()} +} + +// Literal creates a Col from a literal SQL value (e.g. a quoted string). +func Literal(val string) Col { return Col{expr: "'" + val + "'"} } + +// Concat produces a SQL concatenation of columns using ||. +func Concat(cols ...Col) Col { + parts := make([]string, len(cols)) + for i, c := range cols { + parts[i] = c.expr + } + return Col{expr: "(" + strings.Join(parts, " || ") + ")"} +} + +func Coalesce(c Col, defaultVal string) Col { + return Col{expr: "COALESCE(" + c.expr + ", " + defaultVal + ")"} +} + +// CoalesceCols produces COALESCE(col1, col2, ...) from multiple column expressions. +func CoalesceCols(cols ...Col) Col { + parts := make([]string, len(cols)) + for i, c := range cols { + parts[i] = c.expr + } + return Col{expr: "COALESCE(" + strings.Join(parts, ", ") + ")"} +} + +// As sets a column alias for SELECT lists (produces: expr AS "alias"). +func (c Col) As(alias string) Col { + return Col{expr: c.expr, alias: alias} +} + +// Cast applies a PostgreSQL type cast (produces: expr::typeName). +func (c Col) Cast(typeName string) Col { + return Col{expr: c.expr + "::" + typeName, alias: c.alias} +} + +// RawCol creates a Col from a raw SQL expression with an alias. +// String() returns the expression with AS "alias" (for SELECT lists). +// Asc()/Desc() use only the bare expression (for ORDER BY). +func RawCol(expr string, alias string) Col { + return Col{expr: expr, alias: alias} +} + +// Cond + +type Cond struct { + fragment string + args []any +} + +func (c Cond) And(other Cond) Cond { + if c.fragment == "" { + return other + } + if other.fragment == "" { + return c + } + args := make([]any, 0, len(c.args)+len(other.args)) + args = append(args, c.args...) + args = append(args, other.args...) + return Cond{ + fragment: "(" + c.fragment + " AND " + other.fragment + ")", + args: args, + } +} + +func (c Cond) Or(other Cond) Cond { + if c.fragment == "" { + return other + } + if other.fragment == "" { + return c + } + args := make([]any, 0, len(c.args)+len(other.args)) + args = append(args, c.args...) + args = append(args, other.args...) + return Cond{ + fragment: "(" + c.fragment + " OR " + other.fragment + ")", + args: args, + } +} + +func (c Cond) Not() Cond { + return Cond{fragment: "NOT (" + c.fragment + ")", args: c.args} +} + +func True() Cond { return Cond{fragment: "TRUE"} } +func False() Cond { return Cond{fragment: "FALSE"} } +func RawCond(fragment string, args ...any) Cond { return Cond{fragment: fragment, args: args} } + +// Exists produces an EXISTS (subquery) condition. +func Exists(sub *SelectBuilder) Cond { + subSQL, subArgs := sub.toSQL() + return Cond{fragment: "EXISTS (" + subSQL + ")", args: subArgs} +} + +// NotExists produces a NOT EXISTS (subquery) condition. +func NotExists(sub *SelectBuilder) Cond { + subSQL, subArgs := sub.toSQL() + return Cond{fragment: "NOT EXISTS (" + subSQL + ")", args: subArgs} +} + +// SubQuery wraps a SelectBuilder as a Col expression so it can be used in +// comparisons, COALESCE, etc. Produces "(SELECT ...)". +func SubQuery(sub *SelectBuilder) Col { + subSQL, subArgs := sub.toSQL() + // SubQuery args are embedded into the fragment since Col doesn't carry args + // independently. For parameterized subqueries, use Exists/NotExists/InQuery instead. + _ = subArgs + return Col{expr: "(" + subSQL + ")"} +} + +type OrderExpr struct { + expr string +} + +func RawOrder(sql string) OrderExpr { return OrderExpr{expr: sql} } + +// SelectBuilder + +type joinClause struct { + joinType string + table TableRef + on Cond +} + +type SelectBuilder struct { + columns []string + from []TableRef + joins []joinClause + where *Cond + groupBy []string + having *Cond + orderBy []OrderExpr + limit *int64 + offset *int64 + debug bool +} + +func (b *SelectBuilder) Debug() *SelectBuilder { + b.debug = true + return b +} + +func Select(cols ...string) *SelectBuilder { + return &SelectBuilder{columns: cols} +} + +// SetColumns replaces the SELECT column list on an existing builder. +func (b *SelectBuilder) SetColumns(cols ...string) *SelectBuilder { + b.columns = cols + return b +} + +func (b *SelectBuilder) From(tables ...TableExpr) *SelectBuilder { + for _, t := range tables { + b.from = append(b.from, t.tableRef()) + } + return b +} + +func (b *SelectBuilder) InnerJoin(table TableExpr, on Cond) *SelectBuilder { + b.joins = append(b.joins, joinClause{joinType: "INNER JOIN", table: table.tableRef(), on: on}) + return b +} + +func (b *SelectBuilder) LeftJoin(table TableExpr, on Cond) *SelectBuilder { + b.joins = append(b.joins, joinClause{joinType: "LEFT JOIN", table: table.tableRef(), on: on}) + return b +} + +func (b *SelectBuilder) Where(cond Cond) *SelectBuilder { + b.where = &cond + return b +} + +func (b *SelectBuilder) AndWhere(cond Cond) *SelectBuilder { + if b.where == nil { + b.where = &cond + } else { + combined := b.where.And(cond) + b.where = &combined + } + return b +} + +func (b *SelectBuilder) GroupBy(cols ...string) *SelectBuilder { + b.groupBy = append(b.groupBy, cols...) + return b +} + +// Having sets the HAVING clause (filters groups after aggregation). Calling it +// again replaces the previous condition; combine multiple predicates with And. +func (b *SelectBuilder) Having(cond Cond) *SelectBuilder { + b.having = &cond + return b +} + +func (b *SelectBuilder) OrderBy(exprs ...OrderExpr) *SelectBuilder { + b.orderBy = append(b.orderBy, exprs...) + return b +} + +func (b *SelectBuilder) Limit(n int64) *SelectBuilder { + b.limit = &n + return b +} + +func (b *SelectBuilder) Offset(n int64) *SelectBuilder { + b.offset = &n + return b +} + +func (b *SelectBuilder) HasOrderBy() bool { return len(b.orderBy) > 0 } + +func (b *SelectBuilder) Build() (string, []any) { + sql, args := b.toSQL() + return replaceParams(sql), args +} + +func (b *SelectBuilder) toSQL() (string, []any) { + var sb strings.Builder + var args []any + + sb.WriteString("SELECT ") + sb.WriteString(strings.Join(b.columns, ", ")) + + if len(b.from) > 0 { + sb.WriteString(" FROM ") + parts := make([]string, len(b.from)) + for i, t := range b.from { + parts[i] = t.fromExpr() + } + sb.WriteString(strings.Join(parts, ", ")) + } + + for _, j := range b.joins { + sb.WriteString(" ") + sb.WriteString(j.joinType) + sb.WriteString(" ") + sb.WriteString(j.table.fromExpr()) + sb.WriteString(" ON ") + sb.WriteString(j.on.fragment) + args = append(args, j.on.args...) + } + + if b.where != nil { + sb.WriteString(" WHERE ") + sb.WriteString(b.where.fragment) + args = append(args, b.where.args...) + } + + if len(b.groupBy) > 0 { + sb.WriteString(" GROUP BY ") + sb.WriteString(strings.Join(b.groupBy, ", ")) + } + + if b.having != nil { + sb.WriteString(" HAVING ") + sb.WriteString(b.having.fragment) + args = append(args, b.having.args...) + } + + if len(b.orderBy) > 0 { + sb.WriteString(" ORDER BY ") + parts := make([]string, len(b.orderBy)) + for i, o := range b.orderBy { + parts[i] = o.expr + } + sb.WriteString(strings.Join(parts, ", ")) + } + + if b.limit != nil { + fmt.Fprintf(&sb, " LIMIT %d", *b.limit) + } + if b.offset != nil { + fmt.Fprintf(&sb, " OFFSET %d", *b.offset) + } + + return sb.String(), args +} + +func (b *SelectBuilder) Query(ctx context.Context, db Querier, dest any) error { + sql, args := b.Build() + if b.debug { + debugQuery(sql, args) + } + return QueryAll(ctx, db, dest, sql, args...) +} + +func (b *SelectBuilder) QueryRow(ctx context.Context, db Querier, dest any) error { + sql, args := b.Build() + if b.debug { + debugQuery(sql, args) + } + return QueryOne(ctx, db, dest, sql, args...) +} + +func (b *SelectBuilder) QueryScalarTo(ctx context.Context, db Querier, dest any) error { + query, args := b.Build() + if b.debug { + debugQuery(query, args) + } + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + if !rows.Next() { + if err := rows.Err(); err != nil { + return err + } + return sql.ErrNoRows + } + return rows.Scan(dest) +} + +// InsertBuilder + +type InsertBuilder struct { + table TableRef + columns []string + values []any + model any + returning []string + debug bool +} + +func (b *InsertBuilder) Debug() *InsertBuilder { + b.debug = true + return b +} + +func InsertInto(table TableExpr) *InsertBuilder { + return &InsertBuilder{table: table.tableRef()} +} + +func (b *InsertBuilder) Columns(cols ...string) *InsertBuilder { + b.columns = cols + return b +} + +func (b *InsertBuilder) Values(vals ...any) *InsertBuilder { + b.values = vals + return b +} + +func (b *InsertBuilder) Model(model any) *InsertBuilder { + b.model = model + return b +} + +func (b *InsertBuilder) Returning(cols ...string) *InsertBuilder { + b.returning = cols + return b +} + +func (b *InsertBuilder) Build() (string, []any) { + columns := b.columns + var args []any + + if b.model != nil { + if len(columns) == 0 { + t := reflect.TypeOf(b.model) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + columns = allDBColumns(t) + } + args = extractModelValues(b.model, columns) + } else { + args = b.values + } + + placeholders := make([]string, len(columns)) + for i := range columns { + placeholders[i] = "?" + } + + sql := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", + b.table.tableName, + strings.Join(columns, ", "), + strings.Join(placeholders, ", ")) + + if len(b.returning) > 0 { + sql += " RETURNING " + strings.Join(b.returning, ", ") + } + + return replaceParams(sql), args +} + +func (b *InsertBuilder) Exec(ctx context.Context, db Execer) (sql.Result, error) { + sql, args := b.Build() + if b.debug { + debugQuery(sql, args) + } + return db.ExecContext(ctx, sql, args...) +} + +func (b *InsertBuilder) QueryRow(ctx context.Context, db Querier, dest any) error { + sql, args := b.Build() + if b.debug { + debugQuery(sql, args) + } + return QueryOne(ctx, db, dest, sql, args...) +} + +// UpdateBuilder + +type setClause struct { + col string + val any +} + +type UpdateBuilder struct { + table TableRef + sets []setClause + setCols []string + model any + where *Cond + debug bool +} + +func (b *UpdateBuilder) Debug() *UpdateBuilder { + b.debug = true + return b +} + +func Update(table TableExpr) *UpdateBuilder { + return &UpdateBuilder{table: table.tableRef()} +} + +func (b *UpdateBuilder) Set(col any, val any) *UpdateBuilder { + var name string + switch c := col.(type) { + case Col: + name = c.expr + case string: + name = c + default: + panic(fmt.Sprintf("builder: Set col must be Col or string, got %T", col)) + } + b.sets = append(b.sets, setClause{col: name, val: val}) + return b +} + +func (b *UpdateBuilder) SetColumns(cols ...string) *UpdateBuilder { + b.setCols = cols + return b +} + +func (b *UpdateBuilder) Model(model any) *UpdateBuilder { + b.model = model + return b +} + +func (b *UpdateBuilder) Where(cond Cond) *UpdateBuilder { + b.where = &cond + return b +} + +func (b *UpdateBuilder) Build() (string, []any) { + var setClauses []string + var args []any + + if b.model != nil { + cols := b.setCols + if len(cols) == 0 { + t := reflect.TypeOf(b.model) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + cols = allDBColumns(t) + } + vals := extractModelValues(b.model, cols) + for i, col := range cols { + setClauses = append(setClauses, col+" = ?") + args = append(args, vals[i]) + } + } + + for _, s := range b.sets { + setClauses = append(setClauses, s.col+" = ?") + args = append(args, s.val) + } + + sql := "UPDATE " + b.table.tableName + " SET " + strings.Join(setClauses, ", ") + + if b.where != nil { + sql += " WHERE " + b.where.fragment + args = append(args, b.where.args...) + } + + return replaceParams(sql), args +} + +func (b *UpdateBuilder) Exec(ctx context.Context, db Execer) (sql.Result, error) { + sql, args := b.Build() + if b.debug { + debugQuery(sql, args) + } + return db.ExecContext(ctx, sql, args...) +} + +// DeleteBuilder + +type DeleteBuilder struct { + table TableRef + where *Cond + debug bool +} + +func (b *DeleteBuilder) Debug() *DeleteBuilder { + b.debug = true + return b +} + +func DeleteFrom(table TableExpr) *DeleteBuilder { + return &DeleteBuilder{table: table.tableRef()} +} + +func (b *DeleteBuilder) Where(cond Cond) *DeleteBuilder { + b.where = &cond + return b +} + +func (b *DeleteBuilder) Build() (string, []any) { + sql := "DELETE FROM " + b.table.tableName + var args []any + + if b.where != nil { + sql += " WHERE " + b.where.fragment + args = append(args, b.where.args...) + } + + return replaceParams(sql), args +} + +func (b *DeleteBuilder) Exec(ctx context.Context, db Execer) (sql.Result, error) { + sql, args := b.Build() + if b.debug { + debugQuery(sql, args) + } + return db.ExecContext(ctx, sql, args...) +} + +// debugQuery prints the SQL and args to stdout when debug mode is enabled. +func debugQuery(sql string, args []any) { + fmt.Println("\n[dbutil:debug] SQL:", sql) + if len(args) > 0 { + fmt.Print("[dbutil:debug] Args: [") + for i, arg := range args { + if i > 0 { + fmt.Print(", ") + } + fmt.Printf("%v", arg) + } + fmt.Println("]") + } + fmt.Println() +} + +// Helpers + +func replaceParams(sql string) string { + var b strings.Builder + n := 1 + for i := range len(sql) { + if sql[i] == '?' { + fmt.Fprintf(&b, "$%d", n) + n++ + } else { + b.WriteByte(sql[i]) + } + } + return b.String() +} + +func extractModelValues(model any, columns []string) []any { + v := reflect.ValueOf(model) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + t := v.Type() + + tagMap := make(map[string]int, t.NumField()) + for i := range t.NumField() { + if tag := t.Field(i).Tag.Get("db"); tag != "" && tag != "-" { + tagMap[tag] = i + } + } + + vals := make([]any, len(columns)) + for i, col := range columns { + if idx, ok := tagMap[col]; ok { + vals[i] = v.Field(idx).Interface() + } + } + return vals +} + +func allDBColumns(t reflect.Type) []string { + var cols []string + for i := range t.NumField() { + f := t.Field(i) + if !f.IsExported() { + continue + } + if dbTag := f.Tag.Get("db"); dbTag != "" && dbTag != "-" { + cols = append(cols, dbTag) + } + if f.Anonymous { + ft := derefType(f.Type) + if ft.Kind() == reflect.Struct { + cols = append(cols, allDBColumns(ft)...) + } + } + } + return cols +} + +func collectColumnsFlat(t reflect.Type, tableRef string) []string { + var cols []string + for i := range t.NumField() { + f := t.Field(i) + if !f.IsExported() { + continue + } + if dbTag := f.Tag.Get("db"); dbTag != "" && dbTag != "-" { + if tableRef != "" { + cols = append(cols, tableRef+"."+dbTag) + } else { + cols = append(cols, dbTag) + } + continue + } + if f.Anonymous { + ft := derefType(f.Type) + if ft.Kind() == reflect.Struct { + cols = append(cols, collectColumnsFlat(ft, tableRef)...) + } + } + } + return cols +} diff --git a/dbutil/builder_test.go b/dbutil/builder_test.go new file mode 100644 index 00000000..83b798b4 --- /dev/null +++ b/dbutil/builder_test.go @@ -0,0 +1,737 @@ +package dbutil + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +// toSnakeCase + +func TestToSnakeCase(t *testing.T) { + cases := []struct{ in, want string }{ + {"AppUser", "app_user"}, + {"OrgUser", "org_user"}, + {"ID", "id"}, + {"IPAddr", "ip_addr"}, + {"OrgUserDTO", "org_user_dto"}, + {"CreatedBy", "created_by"}, + {"EmailNotificationECd", "email_notification_e_cd"}, + {"HTMLParser", "html_parser"}, + {"Simple", "simple"}, + } + for _, tc := range cases { + got := toSnakeCase(tc.in) + if got != tc.want { + t.Errorf("toSnakeCase(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// TableRef + +func TestTableRef(t *testing.T) { + u := T[testUser]("u") + if u.ref() != "u" { + t.Errorf("ref() = %q, want %q", u.ref(), "u") + } + if u.fromExpr() != "test_user u" { + t.Errorf("fromExpr() = %q, want %q", u.fromExpr(), "test_user u") + } + + noAlias := T[testUser]() + if noAlias.ref() != "test_user" { + t.Errorf("ref() without alias = %q, want %q", noAlias.ref(), "test_user") + } + if noAlias.fromExpr() != "test_user" { + t.Errorf("fromExpr() without alias = %q, want %q", noAlias.fromExpr(), "test_user") + } +} + +func TestTableRefPanicsOnUnregistered(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for unregistered type") + } + }() + type Bogus struct { + X string `db:"x"` + } + T[Bogus]() +} + +func TestTName(t *testing.T) { + ref := TName("my_table", "mt") + if ref.fromExpr() != "my_table mt" { + t.Errorf("fromExpr() = %q, want %q", ref.fromExpr(), "my_table mt") + } +} + +// Cols / ColsFlat / AllColNames + +func TestColsFlat(t *testing.T) { + s := T[testSession]("s") + flat := s.ColsFlat() + if !containsSubstr(flat, "s.id") { + t.Errorf("ColsFlat missing s.id: %s", flat) + } + if containsSubstr(flat, " AS ") { + t.Errorf("ColsFlat should not contain AS aliases: %s", flat) + } +} + +func TestCols(t *testing.T) { + u := T[testUser]("u") + cols := u.Cols() + if !containsSubstr(cols, `u.id AS "test_user.id"`) { + t.Errorf("Cols missing aliased id: %s", cols) + } + if !containsSubstr(cols, `u.username AS "test_user.username"`) { + t.Errorf("Cols missing aliased username: %s", cols) + } +} + +func TestColsMapAs(t *testing.T) { + cb := T[testUser]("cb").MapAs("created_by") + cols := cb.Cols() + if !containsSubstr(cols, `cb.id AS "created_by.id"`) { + t.Errorf("MapAs Cols missing aliased id: %s", cols) + } +} + +func TestAllColNames(t *testing.T) { + s := T[testSession]() + names := s.AllColNames() + want := []string{"id", "key", "user_id", "org_id", "created", "user_agent", "revoked"} + if len(names) != len(want) { + t.Fatalf("AllColNames len = %d, want %d\ngot: %v\nwant: %v", len(names), len(want), names, want) + } + for i, n := range names { + if n != want[i] { + t.Errorf("AllColNames[%d] = %q, want %q", i, n, want[i]) + } + } +} + +// Col conditions + +func TestColConditions(t *testing.T) { + u := T[testUser]("u") + c := u.F(&u.M.ID) + id1 := uuid.New() + id2 := uuid.New() + + cases := []struct { + name string + cond Cond + frag string + argc int + }{ + {"Eq", c.Eq(id1), "u.id = ?", 1}, + {"Neq", c.Neq(id1), "u.id <> ?", 1}, + {"Gt", c.Gt(id1), "u.id > ?", 1}, + {"GtEq", c.GtEq(id1), "u.id >= ?", 1}, + {"Lt", c.Lt(id1), "u.id < ?", 1}, + {"LtEq", c.LtEq(id1), "u.id <= ?", 1}, + {"Like", c.Like("%x%"), "u.id LIKE ?", 1}, + {"IsNull", c.IsNull(), "u.id IS NULL", 0}, + {"IsNotNull", c.IsNotNull(), "u.id IS NOT NULL", 0}, + {"Between", c.Between(id1, id2), "u.id BETWEEN ? AND ?", 2}, + {"EqCol", c.EqCol(u.C("other")), "u.id = u.other", 0}, + } + for _, tc := range cases { + if tc.cond.fragment != tc.frag { + t.Errorf("%s: fragment = %q, want %q", tc.name, tc.cond.fragment, tc.frag) + } + if len(tc.cond.args) != tc.argc { + t.Errorf("%s: args len = %d, want %d", tc.name, len(tc.cond.args), tc.argc) + } + } +} + +func TestColIn(t *testing.T) { + u := T[testUser]("u") + id1, id2, id3 := uuid.New(), uuid.New(), uuid.New() + + // Variadic + c := u.F(&u.M.ID).In(id1, id2, id3) + if c.fragment != "u.id IN (?, ?, ?)" { + t.Errorf("In variadic fragment = %q", c.fragment) + } + if len(c.args) != 3 { + t.Errorf("In variadic args len = %d", len(c.args)) + } + + // Slice expansion + ids := []uuid.UUID{uuid.New(), uuid.New()} + c2 := u.F(&u.M.ID).In(ids) + if c2.fragment != "u.id IN (?, ?)" { + t.Errorf("In slice fragment = %q", c2.fragment) + } + if len(c2.args) != 2 { + t.Errorf("In slice args len = %d", len(c2.args)) + } +} + +func TestLower(t *testing.T) { + u := T[testUser]("u") + c := Lower(u.F(&u.M.Username)) + if c.expr != "LOWER(u.username)" { + t.Errorf("Lower expr = %q", c.expr) + } +} + +// Cond composition + +func TestCondAndOr(t *testing.T) { + a := Cond{fragment: "a = ?", args: []any{1}} + b := Cond{fragment: "b = ?", args: []any{2}} + + and := a.And(b) + if and.fragment != "(a = ? AND b = ?)" { + t.Errorf("And fragment = %q", and.fragment) + } + if len(and.args) != 2 { + t.Errorf("And args len = %d", len(and.args)) + } + + or := a.Or(b) + if or.fragment != "(a = ? OR b = ?)" { + t.Errorf("Or fragment = %q", or.fragment) + } +} + +func TestCondIdentity(t *testing.T) { + empty := Cond{} + real := Cond{fragment: "x = ?", args: []any{1}} + + if empty.And(real).fragment != real.fragment { + t.Error("empty.And(real) should return real") + } + if real.And(empty).fragment != real.fragment { + t.Error("real.And(empty) should return real") + } +} + +func TestCondNot(t *testing.T) { + c := Cond{fragment: "a = ?", args: []any{1}} + n := c.Not() + if n.fragment != "NOT (a = ?)" { + t.Errorf("Not fragment = %q", n.fragment) + } +} + +// SELECT Build + +func TestSelectSimple(t *testing.T) { + u := T[testUser]("u") + id := uuid.New() + sql, args := Select(u.ColsFlat()). + From(u). + Where(u.F(&u.M.ID).Eq(id)). + Build() + + if !containsSubstr(sql, "SELECT u.id") { + t.Errorf("missing columns: %s", sql) + } + if !containsSubstr(sql, "FROM test_user u") { + t.Errorf("missing FROM: %s", sql) + } + if !containsSubstr(sql, "WHERE u.id = $1") { + t.Errorf("missing WHERE with $1: %s", sql) + } + if len(args) != 1 || args[0] != id { + t.Errorf("args = %v", args) + } +} + +func TestSelectJoin(t *testing.T) { + m := T[testMembership]("m") + u := T[testUser]("u") + orgID := uuid.New() + + sql, args := Select(m.Cols(), u.Cols()). + From(m). + InnerJoin(u, u.F(&u.M.ID).EqCol(m.F(&m.M.UserID))). + Where(m.F(&m.M.OrgID).Eq(orgID)). + OrderBy(u.F(&u.M.LastName).Asc()). + Limit(25). + Offset(50). + Build() + + if !containsSubstr(sql, "INNER JOIN test_user u ON u.id = m.user_id") { + t.Errorf("missing JOIN: %s", sql) + } + if !containsSubstr(sql, "WHERE m.org_id = $1") { + t.Errorf("missing WHERE: %s", sql) + } + if !containsSubstr(sql, "ORDER BY u.last_name ASC") { + t.Errorf("missing ORDER BY: %s", sql) + } + if !containsSubstr(sql, "LIMIT 25") { + t.Errorf("missing LIMIT: %s", sql) + } + if !containsSubstr(sql, "OFFSET 50") { + t.Errorf("missing OFFSET: %s", sql) + } + if len(args) != 1 { + t.Errorf("args = %v", args) + } +} + +func TestSelectLeftJoin(t *testing.T) { + s := T[testSession]("s") + u := T[testUser]("u") + + sql, _ := Select(s.Cols(), u.Cols()). + From(s). + LeftJoin(u, u.F(&u.M.ID).EqCol(s.F(&s.M.UserID))). + Build() + + if !containsSubstr(sql, "LEFT JOIN test_user u ON u.id = s.user_id") { + t.Errorf("missing LEFT JOIN: %s", sql) + } +} + +func TestSelectCount(t *testing.T) { + m := T[testMembership]("m") + orgID := uuid.New() + + sql, args := Select("COUNT(*)"). + From(m). + Where(m.F(&m.M.OrgID).Eq(orgID)). + Build() + + if sql != "SELECT COUNT(*) FROM test_membership m WHERE m.org_id = $1" { + t.Errorf("sql = %q", sql) + } + if len(args) != 1 { + t.Errorf("args len = %d", len(args)) + } +} + +func TestSelectMultipleWhereParams(t *testing.T) { + u := T[testUser]("u") + now := time.Now() + + sql, args := Select(u.ColsFlat()). + From(u). + Where( + u.F(&u.M.LoginCount).Gt(int32(5)). + And(u.F(&u.M.Created).GtEq(now)). + And(u.F(&u.M.Username).Like("%admin%")), + ). + Build() + + if !containsSubstr(sql, "$1") && !containsSubstr(sql, "$2") && !containsSubstr(sql, "$3") { + t.Errorf("missing param placeholders: %s", sql) + } + if len(args) != 3 { + t.Errorf("args len = %d, want 3", len(args)) + } +} + +func TestSelectSubquery(t *testing.T) { + s := T[testSession]("s") + + sub := Select(s.F(&s.M.ID).String()). + From(s). + Where(s.F(&s.M.UserID).Eq(uuid.New()).And(s.F(&s.M.Revoked).Eq(false))). + OrderBy(s.F(&s.M.Created).Asc()). + Limit(3) + + s2 := T[testSession]() + sql, args := Update(s2). + Set("revoked", true). + Where(s2.F(&s2.M.ID).InQuery(sub)). + Build() + + if !containsSubstr(sql, "IN (SELECT s.id FROM test_session s WHERE") { + t.Errorf("missing subquery: %s", sql) + } + if len(args) != 3 { + t.Errorf("args len = %d, want 3, args = %v", len(args), args) + } + if !containsSubstr(sql, "$1") || !containsSubstr(sql, "$2") || !containsSubstr(sql, "$3") { + t.Errorf("params not sequential: %s", sql) + } +} + +func TestSelectGroupBy(t *testing.T) { + u := T[testUser]("u") + + sql, _ := Select("u.active", "COUNT(*)"). + From(u). + GroupBy("u.active"). + Build() + + if !containsSubstr(sql, "GROUP BY u.active") { + t.Errorf("missing GROUP BY: %s", sql) + } +} + +func TestAndWhere(t *testing.T) { + u := T[testUser]("u") + q := Select(u.ColsFlat()).From(u) + q.AndWhere(u.F(&u.M.ID).Eq(uuid.New())) + q.AndWhere(u.F(&u.M.Username).Eq("bob")) + + sql, args := q.Build() + if !containsSubstr(sql, "$1") || !containsSubstr(sql, "$2") { + t.Errorf("missing params: %s", sql) + } + if len(args) != 2 { + t.Errorf("args len = %d", len(args)) + } +} + +// INSERT Build + +func TestInsertValues(t *testing.T) { + u := T[testUser]() + id := uuid.New() + + sql, args := InsertInto(u). + Columns(u.FieldNames(&u.M.ID, &u.M.Username, &u.M.Email)...). + Values(id, "alice", "alice@example.com"). + Build() + + if sql != "INSERT INTO test_user (id, username, email) VALUES ($1, $2, $3)" { + t.Errorf("sql = %q", sql) + } + if len(args) != 3 { + t.Errorf("args len = %d", len(args)) + } + if args[1] != "alice" { + t.Errorf("args[1] = %v", args[1]) + } +} + +func TestInsertModel(t *testing.T) { + s := T[testSession]() + sess := testSession{ + Key: "sess_abc", + UserID: uuid.New(), + } + + sql, args := InsertInto(s). + Columns(s.FieldNames(&s.M.Key, &s.M.UserID)...). + Model(sess). + Build() + + if sql != "INSERT INTO test_session (key, user_id) VALUES ($1, $2)" { + t.Errorf("sql = %q", sql) + } + if args[0] != "sess_abc" { + t.Errorf("args[0] = %v", args[0]) + } + if args[1] != sess.UserID { + t.Errorf("args[1] = %v", args[1]) + } +} + +func TestInsertModelAllColumns(t *testing.T) { + s := T[testSession]() + sess := testSession{Key: "k"} + + sql, args := InsertInto(s).Model(sess).Build() + + if !containsSubstr(sql, "INSERT INTO test_session (id, key, user_id") { + t.Errorf("sql = %q", sql) + } + if len(args) != 7 { // testSession has 7 fields + t.Errorf("args len = %d, want 7", len(args)) + } +} + +// UPDATE Build + +func TestUpdateSet(t *testing.T) { + u := T[testUser]() + id := uuid.New() + + sql, args := Update(u). + Set("login_count", 42). + Set("active", false). + Where(u.F(&u.M.ID).Eq(id)). + Build() + + if sql != "UPDATE test_user SET login_count = $1, active = $2 WHERE test_user.id = $3" { + t.Errorf("sql = %q", sql) + } + if len(args) != 3 { + t.Errorf("args len = %d", len(args)) + } + if args[0] != 42 { + t.Errorf("args[0] = %v", args[0]) + } +} + +func TestUpdateModelSetColumns(t *testing.T) { + u := T[testUser]() + user := testUser{ + ID: uuid.New(), + FirstName: "Alice", + LastName: "Smith", + Email: "alice@test.com", + } + + sql, args := Update(u). + SetColumns(u.FieldNames(&u.M.FirstName, &u.M.LastName, &u.M.Email)...). + Model(user). + Where(u.F(&u.M.ID).Eq(user.ID)). + Build() + + if !containsSubstr(sql, "SET first_name = $1, last_name = $2, email = $3") { + t.Errorf("missing SET: %s", sql) + } + if !containsSubstr(sql, "WHERE test_user.id = $4") { + t.Errorf("missing WHERE: %s", sql) + } + if args[0] != "Alice" || args[1] != "Smith" || args[2] != "alice@test.com" { + t.Errorf("args = %v", args) + } +} + +// DELETE Build + +func TestDeleteSimple(t *testing.T) { + s := T[testSession]() + + sql, args := DeleteFrom(s). + Where(s.F(&s.M.Key).Eq("sess_xyz")). + Build() + + if sql != "DELETE FROM test_session WHERE test_session.key = $1" { + t.Errorf("sql = %q", sql) + } + if len(args) != 1 || args[0] != "sess_xyz" { + t.Errorf("args = %v", args) + } +} + +func TestDeleteNoWhere(t *testing.T) { + s := T[testSession]() + sql, args := DeleteFrom(s).Build() + if sql != "DELETE FROM test_session" { + t.Errorf("sql = %q", sql) + } + if len(args) != 0 { + t.Errorf("args = %v", args) + } +} + +// replaceParams + +func TestReplaceParams(t *testing.T) { + cases := []struct{ in, want string }{ + {"x = ?", "x = $1"}, + {"a = ? AND b = ?", "a = $1 AND b = $2"}, + {"IN (?, ?, ?)", "IN ($1, $2, $3)"}, + {"no params", "no params"}, + } + for _, tc := range cases { + got := replaceParams(tc.in) + if got != tc.want { + t.Errorf("replaceParams(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// extractModelValues + +func TestExtractModelValues(t *testing.T) { + user := testUser{ + Username: "bob", + Email: "bob@test.com", + FirstName: "Bob", + } + + vals := extractModelValues(user, []string{"username", "email", "first_name"}) + if vals[0] != "bob" || vals[1] != "bob@test.com" || vals[2] != "Bob" { + t.Errorf("vals = %v", vals) + } +} + +func TestExtractModelValuesMissing(t *testing.T) { + user := testUser{Username: "bob"} + vals := extractModelValues(user, []string{"username", "nonexistent"}) + if vals[0] != "bob" { + t.Errorf("vals[0] = %v", vals[0]) + } + if vals[1] != nil { + t.Errorf("vals[1] for missing column = %v, want nil", vals[1]) + } +} + +// Field references + +func TestFieldReference(t *testing.T) { + u := T[testUser]("u") + + col := u.F(&u.M.ID) + if col.String() != "u.id" { + t.Errorf("F(&u.M.ID) = %q, want %q", col.String(), "u.id") + } + + col2 := u.F(&u.M.FirstName) + if col2.String() != "u.first_name" { + t.Errorf("F(&u.M.FirstName) = %q, want %q", col2.String(), "u.first_name") + } + + col3 := u.F(&u.M.Email) + if col3.String() != "u.email" { + t.Errorf("F(&u.M.Email) = %q, want %q", col3.String(), "u.email") + } +} + +func TestFieldNames(t *testing.T) { + u := T[testUser]() + names := u.FieldNames(&u.M.FirstName, &u.M.LastName, &u.M.Email) + want := []string{"first_name", "last_name", "email"} + if len(names) != len(want) { + t.Fatalf("FieldNames len = %d, want %d", len(names), len(want)) + } + for i, n := range names { + if n != want[i] { + t.Errorf("FieldNames[%d] = %q, want %q", i, n, want[i]) + } + } +} + +func TestFieldReferenceEqCol(t *testing.T) { + u := T[testUser]("u") + m := T[testMembership]("m") + + cond := u.F(&u.M.ID).EqCol(m.F(&m.M.UserID)) + if cond.fragment != "u.id = m.user_id" { + t.Errorf("EqCol fragment = %q", cond.fragment) + } +} + +func TestFieldReferenceInSelect(t *testing.T) { + u := T[testUser]("u") + sql, args := Select(u.F(&u.M.ID).String(), u.F(&u.M.Username).String()). + From(u). + Where(u.F(&u.M.Email).Eq("test@test.com")). + Build() + + if sql != "SELECT u.id, u.username FROM test_user u WHERE u.email = $1" { + t.Errorf("sql = %q", sql) + } + if len(args) != 1 || args[0] != "test@test.com" { + t.Errorf("args = %v", args) + } +} + +func TestFieldNamesWithSetColumns(t *testing.T) { + u := T[testUser]() + user := testUser{ + ID: uuid.New(), + FirstName: "Test", + LastName: "User", + } + + sql, args := Update(u). + SetColumns(u.FieldNames(&u.M.FirstName, &u.M.LastName)...). + Model(user). + Where(u.F(&u.M.ID).Eq(user.ID)). + Build() + + if !containsSubstr(sql, "SET first_name = $1, last_name = $2") { + t.Errorf("missing SET: %s", sql) + } + if !containsSubstr(sql, "WHERE test_user.id = $3") { + t.Errorf("missing WHERE: %s", sql) + } + if args[0] != "Test" || args[1] != "User" { + t.Errorf("args = %v", args) + } +} + +func TestFieldReferencePanicsOnBadPointer(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected panic for bad field pointer") + } + }() + u := T[testUser]("u") + var unrelated int + u.F(&unrelated) +} + +func TestTableGenericAs(t *testing.T) { + u := T[testUser]("u") + u2 := u.As("u2") + + if u2.ref() != "u2" { + t.Errorf("As ref() = %q, want %q", u2.ref(), "u2") + } + col := u2.F(&u2.M.ID) + if col.String() != "u2.id" { + t.Errorf("F after As = %q, want %q", col.String(), "u2.id") + } +} + +func TestTableGenericMapAs(t *testing.T) { + cb := T[testUser]("cb").MapAs("created_by") + cols := cb.Cols() + if !containsSubstr(cols, `cb.id AS "created_by.id"`) { + t.Errorf("MapAs Cols missing aliased id: %s", cols) + } + col := cb.F(&cb.M.ID) + if col.String() != "cb.id" { + t.Errorf("F after MapAs = %q, want %q", col.String(), "cb.id") + } +} + +// checkType + +func TestCheckTypePanicsOnMismatch(t *testing.T) { + u := T[testUser]("u") + + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for type mismatch") + } + msg, ok := r.(string) + if !ok { + t.Fatalf("panic value is not string: %v", r) + } + if !containsSubstr(msg, "type mismatch") { + t.Errorf("panic message = %q, want it to contain 'type mismatch'", msg) + } + }() + + // Active is bool, passing string should panic + u.F(&u.M.Active).Eq("true") +} + +func TestCheckTypeSkipsForRawCol(t *testing.T) { + u := T[testUser]("u") + // C() returns a Col without fieldType — should not panic + u.C("active").Eq("anything") +} + +func TestCheckTypeSkipsForNilVal(t *testing.T) { + u := T[testUser]("u") + // nil should not panic even on typed columns + u.F(&u.M.Active).Eq(nil) +} + +// helpers + +func containsSubstr(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsIdx(s, sub)) +} + +func containsIdx(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/dbutil/db_connect.go b/dbutil/db_connect.go new file mode 100644 index 00000000..a58fa2e4 --- /dev/null +++ b/dbutil/db_connect.go @@ -0,0 +1,64 @@ +package dbutil + +import ( + "database/sql" + "fmt" + "log" + "time" + + _ "github.com/lib/pq" +) + +// The dbutil package provides an interface between go code and a relational database. + +var db *sql.DB + +// ConnConfig holds everything Init needs to open the Postgres connection pool. +// The app builds it from its own config (dbutil.ConnConfig{Username: cfg.X, ...}) +// so the framework never imports application config. +type ConnConfig struct { + Username string + Password string + Host string + Port int + Name string + Schema string + SSLMode string + MaxConns int + TimeoutSeconds int +} + +// BuildConnectionString renders a lib/pq Postgres DSN with the session TimeZone +// pinned to UTC. Exported so cmd/migrate reuses it instead of duplicating the +// format string. +func BuildConnectionString(c ConnConfig) string { + return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?search_path=%s&sslmode=%s&options=-c%%20TimeZone%%3DUTC", + c.Username, + c.Password, + c.Host, + c.Port, + c.Name, + c.Schema, + c.SSLMode, + ) +} + +// Init opens the global connection pool from c and pings it. It fatals on +// failure, matching the previous package behaviour. +func Init(c ConnConfig) { + var err error + db, err = sql.Open("postgres", BuildConnectionString(c)) + if err != nil { + log.Fatal(err.Error()) + } + + if pingErr := db.Ping(); pingErr != nil { + log.Fatal(pingErr.Error()) + } + + db.SetMaxOpenConns(c.MaxConns) + db.SetMaxIdleConns(2) + db.SetConnMaxIdleTime(time.Duration(c.TimeoutSeconds) * time.Second) +} + +func DB() *sql.DB { return db } diff --git a/dbutil/filters.go b/dbutil/filters.go new file mode 100644 index 00000000..d59b99d4 --- /dev/null +++ b/dbutil/filters.go @@ -0,0 +1,306 @@ +package dbutil + +import ( + . "kjol/basic" + "net/http" + "strconv" + "strings" +) + +const ( + PAGE_NUM_KEY = "page_num" + ORDER_BY_KEY = "order_by" + ITEMS_PER_PAGE_KEY = "items_per_page" + SEARCH_KEY_PREFIX = "search_" + FILTER_DEFAULT_MAX_ITEMS = 25 +) + +type Search struct { + Values []string + Identifier string + CaseSensitive bool + IgnoreWhitespace bool +} + +type OrderBy struct { + Identifier string + Descending bool +} + +type Pagination struct { + Disabled bool + CurrentPage int + NextPage int + PreviousPage int + TotalPages int + TotalItems int + MaxItemsPerPage int + ItemsThisPage int + ViewRangeLower int + ViewRangeUpper int +} + +type Filter struct { + Search []Search + Pagination Pagination + OrderBy OrderBy +} + +// GetSearch returns the Search struct for the given identifier, or nil if not found. +func (f *Filter) GetSearch(identifier string) *Search { + for i := range f.Search { + if f.Search[i].Identifier == identifier { + return &f.Search[i] + } + } + + return nil +} + +// BindOrderBy applies an ORDER BY clause to the select builder if the filter's +// OrderBy identifier matches. +func BindOrderBy(identifier string, col Col, f Filter, sb *SelectBuilder) { + if sb == nil { + return + } + if identifier == f.OrderBy.Identifier { + if f.OrderBy.Descending { + sb.OrderBy(col.Desc()) + } else { + sb.OrderBy(col.Asc()) + } + } +} + +// BindOrderByMultiCols applies ORDER BY with multiple columns if the filter's +// OrderBy identifier matches. +func BindOrderByMultiCols(identifier string, f Filter, sb *SelectBuilder, cols ...Col) { + if sb == nil { + return + } + if identifier == f.OrderBy.Identifier { + exprs := make([]OrderExpr, len(cols)) + for i, c := range cols { + if f.OrderBy.Descending { + exprs[i] = c.Desc() + } else { + exprs[i] = c.Asc() + } + } + sb.OrderBy(exprs...) + } +} + +// SetDefaultOrderBy applies the given order if no ORDER BY has been set yet. +func SetDefaultOrderBy(defaultExpr OrderExpr, sb *SelectBuilder) { + if sb == nil { + return + } + if !sb.HasOrderBy() { + sb.OrderBy(defaultExpr) + } +} + +// ApplyPagination applies LIMIT and OFFSET to the select builder based on the filter. +func ApplyPagination(f Filter, sb *SelectBuilder) { + if !f.Pagination.Disabled { + if f.Pagination.MaxItemsPerPage > 0 { + sb.Limit(int64(f.Pagination.MaxItemsPerPage)) + sb.Offset(int64((f.Pagination.CurrentPage - 1) * f.Pagination.MaxItemsPerPage)) + } + } +} + +func ParseFilterFromRequest(r *http.Request) Filter { + if r.Body != nil { + defer r.Body.Close() + } + + r.ParseForm() + + filter := Filter{} + filter.Pagination.MaxItemsPerPage = FILTER_DEFAULT_MAX_ITEMS + filter.Pagination.CurrentPage = 1 + + // Parse pagination + if pageNum := r.FormValue(PAGE_NUM_KEY); pageNum != "" { + if n, err := strconv.Atoi(pageNum); err == nil && n > 0 { + filter.Pagination.CurrentPage = n + } + } + if itemsPerPage := r.FormValue(ITEMS_PER_PAGE_KEY); itemsPerPage != "" { + if n, err := strconv.Atoi(itemsPerPage); err == nil { + if n > 0 { + filter.Pagination.MaxItemsPerPage = n + } else if n == -1 { + filter.Pagination.MaxItemsPerPage = -1 + } + } + } + + // Parse order by + if orderByValue := r.FormValue(ORDER_BY_KEY); orderByValue != "" { + filter.OrderBy.Identifier = orderByValue + filter.OrderBy.Descending = r.FormValue("order_desc") == "true" + } + + // Parse search parameters (keys prefixed with search_) + for key, values := range r.Form { + if strings.HasPrefix(key, SEARCH_KEY_PREFIX) && len(values) > 0 { + identifier := strings.TrimPrefix(key, SEARCH_KEY_PREFIX) + + if len(values) == 1 && values[0] == "__EMPTY_ARRAY__" { + filter.Search = append(filter.Search, Search{ + Identifier: identifier, + Values: []string{}, + }) + } else { + filter.Search = append(filter.Search, Search{ + Identifier: identifier, + Values: values, + }) + } + } + } + + return filter +} + +func (p *Pagination) GeneratePagination(totalItemsInSet int64, itemsDisplayedThisPage int) { + p.TotalItems = int(totalItemsInSet) + p.ItemsThisPage = itemsDisplayedThisPage + + if p.MaxItemsPerPage == 0 { + p.MaxItemsPerPage = FILTER_DEFAULT_MAX_ITEMS + } + + if p.MaxItemsPerPage == -1 { + p.TotalPages = 1 + p.CurrentPage = 1 + p.PreviousPage = 1 + p.NextPage = 1 + if p.TotalItems != 0 { + p.ViewRangeLower = 1 + } else { + p.ViewRangeLower = 0 + } + p.ViewRangeUpper = p.TotalItems + return + } + + if p.MaxItemsPerPage == 0 { + p.TotalPages = 1 + } else { + p.TotalPages = p.TotalItems / p.MaxItemsPerPage + + if p.TotalItems%p.MaxItemsPerPage != 0 { + p.TotalPages++ + } + } + + if p.TotalPages == 0 { + p.TotalPages = 1 + } + + if p.CurrentPage < 1 { + p.CurrentPage = 1 + p.PreviousPage = 1 + } else { + p.PreviousPage = p.CurrentPage - 1 + } + + if p.TotalItems != 0 { + p.ViewRangeLower = p.MaxItemsPerPage*p.CurrentPage - p.MaxItemsPerPage + 1 + } else { + p.ViewRangeLower = 0 + } + p.ViewRangeUpper = p.MaxItemsPerPage*p.CurrentPage - p.MaxItemsPerPage + p.ItemsThisPage + + if p.CurrentPage >= p.TotalPages { + p.CurrentPage = p.TotalPages + p.NextPage = p.TotalPages + } else { + p.NextPage = p.CurrentPage + 1 + } +} + +// PaginateSlice performs in-memory pagination on a slice. +func PaginateSlice[T any](arr []T, f Filter) []T { + if !f.Pagination.Disabled { + if f.Pagination.CurrentPage <= 0 { + f.Pagination.CurrentPage = 1 + } + + if f.Pagination.MaxItemsPerPage > 0 { + offset := (f.Pagination.CurrentPage - 1) * f.Pagination.MaxItemsPerPage + limit := f.Pagination.MaxItemsPerPage + + if offset > len(arr) { + arr = []T{} + } else if offset+limit > len(arr) { + arr = arr[offset:] + } else { + arr = arr[offset : offset+limit] + } + } + } + + return arr +} + +// LikeNonAlphaNumeric creates a condition that strips non-alphanumeric (except space) +// characters from the column and matches against the sanitized search value. +func LikeNonAlphaNumeric(columnName string, searchValue string, cond Cond) Cond { + searchSanitized := SanitizeAlphaNum(strings.ToLower(searchValue)) + + return cond.And(RawCond( + "regexp_replace(lower("+columnName+"), '[^a-zA-Z0-9 ]', '', 'g') LIKE ?", + "%"+searchSanitized+"%", + )) +} + +// LikeNonAlphaNumericStrict creates a condition that strips ALL non-alphanumeric +// characters (including spaces) from the column and matches against the sanitized search value. +func LikeNonAlphaNumericStrict(columnName string, searchValue string, cond Cond) Cond { + searchSanitized := SanitizeAlphaNumStrict(strings.ToLower(searchValue)) + + return cond.And(RawCond( + "regexp_replace(lower("+columnName+"), '[^a-zA-Z0-9]', '', 'g') LIKE ?", + "%"+searchSanitized+"%", + )) +} + +// IsEmpty returns true if the filter has no search values and no order by set. +func (f *Filter) IsEmpty() bool { + if f.OrderBy.Identifier != "" { + return false + } + for _, s := range f.Search { + if len(s.Values) > 0 && s.Values[0] != "" { + return false + } + } + return true +} + +// ToQueryString converts the filter to a URL query string. +func (f *Filter) ToQueryString() string { + params := make([]string, 0) + + for _, s := range f.Search { + for _, v := range s.Values { + if v != "" { + params = append(params, SEARCH_KEY_PREFIX+s.Identifier+"="+v) + } + } + } + + if f.OrderBy.Identifier != "" { + params = append(params, ORDER_BY_KEY+"="+f.OrderBy.Identifier) + if f.OrderBy.Descending { + params = append(params, "order_desc=true") + } + } + + return strings.Join(params, "&") +} diff --git a/dbutil/registry.go b/dbutil/registry.go new file mode 100644 index 00000000..4180261e --- /dev/null +++ b/dbutil/registry.go @@ -0,0 +1,25 @@ +package dbutil + +import "reflect" + +// tableRegistry maps a model struct type to its database table name. The query +// builder (T[M]) resolves table names through it, so the framework never has to +// import the application's models package. Apps populate it once at startup via +// Register / RegisterAll — typically from an init() in their models package: +// +// func init() { dbutil.RegisterAll(Tables) } +var tableRegistry = map[reflect.Type]string{} + +// Register maps a single model type to a table name. +func Register(t reflect.Type, name string) { tableRegistry[t] = name } + +// RegisterAll merges a whole type->table map (e.g. an app's models.Tables) into +// the registry. +func RegisterAll(m map[reflect.Type]string) { + for k, v := range m { + tableRegistry[k] = v + } +} + +// tableNameFor returns the registered table name for t, or "" if none is set. +func tableNameFor(t reflect.Type) string { return tableRegistry[t] } diff --git a/dbutil/test_models_test.go b/dbutil/test_models_test.go new file mode 100644 index 00000000..1ca54ff7 --- /dev/null +++ b/dbutil/test_models_test.go @@ -0,0 +1,52 @@ +package dbutil + +// Test-only model structs for builder and automapper tests. +// These are decoupled from the real models package so that tests +// do not break when application models change. + +import ( + "reflect" + "time" + + "github.com/google/uuid" +) + +// testUser mirrors a typical user table. +type testUser struct { + ID uuid.UUID `db:"id"` + Username string `db:"username"` + Email string `db:"email"` + FirstName string `db:"first_name"` + LastName string `db:"last_name"` + Password string `db:"password"` + LoginCount int32 `db:"login_count"` + Created time.Time `db:"created"` + Active bool `db:"active"` +} + +// testSession mirrors a session / identity table. +type testSession struct { + ID uuid.UUID `db:"id"` + Key string `db:"key"` + UserID uuid.UUID `db:"user_id"` + OrgID *uuid.UUID `db:"org_id"` + Created time.Time `db:"created"` + UserAgent string `db:"user_agent"` + Revoked bool `db:"revoked"` +} + +// testMembership mirrors an org-user / membership table. +type testMembership struct { + ID uuid.UUID `db:"id"` + UserID uuid.UUID `db:"user_id"` + OrgID uuid.UUID `db:"org_id"` + CreatedBy *uuid.UUID `db:"created_by"` + Joined time.Time `db:"joined"` + LoginCount int32 `db:"login_count"` +} + +func init() { + Register(reflect.TypeOf(testUser{}), "test_user") + Register(reflect.TypeOf(testSession{}), "test_session") + Register(reflect.TypeOf(testMembership{}), "test_membership") +} diff --git a/finance/helpers.go b/finance/helpers.go new file mode 100644 index 00000000..61252fde --- /dev/null +++ b/finance/helpers.go @@ -0,0 +1,234 @@ +package finance + +import ( + . "kjol/basic" + "fmt" + "math" + "strconv" + "strings" +) + +// takes a number such as 123456, and outputs (1234, 56) as strings +func SplitInt64(n int64) (string, string) { + // Calculate the first part as string + firstPart := fmt.Sprintf("%d", n/100) + // Calculate the second part as string with leading zero if necessary + secondPart := fmt.Sprintf("%02d", n%100) + return firstPart, secondPart +} + +func Int64ToMoney(value int64) string { + decimalValue := float64(value) / 100.0 + moneyString := fmt.Sprintf("%.2f", decimalValue) + return moneyString +} + +func Int64ToMoneyWithCommas(value int64) string { + decimalValue := float64(value) / 100.0 + moneyString := fmt.Sprintf("%.2f", decimalValue) + + parts := strings.Split(moneyString, ".") + dollars := parts[0] + cents := parts[1] + + negative := false + if strings.HasPrefix(dollars, "-") { + negative = true + dollars = dollars[1:] + } + + result := "" + for i, char := range dollars { + if i > 0 && (len(dollars)-i)%3 == 0 { + result += "," + } + result += string(char) + } + + if negative { + result = "-" + result + } + + return result + "." + cents +} + +func MoneyToInt64(input string) int64 { + if !strings.Contains(input, ".") { + input += ".00" + } else if strings.Count(input, ".") == 1 { + digits := strings.Split(input, ".") + if len(digits[1]) == 1 { + input += "0" + } + } + processedString := strings.ReplaceAll(input, ".", "") + processedString = strings.ReplaceAll(processedString, ",", "") + processedString = strings.ReplaceAll(processedString, " ", "") + result, _ := strconv.Atoi(processedString) + return int64(result) +} + +func Int64ToRate(rate int64) string { + decimalValue := float64(rate) / 1000.0 + rateString := fmt.Sprintf("%.3f", decimalValue) + return rateString +} + +func RoundUpToCeiling(input int64) int64 { + remainder := input % 100 + if remainder == 0 { + return input + } + return input + 100 - remainder +} + +func RoundDownToFloor(input int64) int64 { + remainder := input % 100 + if remainder == 0 { + return input + } + return input - remainder +} + +func MultiplyByPercentageS64(amount int64, percentage float64) int64 { + amount_float64 := float64(amount) / 100 + out_float64 := (amount_float64 * (percentage / 100)) + + out_int64 := int64(math.Round(out_float64 * 100)) + + return out_int64 +} + +func MultiplyByPercentageF64(amount int64, percentage float64) float64 { + amount_float64 := float64(amount) / 100 + + return amount_float64 * percentage +} + +func ProcessDiscount(discount string) float64 { + discount_f64, _ := strconv.ParseFloat(discount, 64) + + if discount_f64 < 0 { + return 0 + } else if discount_f64 > 100 { + return 100 + } + + return discount_f64 +} + +// DaysToRateTerm Takes days as an input and outputs the string representation of the number of +// days, months, or years in the term based on which unit is the best fit for the amount of days +// along with the corresponding unit string. +func DaysToRateTerm(days int32) (string, string) { + years := days / 365 + months := (days - years*365) / 30 + dayRemainder := days - years*365 - months*30 + + if days == 0 { // Return empty string for value + return "", "days" + } else if days <= 270 || dayRemainder > 0 { + return ToString(days), "days" + } else if months > 0 { + return ToString(months + years*12), "months" + } else { + return ToString(years), "years" + } +} + +func RateTermToDays(value string, unit string) int32 { + valueInt := StringToInt32(value) + switch unit { + case "months": + years := valueInt / 12 + months := valueInt % 12 + return years*365 + months*30 + case "years": + return valueInt * 365 + default: // case "days": + return valueInt + } +} + +func RateToString(rateValue float64) string { + str, err := NumberToString(rateValue, 3, "%", true) + if err != nil { + fmt.Println(err) + return fmt.Sprint(rateValue) + } + return str +} + +func RatePlainToString(rateValue float64) string { + str, err := NumberToString(rateValue, 3, "", false) + if err != nil { + fmt.Println(err) + return fmt.Sprint(rateValue) + } + return str +} + +func DollarAmountToString(amount float64) string { + str, err := NumberToString(amount, 2, "", false) + if err != nil { + fmt.Println(err) + return fmt.Sprint(amount) + } + return str +} + +func DollarAmountPlainToString(amount float64) string { + str, err := NumberToString(amount, 2, "$", false) + if err != nil { + fmt.Println(err) + return fmt.Sprint(amount) + } + return str +} + +func NumberToString(i any, precision int, symbol string, symbolLast bool) (string, error) { + var numberFloat float64 = 0.0 + err := fmt.Errorf("error: could not convert %s to a decimal value", i) + switch i.(type) { + case int32, int64, int, uint64, uint32, uint, float32, float64: + if val, ok := i.(float64); ok { + numberFloat = val + } else { + return "", err + } + default: + return "", fmt.Errorf("error: could not convert %s to a decimal value", i) + } + + s := fmt.Sprintf("%.[2]*[1]f", numberFloat, precision) + + parts := strings.Split(s, ".") + if len(parts) == 0 { + return "", fmt.Errorf("error: could not convert '%s' to a decimal value", s) + } + + numberLeftSide := parts[0][len(parts[0])-1:] + + for i := len(parts[0]) - 2; i >= 0; i-- { + + if len(strings.Replace(numberLeftSide, ",", "", -1))%3 == 0 { + numberLeftSide = "," + numberLeftSide + } + numberLeftSide = string(parts[0][i]) + numberLeftSide + + } + numberToReturn := numberLeftSide + if len(parts) > 1 { + numberToReturn = numberToReturn + "." + parts[1] + } + + if symbol != "" { + if symbolLast { + numberToReturn = numberToReturn + symbol + } else { + numberToReturn = symbol + numberToReturn + } + } + + return numberToReturn, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..b67a6ccf --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module kjol + +go 1.26.3 + +require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/btcsuite/btcutil v1.0.2 + github.com/caarlos0/env/v11 v11.4.1 + github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 + github.com/evanw/esbuild v0.28.0 + github.com/google/uuid v1.6.0 + github.com/hhatto/gocloc v0.7.0 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.12.3 + github.com/microcosm-cc/bluemonday v1.0.27 + github.com/minio/highwayhash v1.0.4 + github.com/tdewolff/minify/v2 v2.24.13 + golang.org/x/crypto v0.52.0 + golang.org/x/net v0.55.0 +) + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect + github.com/go-enry/go-enry/v2 v2.9.6 // indirect + github.com/go-enry/go-oniguruma v1.2.1 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/tdewolff/parse/v2 v2.8.13 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..d1559052 --- /dev/null +++ b/go.sum @@ -0,0 +1,108 @@ +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts= +github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8= +github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI= +github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo= +github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-enry/go-enry/v2 v2.9.6 h1:np63eOtMV56zfYDHnFVgpEVOk8fr2kmylcMnAZUDbSs= +github.com/go-enry/go-enry/v2 v2.9.6/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= +github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= +github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hhatto/gocloc v0.7.0 h1:PS+C3H7To0kr8dwNDz+ahKRt05pYkUdhR3YAhr/27RA= +github.com/hhatto/gocloc v0.7.0/go.mod h1:H2qL5xyLUYpiUY8JSLHaXYhACYhRuM/j5HWEOR29hus= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58= +github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0= +github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick= +github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= +github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= +github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/httputil/cors.go b/httputil/cors.go new file mode 100644 index 00000000..368c8669 --- /dev/null +++ b/httputil/cors.go @@ -0,0 +1,57 @@ +package httputil + +import ( + "net/http" + "slices" + "strings" + + "kjol/appenv" +) + +// CorsConfig configures CorsMiddleware. AllowedDomains is consulted only outside +// development (in development every origin is allowed). BundleVersion, when set, +// is called to stamp the X-Bundle-Version header on /api/ responses; leave it nil +// to skip that header. +type CorsConfig struct { + AllowedDomains []string + BundleVersion func() string +} + +// CorsMiddleware returns middleware that applies CORS headers to all requests. +// It is a constructor (not the middleware itself) so the app can inject its +// allowed domains and bundle-version source without this package importing app +// config or handlers. +func CorsMiddleware(cfg CorsConfig) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + if appenv.Environment != appenv.EnvTypeDevelopment { + if len(cfg.AllowedDomains) > 0 && slices.Contains(cfg.AllowedDomains, origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + } else { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Expose-Headers", "X-Bundle-Version") + w.Header().Set("Access-Control-Allow-Credentials", "true") + + if cfg.BundleVersion != nil { + if version := cfg.BundleVersion(); version != "" && strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("X-Bundle-Version", version) + } + } + + // Handle preflight OPTIONS request + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/httputil/cors_test.go b/httputil/cors_test.go new file mode 100644 index 00000000..f664172d --- /dev/null +++ b/httputil/cors_test.go @@ -0,0 +1,45 @@ +package httputil_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "kjol/httputil" +) + +func TestCorsMiddleware_SetsBundleVersionOnAPI(t *testing.T) { + const testVersion = "test-bundle-version" + + mux := http.NewServeMux() + mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + handler := httputil.CorsMiddleware(httputil.CorsConfig{ + BundleVersion: func() string { return testVersion }, + })(mux) + + t.Run("api route", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/ping", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("X-Bundle-Version"); got != testVersion { + t.Fatalf("expected X-Bundle-Version %q, got %q", testVersion, got) + } + }) + + t.Run("non-api route", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if got := rec.Header().Get("X-Bundle-Version"); got != "" { + t.Fatalf("expected no bundle header on non-API route, got %q", got) + } + }) +} diff --git a/httputil/doc.go b/httputil/doc.go new file mode 100644 index 00000000..1e38d719 --- /dev/null +++ b/httputil/doc.go @@ -0,0 +1,5 @@ +// Package httputil holds small, dependency-free HTTP helpers shared across apps: +// CORS middleware, JSON response helpers, and user-agent parsing. Anything that +// needs the application's auth/session/permission model lives app-side (in +// internal/httpauth), not here, so this package never imports app code. +package httputil diff --git a/httputil/respond.go b/httputil/respond.go new file mode 100644 index 00000000..08cc4581 --- /dev/null +++ b/httputil/respond.go @@ -0,0 +1,20 @@ +package httputil + +import ( + "encoding/json" + "net/http" +) + +func RespondJSON(w http.ResponseWriter, statusCode int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(data) +} + +func RespondError(w http.ResponseWriter, statusCode int, message string) { + RespondJSON(w, statusCode, map[string]string{"error": message}) +} + +func RespondSuccess(w http.ResponseWriter) { + RespondJSON(w, http.StatusOK, map[string]bool{"success": true}) +} diff --git a/httputil/useragent.go b/httputil/useragent.go new file mode 100644 index 00000000..d018f089 --- /dev/null +++ b/httputil/useragent.go @@ -0,0 +1,138 @@ +package httputil + +import ( + "regexp" + "strings" +) + +func ParseUserAgent(ua string) string { + if ua == "" { + return "Unknown" + } + + browser := parseBrowser(ua) + os := parseOS(ua) + + if browser == "" && os == "" { + return "Unknown" + } + if browser == "" { + return os + } + if os == "" { + return browser + } + + return browser + " on " + os +} + +func parseBrowser(ua string) string { + if match := regexp.MustCompile(`Edg(?:e|A|iOS)?/(\d+)`).FindStringSubmatch(ua); match != nil { + return "Edge " + match[1] + } + + if match := regexp.MustCompile(`(?:OPR|Opera)[/ ](\d+)`).FindStringSubmatch(ua); match != nil { + return "Opera " + match[1] + } + + if match := regexp.MustCompile(`SamsungBrowser/(\d+)`).FindStringSubmatch(ua); match != nil { + return "Samsung Browser " + match[1] + } + + if strings.Contains(ua, "Chrome") && !strings.Contains(ua, "Chromium") { + if match := regexp.MustCompile(`Chrome/(\d+)`).FindStringSubmatch(ua); match != nil { + return "Chrome " + match[1] + } + } + + if match := regexp.MustCompile(`Chromium/(\d+)`).FindStringSubmatch(ua); match != nil { + return "Chromium " + match[1] + } + + if match := regexp.MustCompile(`Firefox/(\d+)`).FindStringSubmatch(ua); match != nil { + return "Firefox " + match[1] + } + + if strings.Contains(ua, "Safari") && !strings.Contains(ua, "Chrome") { + if match := regexp.MustCompile(`Version/(\d+)`).FindStringSubmatch(ua); match != nil { + return "Safari " + match[1] + } + return "Safari" + } + + if match := regexp.MustCompile(`MSIE (\d+)`).FindStringSubmatch(ua); match != nil { + return "Internet Explorer " + match[1] + } + if strings.Contains(ua, "Trident/") { + if match := regexp.MustCompile(`rv:(\d+)`).FindStringSubmatch(ua); match != nil { + return "Internet Explorer " + match[1] + } + return "Internet Explorer" + } + + return "" +} + +func parseOS(ua string) string { + if strings.Contains(ua, "iPhone") { + if match := regexp.MustCompile(`iPhone OS (\d+)[_\d]*`).FindStringSubmatch(ua); match != nil { + return "iOS " + match[1] + } + return "iOS" + } + if strings.Contains(ua, "iPad") { + if match := regexp.MustCompile(`CPU OS (\d+)[_\d]*`).FindStringSubmatch(ua); match != nil { + return "iPadOS " + match[1] + } + return "iPadOS" + } + + if match := regexp.MustCompile(`Android (\d+)`).FindStringSubmatch(ua); match != nil { + return "Android " + match[1] + } + + if strings.Contains(ua, "Windows") { + if strings.Contains(ua, "Windows NT 10.0") { + return "Windows 10/11" + } + if strings.Contains(ua, "Windows NT 6.3") { + return "Windows 8.1" + } + if strings.Contains(ua, "Windows NT 6.2") { + return "Windows 8" + } + if strings.Contains(ua, "Windows NT 6.1") { + return "Windows 7" + } + if strings.Contains(ua, "Windows NT 6.0") { + return "Windows Vista" + } + if strings.Contains(ua, "Windows NT 5.1") { + return "Windows XP" + } + return "Windows" + } + + if strings.Contains(ua, "Mac OS X") || strings.Contains(ua, "Macintosh") { + if match := regexp.MustCompile(`Mac OS X (\d+)[_.](\d+)`).FindStringSubmatch(ua); match != nil { + return "macOS " + match[1] + "." + match[2] + } + return "macOS" + } + + if strings.Contains(ua, "Ubuntu") { + return "Ubuntu" + } + if strings.Contains(ua, "Fedora") { + return "Fedora" + } + if strings.Contains(ua, "Linux") { + return "Linux" + } + + if strings.Contains(ua, "CrOS") { + return "Chrome OS" + } + + return "" +} diff --git a/l4g/database.go b/l4g/database.go new file mode 100644 index 00000000..0b0e3679 --- /dev/null +++ b/l4g/database.go @@ -0,0 +1,41 @@ +package l4g + +import ( + "os" +) + +// dbWriter is the app-supplied sink that persists a log Entry to the database. +// The framework cannot import the app's repository, so the app registers a +// writer at startup via SetDatabaseWriter. If none is registered, the database +// logger falls back to the terminal so log lines are never silently dropped. +var dbWriter func(Entry) error + +// SetDatabaseWriter registers the function the database logger uses to persist +// entries. Apps typically wire it to their repository: +// +// l4g.SetDatabaseWriter(func(e l4g.Entry) error { +// return repository.InsertLogEntry(context.Background(), models.LogEntry(e)) +// }) +func SetDatabaseWriter(w func(Entry) error) { dbWriter = w } + +type DatabaseLogger struct{} + +func NewDatabaseLogger() *DatabaseLogger { + return &DatabaseLogger{} +} + +func (d *DatabaseLogger) Write(entry Entry) error { + if dbWriter == nil { + return NewTerminalLogger().Write(entry) + } + return dbWriter(entry) +} + +func (d *DatabaseLogger) Fatal(entry Entry) error { + err := d.Write(entry) + if err != nil { + return err + } + os.Exit(1) + return nil +} diff --git a/l4g/debug.go b/l4g/debug.go new file mode 100644 index 00000000..f5ef34d3 --- /dev/null +++ b/l4g/debug.go @@ -0,0 +1,51 @@ +package l4g + +import ( + "fmt" + "log" + "os" + "time" +) + +var debugLogger *log.Logger + +func init() { + debugLogger = log.New(os.Stdout, "[DEBUG] ", log.LstdFlags) +} + +func Debug(v ...any) { + debugLogger.Print(v...) +} + +func Debugf(format string, v ...any) { + debugLogger.Printf(format, v...) +} + +func Debugln(v ...any) { + debugLogger.Println(v...) +} + +func DebugFatal(v ...any) { + debugLogger.Print(v...) + os.Exit(1) +} + +func DebugFatalf(format string, v ...any) { + debugLogger.Printf(format, v...) + os.Exit(1) +} + +func DebugFatalln(v ...any) { + debugLogger.Println(v...) + os.Exit(1) +} + +func DebugInit(message string) { + timestamp := time.Now().Format("15:04:05") + fmt.Printf("[%s] INIT: %s\n", timestamp, message) +} + +func DebugServer(message string) { + timestamp := time.Now().Format("15:04:05") + fmt.Printf("[%s] SERVER: %s\n", timestamp, message) +} diff --git a/l4g/doc.go b/l4g/doc.go new file mode 100644 index 00000000..e308ea22 --- /dev/null +++ b/l4g/doc.go @@ -0,0 +1,66 @@ +// Package l4g provides logging functionality for the application. +// +// This package implements two separate logging systems: +// +// # MAIN LOGGER +// +// The main logger is configurable via the LOGGER_TYPE environment variable +// and is used for structured application logging: +// +// - LOGGER_TYPE="terminal" - Outputs to stdout with formatted messages +// - LOGGER_TYPE="file" - Writes to a log file (default: ./app.log) +// - LOGGER_TYPE="database" - Stores structured log entries in the database +// +// Use the main logger for: +// - User activities and business logic events +// - Error logging and exception handling +// - Audit trails and security events +// - System state changes +// +// Example usage: +// +// l4g.Init(config.GetConfig().LoggerType) +// entry := model.LogEntry{ +// LogType: l4g.LOG_TYPE_USER, +// Severity: l4g.SEVERITY_INFO, +// Content: &message, +// } +// l4g.Write(entry) +// +// # DEBUG LOGGER +// +// The debug logger is completely separate and always outputs to terminal, +// regardless of the main logger configuration. It's used for development +// and initialization messages that should always be visible: +// +// - Program initialization and startup messages +// - Development debugging and troubleshooting +// - Server configuration and status information +// +// Use the debug logger for: +// - Package initialization messages +// - Server startup and configuration +// - Development debugging (temporary print statements) +// - System health checks during startup +// +// Example usage: +// +// l4g.DebugInit("Initializing database connection") +// l4g.DebugServer("Server listening on port 8080") +// l4g.Debug("Temporary debug message") +// +// # LOG TYPES AND SEVERITY LEVELS +// +// Log Types: +// - LOG_TYPE_SYSTEM: System-level events and operations +// - LOG_TYPE_USER: User activities and interactions +// - LOG_TYPE_ORG: Organization-level events +// - LOG_TYPE_AUDIT: Security and compliance events +// +// Severity Levels: +// - SEVERITY_DEBUG: Development and troubleshooting information +// - SEVERITY_INFO: General informational messages +// - TYPE_WARN: Warning conditions that should be noted +// - SEVERITY_ERROR: Error conditions that affect functionality +// - SEVERITY_FATAL: Critical errors that cause program termination +package l4g diff --git a/l4g/entry.go b/l4g/entry.go new file mode 100644 index 00000000..5b4211e5 --- /dev/null +++ b/l4g/entry.go @@ -0,0 +1,23 @@ +package l4g + +import ( + "time" + + "github.com/google/uuid" +) + +// Entry is a single structured log record. It is the framework-owned mirror of +// the application's log-entry model: field names, types, and order match, so an +// app can convert directly (e.g. models.LogEntry(entry)) when persisting via the +// database writer registered with SetDatabaseWriter. +type Entry struct { + ID uuid.UUID + AppUserID *uuid.UUID + OrgID *uuid.UUID + Content *string + StructuredContent *string + Category int32 + LogType int32 + Timestamp time.Time + IdentityID *uuid.UUID +} diff --git a/l4g/file.go b/l4g/file.go new file mode 100644 index 00000000..68782ce8 --- /dev/null +++ b/l4g/file.go @@ -0,0 +1,109 @@ +package l4g + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +type FileLogger struct { + filePath string +} + +func NewFileLogger(filePath string) *FileLogger { + return &FileLogger{filePath: filePath} +} + +func (f *FileLogger) Write(entry Entry) error { + err := f.ensureLogFileExists() + if err != nil { + return err + } + + file, err := os.OpenFile(f.filePath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + defer file.Close() + + timestamp := entry.Timestamp.Format(time.RFC3339) + + var category string + switch entry.Category { + case CATEGORY_SYSTEM: + category = "SYSTEM" + case CATEGORY_ADMIN: + category = "ADMIN" + case CATEGORY_USER: + category = "USER" + case CATEGORY_ORG: + category = "ORG" + case CATEGORY_AUDIT: + category = "AUDIT" + case CATEGORY_AUTH: + category = "AUTH" + default: + category = "UNKNOWN" + } + + var logType string + switch entry.LogType { + case TYPE_DEBUG: + logType = "DEBUG" + case TYPE_INFO: + logType = "INFO" + case TYPE_WARN: + logType = "WARN" + case TYPE_ERROR: + logType = "ERROR" + default: + logType = "UNKNOWN" + } + + content := "" + if entry.Content != nil { + content = *entry.Content + } + + logLine := fmt.Sprintf("[%s] %s/%s: %s", timestamp, category, logType, content) + + if entry.StructuredContent != nil { + logLine += fmt.Sprintf(" | Details: %s", *entry.StructuredContent) + } + + logLine += "\n" + + _, err = file.WriteString(logLine) + if err != nil { + return fmt.Errorf("failed to write to log file: %w", err) + } + + return nil +} + +func (f *FileLogger) Fatal(entry Entry) error { + err := f.Write(entry) + if err != nil { + return err + } + os.Exit(1) + return nil +} + +func (f *FileLogger) ensureLogFileExists() error { + dir := filepath.Dir(f.filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create log directory: %w", err) + } + + if _, err := os.Stat(f.filePath); os.IsNotExist(err) { + file, err := os.Create(f.filePath) + if err != nil { + return fmt.Errorf("failed to create log file: %w", err) + } + file.Close() + } + + return nil +} diff --git a/l4g/logger.go b/l4g/logger.go new file mode 100644 index 00000000..d4c37fe4 --- /dev/null +++ b/l4g/logger.go @@ -0,0 +1,84 @@ +package l4g + +import ( + "encoding/json" +) + +const ( + LOGGER_TYPE_TERMINAL = "terminal" + LOGGER_TYPE_FILE = "file" + LOGGER_TYPE_DATABASE = "database" +) + +var globalLogger Logger + +const ( + CATEGORY_SYSTEM int32 = 0 // System-level logging, such as server starting, database connection problems, etc. + CATEGORY_ADMIN int32 = 1 // Administrative logging, used for CRM, and any "internal" action taken by an employee + CATEGORY_USER int32 = 2 // General category for actions performed by / for a user (E.g. user updates their account) + CATEGORY_ORG int32 = 3 // General category for actions performed by / for an organization (E.g. User updates company profile) + CATEGORY_AUDIT int32 = 4 // Category for transactional logging (Accept request, decline, modified transaction record) + CATEGORY_AUTH int32 = 5 // Authentication / Authorization messages (User logged in, failed login attempts, user failed authorization check, out-of-country IP, etc) +) + +const ( + TYPE_DEBUG int32 = 0 // Testing and debugging log. Should not really be used too often since debug logging statements should be cleaned once an issue is resolved. + TYPE_INFO int32 = 1 // General log message + TYPE_WARN int32 = 2 // "Incorrect" actions taken. Includes failed login attempts, validation issues, etc. Nothing that is a "problem" for us, just malformed user input. + TYPE_ERROR int32 = 3 // Failed program state/behavior. Used for issues such as database connection failures, web API connection failures, etc. + + TYPE_CREATE int32 = 4 // Resource created - Does not need to be broken down to each individual row created in a db table, but rather the "logical action" taken by a user. + TYPE_READ int32 = 5 // Resource consumed - Does not need to be broken down to each individual row created in a db table, but rather the "logical action" taken by a user. + TYPE_UPDATE int32 = 6 // Resource updated - Does not need to be broken down to each individual row created in a db table, but rather the "logical action" taken by a user. + TYPE_DELETE int32 = 7 // Resource deleted - Does not need to be broken down to each individual row created in a db table, but rather the "logical action" taken by a user. +) + +type Logger interface { + Write(entry Entry) error + Fatal(entry Entry) error +} + +func Init(loggerType string, logFilePath ...string) error { + switch loggerType { + case LOGGER_TYPE_TERMINAL: + globalLogger = NewTerminalLogger() + case LOGGER_TYPE_FILE: + filePath := "./app.log" + if len(logFilePath) > 0 && logFilePath[0] != "" { + filePath = logFilePath[0] + } + globalLogger = NewFileLogger(filePath) + case LOGGER_TYPE_DATABASE: + globalLogger = NewDatabaseLogger() + default: + globalLogger = NewTerminalLogger() + } + return nil +} + +func GetLogger() Logger { + if globalLogger == nil { + globalLogger = NewTerminalLogger() + } + return globalLogger +} + +func Serialize(data any) *string { + var structuredJSON *string + if data != nil { + if jsonBytes, err := json.Marshal(data); err == nil { + jsonString := string(jsonBytes) + structuredJSON = &jsonString + } + } + + return structuredJSON +} + +func Write(entry Entry) error { + return GetLogger().Write(entry) +} + +func Fatal(entry Entry) error { + return GetLogger().Fatal(entry) +} diff --git a/l4g/terminal.go b/l4g/terminal.go new file mode 100644 index 00000000..4bc7bd55 --- /dev/null +++ b/l4g/terminal.go @@ -0,0 +1,71 @@ +package l4g + +import ( + "fmt" + "os" + "time" +) + +type TerminalLogger struct{} + +func NewTerminalLogger() *TerminalLogger { + return &TerminalLogger{} +} + +func (t *TerminalLogger) Write(entry Entry) error { + timestamp := entry.Timestamp.Format(time.RFC3339) + + var category string + switch entry.Category { + case CATEGORY_SYSTEM: + category = "SYSTEM" + case CATEGORY_ADMIN: + category = "ADMIN" + case CATEGORY_USER: + category = "USER" + case CATEGORY_ORG: + category = "ORG" + case CATEGORY_AUDIT: + category = "AUDIT" + case CATEGORY_AUTH: + category = "AUTH" + default: + category = "UNKNOWN" + } + + var logType string + switch entry.LogType { + case TYPE_DEBUG: + logType = "DEBUG" + case TYPE_INFO: + logType = "INFO" + case TYPE_WARN: + logType = "WARN" + case TYPE_ERROR: + logType = "ERROR" + default: + logType = "UNKNOWN" + } + + content := "" + if entry.Content != nil { + content = *entry.Content + } + + fmt.Printf("[%s] %s/%s: %s\n", timestamp, category, logType, content) + + if entry.StructuredContent != nil { + fmt.Printf("Details: %s\n", *entry.StructuredContent) + } + + return nil +} + +func (t *TerminalLogger) Fatal(entry Entry) error { + err := t.Write(entry) + if err != nil { + return err + } + os.Exit(1) + return nil +} diff --git a/security/crypt.go b/security/crypt.go new file mode 100644 index 00000000..b1e84a34 --- /dev/null +++ b/security/crypt.go @@ -0,0 +1,228 @@ +package security + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha512" + "encoding/base64" + "encoding/gob" + "fmt" + "io" + "log" + "time" + + "github.com/btcsuite/btcutil/base58" + "github.com/minio/highwayhash" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// dataHashKey is NOT used for hashing passwords or securing session data over +// the wire. It is ONLY used for quick, non-security-sensitive file and string +// hashes (HighwayHash needs a fixed 32-byte key). Kept in the framework because +// the value must stay stable across builds and is identical in every app. +const dataHashKey = "01234567890123456789012345678901" + +//////////////////////////////// +// Encoding Wrappers +//////////////////////////////// + +func EncodeBase64(in []byte) string { + return base64.StdEncoding.EncodeToString(in) +} + +func DecodeBase64(in string) []byte { + out, _ := base64.StdEncoding.DecodeString(in) + return out +} + +func EncodeBase58(in []byte) string { + return base58.Encode(in) +} + +func DecodeBase58(in string) []byte { + return base58.Decode(in) +} + +//////////////////////////////// +// HASH FUNCTIONS +//////////////////////////////// + +// Hash with SHA512 and output a Base58 string +func SHA512_58(in string) string { + hasher := sha512.New() + + hasher.Write([]byte(in)) + + hashBytes := hasher.Sum(nil) + + hashString := base58.Encode(hashBytes) + + return hashString +} + +func HighwayHash58(in string) (string, error) { + key := []byte(dataHashKey) + + hasher, err := highwayhash.New(key) + if err != nil { + log.Println("Error generating hasher.") + return "", err + } + + hasher.Write([]byte(in)) + + hash := hasher.Sum(nil) + + encodedData := base58.Encode(hash) + + return encodedData, nil +} + +func HighwayHash(in string) (string, error) { + key := []byte(dataHashKey) + + hasher, err := highwayhash.New(key) + if err != nil { + log.Println("Error generating hasher.") + return "", err + } + + hasher.Write([]byte(in)) + + hash := hasher.Sum(nil) + + return base64.StdEncoding.EncodeToString(hash), nil +} + +// Hash password using bcrypt +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// Compare password with hash using bcrypt +func ComparePasswords(password string, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func RandBase58String(entropyBytes int) string { + b := make([]byte, entropyBytes) + rand.Read(b) + return base58.Encode(b) +} + +//////////////////////////////// +// Serialization FUNCTIONS +//////////////////////////////// + +func GobSerialize[T any](data *T) ([]byte, error) { + gob.Register(time.Time{}) + gob.Register(uuid.UUID{}) + + b := bytes.Buffer{} + e := gob.NewEncoder(&b) + err := e.Encode(data) + if err != nil { + return nil, err + } + + return b.Bytes(), nil +} + +func GobDeserialize[T any](data []byte) (*T, error) { + dest := new(T) + + b := bytes.Buffer{} + b.Write(data) + + gob.Register(time.Time{}) + gob.Register(uuid.UUID{}) + d := gob.NewDecoder(&b) + err := d.Decode(dest) + if err != nil { + return nil, err + } + + return dest, nil +} + +//////////////////////////////// +// Encryption FUNCTIONS +//////////////////////////////// + +// AES Encrypt +func EncryptSecret(data []byte, passKey string) ([]byte, error) { + key := make([]byte, 32) + copy(key, passKey) + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + encryptedData := gcm.Seal(nonce, nonce, data, nil) + + return encryptedData, nil +} + +// AES Decrypt +func DecryptSecret(encryptedData []byte, passKey string) ([]byte, error) { + key := make([]byte, 32) + copy(key, passKey) + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(encryptedData) < nonceSize { + return nil, fmt.Errorf("ciphertext too short") + } + nonce, encryptedData := encryptedData[:nonceSize], encryptedData[nonceSize:] + + decryptedData, err := gcm.Open(nil, nonce, encryptedData, nil) + if err != nil { + return nil, err + } + + return decryptedData, nil +} + +func EncryptData[T any](data *T, key string) ([]byte, error) { + serialized, err := GobSerialize(data) + if err != nil { + return nil, err + } + + return EncryptSecret(serialized, key) +} + +func DecryptData[T any](data []byte, key string) (*T, error) { + decrypted, err := DecryptSecret(data, key) + if err != nil { + return nil, err + } + + return GobDeserialize[T](decrypted) +} diff --git a/security/crypt_test.go b/security/crypt_test.go new file mode 100644 index 00000000..f315dc96 --- /dev/null +++ b/security/crypt_test.go @@ -0,0 +1,9 @@ +package security + +import "testing" + +func _BenchmarkDecrypt(b *testing.B) { + for i := 0; i < b.N; i++ { + DecryptSecret([]byte("njniYY9+R8kAxUuoI6p+A0AvDfVwtKVKe7FU7q7eW4IlLF1v4hLF14Fwizsddqh54EjiBB2XwD6g07c2Ovd0p8AehEuZgA8vD1N+3zSKKg+ZDVsc/MS+6iNQYK+ARNYHrqreaB2qiJP260Le3YR3xDY/u7n+JN58FxNf2J1DMvBUXD812d7r3ING4TBTkzcCJFXql+TvzUdC1qnhdrz/AOBo919rP2+yodQRTgBsZPiSb0DCZ9nnuwT9t99ORwn8v3AelyzwBOcxiYSlP07WDQE45o962E+GONiA09q8lBIBV6wT5bgZ3GAOdNNJFPrhSUqhblDB8/16Z1NwhS/lHyQUyjGxwt3zsC3axVCNQ6t4AJr8wEyVnoLb"), "password") + } +} diff --git a/security/init.go b/security/init.go new file mode 100644 index 00000000..b5b6630c --- /dev/null +++ b/security/init.go @@ -0,0 +1,11 @@ +package security + +import "github.com/microcosm-cc/bluemonday" + +var SanitizationPolicy *bluemonday.Policy + +func Init() { + SanitizationPolicy = bluemonday.UGCPolicy() + SanitizationPolicy.AllowElements("svg", "path") + SanitizationPolicy.AllowAttrs("xmlns", "height", "width", "fill", "stroke", "d") +} diff --git a/security/random.go b/security/random.go new file mode 100644 index 00000000..23d9a202 --- /dev/null +++ b/security/random.go @@ -0,0 +1,37 @@ +package security + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + + "github.com/btcsuite/btcutil/base58" +) + +func GenerateRandomKeyBase64(bytes int) (string, error) { + if bytes <= 0 { + return "", fmt.Errorf("key size must be positive") + } + + key := make([]byte, bytes) + _, err := rand.Read(key) + if err != nil { + return "", fmt.Errorf("failed to generate random key: %w", err) + } + + return base64.StdEncoding.EncodeToString(key), nil +} + +func GenerateRandomKeyBase58(bytes int) (string, error) { + if bytes <= 0 { + return "", fmt.Errorf("key size must be positive") + } + + key := make([]byte, bytes) + _, err := rand.Read(key) + if err != nil { + return "", fmt.Errorf("failed to generate random key: %w", err) + } + + return base58.Encode(key), nil +} diff --git a/security/random_test.go b/security/random_test.go new file mode 100644 index 00000000..091479b7 --- /dev/null +++ b/security/random_test.go @@ -0,0 +1,172 @@ +package security + +import ( + "encoding/base64" + "testing" + + "github.com/btcsuite/btcutil/base58" +) + +func TestGenerateRandomKeyBase64(t *testing.T) { + tests := []struct { + name string + n int + wantError bool + }{ + { + name: "valid 16 bytes", + n: 16, + wantError: false, + }, + { + name: "valid 32 bytes", + n: 32, + wantError: false, + }, + { + name: "valid 64 bytes", + n: 64, + wantError: false, + }, + { + name: "invalid zero bytes", + n: 0, + wantError: true, + }, + { + name: "invalid negative bytes", + n: -1, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GenerateRandomKeyBase64(tt.n) + + if tt.wantError { + if err == nil { + t.Error("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Verify result is not empty + if result == "" { + t.Error("expected non-empty result") + } + + // Verify result is valid base64 + decoded, err := base64.StdEncoding.DecodeString(result) + if err != nil { + t.Errorf("result is not valid base64: %v", err) + } + + // Verify decoded length matches input + if len(decoded) != tt.n { + t.Errorf("expected decoded length %d, got %d", tt.n, len(decoded)) + } + }) + } + + // Test randomness - two calls should produce different results + t.Run("randomness check", func(t *testing.T) { + result1, err1 := GenerateRandomKeyBase64(32) + result2, err2 := GenerateRandomKeyBase64(32) + + if err1 != nil || err2 != nil { + t.Fatalf("unexpected errors: %v, %v", err1, err2) + } + + if result1 == result2 { + t.Error("expected different random keys, got identical results") + } + }) +} + +func TestGenerateRandomKeyBase58(t *testing.T) { + tests := []struct { + name string + n int + wantError bool + }{ + { + name: "valid 16 bytes", + n: 16, + wantError: false, + }, + { + name: "valid 32 bytes", + n: 32, + wantError: false, + }, + { + name: "valid 64 bytes", + n: 64, + wantError: false, + }, + { + name: "invalid zero bytes", + n: 0, + wantError: true, + }, + { + name: "invalid negative bytes", + n: -1, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GenerateRandomKeyBase58(tt.n) + + if tt.wantError { + if err == nil { + t.Error("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Verify result is not empty + if result == "" { + t.Error("expected non-empty result") + } + + // Verify result is valid base58 by decoding + decoded := base58.Decode(result) + if len(decoded) == 0 { + t.Error("result is not valid base58") + } + + // Verify decoded length matches input + if len(decoded) != tt.n { + t.Errorf("expected decoded length %d, got %d", tt.n, len(decoded)) + } + }) + } + + // Test randomness - two calls should produce different results + t.Run("randomness check", func(t *testing.T) { + result1, err1 := GenerateRandomKeyBase58(32) + result2, err2 := GenerateRandomKeyBase58(32) + + if err1 != nil || err2 != nil { + t.Fatalf("unexpected errors: %v, %v", err1, err2) + } + + if result1 == result2 { + t.Error("expected different random keys, got identical results") + } + }) +} diff --git a/snailmail/cloudflare.go b/snailmail/cloudflare.go new file mode 100644 index 00000000..96ed899a --- /dev/null +++ b/snailmail/cloudflare.go @@ -0,0 +1,89 @@ +package snailmail + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" +) + +type cloudflareSender struct{} + +type cfEmailRequest struct { + To string `json:"to"` + From string `json:"from"` + Subject string `json:"subject"` + HTML string `json:"html,omitempty"` + Text string `json:"text,omitempty"` +} + +type cfEmailResponse struct { + Success bool `json:"success"` + Errors []struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"errors"` +} + +func (c *cloudflareSender) Send(message Email, mailtype int) error { + if len(message.Recipients) == 0 { + return fmt.Errorf("cloudflare email: no recipients") + } + + req := cfEmailRequest{ + To: message.Recipients[0], + From: settings.Cloudflare.FromAddress, + Subject: message.Subject, + } + + if mailtype == TYPE_HTML { + req.HTML = message.Body.String() + } else { + req.Text = message.Body.String() + } + + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("cloudflare email: failed to marshal request: %w", err) + } + + url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/email/sending/send", settings.Cloudflare.AccountID) + + httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("cloudflare email: failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+settings.Cloudflare.APIToken) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + log.Println("cloudflare email: request failed:", err) + return fmt.Errorf("cloudflare email: request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("cloudflare email: failed to read response: %w", err) + } + + var cfResp cfEmailResponse + if err := json.Unmarshal(respBody, &cfResp); err != nil { + return fmt.Errorf("cloudflare email: failed to parse response: %w", err) + } + + if !cfResp.Success { + errMsg := "unknown error" + if len(cfResp.Errors) > 0 { + errMsg = cfResp.Errors[0].Message + } + log.Printf("cloudflare email: send failed: %s (HTTP %d)", errMsg, resp.StatusCode) + return fmt.Errorf("cloudflare email: %s", errMsg) + } + + return nil +} diff --git a/snailmail/smtp.go b/snailmail/smtp.go new file mode 100644 index 00000000..52f4b8c1 --- /dev/null +++ b/snailmail/smtp.go @@ -0,0 +1,53 @@ +package snailmail + +import ( + "encoding/base64" + "fmt" + "log" + "mime" + "net/mail" + "net/smtp" + "strings" + "time" +) + +type smtpSender struct{} + +func (s *smtpSender) Send(message Email, mailtype int) error { + recipientString := strings.Join(message.Recipients, ",") + from := mail.Address{Name: settings.SMTP.DisplayFrom, Address: settings.SMTP.Username} + + header := make(map[string]string) + header["To"] = recipientString + header["From"] = from.String() + header["Subject"] = mime.QEncoding.Encode("UTF-8", message.Subject) + header["MIME-Version"] = "1.0" + header["Content-Transfer-Encoding"] = "base64" + header["Date"] = time.Now().Format(time.RFC1123) + + if mailtype == TYPE_HTML { + header["Content-Type"] = "text/html; charset=\"utf-8\"" + } else { + header["Content-Type"] = "text/plain; charset=\"utf-8\"" + } + + email := "" + for k, v := range header { + email += fmt.Sprintf("%s: %s\r\n", k, v) + } + email += "\r\n" + base64.StdEncoding.EncodeToString(message.Body.Bytes()) + + var auth smtp.Auth = nil + + if settings.SMTP.RequireAuth { + auth = smtp.PlainAuth("", settings.SMTP.Username, settings.SMTP.Password, settings.SMTP.Server) + } + + err := smtp.SendMail(settings.SMTP.Server+":"+settings.SMTP.Port, auth, settings.SMTP.Username, message.Recipients, []byte(email)) + if err != nil { + log.Println(err) + return err + } + + return nil +} diff --git a/snailmail/snailmail.go b/snailmail/snailmail.go new file mode 100644 index 00000000..3d139487 --- /dev/null +++ b/snailmail/snailmail.go @@ -0,0 +1,81 @@ +// package snailmail sends email through a pluggable provider (SMTP or Cloudflare). +// The active provider and its credentials are injected via Configure(Settings), +// so the framework never reads application config. Branded message composition +// (templates, logos, copy) stays app-side: apps build an Email and call SendMail. +package snailmail + +import ( + "bytes" + "log" + "strings" +) + +const ( + TYPE_TEXT = iota + TYPE_HTML +) + +// Email is a composed message ready to send. Body holds the already-rendered +// text or HTML. +type Email struct { + Recipients []string + Subject string + Body *bytes.Buffer +} + +// SMTPSettings holds credentials for the SMTP provider. +type SMTPSettings struct { + Server string + Port string + Username string + Password string + DisplayFrom string + RequireAuth bool +} + +// CloudflareSettings holds credentials for the Cloudflare email provider. +type CloudflareSettings struct { + AccountID string + APIToken string + FromAddress string +} + +// Settings selects and configures the active email provider. Provider is "smtp" +// (default) or "cloudflare". +type Settings struct { + Provider string + SMTP SMTPSettings + Cloudflare CloudflareSettings +} + +type sender interface { + Send(message Email, mailtype int) error +} + +var ( + settings Settings + activeSender sender +) + +// Configure stores the provider credentials and selects the active provider. +// Apps call this once at startup with values from their own config. +func Configure(s Settings) { + settings = s + switch strings.ToLower(strings.TrimSpace(s.Provider)) { + case "cloudflare": + activeSender = &cloudflareSender{} + log.Println("mailer: using Cloudflare email provider") + default: + activeSender = &smtpSender{} + log.Println("mailer: using SMTP email provider") + } +} + +// SendMail sends message using the configured provider (defaulting to SMTP if +// Configure was never called). +func SendMail(message Email, mailtype int) error { + if activeSender == nil { + activeSender = &smtpSender{} + } + return activeSender.Send(message, mailtype) +} diff --git a/validation/validation.go b/validation/validation.go new file mode 100644 index 00000000..12bf3923 --- /dev/null +++ b/validation/validation.go @@ -0,0 +1,211 @@ +package validation + +import ( + "errors" + "regexp" + "slices" + "strconv" + "strings" + "unicode/utf8" +) + +func SanitizeEmail(input string) string { + input = strings.ToLower(strings.TrimSpace(input)) + + re := regexp.MustCompile(`[a-z0-9!#$%&'*+/=?^_` + "`" + `{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_` + "`" + `{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?`) + match := re.FindString(input) + + return match +} + +// Checks an email address against a first and last name and returns true if it matches one of the following patterns: +// firstlast@domain.tld, flast@domain.tld, firstl@domain.tld, first.last@domain.tld, f.last@domain.tld, +// first.l@domain.tld, first-last@domain.tld, f-last@domain.tld, first-l@domain.tld. Otherwise, false. +// Also handles hyphenated and space-separated last names (e.g., Meyer-Ogren or Van Der Berg) +func DoesNameMatchEmail(email string, firstName string, lastName string) bool { + // Validate inputs + if email == "" || firstName == "" || lastName == "" { + return true + } + + email = strings.ToLower(email) + firstName = strings.ToLower(firstName) + lastName = strings.ToLower(lastName) + + emailIdentifier := strings.Split(email, "@")[0] + firstInitial := string(firstName[0]) + + // Split hyphenated and space-separated last names + lastNameParts := []string{lastName} + + // Also add concatenated version (removing hyphens/spaces) + lastNameNormalized := strings.ReplaceAll(lastName, "-", "") + lastNameNormalized = strings.ReplaceAll(lastNameNormalized, " ", "") + if lastNameNormalized != lastName { + lastNameParts = append(lastNameParts, lastNameNormalized) + } + + // Add individual parts + if strings.Contains(lastName, "-") { + lastNameParts = append(lastNameParts, strings.Split(lastName, "-")...) + } + if strings.Contains(lastName, " ") { + lastNameParts = append(lastNameParts, strings.Split(lastName, " ")...) + } + + separators := []string{"", ".", "-"} + + // Check standalone first or last names + if emailIdentifier == firstName { + return true + } + + // Check each part of the last name + for _, lastPart := range lastNameParts { + if lastPart == "" { + continue + } + + if emailIdentifier == lastPart { + return true + } + + lastPartInitial := string(lastPart[0]) + + // Check all separator combinations with each last name part + for _, separator := range separators { + switch emailIdentifier { + case strings.Join([]string{firstName, lastPart}, separator), + strings.Join([]string{firstInitial, lastPart}, separator), + strings.Join([]string{firstName, lastPartInitial}, separator): + return true + } + } + } + + return false +} + +func SanitizePhone(input string) string { + // Regular expression to match non-digit characters + re := regexp.MustCompile(`[^0-9]`) + + // Replace non-digit characters with empty string + sanitized := re.ReplaceAllString(input, "") + + return sanitized +} + +func ValidatePhoneNumber(phoneNumber string) error { + isNumber, _ := regexp.MatchString(`^\d+$`, phoneNumber) + if !isNumber { + return errors.New("Phone number must only contain numbers.") + } + + if len(phoneNumber) != 10 { + return errors.New("Invalid phone number length.") + } + + areaCode, _ := strconv.Atoi(phoneNumber[:3]) + + // Lowest area code is 200, so less than (200) 000-0000 + if areaCode < 200 { + return errors.New("Phone number has invalid area code.") + } + + return nil +} + +// Removes invalid UTF-8 sequences and replaces special characters +func SanitizeString(s string) string { + // Replace non-breaking spaces (0xa0) with regular spaces + s = strings.ReplaceAll(s, "\u00a0", " ") + + // Remove other common problematic characters + s = strings.ReplaceAll(s, "\r", "") + + // Remove replacement character (�) that appears when invalid UTF-8 is encountered + s = strings.ReplaceAll(s, "\ufffd", "") + + // Validate and fix UTF-8 encoding + if !utf8.ValidString(s) { + // Convert to valid UTF-8 by removing invalid bytes + v := make([]rune, 0, len(s)) + for _, r := range s { + if r != utf8.RuneError { + v = append(v, r) + } + } + s = string(v) + } + + // Trim whitespace + return strings.TrimSpace(s) +} + +var stateCodes []string = []string{ + "AL", "AK", "AZ", "AR", "AS", "CA", "CO", "CT", "DE", "DC", "FL", "GA", + "GU", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA", + "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", + "ND", "MP", "OH", "OK", "OR", "PA", "PR", "RI", "SC", "SD", "TN", "TX", + "TT", "UT", "VT", "VA", "VI", "WA", "WV", "WI", "WY", +} + +// Checks if the input string is a proper two-letter state code +func ValidateStateCode(s string) error { + if slices.Contains(stateCodes, s) { + return nil + } + + return errors.New("Invalid state code.") +} + +func ValidateTaxId(id string) error { + isNumber, _ := regexp.MatchString(`^\d+$`, id) + if !isNumber { + return errors.New("Tax ID must only contain numbers.") + } + + if len(id) != 9 { + return errors.New("Tax ID must be 9 digits long.") + } + + idNum, _ := strconv.Atoi(id) + if idNum == 0 { + return errors.New("Tax ID cannot be 00-0000000.") + } + + return nil +} + +func ValidateZipCode(zip string) error { + isNumber, _ := regexp.MatchString(`^\d+$`, zip) + if !isNumber { + return errors.New("ZIP code must only contain numbers.") + } + + if (len(zip) != 5) && (len(zip) != 9) { + return errors.New("Invalid length for ZIP code.") + } + + zipCode, _ := strconv.Atoi(zip[:5]) + + // Lowest 5 digit ZIP code is 00501 + if zipCode < 501 { + return errors.New("Invalid ZIP code.") + } + + return nil +} + +func ValidateUrl(url string) error { + err := errors.New("Invalid URL.") + + valid, _ := regexp.MatchString(`[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)`, url) + + if !valid { + return err + } + + return nil +}