Initial add backend stuff

This commit is contained in:
2026-07-08 15:45:16 -04:00
commit a7964f9410
89 changed files with 25924 additions and 0 deletions

17
appenv/appenv.go Normal file
View 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"
)

View 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
appenv/env_production.go Normal file
View 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
appenv/env_staging.go Normal file
View 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
basic/basic.go Normal file
View 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
basic/basic_test.go Normal file
View 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
bundler/build.go Normal file
View 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
bundler/compile_solid.go Normal file
View 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
bundler/compile_solid_gen.go Normal file

File diff suppressed because it is too large Load Diff

View 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)
}
})
}
}

View 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
bundler/css.go Normal file
View 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
bundler/export_shim.go Normal file
View 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
bundler/faicons.go Normal file
View 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
bundler/genroutes.go Normal file
View 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
bundler/genssr.go Normal file
View 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
bundler/hmr_browser_test.go Normal file
View 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
bundler/hmr_client.go Normal file
View 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 === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;';
});
}
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();
`

View 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
bundler/hmr_e2e_test.go Normal file
View 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
bundler/hmr_error_test.go Normal file
View 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
bundler/hmr_server.go Normal file
View 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
bundler/hmr_vendor.go Normal file
View 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
bundler/hmr_watch.go Normal file
View 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
bundler/hmr_ws.go Normal file
View 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[:])
}

View 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
bundler/hmr_ws_test.go Normal file
View 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
bundler/import_check.go Normal file
View 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
bundler/js.go Normal file
View 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
bundler/js/ssr/dom.js Normal file
View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeAttr(s) {
return String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
// ---- 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
bundler/jsx.go Normal file
View 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
bundler/jsx_bench_test.go Normal file
View 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
bundler/jsx_dev_test.go Normal file
View 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
bundler/jsx_test.go Normal file
View 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
bundler/renderer.go Normal file
View 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
bundler/segment.go Normal file
View 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
bundler/segment_test.go Normal file
View 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
bundler/ssr.go Normal file
View 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
bundler/ssr_test.go Normal file
View 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
bundler/ssrcache.go Normal file
View 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
bundler/tailwind.go Normal file

File diff suppressed because it is too large Load Diff

217
bundler/tailwind_test.go Normal file
View 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
bundler/tw_preflight.css Normal file
View 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
bundler/tw_theme.css Normal file
View 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
bundler/vendor_plugins.go Normal file
View 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
chrono/chrono.go Normal file
View 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
chrono/chrono_test.go Normal file
View 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
cmd/bundle/main.go Normal file
View 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
cmd/loc/main.go Normal file
View 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
cmd/migrate/database.go Normal file
View 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
cmd/migrate/engine.go Normal file
View 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
cmd/migrate/main.go Normal file
View 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
cmd/migrate/source.go Normal file
View 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
cmd/passgen/main.go Normal file
View 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
cmd/typecheck/main.go Normal file
View 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
config/config.go Normal file
View 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
csv/csv.go Normal file
View 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
dbutil/automapper.go Normal file
View 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
dbutil/automapper_test.go Normal file
View 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
dbutil/builder.go Normal file

File diff suppressed because it is too large Load Diff

737
dbutil/builder_test.go Normal file
View 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
dbutil/db_connect.go Normal file
View 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
dbutil/filters.go Normal file
View 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
dbutil/registry.go Normal file
View 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] }

View 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
finance/helpers.go Normal file
View 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.mod Normal file
View 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.sum Normal file
View 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
httputil/cors.go Normal file
View 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
httputil/cors_test.go Normal file
View 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
httputil/doc.go Normal file
View 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
httputil/respond.go Normal file
View 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
httputil/useragent.go Normal file
View 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
l4g/database.go Normal file
View 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
l4g/debug.go Normal file
View 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
l4g/doc.go Normal file
View 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
l4g/entry.go Normal file
View 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
l4g/file.go Normal file
View 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
l4g/logger.go Normal file
View 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
l4g/terminal.go Normal file
View 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
security/crypt.go Normal file
View 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
security/crypt_test.go Normal file
View 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
security/init.go Normal file
View 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
security/random.go Normal file
View 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
security/random_test.go Normal file
View 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
snailmail/cloudflare.go Normal file
View 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
snailmail/smtp.go Normal file
View 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
snailmail/snailmail.go Normal file
View 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
validation/validation.go Normal file
View 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
}