Repository: a-h/templ
Pull request #1144 (2025-05-06 00:19:46)

fix: include all watch patterns in consideration for cmd restart
When `-watch-pattern` is set and a non-go, non-text (`_templ.txt`) file changes that matches the pattern, processing the change is effectively a no-op because templ only restarts the `-cmd` when Go files change.

A use case for this change is when one uses `//go:embed` directives to embed non-Go assets in go binaries. E.g. when changes are made to `app.js` and `-cmd` is set to `go run main.go`, templ must restart the `-cmd` for changes to be seen. 
 
This change causes templ to re-run its "cmd" argument any time files matching its -watch-pattern change.


acaloiaro: Fixes #1145 

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fa1e7a4..7b63f89 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -42,6 +42,7 @@ jobs:
         run: nix build
 
   update-coverage:
+    if: github.event_name == 'push'
     needs: build
     runs-on: ubuntu-latest
     permissions:
@@ -61,8 +62,6 @@ jobs:
           report: 'true'
           chart: 'true'
           reuse-go: 'true'
-        if: |
-          github.event_name == 'push'  
 
   lint:
     runs-on: ubuntu-latest
diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go
index c1069db..d708d49 100644
--- a/cmd/templ/generatecmd/cmd.go
+++ b/cmd/templ/generatecmd/cmd.go
@@ -57,10 +57,10 @@ type Generate struct {
 }
 
 type GenerationEvent struct {
-	Event       fsnotify.Event
-	Updated     bool
-	GoUpdated   bool
-	TextUpdated bool
+	Event              fsnotify.Event
+	WatchedFileUpdated bool
+	GoUpdated          bool
+	TextUpdated        bool
 }
 
 func (cmd Generate) Run(ctx context.Context) (err error) {
@@ -229,15 +229,15 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 				if err != nil {
 					errs <- err
 				}
-				if !(r.GoUpdated || r.TextUpdated) {
+				if !(r.GoUpdated || r.TextUpdated || r.WatchfileUpdated) {
 					cmd.Log.Debug("File not updated", slog.String("file", event.Name))
 					return
 				}
 				e := &GenerationEvent{
-					Event:       event,
-					Updated:     r.Updated,
-					GoUpdated:   r.GoUpdated,
-					TextUpdated: r.TextUpdated,
+					Event:              event,
+					WatchedFileUpdated: r.WatchfileUpdated,
+					GoUpdated:          r.GoUpdated,
+					TextUpdated:        r.TextUpdated,
 				}
 				cmd.Log.Debug("File updated", slog.String("file", event.Name))
 				postGeneration <- e
@@ -256,7 +256,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 		defer postGenerationWG.Done()
 		cmd.Log.Debug("Starting post-generation handler")
 		timeout := time.NewTimer(time.Hour * 24 * 365)
-		var goUpdated, textUpdated bool
+		var goUpdated, textUpdated, watchedFileUpdated bool
 		var p *proxy.Handler
 		for {
 			select {
@@ -266,8 +266,9 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 					return
 				}
 				goUpdated = goUpdated || ge.GoUpdated
+				watchedFileUpdated = watchedFileUpdated || ge.WatchedFileUpdated
 				textUpdated = textUpdated || ge.TextUpdated
-				if goUpdated || textUpdated {
+				if goUpdated || textUpdated || watchedFileUpdated {
 					updates++
 				}
 				// Reset timer.
@@ -276,13 +277,13 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 				}
 				timeout.Reset(time.Millisecond * 100)
 			case <-timeout.C:
-				if !goUpdated && !textUpdated {
+				if !goUpdated && !textUpdated && !watchedFileUpdated {
 					// Nothing to process, reset timer and wait again.
 					timeout.Reset(time.Hour * 24 * 365)
 					break
 				}
 				postGenerationEventsWG.Add(1)
-				if cmd.Args.Command != "" && goUpdated {
+				if cmd.Args.Command != "" && (goUpdated || watchedFileUpdated) {
 					cmd.Log.Debug("Executing command", slog.String("command", cmd.Args.Command))
 					if cmd.Args.Watch {
 						os.Setenv("TEMPL_DEV_MODE", "true")
@@ -300,7 +301,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 					}
 				}
 				// Send server-sent event.
-				if p != nil && (textUpdated || goUpdated) {
+				if p != nil && (textUpdated || goUpdated || watchedFileUpdated) {
 					cmd.Log.Debug("Sending reload event")
 					p.SendSSE("message", "reload")
 				}
@@ -309,6 +310,7 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 				timeout.Reset(time.Millisecond * 100)
 				textUpdated = false
 				goUpdated = false
+				watchedFileUpdated = false
 			}
 		}
 	}()
diff --git a/cmd/templ/generatecmd/eventhandler.go b/cmd/templ/generatecmd/eventhandler.go
index 7821ac9..c07b100 100644
--- a/cmd/templ/generatecmd/eventhandler.go
+++ b/cmd/templ/generatecmd/eventhandler.go
@@ -94,8 +94,8 @@ type FSEventHandler struct {
 }
 
 type GenerateResult struct {
-	// Updated indicates that the file was updated.
-	Updated bool
+	// WatchfileUpdated indicates that a file matching the watch pattern was updated.
+	WatchfileUpdated bool
 	// GoUpdated indicates that Go expressions were updated.
 	GoUpdated bool
 	// TextUpdated indicates that text literals were updated.
@@ -117,7 +117,7 @@ func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event)
 		if err = os.Remove(event.Name); err != nil {
 			h.Log.Warn("Failed to remove orphaned file", slog.Any("error", err))
 		}
-		return GenerateResult{Updated: true, GoUpdated: true, TextUpdated: false}, nil
+		return GenerateResult{WatchfileUpdated: true, GoUpdated: true, TextUpdated: false}, nil
 	}
 
 	// If the file hasn't been updated since the last time we processed it, ignore it.
@@ -133,7 +133,7 @@ func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event)
 		if strings.HasSuffix(event.Name, ".go") {
 			result.GoUpdated = true
 		}
-		result.Updated = true
+		result.WatchfileUpdated = true
 		return result, nil
 	}
 
@@ -253,7 +253,7 @@ func (h *FSEventHandler) generate(ctx context.Context, fileName string) (result
 	// Hash output, and write out the file if the goCodeHash has changed.
 	goCodeHash := sha256.Sum256(formattedGoCode)
 	if h.UpsertHash(targetFileName, goCodeHash) {
-		result.Updated = true
+		result.WatchfileUpdated = true
 		if err = h.writer(targetFileName, formattedGoCode); err != nil {
 			return result, nil, fmt.Errorf("failed to write target file %q: %w", targetFileName, err)
 		}
diff --git a/cmd/templ/lspcmd/main.go b/cmd/templ/lspcmd/main.go
index 1b6dd45..24e5694 100644
--- a/cmd/templ/lspcmd/main.go
+++ b/cmd/templ/lspcmd/main.go
@@ -22,6 +22,7 @@ type Arguments struct {
 	Log           string
 	GoplsLog      string
 	GoplsRPCTrace bool
+	GoplsRemote   string
 	// PPROF sets whether to start a profiling server on localhost:9999
 	PPROF bool
 	// HTTPDebug sets the HTTP endpoint to listen on. Leave empty for no web debug.
@@ -81,6 +82,7 @@ func run(ctx context.Context, log *slog.Logger, templStream jsonrpc2.Stream, arg
 	rwc, err := pls.NewGopls(ctx, log, pls.Options{
 		Log:      args.GoplsLog,
 		RPCTrace: args.GoplsRPCTrace,
+		Remote:   args.GoplsRemote,
 	})
 	if err != nil {
 		log.Error("failed to start gopls", slog.Any("error", err))
diff --git a/cmd/templ/lspcmd/pls/main.go b/cmd/templ/lspcmd/pls/main.go
index 4f80c24..f5b4df5 100644
--- a/cmd/templ/lspcmd/pls/main.go
+++ b/cmd/templ/lspcmd/pls/main.go
@@ -16,6 +16,7 @@ import (
 type Options struct {
 	Log      string
 	RPCTrace bool
+	Remote   string
 }
 
 // AsArguments converts the options into command line arguments for gopls.
@@ -27,6 +28,9 @@ func (opts Options) AsArguments() []string {
 	if opts.RPCTrace {
 		args = append(args, "-rpc.trace")
 	}
+	if opts.Remote != "" {
+		args = append(args, "-remote", opts.Remote)
+	}
 	return args
 }
 
diff --git a/cmd/templ/main.go b/cmd/templ/main.go
index 7da2c8b..a8e451e 100644
--- a/cmd/templ/main.go
+++ b/cmd/templ/main.go
@@ -9,6 +9,7 @@ import (
 	"os"
 	"os/signal"
 	"runtime"
+	"syscall"
 
 	"github.com/a-h/templ"
 	"github.com/a-h/templ/cmd/templ/fmtcmd"
@@ -236,7 +237,7 @@ func generateCmd(stdout, stderr io.Writer, args []string) (code int) {
 
 	ctx, cancel := context.WithCancel(context.Background())
 	signalChan := make(chan os.Signal, 1)
-	signal.Notify(signalChan, os.Interrupt)
+	signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
 	go func() {
 		<-signalChan
 		fmt.Fprintln(stderr, "Stopping...")
@@ -353,6 +354,8 @@ Args:
     The file to log gopls output, or leave empty to disable logging.
   -goplsRPCTrace
     Set gopls to log input and output messages.
+  -gopls-remote
+    Specify remote gopls instance to connect to.
   -help
     Print help and exit.
   -pprof
@@ -368,6 +371,7 @@ func lspCmd(stdin io.Reader, stdout, stderr io.Writer, args []string) (code int)
 	logFlag := cmd.String("log", "", "")
 	goplsLog := cmd.String("goplsLog", "", "")
 	goplsRPCTrace := cmd.Bool("goplsRPCTrace", false, "")
+	goplsRemote := cmd.String("gopls-remote", "", "")
 	helpFlag := cmd.Bool("help", false, "")
 	pprofFlag := cmd.Bool("pprof", false, "")
 	httpDebugFlag := cmd.String("http", "", "")
@@ -386,6 +390,7 @@ func lspCmd(stdin io.Reader, stdout, stderr io.Writer, args []string) (code int)
 		Log:           *logFlag,
 		GoplsLog:      *goplsLog,
 		GoplsRPCTrace: *goplsRPCTrace,
+		GoplsRemote:   *goplsRemote,
 		PPROF:         *pprofFlag,
 		HTTPDebug:     *httpDebugFlag,
 		NoPreload:     *noPreloadFlag && os.Getenv("GOPACKAGESDRIVER") != "",
diff --git a/docs/docs/09-developer-tools/01-cli.md b/docs/docs/09-developer-tools/01-cli.md
index 21e1977..24c4846 100644
--- a/docs/docs/09-developer-tools/01-cli.md
+++ b/docs/docs/09-developer-tools/01-cli.md
@@ -100,6 +100,8 @@ templ fmt -fail .
 
 This command isn't intended to be used directly by users, but is used by IDE integrations such as the VSCode extension and by Neovim support.
 
+By default, `templ lsp` starts its own instance of gopls. However, gopls supports a [shared daemon mode](https://github.com/golang/tools/blob/master/gopls/doc/daemon.md), allowing multiple clients to connect to a single, long-lived instance. You can enable this mode using the `-gopls-remote` flag, which will either connect to an existing shared gopls instance or create one if none is running. This can improve performance and reduce resource usage.
+
 A number of additional options are provided to enable runtime logging and profiling tools.
 
 ```
@@ -107,6 +109,8 @@ A number of additional options are provided to enable runtime logging and profil
         The file to log gopls output, or leave empty to disable logging.
   -goplsRPCTrace
         Set gopls to log input and output messages.
+  -gopls-remote
+        Specify remote gopls instance to connect to.
   -help
         Print help and exit.
   -http string
diff --git a/docs/docs/09-developer-tools/03-live-reload.md b/docs/docs/09-developer-tools/03-live-reload.md
index bee532b..1b92699 100644
--- a/docs/docs/09-developer-tools/03-live-reload.md
+++ b/docs/docs/09-developer-tools/03-live-reload.md
@@ -1,36 +1,21 @@
 # Live reload
 
-To access a Go web application that uses templ in a web browser, a few things must happen:
+To enable live reload on a `templ` app use:
 
-1. `templ generate` must be executed, to create Go code (`*_templ.go` files) from the `*.templ` files.
-2. The Go code must start a web server on a port, e.g. (`http.ListenAndServe("localhost:8080", nil)`.
-3. The Go program must be ran, e.g. by running `go run .`.
-4. The web browser must access or reload the page, e.g. `http://localhost:8080`.
-5. Content-Type must be text/html. 
-
-If the `*.templ` files change, #1 and #2 must be ran.
-
-If the `*.go` files change, #3 and #4 must be ran.
-
-## Built-in
-
-`templ generate --watch` watches the current directory for changes and generates Go code if changes are detected.
-
-To re-run your app automatically, add the `--cmd` argument to `templ generate`, and templ will start or restart your app using the command provided once template code generation is complete (#3).
+```bash
+templ generate --watch --proxy="http://localhost:8080" --cmd="go run ."
+```
 
-To trigger your web browser to reload automatically (without pressing F5), set the `--proxy` argument (#4) to point at your app, and browse to the proxy address (default `http://localhost:7331`).
+This will:
 
-The `--proxy` argument starts a HTTP proxy which proxies requests to your app. For example, if your app runs on port 8080, you would use `--proxy="http://localhost:8080"`. The proxy inserts client-side JavaScript before the `</body>` tag that will cause the browser to reload the window when the app is restarted instead of you having to reload the page manually. Note that the html being served by the webserver MUST have a `<body>` tag, otherwise there will be no javascript injection thus making the browser not reload automatically. In addition, the script might not be inserted if templ cannot read and modify the http response, e.g. due to middleware implementation.   
+- Automatically re-generate Go code if you change `*.templ` files.
+- Restart the web server if you change `*.go` files.
+- Automatically reload the browser if you change `*.go` or `*.templ` files.
+- Run a HTTP proxy on `localhost:7331` (by default) that proxies requests to your web server (default `http://localhost:8080`).
 
-By default, the proxy binds to `127.0.0.1`. You can use `--proxybind` to bind to another address, e.g., `--proxybind="0.0.0.0"`.
+## Example
 
-Altogether, to setup live reload on an app that listens on port 8080, run the following.
-
-```
-templ generate --watch --proxy="http://localhost:8080" --cmd="go run ."
-```
-
-This will start the proxy server on port `7331` and open it in your default browser. If you'd like to prevent it from opening in your browser add the flag `--open-browser=false`.
+Create `main.go` and `hello.templ` files.
 
 ```go title="main.go"
 package main
@@ -62,6 +47,61 @@ templ hello(name string) {
 }
 ```
 
+Run `templ generate --watch --proxy="http://localhost:8080" --cmd="go run ."`.
+
+Observe that the web server is started, and the browser opens to `http://localhost:7331`.
+
+Make changes to `hello.templ` and `main.go`, and see the changes reflected in the browser without having to press F5.
+
+## How it works
+
+### templ watches files for changes
+
+The `templ generate --watch` argument tells `templ` to watch for changes to `*.templ` and `*.go` files in the current directory.
+
+When a change is detected, `templ` will:
+
+- Automatically generate `*.go` code from your `*.templ` files when you save changes to them.
+- Create text files in the `tmp` directory that is read by generated templ Go code if the `TEMPL_DEV_MODE` enabled.
+  - This means that the Go web server doesn't need to be restarted when changes are made to HTML or text in `*.templ` files, `templ` can read the files at runtime instead.
+  - The web server is only restarted when changes are made to Go code in `*.templ` files.
+
+### templ restarts your server automatically
+
+The `--cmd` argument tells `templ` to run a command when `*.go` files change, for example:
+
+```bash
+templ generate --watch --cmd="go run ."
+```
+
+The command is executed if `*.go` files change, or if any Go code within `*.templ` files change.
+
+You can run any command you like, e.g. `go build -o app && ./app`, or `air`, or `wgo`.
+
+### templ uses a proxy to auto-reload the browser
+
+The `--proxy` argument tells `templ` to run a HTTP proxy that proxies requests to your web server.
+
+For example, if your web server listens on port 8080:
+
+```bash
+templ generate --watch --cmd="go run ." --proxy="http://localhost:8080"
+```
+
+This starts a HTTP proxy that proxies requests to your web server (default `http://localhost:7331`). The proxy inserts client-side JavaScript before the `</body>` tag that will cause the browser to reload the window when the app is restarted instead of you having to reload the page manually - no more pressing F5!
+
+By default, the proxy binds to `127.0.0.1:7331`. You can use `--proxybind` to bind to another address, e.g., `--proxybind="0.0.0.0"`.
+
+:::note
+In order for templ to successfully inject the reload JavaScript into the HTML response:
+
+- The HTML must have a `<body>` tag.
+- The HTML must be served with a `Content-Type` of `text/html`.
+- The response must be compressed with no compression, or a supported compression algorithm (e.g. gzip).
+:::
+
+## Live reload process
+
 The live reload process can be shown in the following diagram:
 
 ```mermaid
@@ -97,7 +137,11 @@ This will default to the default templ proxy address of `localhost:7331`, but ca
 templ generate --notify-proxy --proxybind="localhost" --proxyport="8080"
 ```
 
-## Alternative 1: wgo
+## Alternatives
+
+If you don't want to use `templ generate --watch`, you can use other tools to watch for changes and restart the server.
+
+### wgo
 
 [wgo](https://github.com/bokwoon95/wgo):
 
@@ -109,7 +153,7 @@ wgo -file=.go -file=.templ -xfile=_templ.go templ generate :: go run main.go
 
 To avoid a continuous reloading files ending with `_templ.go` should be skipped via `-xfile`.
 
-## Alternative 2: air
+### air
 
 Air can also monitor the filesystem for changes, and provides a proxy to automatically reload pages.
 
@@ -117,7 +161,7 @@ It uses a `toml` configuration file.
 
 See https://github.com/cosmtrek/air for details.
 
-### Example configuration
+#### Example configuration
 
 ```toml title=".air.toml"
 root = "."
diff --git a/generator/generator.go b/generator/generator.go
index 782181a..53b1844 100644
--- a/generator/generator.go
+++ b/generator/generator.go
@@ -999,7 +999,7 @@ func (g *generator) writeElement(indentLevel int, n *parser.Element) (err error)
 
 func (g *generator) writeAttributeCSS(indentLevel int, attr *parser.ExpressionAttribute) (result *parser.ExpressionAttribute, ok bool, err error) {
 	var r parser.Range
-	name := html.EscapeString(attr.Name)
+	name := html.EscapeString(attr.Key.String())
 	if name != "class" {
 		ok = false
 		return
@@ -1030,8 +1030,7 @@ func (g *generator) writeAttributeCSS(indentLevel int, attr *parser.ExpressionAt
 	}
 	// Rewrite the ExpressionAttribute to point at the new variable.
 	newAttr := &parser.ExpressionAttribute{
-		Name:      attr.Name,
-		NameRange: attr.NameRange,
+		Key: attr.Key,
 		Expression: parser.Expression{
 			Value: "templ.CSSClasses(" + classesName + ").String()",
 		},
@@ -1107,7 +1106,7 @@ func getAttributeScripts(attr parser.Attribute) (scripts []string) {
 		}
 	}
 	if attr, ok := attr.(*parser.ExpressionAttribute); ok {
-		name := html.EscapeString(attr.Name)
+		name := html.EscapeString(attr.Key.String())
 		if isScriptAttribute(name) {
 			scripts = append(scripts, attr.Expression.Value)
 		}
@@ -1115,26 +1114,66 @@ func getAttributeScripts(attr parser.Attribute) (scripts []string) {
 	return scripts
 }
 
-func (g *generator) writeBoolConstantAttribute(indentLevel int, attr *parser.BoolConstantAttribute) (err error) {
-	name := html.EscapeString(attr.Name)
-	if _, err = g.w.WriteStringLiteral(indentLevel, fmt.Sprintf(` %s`, name)); err != nil {
-		return err
+func (g *generator) writeAttributeKey(indentLevel int, attr parser.AttributeKey) (err error) {
+	if attr, ok := attr.(parser.ConstantAttributeKey); ok {
+		name := html.EscapeString(attr.Name)
+		if _, err = g.w.WriteStringLiteral(indentLevel, fmt.Sprintf(` %s`, name)); err != nil {
+			return err
+		}
+		return nil
+	}
+	if attr, ok := attr.(parser.ExpressionAttributeKey); ok {
+		var r parser.Range
+		vn := g.createVariableName()
+		// var vn string
+		if _, err = g.w.WriteIndent(indentLevel, "var "+vn+" string\n"); err != nil {
+			return err
+		}
+		// vn, templ_7745c5c3_Err = templ.JoinStringErrs(
+		if _, err = g.w.WriteIndent(indentLevel, vn+", templ_7745c5c3_Err = templ.JoinStringErrs("); err != nil {
+			return err
+		}
+		// p.Name()
+		if r, err = g.w.Write(attr.Expression.Value); err != nil {
+			return err
+		}
+		g.sourceMap.Add(attr.Expression, r)
+		// )
+		if _, err = g.w.Write(")\n"); err != nil {
+			return err
+		}
+		// Attribute expression error handler.
+		err = g.writeExpressionErrorHandler(indentLevel, attr.Expression)
+		if err != nil {
+			return err
+		}
+
+		// _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(vn)
+		if _, err = g.w.WriteIndent(indentLevel, "_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(` `+"+vn+"))\n"); err != nil {
+			return err
+		}
+		return g.writeErrorHandler(indentLevel)
 	}
 	return nil
 }
 
+func (g *generator) writeBoolConstantAttribute(indentLevel int, attr *parser.BoolConstantAttribute) (err error) {
+	return g.writeAttributeKey(indentLevel, attr.Key)
+}
+
 func (g *generator) writeConstantAttribute(indentLevel int, attr *parser.ConstantAttribute) (err error) {
-	name := html.EscapeString(attr.Name)
+	if err = g.writeAttributeKey(indentLevel, attr.Key); err != nil {
+		return err
+	}
 	value := html.EscapeString(attr.Value)
 	value = escapeQuotes(value)
-	if _, err = g.w.WriteStringLiteral(indentLevel, fmt.Sprintf(` %s=\"%s\"`, name, value)); err != nil {
+	if _, err = g.w.WriteStringLiteral(indentLevel, fmt.Sprintf(`=\"%s\"`, value)); err != nil {
 		return err
 	}
 	return nil
 }
 
 func (g *generator) writeBoolExpressionAttribute(indentLevel int, attr *parser.BoolExpressionAttribute) (err error) {
-	name := html.EscapeString(attr.Name)
 	// if
 	if _, err = g.w.WriteIndent(indentLevel, `if `); err != nil {
 		return err
@@ -1151,7 +1190,7 @@ func (g *generator) writeBoolExpressionAttribute(indentLevel int, attr *parser.B
 	}
 	{
 		indentLevel++
-		if _, err = g.w.WriteStringLiteral(indentLevel, fmt.Sprintf(` %s`, name)); err != nil {
+		if err = g.writeAttributeKey(indentLevel, attr.Key); err != nil {
 			return err
 		}
 		indentLevel--
@@ -1273,25 +1312,24 @@ func (g *generator) writeExpressionAttributeValueStyle(indentLevel int, attr *pa
 }
 
 func (g *generator) writeExpressionAttribute(indentLevel int, elementName string, attr *parser.ExpressionAttribute) (err error) {
-	attrName := html.EscapeString(attr.Name)
-	// Name
-	if _, err = g.w.WriteStringLiteral(indentLevel, fmt.Sprintf(` %s=`, attrName)); err != nil {
+	if err = g.writeAttributeKey(indentLevel, attr.Key); err != nil {
 		return err
 	}
-	// Open quote.
-	if _, err = g.w.WriteStringLiteral(indentLevel, `\"`); err != nil {
+	// ="
+	if _, err = g.w.WriteStringLiteral(indentLevel, `=\"`); err != nil {
 		return err
 	}
+	attrKey := html.EscapeString(attr.Key.String())
 	// Value.
-	if (elementName == "a" && attr.Name == "href") || (elementName == "form" && attr.Name == "action") {
+	if (elementName == "a" && attrKey == "href") || (elementName == "form" && attrKey == "action") {
 		if err := g.writeExpressionAttributeValueURL(indentLevel, attr); err != nil {
 			return err
 		}
-	} else if isScriptAttribute(attr.Name) {
+	} else if isScriptAttribute(attrKey) {
 		if err := g.writeExpressionAttributeValueScript(indentLevel, attr); err != nil {
 			return err
 		}
-	} else if attr.Name == "style" {
+	} else if attrKey == "style" {
 		if err := g.writeExpressionAttributeValueStyle(indentLevel, attr); err != nil {
 			return err
 		}
diff --git a/parser/v2/elementparser.go b/parser/v2/elementparser.go
index e093849..fc2f34b 100644
--- a/parser/v2/elementparser.go
+++ b/parser/v2/elementparser.go
@@ -143,11 +143,10 @@ var (
 		attr = &ConstantAttribute{}
 
 		// Attribute name.
-		if attr.Name, ok, err = attributeNameParser.Parse(pi); err != nil || !ok {
+		if attr.Key, ok, err = attributeKeyParser.Parse(pi); err != nil || !ok {
 			pi.Seek(start)
 			return
 		}
-		attr.NameRange = NewRange(pi.PositionAt(pi.Index()-len(attr.Name)), pi.Position())
 
 		for _, p := range attributeValueParsers {
 			attr.Value, ok, err = p.Parse(pi)
@@ -156,7 +155,7 @@ var (
 				if pErr, isParseError := err.(parse.ParseError); isParseError {
 					pos = pErr.Pos
 				}
-				return attr, false, parse.Error(fmt.Sprintf("%s: %v", attr.Name, err), pos)
+				return attr, false, parse.Error(fmt.Sprintf("%s: %v", attr.Key, err), pos)
 			}
 			if ok {
 				attr.SingleQuote = p.UseSingleQuote
@@ -178,6 +177,43 @@ var (
 	})
 )
 
+var expressionAttributeKeyParser = parse.Func(func(pi *parse.Input) (attr AttributeKey, ok bool, err error) {
+	start := pi.Index()
+	// Eat the first brace.
+	if _, ok, err = openBraceWithOptionalPadding.Parse(pi); err != nil || !ok {
+		pi.Seek(start)
+		return
+	}
+	var ek ExpressionAttributeKey
+
+	// Expression.
+	if ek.Expression, err = parseGoSliceArgs(pi); err != nil {
+		return attr, false, err
+	}
+
+	// Eat the Final brace.
+	if _, ok, err = closeBraceWithOptionalPadding.Parse(pi); err != nil || !ok {
+		err = parse.Error("attribute key: missing closing brace", pi.Position())
+		pi.Seek(start)
+		return
+	}
+	return ek, true, nil
+})
+
+var constantAttributeKeyParser = parse.Func(func(pi *parse.Input) (k AttributeKey, ok bool, err error) {
+	start := pi.Index()
+	n, ok, err := attributeNameParser.Parse(pi)
+	if err != nil || !ok {
+		pi.Seek(start)
+		return
+	}
+	r := NewRange(pi.PositionAt(start), pi.Position())
+	k = ConstantAttributeKey{Name: n, NameRange: r}
+	return k, true, nil
+})
+
+var attributeKeyParser = parse.Any(constantAttributeKeyParser, expressionAttributeKeyParser)
+
 // BoolConstantAttribute.
 var boolConstantAttributeParser = parse.Func(func(pi *parse.Input) (attr *BoolConstantAttribute, ok bool, err error) {
 	start := pi.Index()
@@ -190,11 +226,10 @@ var boolConstantAttributeParser = parse.Func(func(pi *parse.Input) (attr *BoolCo
 	attr = &BoolConstantAttribute{}
 
 	// Attribute name.
-	if attr.Name, ok, err = attributeNameParser.Parse(pi); err != nil || !ok {
+	if attr.Key, ok, err = attributeKeyParser.Parse(pi); err != nil || !ok {
 		pi.Seek(start)
 		return
 	}
-	attr.NameRange = NewRange(pi.PositionAt(pi.Index()-len(attr.Name)), pi.Position())
 
 	// We have a name, but if we have an equals sign, it's not a constant boolean attribute.
 	next, ok := pi.Peek(1)
@@ -230,11 +265,10 @@ var boolExpressionAttributeParser = parse.Func(func(pi *parse.Input) (r *BoolExp
 	r = &BoolExpressionAttribute{}
 
 	// Attribute name.
-	if r.Name, ok, err = attributeNameParser.Parse(pi); err != nil || !ok {
+	if r.Key, ok, err = attributeKeyParser.Parse(pi); err != nil || !ok {
 		pi.Seek(start)
 		return
 	}
-	r.NameRange = NewRange(pi.PositionAt(pi.Index()-len(r.Name)), pi.Position())
 
 	// Check whether this is a boolean expression attribute.
 	if _, ok, err = boolExpressionStart.Parse(pi); err != nil || !ok {
@@ -268,11 +302,10 @@ var expressionAttributeParser = parse.Func(func(pi *parse.Input) (attr *Expressi
 	attr = &ExpressionAttribute{}
 
 	// Attribute name.
-	if attr.Name, ok, err = attributeNameParser.Parse(pi); err != nil || !ok {
+	if attr.Key, ok, err = attributeKeyParser.Parse(pi); err != nil || !ok {
 		pi.Seek(start)
 		return
 	}
-	attr.NameRange = NewRange(pi.PositionAt(pi.Index()-len(attr.Name)), pi.Position())
 
 	// ={
 	if _, ok, err = parse.Or(parse.String("={ "), parse.String("={")).Parse(pi); err != nil || !ok {
@@ -343,6 +376,9 @@ var spreadAttributesParser = parse.Func(func(pi *parse.Input) (attr *SpreadAttri
 type attributeParser struct{}
 
 func (attributeParser) Parse(in *parse.Input) (out Attribute, ok bool, err error) {
+	if out, ok, err = spreadAttributesParser.Parse(in); err != nil || ok {
+		return
+	}
 	if out, ok, err = boolExpressionAttributeParser.Parse(in); err != nil || ok {
 		return
 	}
@@ -355,9 +391,6 @@ func (attributeParser) Parse(in *parse.Input) (out Attribute, ok bool, err error
 	if out, ok, err = boolConstantAttributeParser.Parse(in); err != nil || ok {
 		return
 	}
-	if out, ok, err = spreadAttributesParser.Parse(in); err != nil || ok {
-		return
-	}
 	if out, ok, err = constantAttributeParser.Parse(in); err != nil || ok {
 		return
 	}
diff --git a/parser/v2/scriptparser.go b/parser/v2/scriptparser.go
index e259de6..3291095 100644
--- a/parser/v2/scriptparser.go
+++ b/parser/v2/scriptparser.go
@@ -170,7 +170,7 @@ loop:
 
 func isNotJavascript(a Attribute) bool {
 	if ca, ok := a.(*ConstantAttribute); ok {
-		return ca.Name == "type" && !strings.EqualFold(ca.Value, "text/javascript")
+		return ca.Key.String() == "type" && !strings.EqualFold(ca.Value, "text/javascript")
 	}
 	return false
 }
diff --git a/parser/v2/types.go b/parser/v2/types.go
index e3f2e03..6234c0e 100644
--- a/parser/v2/types.go
+++ b/parser/v2/types.go
@@ -835,14 +835,34 @@ type Attribute interface {
 	Copy() Attribute
 }
 
-// <hr noshade/>
-type BoolConstantAttribute struct {
+type AttributeKey interface {
+	fmt.Stringer
+}
+
+type ConstantAttributeKey struct {
 	Name      string
 	NameRange Range
 }
 
+func (c ConstantAttributeKey) String() string {
+	return c.Name
+}
+
+type ExpressionAttributeKey struct {
+	Expression Expression
+}
+
+func (e ExpressionAttributeKey) String() string {
+	return `{ ` + e.Expression.Value + ` }`
+}
+
+// <hr noshade/>
+type BoolConstantAttribute struct {
+	Key AttributeKey
+}
+
 func (bca *BoolConstantAttribute) String() string {
-	return bca.Name
+	return bca.Key.String()
 }
 
 func (bca *BoolConstantAttribute) Write(w io.Writer, indent int) error {
@@ -855,17 +875,15 @@ func (bca *BoolConstantAttribute) Visit(v Visitor) error {
 
 func (bca *BoolConstantAttribute) Copy() Attribute {
 	return &BoolConstantAttribute{
-		Name:      bca.Name,
-		NameRange: bca.NameRange,
+		Key: bca.Key,
 	}
 }
 
 // href=""
 type ConstantAttribute struct {
-	Name        string
+	Key         AttributeKey
 	Value       string
 	SingleQuote bool
-	NameRange   Range
 }
 
 func (ca *ConstantAttribute) String() string {
@@ -873,7 +891,7 @@ func (ca *ConstantAttribute) String() string {
 	if ca.SingleQuote {
 		quote = `'`
 	}
-	return ca.Name + `=` + quote + ca.Value + quote
+	return ca.Key.String() + `=` + quote + ca.Value + quote
 }
 
 func (ca *ConstantAttribute) Write(w io.Writer, indent int) error {
@@ -886,22 +904,20 @@ func (ca *ConstantAttribute) Visit(v Visitor) error {
 
 func (ca *ConstantAttribute) Copy() Attribute {
 	return &ConstantAttribute{
-		Name:        ca.Name,
 		Value:       ca.Value,
 		SingleQuote: ca.SingleQuote,
-		NameRange:   ca.NameRange,
+		Key:         ca.Key,
 	}
 }
 
 // noshade={ templ.Bool(...) }
 type BoolExpressionAttribute struct {
-	Name       string
+	Key        AttributeKey
 	Expression Expression
-	NameRange  Range
 }
 
 func (bea *BoolExpressionAttribute) String() string {
-	return bea.Name + `?={ ` + bea.Expression.Value + ` }`
+	return bea.Key.String() + `?={ ` + bea.Expression.Value + ` }`
 }
 
 func (bea *BoolExpressionAttribute) Write(w io.Writer, indent int) error {
@@ -914,17 +930,15 @@ func (bea *BoolExpressionAttribute) Visit(v Visitor) error {
 
 func (bea *BoolExpressionAttribute) Copy() Attribute {
 	return &BoolExpressionAttribute{
-		Name:       bea.Name,
 		Expression: bea.Expression,
-		NameRange:  bea.NameRange,
+		Key:        bea.Key,
 	}
 }
 
 // href={ ... }
 type ExpressionAttribute struct {
-	Name       string
+	Key        AttributeKey
 	Expression Expression
-	NameRange  Range
 }
 
 func (ea *ExpressionAttribute) String() string {
@@ -965,10 +979,10 @@ func (ea *ExpressionAttribute) formatExpression() (exp []string) {
 func (ea *ExpressionAttribute) Write(w io.Writer, indent int) (err error) {
 	lines := ea.formatExpression()
 	if len(lines) == 1 {
-		return writeIndent(w, indent, ea.Name, `={ `, lines[0], ` }`)
+		return writeIndent(w, indent, ea.Key.String(), `={ `, lines[0], ` }`)
 	}
 
-	if err = writeIndent(w, indent, ea.Name, "={\n"); err != nil {
+	if err = writeIndent(w, indent, ea.Key.String(), "={\n"); err != nil {
 		return err
 	}
 	for _, line := range lines {
@@ -985,9 +999,8 @@ func (ea *ExpressionAttribute) Visit(v Visitor) error {
 
 func (ea *ExpressionAttribute) Copy() Attribute {
 	return &ExpressionAttribute{
-		Name:       ea.Name,
 		Expression: ea.Expression,
-		NameRange:  ea.NameRange,
+		Key:        ea.Key,
 	}
 }
 


Test patch:
diff --git a/generator/test-element-attributes/expected.html b/generator/test-element-attributes/expected.html
index c821a8b..99a4d1e 100644
--- a/generator/test-element-attributes/expected.html
+++ b/generator/test-element-attributes/expected.html
@@ -20,6 +20,10 @@
 <h2>HTMX Wildcard attribute</h2>
 
 <form
+ 	dynamic-attr-key="hello world"
+        dynamic-const-key="hello world"
+        my-string-attr
+        bool-attr
  	hx-post="/api/secret/unlock"
  	hx-target="#secret"
  	hx-target-*="#errors"
diff --git a/generator/test-element-attributes/template.templ b/generator/test-element-attributes/template.templ
index 2e31639..864f166 100644
--- a/generator/test-element-attributes/template.templ
+++ b/generator/test-element-attributes/template.templ
@@ -36,6 +36,11 @@ templ render(p person) {
 	></div>
 	<h2>HTMX Wildcard attribute</h2>
 	<form
+		{ "dynamic"+"-attr-key" }={ "hello world" }
+		{ "dynamic"+"-const-key" }="hello world"
+		{ "my-string" + "-attr" }
+		{ "bool-" + "attr" }?={ true }
+		{ "bool-" + "attr-false" }?={ false }
 		hx-post="/api/secret/unlock"
 		hx-target="#secret"
 		hx-target-*="#errors"
diff --git a/generator/test-element-attributes/template_templ.go b/generator/test-element-attributes/template_templ.go
index 62b1576..33da1a9 100644
--- a/generator/test-element-attributes/template_templ.go
+++ b/generator/test-element-attributes/template_templ.go
@@ -163,7 +163,81 @@ func render(p person) templ.Component {
 				return templ_7745c5c3_Err
 			}
 		}
-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">Else</div><div data-script=\"on click\n                do something\n             end\"></div><h2>HTMX Wildcard attribute</h2><form hx-post=\"/api/secret/unlock\" hx-target=\"#secret\" hx-target-*=\"#errors\" hx-indicator=\"#loading-indicator\"><input type=\"button\" value=\"Unlock\"></form>")
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">Else</div><div data-script=\"on click\n                do something\n             end\"></div><h2>HTMX Wildcard attribute</h2><form")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var10 string
+		templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("dynamic" + "-attr-key")
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-element-attributes/template.templ`, Line: 39, Col: 25}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(` ` + templ_7745c5c3_Var10))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "=\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var11 string
+		templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("hello world")
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-element-attributes/template.templ`, Line: 39, Col: 43}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var12 string
+		templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs("dynamic" + "-const-key")
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-element-attributes/template.templ`, Line: 40, Col: 26}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(` ` + templ_7745c5c3_Var12))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "=\"hello world\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var13 string
+		templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs("my-string" + "-attr")
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-element-attributes/template.templ`, Line: 41, Col: 25}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(` ` + templ_7745c5c3_Var13))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if true {
+			var templ_7745c5c3_Var14 string
+			templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs("bool-" + "attr")
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-element-attributes/template.templ`, Line: 42, Col: 20}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(` ` + templ_7745c5c3_Var14))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+		}
+		if false {
+			var templ_7745c5c3_Var15 string
+			templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs("bool-" + "attr-false")
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-element-attributes/template.templ`, Line: 43, Col: 26}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(` ` + templ_7745c5c3_Var15))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " hx-post=\"/api/secret/unlock\" hx-target=\"#secret\" hx-target-*=\"#errors\" hx-indicator=\"#loading-indicator\"><input type=\"button\" value=\"Unlock\"></form>")
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
diff --git a/parser/v2/elementparser_test.go b/parser/v2/elementparser_test.go
index bc13894..fe44ceb 100644
--- a/parser/v2/elementparser_test.go
+++ b/parser/v2/elementparser_test.go
@@ -53,11 +53,13 @@ func TestAttributeParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "_",
 						Value: "show = true",
-						NameRange: Range{
-							From: Position{Index: 5, Line: 0, Col: 5},
-							To:   Position{Index: 6, Line: 0, Col: 6},
+						Key: ConstantAttributeKey{
+							Name: "_",
+							NameRange: Range{
+								From: Position{Index: 5, Line: 0, Col: 5},
+								To:   Position{Index: 6, Line: 0, Col: 6},
+							},
 						},
 					},
 				},
@@ -75,19 +77,23 @@ func TestAttributeParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "@click",
 						Value: "show = true",
-						NameRange: Range{
-							From: Position{Index: 5, Line: 0, Col: 5},
-							To:   Position{Index: 11, Line: 0, Col: 11},
+						Key: ConstantAttributeKey{
+							Name: "@click",
+							NameRange: Range{
+								From: Position{Index: 5, Line: 0, Col: 5},
+								To:   Position{Index: 11, Line: 0, Col: 11},
+							},
 						},
 					},
 					&ConstantAttribute{
-						Name:  ":class",
 						Value: "{'foo': true}",
-						NameRange: Range{
-							From: Position{Index: 26, Line: 0, Col: 26},
-							To:   Position{Index: 32, Line: 0, Col: 32},
+						Key: ConstantAttributeKey{
+							Name: ":class",
+							NameRange: Range{
+								From: Position{Index: 26, Line: 0, Col: 26},
+								To:   Position{Index: 32, Line: 0, Col: 32},
+							},
 						},
 					},
 				},
@@ -105,19 +111,23 @@ func TestAttributeParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "id",
 						Value: "123",
-						NameRange: Range{
-							From: Position{Index: 5, Line: 0, Col: 5},
-							To:   Position{Index: 7, Line: 0, Col: 7},
+						Key: ConstantAttributeKey{
+							Name: "id",
+							NameRange: Range{
+								From: Position{Index: 5, Line: 0, Col: 5},
+								To:   Position{Index: 7, Line: 0, Col: 7},
+							},
 						},
 					},
 					&ConstantAttribute{
-						Name:  "style",
 						Value: "padding: 10px",
-						NameRange: Range{
-							From: Position{Index: 14, Line: 0, Col: 14},
-							To:   Position{Index: 19, Line: 0, Col: 19},
+						Key: ConstantAttributeKey{
+							Name: "style",
+							NameRange: Range{
+								From: Position{Index: 14, Line: 0, Col: 14},
+								To:   Position{Index: 19, Line: 0, Col: 19},
+							},
 						},
 					},
 				},
@@ -149,11 +159,13 @@ func TestAttributeParser(t *testing.T) {
 				},
 				Then: []Attribute{
 					&ConstantAttribute{
-						Name:  "class",
 						Value: "important",
-						NameRange: Range{
-							From: Position{Index: 23, Line: 2, Col: 3},
-							To:   Position{Index: 28, Line: 2, Col: 8},
+						Key: ConstantAttributeKey{
+							Name: "class",
+							NameRange: Range{
+								From: Position{Index: 23, Line: 2, Col: 3},
+								To:   Position{Index: 28, Line: 2, Col: 8},
+							},
 						},
 					},
 				},
@@ -187,25 +199,31 @@ if test {` + " " + `
 				},
 				Then: []Attribute{
 					&ConstantAttribute{
-						Name:  "class",
 						Value: "itIsTrue",
-						NameRange: Range{
-							From: Position{Index: 13, Line: 2, Col: 1},
-							To:   Position{Index: 18, Line: 2, Col: 6},
+						Key: ConstantAttributeKey{
+							Name: "class",
+							NameRange: Range{
+								From: Position{Index: 13, Line: 2, Col: 1},
+								To:   Position{Index: 18, Line: 2, Col: 6},
+							},
 						},
 					},
 					&BoolConstantAttribute{
-						Name: "noshade",
-						NameRange: Range{
-							From: Position{Index: 31, Line: 3, Col: 1},
-							To:   Position{Index: 38, Line: 3, Col: 8},
+						Key: ConstantAttributeKey{
+							Name: "noshade",
+							NameRange: Range{
+								From: Position{Index: 31, Line: 3, Col: 1},
+								To:   Position{Index: 38, Line: 3, Col: 8},
+							},
 						},
 					},
 					&ExpressionAttribute{
-						Name: "name",
-						NameRange: Range{
-							From: Position{Index: 40, Line: 4, Col: 1},
-							To:   Position{Index: 44, Line: 4, Col: 5},
+						Key: ConstantAttributeKey{
+							Name: "name",
+							NameRange: Range{
+								From: Position{Index: 40, Line: 4, Col: 1},
+								To:   Position{Index: 44, Line: 4, Col: 5},
+							},
 						},
 						Expression: Expression{
 							Value: `"other"`,
@@ -231,10 +249,12 @@ if test {` + " " + `
 			input:  ` noshade?={ true }"`,
 			parser: StripType(boolExpressionAttributeParser),
 			expected: &BoolExpressionAttribute{
-				Name: "noshade",
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 8, Line: 0, Col: 8},
+				Key: ConstantAttributeKey{
+					Name: "noshade",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 8, Line: 0, Col: 8},
+					},
 				},
 				Expression: Expression{
 					Value: "true",
@@ -258,10 +278,12 @@ if test {` + " " + `
 			input:  ` noshade?={true}"`,
 			parser: StripType(boolExpressionAttributeParser),
 			expected: &BoolExpressionAttribute{
-				Name: "noshade",
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 8, Line: 0, Col: 8},
+				Key: ConstantAttributeKey{
+					Name: "noshade",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 8, Line: 0, Col: 8},
+					},
 				},
 				Expression: Expression{
 					Value: "true",
@@ -285,10 +307,12 @@ if test {` + " " + `
 			input:  ` noshade?={ true }`,
 			parser: StripType(attributeParser{}),
 			expected: &BoolExpressionAttribute{
-				Name: "noshade",
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 8, Line: 0, Col: 8},
+				Key: ConstantAttributeKey{
+					Name: "noshade",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 8, Line: 0, Col: 8},
+					},
 				},
 				Expression: Expression{
 					Value: "true",
@@ -312,10 +336,12 @@ if test {` + " " + `
 			input:  ` noshade?={ true   }"`,
 			parser: StripType(boolExpressionAttributeParser),
 			expected: &BoolExpressionAttribute{
-				Name: "noshade",
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 8, Line: 0, Col: 8},
+				Key: ConstantAttributeKey{
+					Name: "noshade",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 8, Line: 0, Col: 8},
+					},
 				},
 				Expression: Expression{
 					Value: "true",
@@ -361,11 +387,13 @@ if test {` + " " + `
 			input:  ` href="test"`,
 			parser: StripType(constantAttributeParser),
 			expected: &ConstantAttribute{
-				Name:  "href",
 				Value: "test",
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 5, Line: 0, Col: 5},
+				Key: ConstantAttributeKey{
+					Name: "href",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 5, Line: 0, Col: 5},
+					},
 				},
 			},
 		},
@@ -374,12 +402,14 @@ if test {` + " " + `
 			input:  ` href='no double quote in value'`,
 			parser: StripType(constantAttributeParser),
 			expected: &ConstantAttribute{
-				Name:        "href",
 				Value:       `no double quote in value`,
 				SingleQuote: false,
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 5, Line: 0, Col: 5},
+				Key: ConstantAttributeKey{
+					Name: "href",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 5, Line: 0, Col: 5},
+					},
 				},
 			},
 		},
@@ -388,12 +418,14 @@ if test {` + " " + `
 			input:  ` href='"test"'`,
 			parser: StripType(constantAttributeParser),
 			expected: &ConstantAttribute{
-				Name:        "href",
 				Value:       `"test"`,
 				SingleQuote: true,
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 5, Line: 0, Col: 5},
+				Key: ConstantAttributeKey{
+					Name: "href",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 5, Line: 0, Col: 5},
+					},
 				},
 			},
 		},
@@ -402,11 +434,13 @@ if test {` + " " + `
 			input:  ` data-turbo-permanent="value"`,
 			parser: StripType(constantAttributeParser),
 			expected: &ConstantAttribute{
-				Name:  "data-turbo-permanent",
 				Value: "value",
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 21, Line: 0, Col: 21},
+				Key: ConstantAttributeKey{
+					Name: "data-turbo-permanent",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 21, Line: 0, Col: 21},
+					},
 				},
 			},
 		},
@@ -415,11 +449,13 @@ if test {` + " " + `
 			input:  ` data=""`,
 			parser: StripType(constantAttributeParser),
 			expected: &ConstantAttribute{
-				Name:  "data",
 				Value: "",
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 5, Line: 0, Col: 5},
+				Key: ConstantAttributeKey{
+					Name: "data",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 5, Line: 0, Col: 5},
+					},
 				},
 			},
 		},
@@ -431,11 +467,13 @@ if test {` + " " + `
 `,
 			parser: StripType(constantAttributeParser),
 			expected: &ConstantAttribute{
-				Name:  "data-script",
 				Value: "on click\n                do something\n             end",
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 12, Line: 0, Col: 12},
+				Key: ConstantAttributeKey{
+					Name: "data-script",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 12, Line: 0, Col: 12},
+					},
 				},
 			},
 		},
@@ -451,11 +489,46 @@ if test {` + " " + `
 				},
 				Attributes: []Attribute{
 					&BoolConstantAttribute{
-						Name: "data",
+						Key: ConstantAttributeKey{
+							Name: "data",
 
-						NameRange: Range{
-							From: Position{Index: 5, Line: 0, Col: 5},
-							To:   Position{Index: 9, Line: 0, Col: 9},
+							NameRange: Range{
+								From: Position{Index: 5, Line: 0, Col: 5},
+								To:   Position{Index: 9, Line: 0, Col: 9},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:   "bool constant attribute with expression key",
+			input:  `<div { "foo" }>`,
+			parser: StripType(elementOpenTagParser),
+			expected: elementOpenTag{
+				Name: "div",
+				NameRange: Range{
+					From: Position{Index: 1, Line: 0, Col: 1},
+					To:   Position{Index: 4, Line: 0, Col: 4},
+				},
+				Attributes: []Attribute{
+					&BoolConstantAttribute{
+						Key: ExpressionAttributeKey{
+							Expression: Expression{
+								Value: `"foo"`,
+								Range: Range{
+									From: Position{
+										Index: 7,
+										Line:  0,
+										Col:   7,
+									},
+									To: Position{
+										Index: 12,
+										Line:  0,
+										Col:   12,
+									},
+								},
+							},
 						},
 					},
 				},
@@ -474,10 +547,12 @@ if test {` + " " + `
 				},
 				Attributes: []Attribute{
 					&BoolConstantAttribute{
-						Name: "required",
-						NameRange: Range{
-							From: Position{Index: 9, Line: 1, Col: 2},
-							To:   Position{Index: 17, Line: 1, Col: 10},
+						Key: ConstantAttributeKey{
+							Name: "required",
+							NameRange: Range{
+								From: Position{Index: 9, Line: 1, Col: 2},
+								To:   Position{Index: 17, Line: 1, Col: 10},
+							},
 						},
 					},
 				},
@@ -496,10 +571,12 @@ if test {` + " " + `
 				},
 				Attributes: []Attribute{
 					&BoolConstantAttribute{
-						Name: "required",
-						NameRange: Range{
-							From: Position{Index: 10, Line: 1, Col: 2},
-							To:   Position{Index: 18, Line: 1, Col: 10},
+						Key: ConstantAttributeKey{
+							Name: "required",
+							NameRange: Range{
+								From: Position{Index: 10, Line: 1, Col: 2},
+								To:   Position{Index: 18, Line: 1, Col: 10},
+							},
 						},
 					},
 				},
@@ -510,11 +587,13 @@ if test {` + " " + `
 			input:  ` href="&lt;&quot;&gt;"`,
 			parser: StripType(constantAttributeParser),
 			expected: &ConstantAttribute{
-				Name:  "href",
 				Value: `<">`,
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 5, Line: 0, Col: 5},
+				Key: ConstantAttributeKey{
+					Name: "href",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 5, Line: 0, Col: 5},
+					},
 				},
 			},
 		},
@@ -523,11 +602,13 @@ if test {` + " " + `
 			input:  ` hx-target-*="#errors"`,
 			parser: StripType(constantAttributeParser),
 			expected: &ConstantAttribute{
-				Name:  "hx-target-*",
 				Value: `#errors`,
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 12, Line: 0, Col: 12},
+				Key: ConstantAttributeKey{
+					Name: "hx-target-*",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 12, Line: 0, Col: 12},
+					},
 				},
 			},
 		},
@@ -536,11 +617,13 @@ if test {` + " " + `
 			input:  ` data=123`,
 			parser: StripType(constantAttributeParser),
 			expected: &ConstantAttribute{
-				Name:  "data",
 				Value: "123",
-				NameRange: Range{
-					From: Position{Index: 1, Line: 0, Col: 1},
-					To:   Position{Index: 5, Line: 0, Col: 5},
+				Key: ConstantAttributeKey{
+					Name: "data",
+					NameRange: Range{
+						From: Position{Index: 1, Line: 0, Col: 1},
+						To:   Position{Index: 5, Line: 0, Col: 5},
+					},
 				},
 			},
 		},
@@ -594,11 +677,13 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "href",
 						Value: "test",
-						NameRange: Range{
-							From: Position{Index: 3, Line: 0, Col: 3},
-							To:   Position{Index: 7, Line: 0, Col: 7},
+						Key: ConstantAttributeKey{
+							Name: "href",
+							NameRange: Range{
+								From: Position{Index: 3, Line: 0, Col: 3},
+								To:   Position{Index: 7, Line: 0, Col: 7},
+							},
 						},
 					},
 				},
@@ -670,10 +755,12 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&BoolConstantAttribute{
-						Name: "noshade",
-						NameRange: Range{
-							From: Position{Index: 4, Line: 0, Col: 4},
-							To:   Position{Index: 11, Line: 0, Col: 11},
+						Key: ConstantAttributeKey{
+							Name: "noshade",
+							NameRange: Range{
+								From: Position{Index: 4, Line: 0, Col: 4},
+								To:   Position{Index: 11, Line: 0, Col: 11},
+							},
 						},
 					},
 				},
@@ -705,10 +792,12 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&BoolExpressionAttribute{
-						Name: "noshade",
-						NameRange: Range{
-							From: Position{Index: 4, Line: 0, Col: 4},
-							To:   Position{Index: 11, Line: 0, Col: 11},
+						Key: ConstantAttributeKey{
+							Name: "noshade",
+							NameRange: Range{
+								From: Position{Index: 4, Line: 0, Col: 4},
+								To:   Position{Index: 11, Line: 0, Col: 11},
+							},
 						},
 						Expression: Expression{
 							Value: `true`,
@@ -781,10 +870,12 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ExpressionAttribute{
-						Name: "href",
-						NameRange: Range{
-							From: Position{Index: 3, Line: 0, Col: 3},
-							To:   Position{Index: 7, Line: 0, Col: 7},
+						Key: ConstantAttributeKey{
+							Name: "href",
+							NameRange: Range{
+								From: Position{Index: 3, Line: 0, Col: 3},
+								To:   Position{Index: 7, Line: 0, Col: 7},
+							},
 						},
 						Expression: Expression{
 							Value: `"test"`,
@@ -816,19 +907,23 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "href",
 						Value: "test",
-						NameRange: Range{
-							From: Position{Index: 3, Line: 0, Col: 3},
-							To:   Position{Index: 7, Line: 0, Col: 7},
+						Key: ConstantAttributeKey{
+							Name: "href",
+							NameRange: Range{
+								From: Position{Index: 3, Line: 0, Col: 3},
+								To:   Position{Index: 7, Line: 0, Col: 7},
+							},
 						},
 					},
 					&ConstantAttribute{
-						Name:  "style",
 						Value: "text-underline: auto",
-						NameRange: Range{
-							From: Position{Index: 15, Line: 0, Col: 15},
-							To:   Position{Index: 20, Line: 0, Col: 20},
+						Key: ConstantAttributeKey{
+							Name: "style",
+							NameRange: Range{
+								From: Position{Index: 15, Line: 0, Col: 15},
+								To:   Position{Index: 20, Line: 0, Col: 20},
+							},
 						},
 					},
 				},
@@ -892,17 +987,21 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&BoolConstantAttribute{
-						Name: "optionA",
-						NameRange: Range{
-							From: Position{Index: 4, Line: 0, Col: 4},
-							To:   Position{Index: 11, Line: 0, Col: 11},
+						Key: ConstantAttributeKey{
+							Name: "optionA",
+							NameRange: Range{
+								From: Position{Index: 4, Line: 0, Col: 4},
+								To:   Position{Index: 11, Line: 0, Col: 11},
+							},
 						},
 					},
 					&BoolExpressionAttribute{
-						Name: "optionB",
-						NameRange: Range{
-							From: Position{Index: 12, Line: 0, Col: 12},
-							To:   Position{Index: 19, Line: 0, Col: 19},
+						Key: ConstantAttributeKey{
+							Name: "optionB",
+							NameRange: Range{
+								From: Position{Index: 12, Line: 0, Col: 12},
+								To:   Position{Index: 19, Line: 0, Col: 19},
+							},
 						},
 						Expression: Expression{
 							Value: `true`,
@@ -921,11 +1020,13 @@ func TestElementParser(t *testing.T) {
 						},
 					},
 					&ConstantAttribute{
-						Name:  "optionC",
 						Value: "other",
-						NameRange: Range{
-							From: Position{Index: 30, Line: 0, Col: 30},
-							To:   Position{Index: 37, Line: 0, Col: 37},
+						Key: ConstantAttributeKey{
+							Name: "optionC",
+							NameRange: Range{
+								From: Position{Index: 30, Line: 0, Col: 30},
+								To:   Position{Index: 37, Line: 0, Col: 37},
+							},
 						},
 					},
 				},
@@ -942,18 +1043,22 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "href",
 						Value: "test",
-						NameRange: Range{
-							From: Position{Index: 3, Line: 0, Col: 3},
-							To:   Position{Index: 7, Line: 0, Col: 7},
+						Key: ConstantAttributeKey{
+							Name: "href",
+							NameRange: Range{
+								From: Position{Index: 3, Line: 0, Col: 3},
+								To:   Position{Index: 7, Line: 0, Col: 7},
+							},
 						},
 					},
 					&ExpressionAttribute{
-						Name: "title",
-						NameRange: Range{
-							From: Position{Index: 15, Line: 0, Col: 15},
-							To:   Position{Index: 20, Line: 0, Col: 20},
+						Key: ConstantAttributeKey{
+							Name: "title",
+							NameRange: Range{
+								From: Position{Index: 15, Line: 0, Col: 15},
+								To:   Position{Index: 20, Line: 0, Col: 20},
+							},
 						},
 						Expression: Expression{
 							Value: `localisation.Get("a_title")`,
@@ -972,11 +1077,13 @@ func TestElementParser(t *testing.T) {
 						},
 					},
 					&ConstantAttribute{
-						Name:  "style",
 						Value: "text-underline: auto",
-						NameRange: Range{
-							From: Position{Index: 53, Line: 0, Col: 53},
-							To:   Position{Index: 58, Line: 0, Col: 58},
+						Key: ConstantAttributeKey{
+							Name: "style",
+							NameRange: Range{
+								From: Position{Index: 53, Line: 0, Col: 53},
+								To:   Position{Index: 58, Line: 0, Col: 58},
+							},
 						},
 					},
 				},
@@ -1000,11 +1107,13 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "style",
 						Value: "width: 100;",
-						NameRange: Range{
-							From: Position{Index: 5, Line: 0, Col: 5},
-							To:   Position{Index: 10, Line: 0, Col: 10},
+						Key: ConstantAttributeKey{
+							Name: "style",
+							NameRange: Range{
+								From: Position{Index: 5, Line: 0, Col: 5},
+								To:   Position{Index: 10, Line: 0, Col: 10},
+							},
 						},
 					},
 					&ConditionalAttribute{
@@ -1025,11 +1134,13 @@ func TestElementParser(t *testing.T) {
 						},
 						Then: []Attribute{
 							&ConstantAttribute{
-								Name:  "class",
 								Value: "important",
-								NameRange: Range{
-									From: Position{Index: 47, Line: 2, Col: 3},
-									To:   Position{Index: 52, Line: 2, Col: 8},
+								Key: ConstantAttributeKey{
+									Name: "class",
+									NameRange: Range{
+										From: Position{Index: 47, Line: 2, Col: 3},
+										To:   Position{Index: 52, Line: 2, Col: 8},
+									},
 								},
 							},
 						},
@@ -1070,11 +1181,13 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "style",
 						Value: "padding: 10px",
-						NameRange: Range{
-							From: Position{Index: 4, Line: 0, Col: 4},
-							To:   Position{Index: 9, Line: 0, Col: 9},
+						Key: ConstantAttributeKey{
+							Name: "style",
+							NameRange: Range{
+								From: Position{Index: 4, Line: 0, Col: 4},
+								To:   Position{Index: 9, Line: 0, Col: 9},
+							},
 						},
 					},
 				},
@@ -1095,11 +1208,13 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "style",
 						Value: "padding: 10px",
-						NameRange: Range{
-							From: Position{Index: 4, Line: 0, Col: 4},
-							To:   Position{Index: 9, Line: 0, Col: 9},
+						Key: ConstantAttributeKey{
+							Name: "style",
+							NameRange: Range{
+								From: Position{Index: 4, Line: 0, Col: 4},
+								To:   Position{Index: 9, Line: 0, Col: 9},
+							},
 						},
 					},
 					&ConditionalAttribute{
@@ -1120,11 +1235,13 @@ func TestElementParser(t *testing.T) {
 						},
 						Then: []Attribute{
 							&ConstantAttribute{
-								Name:  "class",
 								Value: "itIsTrue",
-								NameRange: Range{
-									From: Position{Index: 44, Line: 2, Col: 4},
-									To:   Position{Index: 49, Line: 2, Col: 9},
+								Key: ConstantAttributeKey{
+									Name: "class",
+									NameRange: Range{
+										From: Position{Index: 44, Line: 2, Col: 4},
+										To:   Position{Index: 49, Line: 2, Col: 9},
+									},
 								},
 							},
 						},
@@ -1150,11 +1267,13 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "style",
 						Value: "padding: 10px",
-						NameRange: Range{
-							From: Position{Index: 4, Line: 0, Col: 4},
-							To:   Position{Index: 9, Line: 0, Col: 9},
+						Key: ConstantAttributeKey{
+							Name: "style",
+							NameRange: Range{
+								From: Position{Index: 4, Line: 0, Col: 4},
+								To:   Position{Index: 9, Line: 0, Col: 9},
+							},
 						},
 					},
 					&ConditionalAttribute{
@@ -1175,21 +1294,25 @@ func TestElementParser(t *testing.T) {
 						},
 						Then: []Attribute{
 							&ConstantAttribute{
-								Name:  "class",
 								Value: "itIsTrue",
-								NameRange: Range{
-									From: Position{Index: 44, Line: 2, Col: 4},
-									To:   Position{Index: 49, Line: 2, Col: 9},
+								Key: ConstantAttributeKey{
+									Name: "class",
+									NameRange: Range{
+										From: Position{Index: 44, Line: 2, Col: 4},
+										To:   Position{Index: 49, Line: 2, Col: 9},
+									},
 								},
 							},
 						},
 						Else: []Attribute{
 							&ConstantAttribute{
-								Name:  "class",
 								Value: "itIsNotTrue",
-								NameRange: Range{
-									From: Position{Index: 77, Line: 4, Col: 4},
-									To:   Position{Index: 82, Line: 4, Col: 9},
+								Key: ConstantAttributeKey{
+									Name: "class",
+									NameRange: Range{
+										From: Position{Index: 77, Line: 4, Col: 4},
+										To:   Position{Index: 82, Line: 4, Col: 9},
+									},
 								},
 							},
 						},
@@ -1213,11 +1336,13 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "style",
 						Value: "padding: 10px",
-						NameRange: Range{
-							From: Position{Index: 3, Line: 0, Col: 3},
-							To:   Position{Index: 8, Line: 0, Col: 8},
+						Key: ConstantAttributeKey{
+							Name: "style",
+							NameRange: Range{
+								From: Position{Index: 3, Line: 0, Col: 3},
+								To:   Position{Index: 8, Line: 0, Col: 8},
+							},
 						},
 					},
 					&ConditionalAttribute{
@@ -1238,11 +1363,13 @@ func TestElementParser(t *testing.T) {
 						},
 						Then: []Attribute{
 							&ConstantAttribute{
-								Name:  "class",
 								Value: "itIsTrue",
-								NameRange: Range{
-									From: Position{Index: 43, Line: 2, Col: 4},
-									To:   Position{Index: 48, Line: 2, Col: 9},
+								Key: ConstantAttributeKey{
+									Name: "class",
+									NameRange: Range{
+										From: Position{Index: 43, Line: 2, Col: 4},
+										To:   Position{Index: 48, Line: 2, Col: 9},
+									},
 								},
 							},
 						},
@@ -1445,34 +1572,42 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "type",
 						Value: "email",
-						NameRange: Range{
-							From: Position{Index: 8, Line: 0, Col: 8},
-							To:   Position{Index: 12, Line: 0, Col: 12},
+						Key: ConstantAttributeKey{
+							Name: "type",
+							NameRange: Range{
+								From: Position{Index: 8, Line: 0, Col: 8},
+								To:   Position{Index: 12, Line: 0, Col: 12},
+							},
 						},
 					},
 					&ConstantAttribute{
-						Name:  "id",
 						Value: "email",
-						NameRange: Range{
-							From: Position{Index: 21, Line: 0, Col: 21},
-							To:   Position{Index: 23, Line: 0, Col: 23},
+						Key: ConstantAttributeKey{
+							Name: "id",
+							NameRange: Range{
+								From: Position{Index: 21, Line: 0, Col: 21},
+								To:   Position{Index: 23, Line: 0, Col: 23},
+							},
 						},
 					},
 					&ConstantAttribute{
-						Name:  "name",
 						Value: "email",
-						NameRange: Range{
-							From: Position{Index: 32, Line: 0, Col: 32},
-							To:   Position{Index: 36, Line: 0, Col: 36},
+						Key: ConstantAttributeKey{
+							Name: "name",
+							NameRange: Range{
+								From: Position{Index: 32, Line: 0, Col: 32},
+								To:   Position{Index: 36, Line: 0, Col: 36},
+							},
 						},
 					},
 					&ExpressionAttribute{
-						Name: "class",
-						NameRange: Range{
-							From: Position{Index: 45, Line: 0, Col: 45},
-							To:   Position{Index: 50, Line: 0, Col: 50},
+						Key: ConstantAttributeKey{
+							Name: "class",
+							NameRange: Range{
+								From: Position{Index: 45, Line: 0, Col: 45},
+								To:   Position{Index: 50, Line: 0, Col: 50},
+							},
 						},
 						Expression: Expression{
 							Value: `"a", "b", "c",  templ.KV("c", false)`,
@@ -1491,19 +1626,23 @@ func TestElementParser(t *testing.T) {
 						},
 					},
 					&ConstantAttribute{
-						Name:  "placeholder",
 						Value: "your@email.com",
-						NameRange: Range{
-							From: Position{Index: 91, Line: 0, Col: 91},
-							To:   Position{Index: 102, Line: 0, Col: 102},
+						Key: ConstantAttributeKey{
+							Name: "placeholder",
+							NameRange: Range{
+								From: Position{Index: 91, Line: 0, Col: 91},
+								To:   Position{Index: 102, Line: 0, Col: 102},
+							},
 						},
 					},
 					&ConstantAttribute{
-						Name:  "autocomplete",
 						Value: "off",
-						NameRange: Range{
-							From: Position{Index: 120, Line: 0, Col: 120},
-							To:   Position{Index: 132, Line: 0, Col: 132},
+						Key: ConstantAttributeKey{
+							Name: "autocomplete",
+							NameRange: Range{
+								From: Position{Index: 120, Line: 0, Col: 120},
+								To:   Position{Index: 132, Line: 0, Col: 132},
+							},
 						},
 					},
 				},
@@ -1525,27 +1664,33 @@ func TestElementParser(t *testing.T) {
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "type",
 						Value: "email",
-						NameRange: Range{
-							From: Position{Index: 8, Line: 1, Col: 1},
-							To:   Position{Index: 12, Line: 1, Col: 5},
+						Key: ConstantAttributeKey{
+							Name: "type",
+							NameRange: Range{
+								From: Position{Index: 8, Line: 1, Col: 1},
+								To:   Position{Index: 12, Line: 1, Col: 5},
+							},
 						},
 					},
 					&ConstantAttribute{
-						Name:  "id",
 						Value: "email",
-						NameRange: Range{
-							From: Position{Index: 23, Line: 2, Col: 1},
-							To:   Position{Index: 25, Line: 2, Col: 3},
+						Key: ConstantAttributeKey{
+							Name: "id",
+							NameRange: Range{
+								From: Position{Index: 23, Line: 2, Col: 1},
+								To:   Position{Index: 25, Line: 2, Col: 3},
+							},
 						},
 					},
 					&ConstantAttribute{
-						Name:  "name",
 						Value: "email",
-						NameRange: Range{
-							From: Position{Index: 36, Line: 3, Col: 1},
-							To:   Position{Index: 40, Line: 3, Col: 5},
+						Key: ConstantAttributeKey{
+							Name: "name",
+							NameRange: Range{
+								From: Position{Index: 36, Line: 3, Col: 1},
+								To:   Position{Index: 40, Line: 3, Col: 5},
+							},
 						},
 					},
 				},
@@ -1593,11 +1738,13 @@ amount is charged</div>`,
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "noshade",
 						Value: "noshade",
-						NameRange: Range{
-							From: Position{Index: 4, Line: 0, Col: 4},
-							To:   Position{Index: 11, Line: 0, Col: 11},
+						Key: ConstantAttributeKey{
+							Name: "noshade",
+							NameRange: Range{
+								From: Position{Index: 4, Line: 0, Col: 4},
+								To:   Position{Index: 11, Line: 0, Col: 11},
+							},
 						},
 					},
 				},
@@ -1614,25 +1761,31 @@ amount is charged</div>`,
 				},
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "noshade",
 						Value: "noshade",
-						NameRange: Range{
-							From: Position{Index: 4, Line: 0, Col: 4},
-							To:   Position{Index: 11, Line: 0, Col: 11},
+						Key: ConstantAttributeKey{
+							Name: "noshade",
+							NameRange: Range{
+								From: Position{Index: 4, Line: 0, Col: 4},
+								To:   Position{Index: 11, Line: 0, Col: 11},
+							},
 						},
 					},
 					&BoolConstantAttribute{
-						Name: "disabled",
-						NameRange: Range{
-							From: Position{Index: 20, Line: 0, Col: 20},
-							To:   Position{Index: 28, Line: 0, Col: 28},
+						Key: ConstantAttributeKey{
+							Name: "disabled",
+							NameRange: Range{
+								From: Position{Index: 20, Line: 0, Col: 20},
+								To:   Position{Index: 28, Line: 0, Col: 28},
+							},
 						},
 					},
 					&ExpressionAttribute{
-						Name: "other-attribute",
-						NameRange: Range{
-							From: Position{Index: 29, Line: 0, Col: 29},
-							To:   Position{Index: 44, Line: 0, Col: 44},
+						Key: ConstantAttributeKey{
+							Name: "other-attribute",
+							NameRange: Range{
+								From: Position{Index: 29, Line: 0, Col: 29},
+								To:   Position{Index: 44, Line: 0, Col: 44},
+							},
 						},
 						Expression: Expression{
 							Value: "false",
diff --git a/parser/v2/raw_test.go b/parser/v2/raw_test.go
index 3f6fa0a..fd45026 100644
--- a/parser/v2/raw_test.go
+++ b/parser/v2/raw_test.go
@@ -13,7 +13,7 @@ var ignoredContent = `{
 }`
 
 func TestRawElementParser(t *testing.T) {
-	var tests = []struct {
+	tests := []struct {
 		name     string
 		input    string
 		expected *RawElement
@@ -25,11 +25,13 @@ func TestRawElementParser(t *testing.T) {
 				Name: "style",
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "type",
 						Value: "text/css",
-						NameRange: Range{
-							From: Position{Index: 7, Line: 0, Col: 7},
-							To:   Position{Index: 11, Line: 0, Col: 11},
+						Key: ConstantAttributeKey{
+							Name: "type",
+							NameRange: Range{
+								From: Position{Index: 7, Line: 0, Col: 7},
+								To:   Position{Index: 11, Line: 0, Col: 11},
+							},
 						},
 					},
 				},
@@ -43,11 +45,13 @@ func TestRawElementParser(t *testing.T) {
 				Name: "style",
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "type",
 						Value: "text/css",
-						NameRange: Range{
-							From: Position{Index: 7, Line: 0, Col: 7},
-							To:   Position{Index: 11, Line: 0, Col: 11},
+						Key: ConstantAttributeKey{
+							Name: "type",
+							NameRange: Range{
+								From: Position{Index: 7, Line: 0, Col: 7},
+								To:   Position{Index: 11, Line: 0, Col: 11},
+							},
 						},
 					},
 				},
@@ -74,7 +78,7 @@ func TestRawElementParser(t *testing.T) {
 }
 
 func TestRawElementParserIsNotGreedy(t *testing.T) {
-	var tests = []struct {
+	tests := []struct {
 		name     string
 		input    string
 		expected RawElement
diff --git a/parser/v2/scriptparser_test.go b/parser/v2/scriptparser_test.go
index a6274cb..dbbe225 100644
--- a/parser/v2/scriptparser_test.go
+++ b/parser/v2/scriptparser_test.go
@@ -77,11 +77,13 @@ func TestScriptElementParser(t *testing.T) {
 			expected: &ScriptElement{
 				Attributes: []Attribute{
 					&ConstantAttribute{
-						Name:  "type",
 						Value: "vbscript",
-						NameRange: Range{
-							From: Position{Index: 8, Line: 0, Col: 8},
-							To:   Position{Index: 12, Line: 0, Col: 12},
+						Key: ConstantAttributeKey{
+							Name: "type",
+							NameRange: Range{
+								From: Position{Index: 8, Line: 0, Col: 8},
+								To:   Position{Index: 12, Line: 0, Col: 12},
+							},
 						},
 					},
 				},
@@ -112,9 +114,12 @@ func TestScriptElementParser(t *testing.T) {
 			input: `<script type="text/javascript">{{ name }}</script>`,
 			expected: &ScriptElement{
 				Attributes: []Attribute{&ConstantAttribute{
-					Name: "type", Value: "text/javascript", NameRange: Range{
-						From: Position{Index: 8, Line: 0, Col: 8},
-						To:   Position{Index: 12, Line: 0, Col: 12},
+					Value: "text/javascript",
+					Key: ConstantAttributeKey{
+						Name: "type", NameRange: Range{
+							From: Position{Index: 8, Line: 0, Col: 8},
+							To:   Position{Index: 12, Line: 0, Col: 12},
+						},
 					},
 				}},
 				Contents: []ScriptContents{
@@ -263,9 +268,12 @@ set tier_1 to #tier-1's value
 </script>`,
 			expected: &ScriptElement{
 				Attributes: []Attribute{&ConstantAttribute{
-					Name: "type", Value: "text/hyperscript", NameRange: Range{
-						From: Position{Index: 8, Line: 0, Col: 8},
-						To:   Position{Index: 12, Line: 0, Col: 12},
+					Value: "text/hyperscript",
+					Key: ConstantAttributeKey{
+						Name: "type", NameRange: Range{
+							From: Position{Index: 8, Line: 0, Col: 8},
+							To:   Position{Index: 12, Line: 0, Col: 12},
+						},
 					},
 				}},
 				Contents: []ScriptContents{
diff --git a/parser/v2/templateparser_test.go b/parser/v2/templateparser_test.go
index e4121de..ed7df31 100644
--- a/parser/v2/templateparser_test.go
+++ b/parser/v2/templateparser_test.go
@@ -8,7 +8,7 @@ import (
 )
 
 func TestTemplateParser(t *testing.T) {
-	var tests = []struct {
+	tests := []struct {
 		name        string
 		input       string
 		expected    *HTMLTemplate
@@ -473,19 +473,23 @@ func TestTemplateParser(t *testing.T) {
 						},
 						Attributes: []Attribute{
 							&ConstantAttribute{
-								Name:  "type",
 								Value: "text",
-								NameRange: Range{
-									From: Position{Index: 34, Line: 1, Col: 8},
-									To:   Position{Index: 38, Line: 1, Col: 12},
+								Key: ConstantAttributeKey{
+									Name: "type",
+									NameRange: Range{
+										From: Position{Index: 34, Line: 1, Col: 8},
+										To:   Position{Index: 38, Line: 1, Col: 12},
+									},
 								},
 							},
 							&ConstantAttribute{
-								Name:  "value",
 								Value: "a",
-								NameRange: Range{
-									From: Position{Index: 46, Line: 1, Col: 20},
-									To:   Position{Index: 51, Line: 1, Col: 25},
+								Key: ConstantAttributeKey{
+									Name: "value",
+									NameRange: Range{
+										From: Position{Index: 46, Line: 1, Col: 20},
+										To:   Position{Index: 51, Line: 1, Col: 25},
+									},
 								},
 							},
 						},
@@ -499,19 +503,23 @@ func TestTemplateParser(t *testing.T) {
 						},
 						Attributes: []Attribute{
 							&ConstantAttribute{
-								Name:  "type",
 								Value: "text",
-								NameRange: Range{
-									From: Position{Index: 67, Line: 2, Col: 8},
-									To:   Position{Index: 71, Line: 2, Col: 12},
+								Key: ConstantAttributeKey{
+									Name: "type",
+									NameRange: Range{
+										From: Position{Index: 67, Line: 2, Col: 8},
+										To:   Position{Index: 71, Line: 2, Col: 12},
+									},
 								},
 							},
 							&ConstantAttribute{
-								Name:  "value",
 								Value: "b",
-								NameRange: Range{
-									From: Position{Index: 79, Line: 2, Col: 20},
-									To:   Position{Index: 84, Line: 2, Col: 25},
+								Key: ConstantAttributeKey{
+									Name: "value",
+									NameRange: Range{
+										From: Position{Index: 79, Line: 2, Col: 20},
+										To:   Position{Index: 84, Line: 2, Col: 25},
+									},
 								},
 							},
 						},
@@ -600,11 +608,13 @@ func TestTemplateParser(t *testing.T) {
 						},
 						Attributes: []Attribute{
 							&ConstantAttribute{
-								Name:  "href",
 								Value: "/",
-								NameRange: Range{
-									From: Position{Index: 16, Line: 1, Col: 4},
-									To:   Position{Index: 20, Line: 1, Col: 8},
+								Key: ConstantAttributeKey{
+									Name: "href",
+									NameRange: Range{
+										From: Position{Index: 16, Line: 1, Col: 4},
+										To:   Position{Index: 20, Line: 1, Col: 8},
+									},
 								},
 							},
 						},
@@ -866,7 +876,7 @@ func TestTemplateParser(t *testing.T) {
 }
 
 func TestTemplateParserErrors(t *testing.T) {
-	var tests = []struct {
+	tests := []struct {
 		name     string
 		input    string
 		expected string
diff --git a/parser/v2/templelementparser_test.go b/parser/v2/templelementparser_test.go
index 1b46b7f..7f89749 100644
--- a/parser/v2/templelementparser_test.go
+++ b/parser/v2/templelementparser_test.go
@@ -139,18 +139,21 @@ func TestTemplElementExpressionParser(t *testing.T) {
 				},
 				Children: []Node{
 					&Whitespace{Value: "\n\t\t\t"},
-					&Element{Name: "a",
+					&Element{
+						Name: "a",
 						NameRange: Range{
 							From: Position{Index: 20, Line: 1, Col: 4},
 							To:   Position{Index: 21, Line: 1, Col: 5},
 						},
 						Attributes: []Attribute{
 							&ConstantAttribute{
-								Name:  "href",
 								Value: "someurl",
-								NameRange: Range{
-									From: Position{Index: 22, Line: 1, Col: 6},
-									To:   Position{Index: 26, Line: 1, Col: 10},
+								Key: ConstantAttributeKey{
+									Name: "href",
+									NameRange: Range{
+										From: Position{Index: 22, Line: 1, Col: 6},
+										To:   Position{Index: 26, Line: 1, Col: 10},
+									},
 								},
 							},
 						},
diff --git a/parser/v2/visitor/visitor_test.go b/parser/v2/visitor/visitor_test.go
index 1bd5e58..f165296 100644
--- a/parser/v2/visitor/visitor_test.go
+++ b/parser/v2/visitor/visitor_test.go
@@ -47,10 +47,10 @@ func assetRewriter(rewrite func(path string) string) parser.Visitor {
 	// Rewrite attributes
 	visitConstantAttribute := ar.ConstantAttribute
 	ar.ConstantAttribute = func(n *parser.ConstantAttribute) error {
-		if inSrcElement && n.Name == "src" {
+		if inSrcElement && n.Key.String() == "src" {
 			n.Value = rewrite(n.Value)
 		}
-		if inHrefElement && n.Name == "href" {
+		if inHrefElement && n.Key.String() == "href" {
 			n.Value = rewrite(n.Value)
 		}
 		return visitConstantAttribute(n)