Skip to content

Commit 4b3d5ad

Browse files
authored
Merge pull request #191 from jg-rp/snippet
Implement the `snippet` tag
2 parents 06537e6 + 03659e3 commit 4b3d5ad

File tree

15 files changed

+474
-75
lines changed

15 files changed

+474
-75
lines changed

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Python Liquid Change Log
22

3+
## Version 2.2.0 (unreleased)
4+
5+
- Added the `{% snippet %}` tag.
6+
- Improved static analysis of partial templates. Previously we would visit a partial template only once, regardless of how many times it is rendered with `{% render %}`. Now we visit partial templates once for each distinct set of arguments passed to `{% render %}`, potentially reporting "global" variables that we'd previously missed.
7+
38
## Version 2.1.0
49

510
**Features**

docs/tag_reference.md

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -540,10 +540,86 @@ Additional keyword arguments given to the `render` tag will be added to the rend
540540
{% render "partial_template" greeting: "Hello", num: 3, skip: 2 %}
541541
```
542542

543-
## tablerow
543+
## snippet
544+
545+
**_New in version 2.2.0_**
546+
547+
```plain
548+
{% snippet <identifier> %}
549+
<liquid markup>
550+
{% endsnippet %}
551+
```
552+
553+
A snippet is a reusable block of Liquid markup. Traditionally we'd save a snippet to a file and include it in a template with the `{% render %}` tag.
554+
555+
```liquid
556+
{% render "some_snippet" %}
557+
```
544558

545-
<!-- md:version 0.1.0 -->
546-
<!-- md:shopify -->
559+
With the `{% snippet %}` tag we can define blocks for reuse inside a single template, potentially reducing the number of snippet files we need.
560+
561+
```liquid
562+
{% snippet div %}
563+
<div>
564+
{{ content }}
565+
</div>
566+
{% endsnippet %}
567+
```
568+
569+
Defining a snippet does not render it. We use `{% render snippet_name %}` to render a snippet, where `snippet_name` is the name of your snippet without quotes (file-based snippet names must be quoted).
570+
571+
```liquid
572+
{% snippet div %}
573+
<div>
574+
{{ content }}
575+
</div>
576+
{% endsnippet %}
577+
578+
{% render div, content: "Some content" %}
579+
{% render div, content: "Other content" %}
580+
```
581+
582+
```html title="output"
583+
<div>Some content</div>
584+
585+
<div>Other content</div>
586+
```
587+
588+
Inline snippets share the same namespace as variables defined with `{% assign %}` and `{% capture %}`, so be wary of accidentally overwriting snippets with non-snippet data.
589+
590+
```liquid
591+
{% snippet foo %}Hello{% endsnippet %}
592+
{% foo = 42 %}
593+
{% render foo %} {% # error %}
594+
```
595+
596+
Snippets can be nested and follow the same scoping rules as file-based snippets.
597+
598+
```liquid
599+
{% snippet a %}
600+
b
601+
{% snippet c %}
602+
d
603+
{% endsnippet %}
604+
{% render c %}
605+
{% endsnippet %}
606+
607+
{% render a %}
608+
{% render c %} {% # error, c is out of scope %}
609+
```
610+
611+
Snippet blocks are bound to their names late. You can conditionally define multiple snippets with the same name and pick one at render time.
612+
613+
```liquid
614+
{% if x %}
615+
{% snippet a %}b{% endsnippet %}
616+
{% else %}
617+
{% snippet a %}c{% endsnippet %}
618+
{% endif %}
619+
{% render a %}
620+
```
621+
622+
## tablerow
547623

548624
```plain
549625
{% tablerow <identifier> in <expression>

liquid/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656

5757
from . import future
5858

59-
__version__ = "2.1.0"
59+
__version__ = "2.2.0"
6060

6161
__all__ = (
6262
"AwareBoundTemplate",

liquid/ast.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
from typing import TYPE_CHECKING
1010
from typing import Collection
1111
from typing import Iterable
12+
from typing import Optional
1213
from typing import TextIO
14+
from typing import Union
1315

1416
from .exceptions import DisabledTagError
1517
from .output import NullIO
@@ -104,7 +106,7 @@ def block_scope(self) -> Iterable[Identifier]:
104106
"""Return variables this node adds to the node's block scope."""
105107
return []
106108

107-
def partial_scope(self) -> Partial | None:
109+
def partial_scope(self) -> Optional[Partial]:
108110
"""Return information about a partial template loaded by this node."""
109111
return None
110112

@@ -121,19 +123,30 @@ class Partial:
121123
"""Partial template meta data.
122124
123125
Args:
124-
name: An expression resolving to the name associated with the partial template.
126+
name: The name of the partial or an expression resolving to the name
127+
associated with the partial template.
125128
scope: The kind of scope the partial template should have when loaded.
126129
in_scope: Names that will be added to the partial template scope.
130+
key: A hash of the partial template name and any arguments the partial
131+
template will be rendered with that might affect its scope. If a
132+
key is provided, static analysis helpers will visit a partial
133+
template once for each distinct key.
127134
"""
128135

129-
__slots__ = ("name", "scope", "in_scope")
136+
__slots__ = ("name", "scope", "in_scope", "key")
130137

131138
def __init__(
132-
self, name: Expression, scope: PartialScope, in_scope: Iterable[Identifier]
139+
self,
140+
name: Union[Expression, str],
141+
scope: PartialScope,
142+
in_scope: Iterable[Identifier],
143+
*,
144+
key: Optional[int] = None,
133145
) -> None:
134146
self.name = name
135147
self.scope = scope
136148
self.in_scope = in_scope
149+
self.key = key
137150

138151

139152
class IllegalNode(Node):

liquid/builtin/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
from .tags import inline_comment_tag
9191
from .tags import liquid_tag
9292
from .tags import render_tag
93+
from .tags import snippet
9394
from .tags import tablerow_tag
9495
from .tags import unless_tag
9596

@@ -133,6 +134,7 @@ def register(env: Environment) -> None: # noqa: PLR0915
133134
env.add_tag(ifchanged_tag.IfChangedTag)
134135
env.add_tag(inline_comment_tag.InlineCommentTag)
135136
env.add_tag(doc_tag.DocTag)
137+
env.add_tag(snippet.SnippetTag)
136138

137139
env.add_filter("abs", abs_)
138140
env.add_filter("at_most", at_most)

liquid/builtin/tags/doc_tag.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def parse(self, stream: TokenStream) -> DocNode:
5050

5151
# This only happens if the doc tag is malformed
5252
token = stream.eat(TOKEN_TAG)
53-
text = []
53+
text: list[str] = []
5454

5555
if stream.current.kind == TOKEN_EXPRESSION:
5656
raise LiquidSyntaxError("unexpected expression", token=stream.current)

liquid/builtin/tags/render_tag.py

Lines changed: 85 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Iterable
88
from typing import Optional
99
from typing import TextIO
10+
from typing import Union
1011

1112
from liquid.ast import Node
1213
from liquid.ast import Partial
@@ -21,11 +22,12 @@
2122
from liquid.builtin.expressions import parse_primitive
2223
from liquid.builtin.tags.for_tag import ForLoop
2324
from liquid.builtin.tags.include_tag import TAG_INCLUDE
25+
from liquid.exceptions import LiquidSyntaxError
2426
from liquid.exceptions import TemplateNotFoundError
2527
from liquid.tag import Tag
28+
from liquid.template import BoundTemplate
2629
from liquid.token import TOKEN_AS
2730
from liquid.token import TOKEN_FOR
28-
from liquid.token import TOKEN_STRING
2931
from liquid.token import TOKEN_TAG
3032
from liquid.token import TOKEN_WITH
3133
from liquid.token import TOKEN_WORD
@@ -50,7 +52,8 @@ class RenderNode(Node):
5052
def __init__(
5153
self,
5254
token: Token,
53-
name: StringLiteral,
55+
name: Union[StringLiteral, Identifier],
56+
*,
5457
var: Optional[Expression] = None,
5558
loop: bool = False,
5659
alias: Optional[Identifier] = None,
@@ -77,14 +80,26 @@ def __str__(self) -> str:
7780

7881
def render_to_output(self, context: RenderContext, buffer: TextIO) -> int:
7982
"""Render the node to the output buffer."""
80-
try:
81-
template = context.env.get_template(
82-
self.name.value, context=context, tag=self.tag
83+
if isinstance(self.name, Identifier):
84+
# We're expecting an inline snippet.
85+
template: Optional[BoundTemplate] = context.resolve(
86+
self.name, token=self.token, default=None
8387
)
84-
except TemplateNotFoundError as err:
85-
err.token = self.name.token
86-
err.template_name = context.template.full_name()
87-
raise
88+
if not isinstance(template, BoundTemplate):
89+
raise TemplateNotFoundError(
90+
self.name,
91+
filename=context.template.full_name(),
92+
token=self.name.token,
93+
)
94+
else:
95+
try:
96+
template = context.env.get_template(
97+
self.name.value, context=context, tag=self.tag
98+
)
99+
except TemplateNotFoundError as err:
100+
err.token = self.name.token
101+
err.template_name = context.template.full_name()
102+
raise
88103

89104
# Evaluate keyword arguments once. Unlike 'include', 'render' can not
90105
# mutate variables in the outer scope, so there's no need to re-evaluate
@@ -145,14 +160,26 @@ async def render_to_output_async(
145160
self, context: RenderContext, buffer: TextIO
146161
) -> int:
147162
"""Render the node to the output buffer."""
148-
try:
149-
template = await context.env.get_template_async(
150-
self.name.value, context=context, tag=self.tag
163+
if isinstance(self.name, Identifier):
164+
# We're expecting an inline snippet.
165+
template: Optional[BoundTemplate] = context.resolve(
166+
self.name, token=self.token, default=None
151167
)
152-
except TemplateNotFoundError as err:
153-
err.token = self.name.token
154-
err.template_name = context.template.full_name()
155-
raise
168+
if not isinstance(template, BoundTemplate):
169+
raise TemplateNotFoundError(
170+
self.name,
171+
filename=context.template.full_name(),
172+
token=self.name.token,
173+
)
174+
else:
175+
try:
176+
template = await context.env.get_template_async(
177+
self.name.value, context=context, tag=self.tag
178+
)
179+
except TemplateNotFoundError as err:
180+
err.token = self.name.token
181+
err.template_name = context.template.full_name()
182+
raise
156183

157184
# Evaluate keyword arguments once. Unlike 'include', 'render' can not
158185
# mutate variables in the outer scope, so there's no need to re-evaluate
@@ -215,7 +242,15 @@ def children(
215242
self, static_context: RenderContext, *, include_partials: bool = True
216243
) -> Iterable[Node]:
217244
"""Return this node's children."""
218-
if include_partials:
245+
if isinstance(self.name, Identifier):
246+
# We're expecting an inline snippet.
247+
# Always visit inline snippets, even if include_partials is False.
248+
template: Optional[BoundTemplate] = static_context.resolve(
249+
self.name, token=self.token, default=None
250+
)
251+
if template:
252+
yield from template.nodes
253+
elif include_partials:
219254
name = self.name.evaluate(static_context)
220255
try:
221256
template = static_context.env.get_template(
@@ -231,7 +266,14 @@ async def children_async(
231266
self, static_context: RenderContext, *, include_partials: bool = True
232267
) -> Iterable[Node]:
233268
"""Return this node's children."""
234-
if include_partials:
269+
if isinstance(self.name, Identifier):
270+
# We're expecting an inline snippet.
271+
template: Optional[BoundTemplate] = static_context.resolve(
272+
self.name, token=self.token, default=None
273+
)
274+
if template:
275+
return template.nodes
276+
elif include_partials:
235277
name = await self.name.evaluate_async(static_context)
236278
try:
237279
template = await static_context.env.get_template_async(
@@ -246,12 +288,11 @@ async def children_async(
246288

247289
def expressions(self) -> Iterable[Expression]:
248290
"""Return this node's expressions."""
249-
yield self.name
250291
if self.var:
251292
yield self.var
252293
yield from (arg.value for arg in self.args)
253294

254-
def partial_scope(self) -> Partial | None:
295+
def partial_scope(self) -> Optional[Partial]:
255296
"""Return information about a partial template loaded by this node."""
256297
scope: list[Identifier] = [
257298
Identifier(arg.name, token=arg.token) for arg in self.args
@@ -267,7 +308,17 @@ def partial_scope(self) -> Partial | None:
267308
)
268309
)
269310

270-
return Partial(name=self.name, scope=PartialScope.ISOLATED, in_scope=scope)
311+
partial_name = self.name.value if isinstance(self.name, StringLiteral) else ""
312+
partial_key = hash((partial_name, *[arg.name for arg in self.args]))
313+
314+
# Static analysis will use the parent template name if Partial.name is
315+
# empty. Which is what we want for inline snippets.
316+
return Partial(
317+
name=partial_name,
318+
scope=PartialScope.ISOLATED,
319+
in_scope=scope,
320+
key=partial_key,
321+
)
271322

272323

273324
BIND_TAGS = frozenset((TOKEN_WITH, TOKEN_FOR))
@@ -284,12 +335,18 @@ def parse(self, stream: TokenStream) -> Node:
284335
"""Parse tokens from _stream_ into an AST node."""
285336
token = stream.eat(TOKEN_TAG)
286337
tokens = stream.into_inner(tag=token, eat=False)
287-
288-
# Need a string. 'render' does not accept identifiers that resolve to a string.
289-
# This is the name of the template to be included.
290-
tokens.expect(TOKEN_STRING)
291-
name = parse_primitive(self.env, tokens)
292-
assert isinstance(name, StringLiteral)
338+
name: Union[Expression, Identifier] = parse_primitive(self.env, tokens)
339+
340+
if isinstance(name, Path):
341+
head = name.head()
342+
if len(name.path) != 1 or not isinstance(head, str):
343+
raise LiquidSyntaxError(
344+
"expected an identifier, found a path",
345+
token=name.token,
346+
)
347+
name = Identifier(head, token=name.token)
348+
elif not isinstance(name, StringLiteral):
349+
raise LiquidSyntaxError("expected a string or identifier", token=name.token)
293350

294351
alias: Optional[Identifier] = None
295352
var: Optional[Path] = None
@@ -311,4 +368,4 @@ def parse(self, stream: TokenStream) -> Node:
311368

312369
# Zero or more keyword arguments
313370
args = KeywordArgument.parse(self.env, tokens)
314-
return self.node_class(token, name, var, loop, alias, args)
371+
return self.node_class(token, name, var=var, loop=loop, alias=alias, args=args)

0 commit comments

Comments
 (0)