; const p = { class: "from-p" }; return ; };`},
}
for _, c := range cases {
c := c
@@ -299,3 +302,71 @@ func TestGoCompilerStyleAndInnerHTML(t *testing.T) {
})
}
}
+
+// Regression: HTML boolean attributes (checked, disabled, required, readonly,
+// hidden, selected, ...) are presence-based — any attribute value, including
+// the string "false", still counts as present. A dynamic false must go through
+// setBoolAttribute (which removes the attribute), not setAttribute (which would
+// emit e.g. checked="false", read by the browser as checked=true).
+func TestGoCompilerBooleanAttrs(t *testing.T) {
+ root, _ := filepath.Abs("../..")
+ t.Chdir(root)
+ src := `export const A = () => {
+ const no = () => false;
+ const yes = () => true;
+ return
+
+
+
;
+ };`
+ out, err := compileSolidGo(src, "bools.tsx", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ html, err := renderComponent(t, out)
+ if err != nil {
+ t.Fatalf("render: %v\n%s", err, out)
+ }
+ for _, broken := range []string{`checked="false"`, `disabled="false"`} {
+ if strings.Contains(html, broken) {
+ t.Errorf("false-valued boolean attr still present (reads as true in browser): %q in %s", broken, html)
+ }
+ }
+ for _, present := range []string{`checked=""`, `required=""`} {
+ if !strings.Contains(html, present) {
+ t.Errorf("true-valued boolean attr missing: expected %q in %s", present, html)
+ }
+ }
+}
+
+// Regression: a comment-only JSX expression child ({/* note */}) must be
+// dropped like whitespace, not compiled as a real expression. childrenProp
+// (component children) already filtered these via renderedChild, but a bare
+// root fragment with 2+ children went through genFragment's own filter, which
+// only skipped blank text — a comment-only child slipped through and compiled
+// to `_$memo(() => /* note */)`, a syntax error that breaks the whole module.
+func TestGoCompilerFragmentCommentChild(t *testing.T) {
+ root, _ := filepath.Abs("../..")
+ t.Chdir(root)
+ src := `export const A = () => {
+ return <>
+ {/* a comment */}
+
one
+
two
+ >;
+ };`
+ out, err := compileSolidGo(src, "frag-comment.tsx", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := validateJS(out); err != nil {
+ t.Fatalf("compiled output doesn't parse: %v\n--- output ---\n%s", err, out)
+ }
+ html, err := renderComponent(t, out)
+ if err != nil {
+ t.Fatalf("render: %v\n%s", err, out)
+ }
+ if !strings.Contains(html, "one") || !strings.Contains(html, "two") {
+ t.Errorf("expected both siblings in output, got: %q", html)
+ }
+}
diff --git a/go/jsbundler/hmr_e2e_test.go b/go/jsbundler/hmr_e2e_test.go
index a0a7d9e2..1055e441 100644
--- a/go/jsbundler/hmr_e2e_test.go
+++ b/go/jsbundler/hmr_e2e_test.go
@@ -115,20 +115,20 @@ func TestDevServerEndToEnd(t *testing.T) {
}
// --- SPA entry: hot bootstrap + bare imports kept + relative imports rewritten
- code, app := get(srcURLPrefix + "app.ts")
+ code, app := get(srcURLPrefix + "app.tsx")
if code != 200 {
- t.Fatalf("app.ts status %d:\n%s", code, app)
+ t.Fatalf("app.tsx 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
+ `__createHotContext("/@src/app.tsx")`, // 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.tsx`, // relative import rewritten
} {
if !strings.Contains(app, want) {
- t.Errorf("app.ts missing %q", want)
+ t.Errorf("app.tsx missing %q", want)
}
}
diff --git a/go/jsbundler/hmr_server.go b/go/jsbundler/hmr_server.go
index 3502725f..6ca7abd8 100644
--- a/go/jsbundler/hmr_server.go
+++ b/go/jsbundler/hmr_server.go
@@ -65,6 +65,17 @@ func StartDevHMR(mux *http.ServeMux, cfg Config) (importMap string, err error) {
return "", err
}
d.register(mux)
+
+ // The watcher's seed scan only records starting mtimes and never fires
+ // onchange (see watch()), so anything already referenced in source before
+ // this boot — e.g. an icon name — would otherwise sit missing from the
+ // generated registry until some later edit happened to retrigger it. Run it
+ // once up front so a bare `go run -tags dev ./cmd/server` (skipping the
+ // "Bundle: Build" preLaunchTask) still starts consistent.
+ if err := generateFAIcons(); err != nil {
+ fmt.Fprintf(os.Stderr, "[hmr] generating FA icons: %v\n", err)
+ }
+
go d.watch()
fmt.Println("HMR dev server: serving native-ESM source from /@src/, WebSocket at /@hmr/ws")
diff --git a/go/jsbundler/hmr_watch.go b/go/jsbundler/hmr_watch.go
index 63394d68..4678ee90 100644
--- a/go/jsbundler/hmr_watch.go
+++ b/go/jsbundler/hmr_watch.go
@@ -75,8 +75,10 @@ func (d *devServer) scan(roots []string, mtimes map[string]time.Time, onchange f
func (d *devServer) handleChanges(changed []string) {
pagesManifestAbs := filepath.Join(d.frontend, filepath.FromSlash(pagesManifest))
+ faIconsOutAbs := filepath.Join(d.frontend, "src", "ui", "generated", "faIcons.ts")
cssDirty := false
srcDirty := false
+ iconsDirty := false
for _, p := range changed {
base := filepath.Base(p)
@@ -102,9 +104,23 @@ func (d *devServer) handleChanges(changed []string) {
d.hmrJS(p)
cssDirty = true // a class may have been added/removed
srcDirty = true
+ // The icon registry is itself a generated .ts file under srcRoot, so its
+ // own hot-reload (above) must not re-trigger a regeneration pass — that
+ // would bump its mtime and loop forever.
+ if p != faIconsOutAbs {
+ iconsDirty = true
+ }
}
}
+ if iconsDirty {
+ // A source edit may have referenced a new icon name; rescan so it's
+ // available without a manual `go run ./cmd/bundle`. The regenerated file's
+ // own mtime change is picked up and hot-reloaded on the next poll.
+ if err := generateFAIcons(); err != nil {
+ fmt.Fprintf(os.Stderr, "[hmr] regenerating FA icons: %v\n", err)
+ }
+ }
if cssDirty {
select {
case d.cssTrigger <- struct{}{}:
diff --git a/go/jsbundler/js.go b/go/jsbundler/js.go
index 8acca65f..8773419e 100644
--- a/go/jsbundler/js.go
+++ b/go/jsbundler/js.go
@@ -26,15 +26,15 @@ func esbuildDefine() map[string]string {
}
// resolveEntryPoint returns the path (relative to frontendDir) of the
-// SPA entry point, preferring app.ts over app.js.
+// SPA entry point, preferring app.tsx over app.js.
func resolveEntryPoint() string {
- if _, err := os.Stat(filepath.Join(frontendDir, "src/app.ts")); err == nil {
- return "src/app.ts"
+ if _, err := os.Stat(filepath.Join(frontendDir, "src/app.tsx")); err == nil {
+ return "src/app.tsx"
}
return "src/app.js"
}
-// bundleJS bundles the SPA entry (app.ts/app.js) into bundle.min.js.
+// bundleJS bundles the SPA entry (app.tsx/app.js) into bundle.min.js.
func bundleJS() (bundleStats, error) {
return bundleJSEntry(filepath.Join(frontendDir, resolveEntryPoint()), "bundle.min.js")
}
@@ -145,7 +145,7 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
// 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`.
+// yields project-rooted paths like `frontend/src/app.tsx`.
func stripSourcemapParentPrefix(mapPath string) error {
data, err := os.ReadFile(mapPath)
if err != nil {
diff --git a/go/tw/tailwind.go b/go/tw/tailwind.go
index 02d8d264..95b7c3f7 100644
--- a/go/tw/tailwind.go
+++ b/go/tw/tailwind.go
@@ -4435,7 +4435,7 @@ func (t *Theme) keysInNamespaces(themeKeys []string) []string {
if !strings.HasPrefix(key, prefix) {
continue
}
- if strings.Index(key[2:], "--") != -1 {
+ if strings.Contains(key[2:], "--") {
continue
}
if isIgnoredThemeKey(key, namespace) {
@@ -8989,7 +8989,7 @@ func createVariants(theme *Theme) *Variants {
registerCompoundVariants(variants, theme)
registerPseudoVariants(variants)
- registerFunctionalVariants(variants, theme)
+ registerFunctionalVariants(variants)
registerBreakpointVariants(variants, theme)
registerMediaVariants(variants)
@@ -9362,7 +9362,7 @@ func registerPseudoVariants(variants *Variants) {
sv("inert", "&:is([inert], [inert] *)")
}
-func registerFunctionalVariants(variants *Variants, theme *Theme) {
+func registerFunctionalVariants(variants *Variants) {
variants.functional("aria", func(r *AstNode, variant *Variant) bool {
if variant.Value == nil || variant.Modifier != nil {
return false
diff --git a/go/validation/validation.go b/go/validation/validation.go
index 12bf3923..e9f8fbf8 100644
--- a/go/validation/validation.go
+++ b/go/validation/validation.go
@@ -198,6 +198,56 @@ func ValidateZipCode(zip string) error {
return nil
}
+func ValidateEmail(email string) error {
+ err := errors.New("Invalid email address.")
+
+ if email == "" {
+ return err
+ }
+
+ emailParts := strings.Split(email, "@")
+ if len(emailParts) != 2 {
+ return err
+ }
+
+ account := emailParts[0]
+ address := emailParts[1]
+
+ lenErr := errors.New("Email address is too long.")
+ if len(account) > 64 {
+ return lenErr
+ } else if len(address) > 255 {
+ return lenErr
+ }
+
+ domainParts := strings.Split(address, ".")
+ for _, part := range domainParts {
+ if len(part) > 63 {
+ return lenErr
+ }
+ }
+
+ re := regexp.MustCompile(`^[-!#$%&'*+/0-9=?A-Z^_a-z` + "`" + `{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z` + "`" + `{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$`)
+ if !re.MatchString(email) {
+ return err
+ }
+
+ return nil
+}
+
+func ValidateUsername(username string) error {
+ if len(username) < 5 || len(username) > 50 {
+ return errors.New("Username must have 5-50 characters.")
+ }
+
+ isAlphanumeric, _ := regexp.MatchString(`^[A-Za-z0-9]*$`, username)
+ if !isAlphanumeric {
+ return errors.New("Username must only contain alphanumeric characters.")
+ }
+
+ return nil
+}
+
func ValidateUrl(url string) error {
err := errors.New("Invalid URL.")