Skip to content

Commit

Permalink
feat: add boolean attribute support (#21)
Browse files Browse the repository at this point in the history
* feat: add boolean attribute expression support

* feat: added support for constant boolean attributes

* refactor: update syntax based on community feedback

* fix: updated syntax for boolean attributes
  • Loading branch information
a-h committed Jul 12, 2021
1 parent cc265e5 commit 3aef485
Show file tree
Hide file tree
Showing 11 changed files with 369 additions and 15 deletions.
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,17 +156,33 @@ Then call it in a template. So long as the `Raw` function is in scope, you can u

HTML elements look like HTML and you can write static attributes into them, just like with normal HTML. Don't worry about the spacing, the HTML will be minified when it's rendered.

All elements must be balanced (have a start and and end tag, or be self-closing).

```
<div id="address1">{%= addr.Address1 %}</div>
```

You can also have dynamic attributes that use template parameter, other Go variables that happen to be in scope, or call Go functions that return a string. Don't worry about HTML encoding element text and attribute values, that will be taken care of automatically.
You can also have dynamic attributes that use template parameters, other Go variables that happen to be in scope, or call Go functions that return a string. Don't worry about HTML encoding element text and attribute values, that will be taken care of automatically.

```
<a title={%= p.TitleText %}>{%= strings.ToUpper(p.Name()) %}</a>
```

However, the `a` element's `href` attribute is treated differently. Templ expects you to provide a `templ.SafeURL`. A `templ.SafeURL` is a URL that is definitely safe to use (i.e. has come from a configuration system controlled by the developer), or has been through a sanitization process to filter out potential XSS attacks.
Boolean attributes (see https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes) where the presence of an attribute name without a value means `true`, and the attribute name not being present means false are supported:

With constant values:

```
<hr noshade/>
```

To set boolean attributes using variables or template parameters, a question mark after the attribute name is used to denote that the attribute is boolean. In this example, the `noshade` attribute would be omitted from the output altogether:

```
<hr noshade?={%= false %} />
```

The `a` element's `href` attribute is treated differently. Templ expects you to provide a `templ.SafeURL`. A `templ.SafeURL` is a URL that is definitely safe to use (i.e. has come from a configuration system controlled by the developer), or has been through a sanitization process to filter out potential XSS attacks.

Templ provides a `templ.URL` function that sanitizes input URLs and checks that the protocol is http/https/mailto rather than `javascript` or another unexpected protocol.

Expand Down
45 changes: 41 additions & 4 deletions generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ func (g *generator) writeSwitchExpression(indentLevel int, n parser.SwitchExpres
if _, err = g.w.WriteIndent(indentLevel, `case `); err != nil {
return err
}
//val
// val
if r, err = g.w.Write(c.Expression.Value); err != nil {
return err
}
Expand Down Expand Up @@ -478,7 +478,7 @@ func (g *generator) writeCallTemplateExpression(indentLevel int, n parser.CallTe
func (g *generator) writeForExpression(indentLevel int, n parser.ForExpression) error {
var r parser.Range
var err error
// if
// for
if _, err = g.w.WriteIndent(indentLevel, `for `); err != nil {
return err
}
Expand Down Expand Up @@ -532,15 +532,15 @@ func (g *generator) writeVoidElement(indentLevel int, n parser.Element) (err err
return fmt.Errorf("writeVoidElement: void element %q must not have child elements", n.Name)
}
if len(n.Attributes) == 0 {
// <div/>
// <br>
if _, err = g.w.WriteIndent(indentLevel, fmt.Sprintf(`_, err = io.WriteString(w, "<%s>")`+"\n", html.EscapeString(n.Name))); err != nil {
return err
}
if err = g.writeErrorHandler(indentLevel); err != nil {
return err
}
} else {
// <div
// <hr
if _, err = g.w.WriteIndent(indentLevel, fmt.Sprintf(`_, err = io.WriteString(w, "<%s")`+"\n", html.EscapeString(n.Name))); err != nil {
return err
}
Expand Down Expand Up @@ -649,6 +649,14 @@ func (g *generator) writeElementAttributes(indentLevel int, n parser.Element) (e
var r parser.Range
for i := 0; i < len(n.Attributes); i++ {
switch attr := n.Attributes[i].(type) {
case parser.BoolConstantAttribute:
name := html.EscapeString(attr.Name)
if _, err = g.w.WriteIndent(indentLevel, fmt.Sprintf(`_, err = io.WriteString(w, " %s")`+"\n", name)); err != nil {
return err
}
if err = g.writeErrorHandler(indentLevel); err != nil {
return err
}
case parser.ConstantAttribute:
name := html.EscapeString(attr.Name)
value := html.EscapeString(attr.Value)
Expand All @@ -658,6 +666,35 @@ func (g *generator) writeElementAttributes(indentLevel int, n parser.Element) (e
if err = g.writeErrorHandler(indentLevel); err != nil {
return err
}
case parser.BoolExpressionAttribute:
name := html.EscapeString(attr.Name)
// if
if _, err = g.w.WriteIndent(indentLevel, `if `); err != nil {
return err
}
// x == y
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
}
{
indentLevel++
if _, err = g.w.WriteIndent(indentLevel, fmt.Sprintf(`_, err = io.WriteString(w, " %s")`+"\n", name)); err != nil {
return err
}
if err = g.writeErrorHandler(indentLevel); err != nil {
return err
}
indentLevel--
}
// }
if _, err = g.w.WriteIndent(indentLevel, `}`+"\n"); err != nil {
return err
}
case parser.ExpressionAttribute:
name := html.EscapeString(attr.Name)
// Name
Expand Down
2 changes: 1 addition & 1 deletion generator/test-doctype/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"github.com/google/go-cmp/cmp"
)

const expected = `<!DOCTYPE html>` +
const expected = `<!doctype html>` +
`<html lang="en">` +
`<head>` +
`<meta charset="UTF-8">` +
Expand Down
2 changes: 1 addition & 1 deletion generator/test-doctype/template_templ.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion generator/test-html/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,16 @@ import (
"github.com/google/go-cmp/cmp"
)

const expected = `<div><h1>Luiz Bonfa</h1><div style="font-family: &#39;sans-serif&#39;" id="test" data-contents="something with &#34;quotes&#34; and a &lt;tag&gt;"><div>email:<a href="mailto: luiz@example.com">luiz@example.com</a></div></div></div>`
const expected = `<div>` +
`<h1>Luiz Bonfa</h1>` +
`<div style="font-family: &#39;sans-serif&#39;" id="test" data-contents="something with &#34;quotes&#34; and a &lt;tag&gt;">` +
`<div>email:<a href="mailto: luiz@example.com">luiz@example.com</a>` +
`</div>` +
`</div>` +
`</div>` +
`<hr noshade>` +
`<hr optionA optionB optionC="other">` +
`<hr noshade>`

func TestHTML(t *testing.T) {
w := new(strings.Builder)
Expand Down
3 changes: 3 additions & 0 deletions generator/test-html/template.templ
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@
<div>{%= "email:" %}<a href={%= templ.URL("mailto: " + p.email) %}>{%= p.email %}</a></div>
</div>
</div>
<hr noshade?={%= true %} />
<hr optionA optionB?={%= true %} optionC="other" optionD?={%= false %} />
<hr noshade/>
{% endtempl %}

54 changes: 54 additions & 0 deletions generator/test-html/template_templ.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 106 additions & 6 deletions parser/elementparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,100 @@ func (p constantAttributeParser) Parse(pi parse.Input) parse.Result {
)(pi)
}

// BoolConstantAttribute.
func newBoolConstantAttributeParser() boolConstantAttributeParser {
return boolConstantAttributeParser{}
}

type boolConstantAttributeParser struct {
}

func (p boolConstantAttributeParser) Parse(pi parse.Input) parse.Result {
var r BoolConstantAttribute

start := pi.Index()
pr := whitespaceParser(pi)
if !pr.Success {
return pr
}

pr = attributeNameParser(pi)
if !pr.Success {
rewind(pi, start)
return pr
}
r.Name = pr.Item.(string)

// We have a name, but if we have an equals sign, it's not a constant boolean attribute.
next, err := pi.Peek()
if err != nil {
return parse.Failure("boolConstantAttributeParser", fmt.Errorf("boolConstantAttributeParser: unexpected error reading after attribute name: %w", pr.Error))
}
if next == '=' || next == '?' {
// It's one of the other attribute types.
rewind(pi, start)
return parse.Failure("boolConstantAttributeParser", nil)
}
if !(next == ' ' || next == '\n' || next == '/') {
return parse.Failure("boolConstantAttributeParser", fmt.Errorf("boolConstantAttributeParser: expected attribute name to end with space, newline or '/>', but got %q", string(next)))
}

return parse.Success("boolConstantAttributeParser", r, nil)
}

// BoolExpressionAttribute.
func newBoolExpressionAttributeParser() boolExpressionAttributeParser {
return boolExpressionAttributeParser{}
}

type boolExpressionAttributeParser struct {
}

func (p boolExpressionAttributeParser) Parse(pi parse.Input) parse.Result {
var r BoolExpressionAttribute

start := pi.Index()
from := NewPositionFromInput(pi)
pr := whitespaceParser(pi)
if !pr.Success {
return pr
}

pr = attributeNameParser(pi)
if !pr.Success {
rewind(pi, start)
return pr
}
r.Name = pr.Item.(string)

if pr = parse.String("?={%= ")(pi); !pr.Success {
rewind(pi, start)
return pr
}

// Once we've seen a expression prefix, read until the tag end.
from = NewPositionFromInput(pi)
pr = parse.StringUntil(tagEnd)(pi)
if pr.Error != nil && pr.Error != io.EOF {
return parse.Failure("boolExpressionAttributeParser", fmt.Errorf("boolExpressionAttributeParser: failed to read until tag end: %w", pr.Error))
}
// If there's no tag end, the string expression parser wasn't terminated.
if !pr.Success {
return parse.Failure("boolExpressionAttributeParser", newParseError("bool expression attribute not terminated", from, NewPositionFromInput(pi)))
}

// Success! Create the expression.
to := NewPositionFromInput(pi)
r.Expression = NewExpression(pr.Item.(string), from, to)

// Eat the tag end.
if te := tagEnd(pi); !te.Success {
return parse.Failure("boolExpressionAttributeParser", newParseError("could not terminate boolean expression", from, NewPositionFromInput(pi)))
}

return parse.Success("boolExpressionAttributeParser", r, nil)
}

// ExpressionAttribute.
func newExpressionAttributeParser() expressionAttributeParser {
return expressionAttributeParser{}
Expand Down Expand Up @@ -167,22 +261,28 @@ func (p attributesParser) asAttributeArray(parts []interface{}) (result interfac
op := make([]Attribute, len(parts))
for i := 0; i < len(parts); i++ {
switch v := parts[i].(type) {
case BoolConstantAttribute:
op[i] = v
case ConstantAttribute:
op[i] = v
case BoolExpressionAttribute:
op[i] = v
case ExpressionAttribute:
op[i] = v
}
}
return op, true
}

var attributeParser = parse.Any(
newBoolConstantAttributeParser().Parse,
newConstantAttributeParser().Parse,
newBoolExpressionAttributeParser().Parse,
newExpressionAttributeParser().Parse,
)

func (p attributesParser) Parse(pi parse.Input) parse.Result {
return parse.Many(p.asAttributeArray, 0, 255,
parse.Or(
newExpressionAttributeParser().Parse,
newConstantAttributeParser().Parse,
),
)(pi)
return parse.Many(p.asAttributeArray, 0, 255, attributeParser)(pi)
}

// Element name.
Expand Down

0 comments on commit 3aef485

Please sign in to comment.