Repository: a-h/templ

Issue:
form action, anchor href should allow (SafeURL, error)
**Before you begin**

Release one hour ago:

templ version
v0.3.887

**Describe the bug**

form action and anchor href should allow (SafeURL, error), not just SafeURL

**To Reproduce**
A small, self-contained, complete reproduction, uploaded to a GitHub repo, containing the minimum amount of files required to reproduce the behaviour, along with a list of commands that need to be run. Keep it simple.

To reproduce, run `templ generate` on this example:

```templ
package main

import "errors"

templ hello() {
	<form action={ foo() }></form>
	<a href={ foo() }></a>
}

func foo() (templ.SafeURL, error) {
	return templ.URL("/foo"), errors.New("bar")
}
```

**Expected behavior**
A clear and concise description of what you expected to happen.

No compilation error, but instead the generated code assigns the result directly to one var:

```go
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<form action=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var2 templ.SafeURL = foo() <--- WRONG
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var2)))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"></form><a href=\"")
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
		var templ_7745c5c3_Var3 templ.SafeURL = foo() <--- WRONG
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3)))
		if templ_7745c5c3_Err != nil {
			return templ_7745c5c3_Err
		}
```





Hints from issue comments:
@a-h: Good idea. I think if we add a new function to the templ runtime, we can maintain the security aspect of sanitizing strings (so a Go variable under control of a user can't be used to redirect form actions, e.g. point to "https://evilsite.com/steal_passwords" can't be used), but still allow this sanitization to be bypassed by marking the URL as safe with `templ.SafeURL(myURLString)`.

```go
// JoinURLErrs joins an optional list of errors and returns a sanitized string.
func JoinURLErrs[T ~string](s T, errs ...error) (string, error) {
	// Bypass santization if the type is SafeURL.
	if safeURL, ok := any(s).(SafeURL); ok {
		return string(safeURL), errors.Join(errs...)
	}
	return string(URL(fmt.Sprint(s))), errors.Join(errs...)
}
```

Would need a little change to what you've done, and the new stdlib function, with tests, but fairly straightforward, I think.

Would also need to check that the LSP doesn't insert something like `string(s)`. The docs would also need a tweak for URL handling, I think.

Happy to take it from here, unless you feel like having a crack at it?

This will be a good update to templ. People have never liked `templ.SafeURL`, and this would retain the security while improving the developer UX for the most common cases.@jackielii: nice! @a-h happy for you to take over!@jackielii: @a-h How about I finish the generator part, and you take over the rest?@jackielii: @a-h added the new function, and some light test in the test-form-action

Solution patch:
diff --git a/.version b/.version
index 770ef5d..e4db128 100644
--- a/.version
+++ b/.version
@@ -1 +1 @@
-0.3.894
\ No newline at end of file
+0.3.896
\ No newline at end of file
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 0891cdd..ad30430 100644
--- a/generator/generator.go
+++ b/generator/generator.go
@@ -262,6 +262,9 @@ func (g *generator) writeTemplateNodes() error {
 }
 
 func (g *generator) writeCSS(n *parser.CSSTemplate) error {
+	if n == nil {
+		return errors.New("CSS template is nil")
+	}
 	var r parser.Range
 	var tgtSymbolRange parser.Range
 	var err error
@@ -346,6 +349,9 @@ func (g *generator) writeCSS(n *parser.CSSTemplate) error {
 }
 
 func (g *generator) writeGoExpression(n *parser.TemplateFileGoExpression) (err error) {
+	if n == nil {
+		return errors.New("go expression is nil")
+	}
 	var tgtSymbolRange parser.Range
 
 	r, err := g.w.Write(n.Expression.Value)
@@ -427,6 +433,9 @@ func (g *generator) writeTemplBuffer(indentLevel int) (err error) {
 }
 
 func (g *generator) writeTemplate(nodeIdx int, t *parser.HTMLTemplate) error {
+	if t == nil {
+		return errors.New("template is nil")
+	}
 	var r parser.Range
 	var tgtSymbolRange parser.Range
 	var err error
@@ -1207,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()
@@ -1217,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
 	}
-	if _, err = g.w.WriteIndent(indentLevel, "_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string("+vn+")))\n"); err != nil {
+	// 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)
@@ -1665,6 +1685,9 @@ func createGoString(s string) string {
 }
 
 func (g *generator) writeScript(t *parser.ScriptTemplate) error {
+	if t == nil {
+		return errors.New("script template is nil")
+	}
 	var r parser.Range
 	var tgtSymbolRange parser.Range
 	var err error
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/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"))
+	})
+}