restructure project, add claudemd
This commit is contained in:
17
go/appenv/appenv.go
Normal file
17
go/appenv/appenv.go
Normal file
@@ -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"
|
||||
)
|
||||
8
go/appenv/env_development.go
Normal file
8
go/appenv/env_development.go
Normal file
@@ -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
|
||||
6
go/appenv/env_production.go
Normal file
6
go/appenv/env_production.go
Normal file
@@ -0,0 +1,6 @@
|
||||
//go:build production
|
||||
|
||||
package appenv
|
||||
|
||||
// Selected by `-tags production`. See env_development.go for the full rationale.
|
||||
const Environment = EnvTypeProduction
|
||||
6
go/appenv/env_staging.go
Normal file
6
go/appenv/env_staging.go
Normal file
@@ -0,0 +1,6 @@
|
||||
//go:build staging
|
||||
|
||||
package appenv
|
||||
|
||||
// Selected by `-tags staging`. See env_development.go for the full rationale.
|
||||
const Environment = EnvTypeStaging
|
||||
643
go/basic/basic.go
Normal file
643
go/basic/basic.go
Normal file
@@ -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
|
||||
}
|
||||
150
go/basic/basic_test.go
Normal file
150
go/basic/basic_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
147
go/bundler/build.go
Normal file
147
go/bundler/build.go
Normal file
@@ -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)
|
||||
}
|
||||
399
go/bundler/compile_solid.go
Normal file
399
go/bundler/compile_solid.go
Normal file
@@ -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 `</`. It returns the nodes
|
||||
// and the index at the `<` of the closing tag. Adjacent character data becomes a
|
||||
// single jsxText node (JSX whitespace normalization happens in codegen).
|
||||
func parseChildren(src string, i int) ([]jsxNode, int, error) {
|
||||
n := len(src)
|
||||
var nodes []jsxNode
|
||||
var text strings.Builder
|
||||
flush := func() {
|
||||
if text.Len() > 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 `</name>` (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 '</' at %d", i)
|
||||
}
|
||||
i += 2
|
||||
for i < n && src[i] != '>' {
|
||||
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 `</tag>` 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. <Foo.Bar>
|
||||
}
|
||||
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'
|
||||
}
|
||||
1038
go/bundler/compile_solid_gen.go
Normal file
1038
go/bundler/compile_solid_gen.go
Normal file
File diff suppressed because it is too large
Load Diff
257
go/bundler/compile_solid_render_test.go
Normal file
257
go/bundler/compile_solid_render_test.go
Normal file
@@ -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 = () => <div class="x">hi</div>;`},
|
||||
{"dyn-text", `export const A = () => { const c = () => 42; return <div>{c()}</div>; };`},
|
||||
{"nested", `export const A = () => { const x = () => "X"; return <div><span>a</span><b>{x()}</b></div>; };`},
|
||||
{"mixed-children", `export const A = () => { const x = () => "X"; const y = () => "Y"; return <div>before {x()} after {y()}</div>; };`},
|
||||
{"dyn-attr", `export const A = () => { const id = () => "foo"; return <div class="s" id={id()}>hi</div>; };`},
|
||||
{"list", `export const A = () => { const items = ["a", "b", "c"]; return <ul>{items.map((i) => <li>{i}</li>)}</ul>; };`},
|
||||
{"deep-static", `export const A = () => <section><header><h1>Title</h1></header><p>body text</p></section>;`},
|
||||
{"multi-attr", `export const A = () => <input type="text" name="q" disabled />;`},
|
||||
}
|
||||
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) => <div class="box">{props.children}</div>; return <Box>hi</Box>; };`},
|
||||
{"component-dyn-prop", `export const A = () => { const Lbl = (props) => <span>{props.text}</span>; const t = () => "yo"; return <Lbl text={t()} />; };`},
|
||||
{"nested-components", `export const A = () => { const Row = (props) => <li>{props.children}</li>; return <ul><Row>one</Row><Row>two</Row></ul>; };`},
|
||||
{"show-true", `import { Show } from "solid-js"; export const A = () => <div><Show when={true} fallback={<p>no</p>}>yes</Show></div>;`},
|
||||
{"show-false", `import { Show } from "solid-js"; export const A = () => <div><Show when={false} fallback={<p>no</p>}>yes</Show></div>;`},
|
||||
{"for", `import { For } from "solid-js"; export const A = () => <ul><For each={[1, 2, 3]}>{(n) => <li>{n}</li>}</For></ul>;`},
|
||||
{"fragment", `export const A = () => { const a = () => "A"; const b = () => "B"; return <div>{a()}{b()}</div>; };`},
|
||||
}
|
||||
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 <div {...p} class="x">hi</div>; };`},
|
||||
{"spread-override", `export const A = () => { const p = { class: "from-p" }; return <div {...p} class="from-attr">hi</div>; };`},
|
||||
{"ref", `export const A = () => { let r; return <div ref={r}>hi</div>; };`},
|
||||
}
|
||||
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 <div>{c()}</div>; }
|
||||
export const Banner = () => <span>hi</span>;
|
||||
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 <Ctx.Provider value="ok">{props.children}</Ctx.Provider>; }
|
||||
function Consumer() { const v = useContext(Ctx); if (!v) throw new Error("no ctx"); return <span>{v}</span>; }
|
||||
export const A = () => <Provider><Consumer /></Provider>;`
|
||||
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 <div style={s()}>x</div>; };`, "10px", "[object Object]"},
|
||||
{"innerHTML", "export const A = () => { const h = () => '<path d=\"M1 2\"/>'; return <svg innerHTML={h()}></svg>; };", "<path", "innerHTML="},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
out, err := compileSolidGo(c.src, c.name+".tsx", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html, err := renderComponent(t, out)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(html, c.want) {
|
||||
t.Errorf("output %q missing %q\ncompiled:\n%s", html, c.want, out)
|
||||
}
|
||||
if strings.Contains(html, c.absent) {
|
||||
t.Errorf("output %q still contains broken %q", html, c.absent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
116
go/bundler/compile_solid_test.go
Normal file
116
go/bundler/compile_solid_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package bundler
|
||||
|
||||
import "testing"
|
||||
|
||||
// parseOne parses a single JSX element starting at the first '<'.
|
||||
func parseOne(t *testing.T, src string) jsxNode {
|
||||
t.Helper()
|
||||
i := 0
|
||||
for i < len(src) && src[i] != '<' {
|
||||
i++
|
||||
}
|
||||
node, next, err := parseJSX(src, i)
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSX(%q): %v", src, err)
|
||||
}
|
||||
if next != len(src) {
|
||||
t.Fatalf("parseJSX(%q): consumed to %d, want %d (trailing %q)", src, next, len(src), src[next:])
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func TestParseStaticElement(t *testing.T) {
|
||||
n := parseOne(t, `<div class="x">hi</div>`)
|
||||
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, `<Foo bar={1}>child</Foo>`)
|
||||
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, `<div><span>a</span><b>{x()}</b></div>`)
|
||||
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, `<input disabled type="text" />`)
|
||||
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, `<div {...p} class="x">hi</div>`)
|
||||
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, "<ul>{items.map((x) => <li>{x}</li>)}</ul>")
|
||||
if len(n.children) != 1 || n.children[0].kind != jsxExpr {
|
||||
t.Fatalf("children = %+v", n.children)
|
||||
}
|
||||
if n.children[0].expr != "items.map((x) => <li>{x}</li>)" {
|
||||
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
|
||||
}
|
||||
}
|
||||
91
go/bundler/css.go
Normal file
91
go/bundler/css.go
Normal file
@@ -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
|
||||
}
|
||||
89
go/bundler/export_shim.go
Normal file
89
go/bundler/export_shim.go
Normal file
@@ -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"
|
||||
}
|
||||
201
go/bundler/faicons.go
Normal file
201
go/bundler/faicons.go
Normal file
@@ -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/<dir>. 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(`<path[^>]*\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<string, readonly [number, number, number, number, string]> = {\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) }
|
||||
138
go/bundler/genroutes.go
Normal file
138
go/bundler/genroutes.go
Normal file
@@ -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 + <title> +
|
||||
// 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)
|
||||
}
|
||||
154
go/bundler/genssr.go
Normal file
154
go/bundler/genssr.go
Normal file
@@ -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)
|
||||
}
|
||||
201
go/bundler/hmr_browser_test.go
Normal file
201
go/bundler/hmr_browser_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
279
go/bundler/hmr_client.go
Normal file
279
go/bundler/hmr_client.go
Normal file
@@ -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();
|
||||
`
|
||||
32
go/bundler/hmr_client_test.go
Normal file
32
go/bundler/hmr_client_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
188
go/bundler/hmr_e2e_test.go
Normal file
188
go/bundler/hmr_e2e_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
78
go/bundler/hmr_error_test.go
Normal file
78
go/bundler/hmr_error_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
497
go/bundler/hmr_server.go
Normal file
497
go/bundler/hmr_server.go
Normal file
@@ -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"
|
||||
}
|
||||
118
go/bundler/hmr_vendor.go
Normal file
118
go/bundler/hmr_vendor.go
Normal file
@@ -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
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
158
go/bundler/hmr_watch.go
Normal file
158
go/bundler/hmr_watch.go
Normal file
@@ -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"})
|
||||
}
|
||||
}
|
||||
}
|
||||
235
go/bundler/hmr_ws.go
Normal file
235
go/bundler/hmr_ws.go
Normal file
@@ -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[:])
|
||||
}
|
||||
88
go/bundler/hmr_ws_integration_test.go
Normal file
88
go/bundler/hmr_ws_integration_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
67
go/bundler/hmr_ws_test.go
Normal file
67
go/bundler/hmr_ws_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
87
go/bundler/import_check.go
Normal file
87
go/bundler/import_check.go
Normal file
@@ -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.")
|
||||
}
|
||||
198
go/bundler/js.go
Normal file
198
go/bundler/js.go
Normal file
@@ -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], `":{`)
|
||||
}
|
||||
352
go/bundler/js/ssr/dom.js
Normal file
352
go/bundler/js/ssr/dom.js
Normal file
@@ -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);
|
||||
94
go/bundler/jsx.go
Normal file
94
go/bundler/jsx.go
Normal file
@@ -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
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
43
go/bundler/jsx_bench_test.go
Normal file
43
go/bundler/jsx_bench_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
48
go/bundler/jsx_dev_test.go
Normal file
48
go/bundler/jsx_dev_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
61
go/bundler/jsx_test.go
Normal file
61
go/bundler/jsx_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
228
go/bundler/renderer.go
Normal file
228
go/bundler/renderer.go
Normal file
@@ -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
|
||||
}
|
||||
293
go/bundler/segment.go
Normal file
293
go/bundler/segment.go
Normal file
@@ -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')
|
||||
}
|
||||
108
go/bundler/segment_test.go
Normal file
108
go/bundler/segment_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
340
go/bundler/ssr.go
Normal file
340
go/bundler/ssr.go
Normal file
@@ -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
|
||||
}
|
||||
145
go/bundler/ssr_test.go
Normal file
145
go/bundler/ssr_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
192
go/bundler/ssrcache.go
Normal file
192
go/bundler/ssrcache.go
Normal file
@@ -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
|
||||
}
|
||||
9586
go/bundler/tailwind.go
Normal file
9586
go/bundler/tailwind.go
Normal file
File diff suppressed because it is too large
Load Diff
217
go/bundler/tailwind_test.go
Normal file
217
go/bundler/tailwind_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
393
go/bundler/tw_preflight.css
Normal file
393
go/bundler/tw_preflight.css
Normal file
@@ -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;
|
||||
}
|
||||
510
go/bundler/tw_theme.css
Normal file
510
go/bundler/tw_theme.css
Normal file
@@ -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;
|
||||
}
|
||||
111
go/bundler/vendor_plugins.go
Normal file
111
go/bundler/vendor_plugins.go
Normal file
@@ -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
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
391
go/chrono/chrono.go
Normal file
391
go/chrono/chrono.go
Normal file
@@ -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)
|
||||
}
|
||||
86
go/chrono/chrono_test.go
Normal file
86
go/chrono/chrono_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
go/cmd/bundle/main.go
Normal file
18
go/cmd/bundle/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
129
go/cmd/loc/main.go
Normal file
129
go/cmd/loc/main.go
Normal file
@@ -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)
|
||||
}
|
||||
380
go/cmd/migrate/database.go
Normal file
380
go/cmd/migrate/database.go
Normal file
@@ -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
|
||||
}
|
||||
260
go/cmd/migrate/engine.go
Normal file
260
go/cmd/migrate/engine.go
Normal file
@@ -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)
|
||||
}
|
||||
273
go/cmd/migrate/main.go
Normal file
273
go/cmd/migrate/main.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
151
go/cmd/migrate/source.go
Normal file
151
go/cmd/migrate/source.go
Normal file
@@ -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
|
||||
}
|
||||
23
go/cmd/passgen/main.go
Normal file
23
go/cmd/passgen/main.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
150
go/cmd/typecheck/main.go
Normal file
150
go/cmd/typecheck/main.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
go/config/config.go
Normal file
42
go/config/config.go
Normal file
@@ -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)
|
||||
}
|
||||
123
go/csv/csv.go
Normal file
123
go/csv/csv.go
Normal file
@@ -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
|
||||
}
|
||||
554
go/dbutil/automapper.go
Normal file
554
go/dbutil/automapper.go
Normal file
@@ -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()
|
||||
}
|
||||
507
go/dbutil/automapper_test.go
Normal file
507
go/dbutil/automapper_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
1270
go/dbutil/builder.go
Normal file
1270
go/dbutil/builder.go
Normal file
File diff suppressed because it is too large
Load Diff
737
go/dbutil/builder_test.go
Normal file
737
go/dbutil/builder_test.go
Normal file
@@ -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
|
||||
}
|
||||
64
go/dbutil/db_connect.go
Normal file
64
go/dbutil/db_connect.go
Normal file
@@ -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 }
|
||||
306
go/dbutil/filters.go
Normal file
306
go/dbutil/filters.go
Normal file
@@ -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, "&")
|
||||
}
|
||||
25
go/dbutil/registry.go
Normal file
25
go/dbutil/registry.go
Normal file
@@ -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] }
|
||||
52
go/dbutil/test_models_test.go
Normal file
52
go/dbutil/test_models_test.go
Normal file
@@ -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")
|
||||
}
|
||||
234
go/finance/helpers.go
Normal file
234
go/finance/helpers.go
Normal file
@@ -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
|
||||
}
|
||||
33
go/go.mod
Normal file
33
go/go.mod
Normal file
@@ -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
|
||||
)
|
||||
108
go/go.sum
Normal file
108
go/go.sum
Normal file
@@ -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=
|
||||
57
go/httputil/cors.go
Normal file
57
go/httputil/cors.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
45
go/httputil/cors_test.go
Normal file
45
go/httputil/cors_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
5
go/httputil/doc.go
Normal file
5
go/httputil/doc.go
Normal file
@@ -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
|
||||
20
go/httputil/respond.go
Normal file
20
go/httputil/respond.go
Normal file
@@ -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})
|
||||
}
|
||||
138
go/httputil/useragent.go
Normal file
138
go/httputil/useragent.go
Normal file
@@ -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 ""
|
||||
}
|
||||
41
go/l4g/database.go
Normal file
41
go/l4g/database.go
Normal file
@@ -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
|
||||
}
|
||||
51
go/l4g/debug.go
Normal file
51
go/l4g/debug.go
Normal file
@@ -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)
|
||||
}
|
||||
66
go/l4g/doc.go
Normal file
66
go/l4g/doc.go
Normal file
@@ -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
|
||||
23
go/l4g/entry.go
Normal file
23
go/l4g/entry.go
Normal file
@@ -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
|
||||
}
|
||||
109
go/l4g/file.go
Normal file
109
go/l4g/file.go
Normal file
@@ -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
|
||||
}
|
||||
84
go/l4g/logger.go
Normal file
84
go/l4g/logger.go
Normal file
@@ -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)
|
||||
}
|
||||
71
go/l4g/terminal.go
Normal file
71
go/l4g/terminal.go
Normal file
@@ -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
|
||||
}
|
||||
228
go/security/crypt.go
Normal file
228
go/security/crypt.go
Normal file
@@ -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)
|
||||
}
|
||||
9
go/security/crypt_test.go
Normal file
9
go/security/crypt_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
11
go/security/init.go
Normal file
11
go/security/init.go
Normal file
@@ -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")
|
||||
}
|
||||
37
go/security/random.go
Normal file
37
go/security/random.go
Normal file
@@ -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
|
||||
}
|
||||
172
go/security/random_test.go
Normal file
172
go/security/random_test.go
Normal file
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
89
go/snailmail/cloudflare.go
Normal file
89
go/snailmail/cloudflare.go
Normal file
@@ -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
|
||||
}
|
||||
53
go/snailmail/smtp.go
Normal file
53
go/snailmail/smtp.go
Normal file
@@ -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
|
||||
}
|
||||
81
go/snailmail/snailmail.go
Normal file
81
go/snailmail/snailmail.go
Normal file
@@ -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)
|
||||
}
|
||||
211
go/validation/validation.go
Normal file
211
go/validation/validation.go
Normal file
@@ -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 (<28>) 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
|
||||
}
|
||||
Reference in New Issue
Block a user