Repository: a-h/templ
Pull request #765 (2024-05-30 12:07:51)

feat: preserve indentation of multiline go string literals
Fixes #743 

The solution to this was a bit odd. I thought it would be more straight forward but no!

Essentially, I allow go fmt to format the code, then I indent everything by one, then format again. If go fmt removes the line then we are safe to auto-indent, but if the new indentation is preserved then we should leave it be.

Open to other solutions.


Patch:
diff --git a/.version b/.version
index 75088f8..ae05c28 100644
--- a/.version
+++ b/.version
@@ -1,1 +1,1 @@
-0.2.709
\ No newline at end of file
+0.2.717
\ No newline at end of file
diff --git a/cmd/templ/generatecmd/proxy/proxy.go b/cmd/templ/generatecmd/proxy/proxy.go
index 9b942be..a129888 100644
--- a/cmd/templ/generatecmd/proxy/proxy.go
+++ b/cmd/templ/generatecmd/proxy/proxy.go
@@ -16,6 +16,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/PuerkitoBio/goquery"
 	"github.com/a-h/templ/cmd/templ/generatecmd/sse"
 	"github.com/andybalholm/brotli"
 
@@ -36,7 +37,16 @@ type Handler struct {
 }
 
 func insertScriptTagIntoBody(body string) (updated string) {
-	return strings.Replace(body, "</body>", scriptTag+"</body>", -1)
+	doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
+	if err != nil {
+		return strings.Replace(body, "</body>", scriptTag+"</body>", -1)
+	}
+	doc.Find("body").AppendHtml(scriptTag)
+	r, err := doc.Html()
+	if err != nil {
+		return strings.Replace(body, "</body>", scriptTag+"</body>", -1)
+	}
+	return r
 }
 
 type passthroughWriteCloser struct {
diff --git a/cmd/templ/lspcmd/proxy/server.go b/cmd/templ/lspcmd/proxy/server.go
index 6ef281d..94a36c8 100644
--- a/cmd/templ/lspcmd/proxy/server.go
+++ b/cmd/templ/lspcmd/proxy/server.go
@@ -291,12 +291,14 @@ func (p *Server) CodeAction(ctx context.Context, params *lsp.CodeActionParams) (
 			r.Diagnostics[di].Range = p.convertGoRangeToTemplRange(templURI, r.Diagnostics[di].Range)
 		}
 		// Rewrite the DocumentChanges.
-		for dci := 0; dci < len(r.Edit.DocumentChanges); dci++ {
-			dc := r.Edit.DocumentChanges[0]
-			for ei := 0; ei < len(dc.Edits); ei++ {
-				dc.Edits[ei].Range = p.convertGoRangeToTemplRange(templURI, dc.Edits[ei].Range)
+		if r.Edit != nil {
+			for dci := 0; dci < len(r.Edit.DocumentChanges); dci++ {
+				dc := r.Edit.DocumentChanges[0]
+				for ei := 0; ei < len(dc.Edits); ei++ {
+					dc.Edits[ei].Range = p.convertGoRangeToTemplRange(templURI, dc.Edits[ei].Range)
+				}
+				dc.TextDocument.URI = templURI
 			}
-			dc.TextDocument.URI = templURI
 		}
 		result[i] = r
 	}
diff --git a/docs/docs/03-syntax-and-usage/12-script-templates.md b/docs/docs/03-syntax-and-usage/12-script-templates.md
index d197a66..c9c25b8 100644
--- a/docs/docs/03-syntax-and-usage/12-script-templates.md
+++ b/docs/docs/03-syntax-and-usage/12-script-templates.md
@@ -1,6 +1,6 @@
 # Using JavaScript with templ
 
-## Scripts
+## Script tags
 
 Use standard `<script>` tags, and standard HTML attributes to run JavaScript on the client.
 
@@ -15,6 +15,93 @@ templ body() {
 }
 ```
 
+To pass data from the server to client-side scripts, see [Passing server-side data to scripts](#passing-server-side-data-to-scripts).
+
+## Adding client side behaviours to components
+
+To ensure that a `<script>` tag within a templ component is only rendered once per HTTP response, use a [templ.OnceHandle](18-render-once.md).
+
+Using a `templ.OnceHandle` allows a component to define global client-side scripts that it needs to run without including the scripts multiple times in the response.
+
+The example below also demonstrates applying behaviour that's defined in a multiline script to its sibling element.
+
+```templ title="component.templ"
+package main
+
+import "net/http"
+
+var helloHandle = templ.NewOnceHandle()
+
+templ hello(label, name string) {
+  // This script is only rendered once per HTTP request.
+  @helloHandle.Once() {
+    <script type="text/javascript">
+      function hello(name) {
+        alert('Hello, ' + name + '!');
+      }
+    </script>
+  }
+  <div>
+    <input type="button" value={ label } data-name={ name }/>
+    <script type="text/javascript">
+      // To prevent the variables from leaking into the global scope,
+      // this script is wrapped in an IIFE (Immediately Invoked Function Expression).
+      (() => {
+        let scriptElement = document.currentScript;
+        let parent = scriptElement.closest('div');
+        let nearestButtonWithName = parent.querySelector('input[data-name]');
+        nearestButtonWithName.addEventListener('click', function() {
+          let name = nearestButtonWithName.getAttribute('data-name');
+          hello(name);
+        })
+      })()
+    </script>
+  </div>
+}
+
+templ page() {
+  @hello("Hello User", "user")
+  @hello("Hello World", "world")
+}
+
+func main() {
+  http.Handle("/", templ.Handler(page()))
+  http.ListenAndServe("127.0.0.1:8080", nil)
+}
+```
+
+:::tip
+You might find libraries like [surreal](https://github.com/gnat/surreal) useful for reducing boilerplate.
+
+```templ
+var helloHandle = templ.NewOnceHandle()
+var surrealHandle = templ.NewOnceHandle()
+
+templ hello(label, name string) {
+  @helloHandle.Once() {
+    <script type="text/javascript">
+      function hello(name) {
+        alert('Hello, ' + name + '!');
+      }
+    </script>
+  }
+  @surrealHandle.Once() {
+    <script src="https://cdn.jsdelivr.net/gh/gnat/surreal@3b4572dd0938ce975225ee598a1e7381cb64ffd8/surreal.js"></script>
+  }
+  <div>
+    <input type="button" value={ label } data-name={ name }/>
+    <script type="text/javascript">
+      // me("-") returns the previous sibling element.
+      me("-").addEventListener('click', function() {
+        let name = this.getAttribute('data-name');
+        hello(name);
+      })
+    </script>
+  </div>
+}
+```
+:::
+
 ## Importing scripts
 
 Use standard `<script>` tags to load JavaScript from a URL.
diff --git a/docs/docs/09-commands-and-tools/02-ide-support.md b/docs/docs/09-commands-and-tools/02-ide-support.md
index 639c9bf..6606532 100644
--- a/docs/docs/09-commands-and-tools/02-ide-support.md
+++ b/docs/docs/09-commands-and-tools/02-ide-support.md
@@ -322,6 +322,15 @@ require'nvim-treesitter.configs'.setup {
 }
 ```
 
+## Vim
+
+Currently support for vim is limited. Configure formatting with the following VimScript:
+
+```vim
+set autoread
+autocmd BufWritePost *.templ silent! execute "!PATH=\"$PATH:$(go env GOPATH)/bin\" templ fmt <afile> >/dev/null 2>&1" | redraw!
+```
+
 ## Helix
 
 https://helix-editor.com/
diff --git a/parser/v2/types.go b/parser/v2/types.go
index 12c305c..cacb6cb 100644
--- a/parser/v2/types.go
+++ b/parser/v2/types.go
@@ -194,20 +194,6 @@ func (exp TemplateFileGoExpression) Write(w io.Writer, indent int) error {
 	return err
 }
 
-func writeLinesIndented(w io.Writer, level int, s string) (err error) {
-	indent := strings.Repeat("\t", level)
-	lines := strings.Split(s, "\n")
-	indented := strings.Join(lines, "\n"+indent)
-	if _, err = io.WriteString(w, indent); err != nil {
-		return err
-	}
-	_, err = io.WriteString(w, indented)
-	if err != nil {
-		return
-	}
-	return
-}
-
 func writeIndent(w io.Writer, level int, s ...string) (err error) {
 	indent := strings.Repeat("\t", level)
 	if _, err = io.WriteString(w, indent); err != nil {
@@ -963,8 +949,32 @@ func (tee TemplElementExpression) Write(w io.Writer, indent int) error {
 	if err != nil {
 		source = []byte(tee.Expression.Value)
 	}
-	if err := writeLinesIndented(w, indent, "@"+string(source)); err != nil {
-		return err
+	// Indent all lines and re-format, we can then use this to only re-indent lines that gofmt would modify.
+	reformattedSource, err := format.Source(bytes.ReplaceAll(source, []byte("\n"), []byte("\n\t")))
+	if err != nil {
+		reformattedSource = source
+	}
+	sourceLines := bytes.Split(source, []byte("\n"))
+	reformattedSourceLines := bytes.Split(reformattedSource, []byte("\n"))
+	for i := range sourceLines {
+		if i == 0 {
+			if err := writeIndent(w, indent, "@"+string(sourceLines[i])); err != nil {
+				return err
+			}
+			continue
+		}
+		if _, err := io.WriteString(w, "\n"); err != nil {
+			return err
+		}
+		if string(sourceLines[i]) != string(reformattedSourceLines[i]) {
+			if _, err := io.WriteString(w, string(sourceLines[i])); err != nil {
+				return err
+			}
+			continue
+		}
+		if err := writeIndent(w, indent, string(sourceLines[i])); err != nil {
+			return err
+		}
 	}
 	if len(tee.Children) == 0 {
 		return nil
@@ -1136,14 +1146,14 @@ func (gc GoCode) Write(w io.Writer, indent int) error {
 	if isWhitespace(gc.Expression.Value) {
 		gc.Expression.Value = ""
 	}
-	if !gc.Multiline {
-		return writeIndent(w, indent, `{{ `, gc.Expression.Value, ` }}`)
-	}
-	formatted, err := format.Source([]byte(gc.Expression.Value))
+	source, err := format.Source([]byte(gc.Expression.Value))
 	if err != nil {
-		return err
+		source = []byte(gc.Expression.Value)
+	}
+	if !gc.Multiline {
+		return writeIndent(w, indent, `{{ `, string(source), ` }}`)
 	}
-	if err := writeIndent(w, indent, "{{"+string(formatted)+"\n"); err != nil {
+	if err := writeIndent(w, indent, "{{"+string(source)+"\n"); err != nil {
 		return err
 	}
 	return writeIndent(w, indent, "}}")
diff --git a/runtime.go b/runtime.go
index 1a18e90..9f6df50 100644
--- a/runtime.go
+++ b/runtime.go
@@ -233,6 +233,10 @@ func (cp *cssProcessor) Add(item any) {
 		for _, item := range c {
 			cp.Add(item)
 		}
+	case []CSSClass:
+		for _, item := range c {
+			cp.Add(item)
+		}
 	case func() CSSClass:
 		cp.AddClassName(c().ClassName(), true)
 	default:
@@ -438,6 +442,10 @@ func renderCSSItemsToBuilder(sb *strings.Builder, v *contextValue, classes ...an
 			renderCSSItemsToBuilder(sb, v, ccc.Key)
 		case CSSClasses:
 			renderCSSItemsToBuilder(sb, v, ccc...)
+		case []CSSClass:
+			for _, item := range ccc {
+				renderCSSItemsToBuilder(sb, v, item)
+			}
 		case func() CSSClass:
 			renderCSSItemsToBuilder(sb, v, ccc())
 		case []string:


Test patch:
diff --git a/cmd/templ/generatecmd/proxy/proxy_test.go b/cmd/templ/generatecmd/proxy/proxy_test.go
index b33478f..d2bc915 100644
--- a/cmd/templ/generatecmd/proxy/proxy_test.go
+++ b/cmd/templ/generatecmd/proxy/proxy_test.go
@@ -161,6 +161,49 @@ func TestProxy(t *testing.T) {
 			t.Errorf("unexpected response body (-got +want):\n%s", diff)
 		}
 	})
+	t.Run("plain: body tags get the script inserted ignoring js with body tags", func(t *testing.T) {
+		// Arrange
+		r := &http.Response{
+			Body:   io.NopCloser(strings.NewReader(`<html><body><script>console.log("<body></body>")</script></body></html>`)),
+			Header: make(http.Header),
+			Request: &http.Request{
+				URL: &url.URL{
+					Scheme: "http",
+					Host:   "example.com",
+				},
+			},
+		}
+		r.Header.Set("Content-Type", "text/html, charset=utf-8")
+		r.Header.Set("Content-Length", "26")
+
+		expectedString := insertScriptTagIntoBody(`<html><body><script>console.log("<body></body>")</script></body></html>`)
+		if !strings.Contains(expectedString, scriptTag) {
+			t.Fatalf("expected the script tag to be inserted, but it wasn't: %q", expectedString)
+		}
+		if !strings.Contains(expectedString, `console.log("<body></body>")`) {
+			t.Fatalf("expected the script tag to be inserted, but mangled the html: %q", expectedString)
+		}
+
+		// Act
+		log := slog.New(slog.NewJSONHandler(io.Discard, nil))
+		h := New(log, "127.0.0.1", 7474, &url.URL{Scheme: "http", Host: "example.com"})
+		err := h.modifyResponse(r)
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+
+		// Assert
+		if r.Header.Get("Content-Length") != fmt.Sprintf("%d", len(expectedString)) {
+			t.Errorf("expected content length to be %d, got %v", len(expectedString), r.Header.Get("Content-Length"))
+		}
+		actualBody, err := io.ReadAll(r.Body)
+		if err != nil {
+			t.Fatalf("unexpected error reading response: %v", err)
+		}
+		if diff := cmp.Diff(expectedString, string(actualBody)); diff != "" {
+			t.Errorf("unexpected response body (-got +want):\n%s", diff)
+		}
+	})
 	t.Run("gzip: non-html content is not modified", func(t *testing.T) {
 		// Arrange
 		r := &http.Response{
diff --git a/parser/v2/format_test.go b/parser/v2/format_test.go
index 3c6ee1e..bd96a3e 100644
--- a/parser/v2/format_test.go
+++ b/parser/v2/format_test.go
@@ -5,11 +5,17 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/a-h/templ/cfg"
 	"github.com/google/go-cmp/cmp"
 	"golang.org/x/tools/txtar"
 )
 
 func TestFormatting(t *testing.T) {
+	oldFlags := cfg.Experiment
+	t.Cleanup(func() {
+		cfg.Experiment = oldFlags
+	})
+	cfg.Experiment.RawGo = true
 	files, _ := filepath.Glob("formattestdata/*.txt")
 	if len(files) == 0 {
 		t.Errorf("no test files found")
diff --git a/parser/v2/formattestdata/multiline_string_literal_indentation_preserved.txt b/parser/v2/formattestdata/multiline_string_literal_indentation_preserved.txt
new file mode 100644
index 0000000..8e5fa99
--- /dev/null
+++ b/parser/v2/formattestdata/multiline_string_literal_indentation_preserved.txt
@@ -0,0 +1,40 @@
+-- in --
+package main
+
+templ x() {
+	@something(`Hi
+some cool text
+
+foo 
+
+bar
+
+		`)
+	@something(`
+		something
+				`,
+	`
+				something
+				`,
+		)
+}
+-- out --
+package main
+
+templ x() {
+	@something(`Hi
+some cool text
+
+foo 
+
+bar
+
+		`)
+	@something(`
+		something
+				`,
+		`
+				something
+				`,
+	)
+}
diff --git a/parser/v2/formattestdata/raw_go_is_formatted.txt b/parser/v2/formattestdata/raw_go_is_formatted.txt
new file mode 100644
index 0000000..3209bd6
--- /dev/null
+++ b/parser/v2/formattestdata/raw_go_is_formatted.txt
@@ -0,0 +1,12 @@
+-- in --
+package test
+
+templ nameList(items []Item) {
+	{{ first  := items[0] }}
+}
+-- out --
+package test
+
+templ nameList(items []Item) {
+	{{ first := items[0] }}
+}
diff --git a/runtime_test.go b/runtime_test.go
index d481acf..8fcd1f8 100644
--- a/runtime_test.go
+++ b/runtime_test.go
@@ -145,6 +145,7 @@ var cssInputs = []any{
 	func() templ.CSSClass { return templ.ConstantCSSClass("l") },              // func() CSSClass
 	templ.CSSClass(templ.ConstantCSSClass("m")),                               // CSSClass
 	customClass{name: "n"},                                                    // CSSClass
+	[]templ.CSSClass{customClass{name: "n"}},                                  // []CSSClass
 	templ.KV[templ.ConstantCSSClass, bool](templ.ConstantCSSClass("o"), true), // KeyValue[ConstantCSSClass, bool]
 	[]templ.KeyValue[templ.ConstantCSSClass, bool]{
 		templ.KV(templ.ConstantCSSClass("p"), true),