Repository: a-h/templ

Issue:
Update Storybook install command for v8.6.7
When using the current latest Storybook CLI (v8.6.7 at the time of writing) the install fails due to a prompt to select [features](https://storybook.js.org/docs/api/cli-options#init) to use.

The `npx` install [command](https://github.com/a-h/templ/blob/f0a1f0ca079ed241ec75e8bf7de78891e4cb7bbf/storybook/storybook.go#L235) should now be:

```
cmd.Args = []string{"npx", "sb", "init", "--features", "docs", "test", "-t", "server", "--no-dev"}
```

Related to #957, #1018,  and #1024

Hints from issue comments:
@a-h: Happy to take a PR on this.@a-h: Closed in https://github.com/a-h/templ/pull/1185

Solution patch:
diff --git a/benchmarks/templ/template_templ.go b/benchmarks/templ/template_templ.go
index c5f68b1..7ec7b4a 100644
--- a/benchmarks/templ/template_templ.go
+++ b/benchmarks/templ/template_templ.go
@@ -58,8 +58,12 @@ func Render(p Person) templ.Component {
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
-		var templ_7745c5c3_Var4 templ.SafeURL = templ.URL("mailto: " + p.Email)
-		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4)))
+		var templ_7745c5c3_Var4 templ.SafeURL
+		templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("mailto: " + p.Email))
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `benchmarks/templ/template.templ`, Line: 7, Col: 55}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
diff --git a/cmd/templ/lspcmd/httpdebug/list_templ.go b/cmd/templ/lspcmd/httpdebug/list_templ.go
index 5ee3ede..170a26c 100644
--- a/cmd/templ/lspcmd/httpdebug/list_templ.go
+++ b/cmd/templ/lspcmd/httpdebug/list_templ.go
@@ -50,8 +50,12 @@ func list(uris []string) templ.Component {
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var3 templ.SafeURL = getMapURL(uri)
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3)))
+			var templ_7745c5c3_Var3 templ.SafeURL
+			templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(getMapURL(uri))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/templ/lspcmd/httpdebug/list.templ`, Line: 15, Col: 32}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
@@ -59,8 +63,12 @@ func list(uris []string) templ.Component {
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var4 templ.SafeURL = getSourceMapURL(uri)
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4)))
+			var templ_7745c5c3_Var4 templ.SafeURL
+			templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(getSourceMapURL(uri))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/templ/lspcmd/httpdebug/list.templ`, Line: 16, Col: 38}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
@@ -68,8 +76,12 @@ func list(uris []string) templ.Component {
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var5 templ.SafeURL = getTemplURL(uri)
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var5)))
+			var templ_7745c5c3_Var5 templ.SafeURL
+			templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(getTemplURL(uri))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/templ/lspcmd/httpdebug/list.templ`, Line: 17, Col: 34}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
@@ -77,8 +89,12 @@ func list(uris []string) templ.Component {
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var6 templ.SafeURL = getGoURL(uri)
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var6)))
+			var templ_7745c5c3_Var6 templ.SafeURL
+			templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(getGoURL(uri))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `cmd/templ/lspcmd/httpdebug/list.templ`, Line: 18, Col: 31}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
diff --git a/docs/docs/03-syntax-and-usage/03-attributes.md b/docs/docs/03-syntax-and-usage/03-attributes.md
index 39e8f03..85b79e0 100644
--- a/docs/docs/03-syntax-and-usage/03-attributes.md
+++ b/docs/docs/03-syntax-and-usage/03-attributes.md
@@ -180,48 +180,50 @@ templ usage() {
 
 ## URL attributes
 
-The `<a>` element's `href` attribute is treated differently. templ expects you to provide a `templ.SafeURL` instead of a `string`.
-
-Typically, you would do this by using the `templ.URL` function.
-
-The `templ.URL` function sanitizes input URLs and checks that the protocol is `http`/`https`/`mailto` rather than `javascript` or another unexpected protocol.
+Attributes that expect a URL, such as `<a href={ url }>`, `<form action={ url }>`, or `<img src={ url }>`, have special behavior if you use a dynamic value.
 
 ```templ
 templ component(p Person) {
-  <a href={ templ.URL(p.URL) }>{ strings.ToUpper(p.Name) }</a>
+  <a href={ p.URL }>{ strings.ToUpper(p.Name) }</a>
 }
 ```
 
-:::tip
-In templ, all attributes are HTML-escaped. This means that:
-
-- `&` characters in the URL are escaped to `&amp;`.
-- `"` characters are escaped to `&quot;`.
-- `'` characters are escaped to `&#39;`.
+When you pass a `string` to these attributes, templ will automatically sanitize the input URL, ensuring that the protocol is safe (e.g., `http`, `https`, or `mailto`) and does not contain potentially harmful protocols like `javascript:`.
 
-This done to prevent XSS attacks. For example, without escaping, if a string contained `http://google.com" onclick="alert('hello')"`, the browser would interpret this as a URL followed by an `onclick` attribute, which would execute JavaScript code.
-
-The escaping does not change the URL's functionality.
+:::caution
+To bypass URL sanitization, you can use `templ.SafeURL(myURL)` to mark that your string is safe to use.
 
-Sanitization is the process of examining the URL scheme (protocol) and structure to ensure that it's safe to use, e.g. that it doesn't contain `javascript:` or other potentially harmful schemes. If a URL is not safe, templ will replace the URL with `about:invalid#TemplFailedSanitizationURL`.
+This may introduce security vulnerabilities to your program.
 :::
 
-The `templ.URL` function only supports standard HTML elements and attributes (`<a href=""` and `<form action=""`).
+If you use a constant value, e.g. `<a href="javascript:alert('hello')">`, templ will not modify it, and it will be rendered as is.
+
+:::tip
+Non-standard HTML attributes can contain URLs, for example HTMX's `hx-*` attributes).
 
-For use on non-standard HTML elements (e.g. HTMX's `hx-*` attributes), convert the `templ.URL` to a `string` after sanitization.
+To sanitize URLs in that context, use the `templ.URL(urlString)` function.
 
 ```templ
 templ component(contact model.Contact) {
-  <div hx-get={ string(templ.URL(fmt.Sprintf("/contacts/%s/email", contact.ID)))}>
+  <div hx-get={ templ.URL(fmt.Sprintf("/contacts/%s/email", contact.ID)) }>
     { contact.Name }
   </div>
 }
 ```
+:::
 
-:::caution
-If you need to bypass this sanitization, you can use `templ.SafeURL(myURL)` to mark that your string is safe to use.
+:::note
+In templ, all attributes are HTML-escaped. This means that:
 
-This may introduce security vulnerabilities to your program.
+- `&` characters in the URL are escaped to `&amp;`.
+- `"` characters are escaped to `&quot;`.
+- `'` characters are escaped to `&#39;`.
+
+This done to prevent XSS attacks. For example, without escaping, if a string contained `http://google.com" onclick="alert('hello')"`, the browser would interpret this as a URL followed by an `onclick` attribute, which would execute JavaScript code.
+
+The escaping does not change the URL's functionality.
+
+Sanitization is the process of examining the URL scheme (protocol) and structure to ensure that it's safe to use, e.g. that it doesn't contain `javascript:` or other potentially harmful schemes. If a URL is not safe, templ will replace the URL with `about:invalid#TemplFailedSanitizationURL`.
 :::
 
 ## JavaScript attributes
diff --git a/examples/static-generator/blog_templ.go b/examples/static-generator/blog_templ.go
index 122fb22..4762400 100644
--- a/examples/static-generator/blog_templ.go
+++ b/examples/static-generator/blog_templ.go
@@ -183,8 +183,12 @@ func indexPage(posts []Post) templ.Component {
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var7 templ.SafeURL = templ.SafeURL(path.Join(post.Date.Format("2006/01/02"), slug.Make(post.Title), "/"))
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var7)))
+			var templ_7745c5c3_Var7 templ.SafeURL
+			templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(path.Join(post.Date.Format("2006/01/02"), slug.Make(post.Title), "/")))
+			if templ_7745c5c3_Err != nil {
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `examples/static-generator/blog.templ`, Line: 34, Col: 103}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
diff --git a/generator/generator.go b/generator/generator.go
index 68b901c..ad30430 100644
--- a/generator/generator.go
+++ b/generator/generator.go
@@ -1216,8 +1216,12 @@ func (g *generator) writeBoolExpressionAttribute(indentLevel int, attr *parser.B
 
 func (g *generator) writeExpressionAttributeValueURL(indentLevel int, attr *parser.ExpressionAttribute) (err error) {
 	vn := g.createVariableName()
-	// var vn templ.SafeURL =
-	if _, err = g.w.WriteIndent(indentLevel, "var "+vn+" templ.SafeURL = "); err != nil {
+	// var vn templ.SafeURL
+	if _, err = g.w.WriteIndent(indentLevel, "var "+vn+" templ.SafeURL\n"); err != nil {
+		return err
+	}
+	// vn, templ_7745c5c3_Err = templ.JoinURLErrs(
+	if _, err = g.w.WriteIndent(indentLevel, vn+", templ_7745c5c3_Err = templ.JoinURLErrs("); err != nil {
 		return err
 	}
 	// p.Name()
@@ -1226,10 +1230,17 @@ func (g *generator) writeExpressionAttributeValueURL(indentLevel int, attr *pars
 		return err
 	}
 	g.sourceMap.Add(attr.Expression, r)
-	if _, err = g.w.Write("\n"); err != nil {
+	// )
+	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
 	}
-	if _, err = g.w.WriteIndent(indentLevel, "_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string("+vn+")))\n"); err != nil {
+	// _, 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)
diff --git a/generator/test-form-action/expected.html b/generator/test-form-action/expected.html
index 198a1cd..6961867 100644
--- a/generator/test-form-action/expected.html
+++ b/generator/test-form-action/expected.html
@@ -1,3 +1,5 @@
 <form action="javascript:alert(&#39;unaffected&#39;);">Ignored</form>
 <form action="about:invalid#TemplFailedSanitizationURL">Sanitized</form>
 <form action="javascript:alert(&#39;should not be sanitized&#39;)">Unsanitized</form>
+<form action="javascript:alert(&#39;should not be sanitized&#39;)">with error unsanitized</form>
+<form action="about:invalid#TemplFailedSanitizationURL">with error sanitized</form>
diff --git a/generator/test-form-action/template.templ b/generator/test-form-action/template.templ
index 0ff3802..8ee3beb 100644
--- a/generator/test-form-action/template.templ
+++ b/generator/test-form-action/template.templ
@@ -4,4 +4,14 @@ templ render() {
 	<form action="javascript:alert('unaffected');">Ignored</form>
 	<form action={ templ.URL("javascript:alert('should be sanitized')") }>Sanitized</form>
 	<form action={ templ.SafeURL("javascript:alert('should not be sanitized')") }>Unsanitized</form>
+	<form action={ safeUrl("javascript:alert('should not be sanitized')") }>with error unsanitized</form>
+	<form action={ stringUrl("javascript:alert('should be sanitized')") }>with error sanitized</form>
+}
+
+func safeUrl(s string) (templ.SafeURL, error) {
+	return templ.SafeURL(s), nil
+}
+
+func stringUrl(s string) (string, error) {
+	return s, nil
 }
diff --git a/runtime.go b/runtime.go
index df804c0..156fb09 100644
--- a/runtime.go
+++ b/runtime.go
@@ -78,8 +78,8 @@ func GetChildren(ctx context.Context) Component {
 }
 
 // EscapeString escapes HTML text within templates.
-func EscapeString(s string) string {
-	return html.EscapeString(s)
+func EscapeString[T ~string](s T) string {
+	return html.EscapeString(string(s))
 }
 
 // Bool attribute value.
diff --git a/storybook/storybook.go b/storybook/storybook.go
index 0bc78fb..5720aa1 100644
--- a/storybook/storybook.go
+++ b/storybook/storybook.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"log/slog"
 	"math"
 	"net/http"
 	"net/url"
@@ -20,7 +21,6 @@ import (
 	"golang.org/x/mod/sumdb/dirhash"
 
 	_ "embed"
-	"log/slog"
 
 	"github.com/a-h/templ"
 	"github.com/rs/cors"
@@ -94,7 +94,7 @@ func New(conf ...StorybookConfig) *Storybook {
 }
 
 func (sh *Storybook) AddComponent(name string, componentConstructor any, args ...Arg) *Conf {
-	//TODO: Check that the component constructor is a function that returns a templ.Component.
+	// TODO: Check that the component constructor is a function that returns a templ.Component.
 	c := NewConf(name, args...)
 	sh.Config[name] = c
 	h := NewHandler(name, componentConstructor, args...)
@@ -232,7 +232,7 @@ func (sh *Storybook) installStorybook() (err error) {
 	if err != nil {
 		return fmt.Errorf("templ-storybook: cannot install storybook, cannot find npx on the path, check that Node.js is installed: %w", err)
 	}
-	cmd.Args = []string{"npx", "sb", "init", "-t", "server", "--no-dev"}
+	cmd.Args = []string{"npx", "sb", "init", "--features", "docs", "test", "-t", "server", "--no-dev"}
 	return cmd.Run()
 }
 
diff --git a/url.go b/url.go
index bf912e1..860323d 100644
--- a/url.go
+++ b/url.go
@@ -1,6 +1,9 @@
 package templ
 
-import "strings"
+import (
+	"errors"
+	"strings"
+)
 
 // FailedSanitizationURL is returned if a URL fails sanitization checks.
 const FailedSanitizationURL = SafeURL("about:invalid#TemplFailedSanitizationURL")
@@ -18,3 +21,11 @@ func URL(s string) SafeURL {
 
 // SafeURL is a URL that has been sanitized.
 type SafeURL string
+
+// JoinURLErrs joins an optional list of errors and returns a sanitized SafeURL.
+func JoinURLErrs[T ~string](s T, errs ...error) (SafeURL, error) {
+	if safeURL, ok := any(s).(SafeURL); ok {
+		return safeURL, errors.Join(errs...)
+	}
+	return URL(string(s)), errors.Join(errs...)
+}



Test patch:
diff --git a/generator/test-a-href/template_templ.go b/generator/test-a-href/template_templ.go
index 7e1416c..244b196 100644
--- a/generator/test-a-href/template_templ.go
+++ b/generator/test-a-href/template_templ.go
@@ -32,8 +32,12 @@ func render() templ.Component {
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
-		var templ_7745c5c3_Var2 templ.SafeURL = templ.URL("javascript:alert('should be sanitized')")
-		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var2)))
+		var templ_7745c5c3_Var2 templ.SafeURL
+		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("javascript:alert('should be sanitized')"))
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-a-href/template.templ`, Line: 5, Col: 63}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
@@ -41,8 +45,12 @@ func render() templ.Component {
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
-		var templ_7745c5c3_Var3 templ.SafeURL = templ.SafeURL("javascript:alert('should not be sanitized')")
-		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3)))
+		var templ_7745c5c3_Var3 templ.SafeURL
+		templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("javascript:alert('should not be sanitized')"))
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-a-href/template.templ`, Line: 6, Col: 71}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
diff --git a/generator/test-attribute-escaping/template_templ.go b/generator/test-attribute-escaping/template_templ.go
index fbd7917..1996ac1 100644
--- a/generator/test-attribute-escaping/template_templ.go
+++ b/generator/test-attribute-escaping/template_templ.go
@@ -32,8 +32,12 @@ func BasicTemplate(url string) templ.Component {
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
-		var templ_7745c5c3_Var2 templ.SafeURL = templ.URL(url)
-		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var2)))
+		var templ_7745c5c3_Var2 templ.SafeURL
+		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(url))
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-attribute-escaping/template.templ`, Line: 5, Col: 26}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
diff --git a/generator/test-form-action/template_templ.go b/generator/test-form-action/template_templ.go
index 2fa9baa..9d61417 100644
--- a/generator/test-form-action/template_templ.go
+++ b/generator/test-form-action/template_templ.go
@@ -32,8 +32,12 @@ func render() templ.Component {
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
-		var templ_7745c5c3_Var2 templ.SafeURL = templ.URL("javascript:alert('should be sanitized')")
-		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var2)))
+		var templ_7745c5c3_Var2 templ.SafeURL
+		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("javascript:alert('should be sanitized')"))
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-form-action/template.templ`, Line: 5, Col: 68}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
@@ -41,12 +45,42 @@ func render() templ.Component {
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
-		var templ_7745c5c3_Var3 templ.SafeURL = templ.SafeURL("javascript:alert('should not be sanitized')")
-		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3)))
+		var templ_7745c5c3_Var3 templ.SafeURL
+		templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("javascript:alert('should not be sanitized')"))
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-form-action/template.templ`, Line: 6, Col: 76}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">Unsanitized</form><form action=\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var4 templ.SafeURL
+		templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(safeUrl("javascript:alert('should not be sanitized')"))
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-form-action/template.templ`, Line: 7, Col: 70}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">with error unsanitized</form><form action=\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var5 templ.SafeURL
+		templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(stringUrl("javascript:alert('should be sanitized')"))
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-form-action/template.templ`, Line: 8, Col: 68}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">Unsanitized</form>")
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">with error sanitized</form>")
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
@@ -54,4 +88,12 @@ func render() templ.Component {
 	})
 }
 
+func safeUrl(s string) (templ.SafeURL, error) {
+	return templ.SafeURL(s), nil
+}
+
+func stringUrl(s string) (string, error) {
+	return s, nil
+}
+
 var _ = templruntime.GeneratedTemplate
diff --git a/generator/test-html/template_templ.go b/generator/test-html/template_templ.go
index cf44f45..e862c21 100644
--- a/generator/test-html/template_templ.go
+++ b/generator/test-html/template_templ.go
@@ -58,8 +58,12 @@ func render(p person) templ.Component {
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
-		var templ_7745c5c3_Var4 templ.SafeURL = templ.URL("mailto: " + p.email)
-		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4)))
+		var templ_7745c5c3_Var4 templ.SafeURL
+		templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("mailto: " + p.email))
+		if templ_7745c5c3_Err != nil {
+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `generator/test-html/template.templ`, Line: 7, Col: 55}
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
 		if templ_7745c5c3_Err != nil {
 			return templ_7745c5c3_Err
 		}
diff --git a/url_test.go b/url_test.go
index 248f476..50f2f70 100644
--- a/url_test.go
+++ b/url_test.go
@@ -1,6 +1,7 @@
 package templ
 
 import (
+	"errors"
 	"strings"
 	"testing"
 )
@@ -55,3 +56,61 @@ func BenchmarkURL(b *testing.B) {
 		}
 	}
 }
+
+func TestJoinURLErrs(t *testing.T) {
+	err1 := errors.New("error 1")
+	err2 := errors.New("error 2")
+
+	type CustomString string
+
+	checkResult := func(t *testing.T, result SafeURL, err error, expected SafeURL, expectedErrs ...error) {
+		t.Helper()
+		if result != expected {
+			t.Errorf("expected result %q, got %q", expected, result)
+		}
+		if len(expectedErrs) == 0 {
+			if err != nil {
+				t.Errorf("expected nil error, got %v", err)
+			}
+			return
+		}
+		if err == nil {
+			t.Errorf("expected error(s) %v, got nil", expectedErrs)
+			return
+		}
+		for _, exp := range expectedErrs {
+			if !errors.Is(err, exp) {
+				t.Errorf("expected error to contain %v, but it did not", exp)
+			}
+		}
+	}
+	t.Run("strings are sanitized", func(t *testing.T) {
+		result, err := JoinURLErrs("javascript:alert(1)")
+		checkResult(t, result, err, FailedSanitizationURL)
+	})
+	t.Run("custom string types are sanitized", func(t *testing.T) {
+		result, err := JoinURLErrs(CustomString("javascript:alert(1)"))
+		checkResult(t, result, err, FailedSanitizationURL)
+	})
+	t.Run("SafeURLs bypass sanitization", func(t *testing.T) {
+		safeURL := SafeURL("javascript:alert(1)")
+		result, err := JoinURLErrs(safeURL)
+		checkResult(t, result, err, safeURL)
+	})
+	t.Run("safe URL strings are returned unchanged", func(t *testing.T) {
+		result, err := JoinURLErrs("https://example.com")
+		checkResult(t, result, err, SafeURL("https://example.com"))
+	})
+	t.Run("single errors are joined", func(t *testing.T) {
+		result, err := JoinURLErrs("https://example.com", err1)
+		checkResult(t, result, err, SafeURL("https://example.com"), err1)
+	})
+	t.Run("multiple errors are joined", func(t *testing.T) {
+		result, err := JoinURLErrs("https://example.com", err1, err2)
+		checkResult(t, result, err, SafeURL("https://example.com"), err1, err2)
+	})
+	t.Run("nil errors are preserved", func(t *testing.T) {
+		result, err := JoinURLErrs("https://example.com", nil)
+		checkResult(t, result, err, SafeURL("https://example.com"))
+	})
+}