1
0
mirror of https://github.com/danog/liquid.git synced 2025-01-22 14:02:34 +01:00

Implement include

This commit is contained in:
Oliver Steele 2017-07-05 10:37:46 -04:00
parent f313e6f2cd
commit fab31d9e8b
11 changed files with 170 additions and 99 deletions

View File

@ -52,6 +52,7 @@ This library is in its early days. The API may still change.
- [x] loop variables
- [ ] `tablerow`
- [ ] `cycle`
- [x] Include
- [x] Raw
- [x] Variables
- [x] Assign
@ -72,10 +73,10 @@ Please refer to the [contribution guidelines](./CONTRIBUTING.md).
## References
* [Shopify.github.io/liquid](https://shopify.github.io/liquid) is the definitive reference.
* [Shopify.github.io/liquid](https://shopify.github.io/liquid)
* [Liquid for Designers](https://github.com/Shopify/liquid/wiki/Liquid-for-Designers)
* [Liquid for Programmers](https://github.com/Shopify/liquid/wiki/Liquid-for-Programmers)
* [Help.shopify.com](https://help.shopify.com/themes/liquid) goes into more detail, but includes features that aren't present in core Liquid as used by Jekyll.
* Shopify's [Liquid for Designers](https://github.com/Shopify/liquid/wiki/Liquid-for-Designers) is another take.
## Attribution

View File

@ -28,10 +28,6 @@ func (c *blockDef) CanHaveParent(parent BlockSyntax) bool {
return c.parent == p
}
func (c *blockDef) requiresParent() bool {
return c.isBranchTag || c.isEndTag
}
func (c *blockDef) IsBlock() bool { return true }
func (c *blockDef) IsBlockEnd() bool { return c.isEndTag }
func (c *blockDef) IsBlockStart() bool { return !c.isBranchTag && !c.isEndTag }
@ -55,6 +51,7 @@ func (s Config) findBlockDef(name string) (*blockDef, bool) {
return ct, found
}
// BlockSyntax is part of the Grammar interface.
func (s Config) BlockSyntax(name string) (BlockSyntax, bool) {
ct, found := s.blockDefs[name]
return ct, found

View File

@ -1,9 +1,11 @@
package render
// Grammar supplies the parser with syntax information about blocks.
type Grammar interface {
BlockSyntax(string) (BlockSyntax, bool)
}
// BlockSyntax supplies the parser with syntax information about blocks.
type BlockSyntax interface {
IsBlock() bool
CanHaveParent(BlockSyntax) bool
@ -15,39 +17,5 @@ type BlockSyntax interface {
TagName() string
}
// Grammar returns a configuration's grammar.
func (c *Config) Grammar() Grammar { return c }
// func (c *Config) BlockSyntax(tagName string) BlockSyntax {
// s, _ := c.findBlockDef(tagName)
// return s
// }
// IsBlockTag(string) bool
// func (c *Config) IsBranch(tag string) bool {
// // RequiresBalancedChildren(string) bool
// }
// // func (c *Config) RequiresBalancedChildren(tag string) bool {
// // return tag != "comment" && tag != "raw"
// // }
// func (c *Config) IsBlockTag(tag string) bool {
// return c.findBlockDef(c.Name) != nil
// }
// func (c *Config) CanHaveParent(tag, parent string) bool {
// cd := c.findBlockDef(c.Name)
// p := c.findBlockDef(parent)
// return cd.compatibleParent(ccd)
// }
// func (c *Config) IsBlockStart(tag string) bool {
// return c.findBlockDef(tag).cd.isStartTag()
// }
// func (c *Config) IsBlockEnd(tag string) bool {
// return c.findBlockDef(tag).cd.isEndTag
// }
// func (c *Config) IsBranch(tag string) bool {
// return c.findBlockDef(tag).isBranchTag
// }

View File

@ -7,6 +7,7 @@ import (
"github.com/osteele/liquid/expression"
)
// A ParseError is a parse error during the template parsing.
type ParseError string
func (e ParseError) Error() string { return string(e) }
@ -76,7 +77,7 @@ func (s Config) parseChunks(chunks []Chunk) (ASTNode, error) { // nolint: gocycl
if sd != nil {
suffix = "; immediate parent is " + sd.TagName()
}
return nil, fmt.Errorf("%s not inside %s%s", c.Name, strings.Join(cs.ParentTags(), " or "), suffix)
return nil, parseError("%s not inside %s%s", c.Name, strings.Join(cs.ParentTags(), " or "), suffix)
case cs.IsBlockStart():
push := func() {
stack = append(stack, frame{syntax: sd, node: bn, ap: ap})
@ -106,12 +107,12 @@ func (s Config) parseChunks(chunks []Chunk) (ASTNode, error) { // nolint: gocycl
}
*ap = append(*ap, &ASTFunctional{c, f})
} else {
return nil, fmt.Errorf("unknown tag: %s", c.Name)
return nil, parseError("unknown tag: %s", c.Name)
}
}
}
if bn != nil {
return nil, fmt.Errorf("unterminated %s tag at %s", bn.Name, bn.SourceInfo)
return nil, parseError("unterminated %s tag at %s", bn.Name, bn.SourceInfo)
}
if err := s.evaluateBuilders(root); err != nil {
return nil, err

View File

@ -9,6 +9,16 @@ import (
"github.com/osteele/liquid/generics"
)
// A RenderError is an evaluation error during template rendering.
type renderError string
func (e renderError) Error() string { return string(e) }
// Error creates a render error.
func Error(format string, a ...interface{}) renderError {
return renderError(fmt.Sprintf(format, a...))
}
// Render renders the AST rooted at node to the writer.
func Render(node ASTNode, w io.Writer, b map[string]interface{}, c Config) error {
return renderNode(node, w, newNodeContext(b, c))
@ -37,21 +47,21 @@ func renderNode(node ASTNode, w io.Writer, ctx nodeContext) error { // nolint: g
case *ASTBlock:
cd, ok := ctx.config.findBlockDef(n.Name)
if !ok || cd.parser == nil {
return fmt.Errorf("unknown tag: %s", n.Name)
return parseError("unknown tag: %s", n.Name)
}
renderer := n.renderer
if renderer == nil {
panic(fmt.Errorf("unset renderer for %v", n))
panic(parseError("unset renderer for %v", n))
}
return renderer(w, renderContext{ctx, nil, n})
case *ASTObject:
value, err := ctx.Evaluate(n.expr)
if err != nil {
return fmt.Errorf("%s in %s", err, n.Source)
return parseError("%s in %s", err, n.Source)
}
return writeObject(value, w)
default:
panic(fmt.Errorf("unknown node type %T", node))
panic(parseError("unknown node type %T", node))
}
return nil
}

31
tags/include.go Normal file
View File

@ -0,0 +1,31 @@
package tags
import (
"io"
"path/filepath"
"github.com/osteele/liquid/render"
)
func includeTag(source string) (func(io.Writer, render.Context) error, error) {
return func(w io.Writer, ctx render.Context) error {
// It might be more efficient to add an context interface to render bytes
// to a writer. The status quo keeps the interface light at the expense of some overhead
// here.
value, err := ctx.EvaluateString(ctx.TagArgs())
if err != nil {
return err
}
rel, ok := value.(string)
if !ok {
return render.Error("include requires a string argument; got %v", value)
}
filename := filepath.Join(filepath.Dir(ctx.SourceFile()), rel)
s, err := ctx.RenderFile(filename, map[string]interface{}{})
if err != nil {
return err
}
_, err = w.Write([]byte(s))
return err
}, nil
}

32
tags/include_test.go Normal file
View File

@ -0,0 +1,32 @@
package tags
import (
"bytes"
"io/ioutil"
"strings"
"testing"
"github.com/osteele/liquid/render"
"github.com/stretchr/testify/require"
)
var includeTestBindings = map[string]interface{}{}
func TestIncludeTag(t *testing.T) {
config := render.NewConfig()
config.Filename = "testdata/include_source.html"
AddStandardTags(config)
ast, err := config.Parse(`{% include "include_target.html" %}`)
require.NoError(t, err)
buf := new(bytes.Buffer)
err = render.Render(ast, buf, includeTestBindings, config)
require.NoError(t, err)
require.Equal(t, "include target", strings.TrimSpace(buf.String()))
ast, err = config.Parse(`{% include 10 %}`)
require.NoError(t, err)
err = render.Render(ast, ioutil.Discard, includeTestBindings, config)
require.Error(t, err)
require.Contains(t, err.Error(), "requires a string")
}

76
tags/loop_test.go Normal file
View File

@ -0,0 +1,76 @@
package tags
import (
"bytes"
"fmt"
"testing"
"github.com/osteele/liquid/render"
"github.com/stretchr/testify/require"
)
var loopTests = []struct{ in, expected string }{
{`{% for a in array %}{{ a }} {% endfor %}`, "first second third "},
// loop modifiers
{`{% for a in array reversed %}{{ a }}.{% endfor %}`, "third.second.first."},
{`{% for a in array limit:2 %}{{ a }}.{% endfor %}`, "first.second."},
{`{% for a in array offset:1 %}{{ a }}.{% endfor %}`, "second.third."},
{`{% for a in array reversed limit:1 %}{{ a }}.{% endfor %}`, "third."},
// TODO investigate how these combine; does it depend on the order
// {`{% for a in array reversed offset:1 %}{{ a }}.{% endfor %}`, "second.first."},
// {`{% for a in array limit:1 offset:1 %}{{ a }}.{% endfor %}`, "second."},
// {`{% for a in array reversed limit:1 offset:1 %}{{ a }}.{% endfor %}`, "second."},
// loop variables
{`{% for a in array %}{{ forloop.first }}.{% endfor %}`, "true.false.false."},
{`{% for a in array %}{{ forloop.last }}.{% endfor %}`, "false.false.true."},
{`{% for a in array %}{{ forloop.index }}.{% endfor %}`, "1.2.3."},
{`{% for a in array %}{{ forloop.index0 }}.{% endfor %}`, "0.1.2."},
{`{% for a in array %}{{ forloop.rindex }}.{% endfor %}`, "3.2.1."},
{`{% for a in array %}{{ forloop.rindex0 }}.{% endfor %}`, "2.1.0."},
{`{% for a in array %}{{ forloop.length }}.{% endfor %}`, "3.3.3."},
{`{% for i in array %}{{ forloop.index }}[{% for j in array %}{{ forloop.index }}{% endfor %}]{{ forloop.index }}{% endfor %}`,
"1[123]12[123]23[123]3"},
{`{% for a in array reversed %}{{ forloop.first }}.{% endfor %}`, "true.false.false."},
{`{% for a in array reversed %}{{ forloop.last }}.{% endfor %}`, "false.false.true."},
{`{% for a in array reversed %}{{ forloop.index }}.{% endfor %}`, "1.2.3."},
{`{% for a in array reversed %}{{ forloop.rindex }}.{% endfor %}`, "3.2.1."},
{`{% for a in array reversed %}{{ forloop.length }}.{% endfor %}`, "3.3.3."},
{`{% for a in array limit:2 %}{{ forloop.index }}.{% endfor %}`, "1.2."},
{`{% for a in array limit:2 %}{{ forloop.rindex }}.{% endfor %}`, "2.1."},
{`{% for a in array limit:2 %}{{ forloop.first }}.{% endfor %}`, "true.false."},
{`{% for a in array limit:2 %}{{ forloop.last }}.{% endfor %}`, "false.true."},
{`{% for a in array limit:2 %}{{ forloop.length }}.{% endfor %}`, "2.2."},
{`{% for a in array offset:1 %}{{ forloop.index }}.{% endfor %}`, "1.2."},
{`{% for a in array offset:1 %}{{ forloop.rindex }}.{% endfor %}`, "2.1."},
{`{% for a in array offset:1 %}{{ forloop.first }}.{% endfor %}`, "true.false."},
{`{% for a in array offset:1 %}{{ forloop.last }}.{% endfor %}`, "false.true."},
{`{% for a in array offset:1 %}{{ forloop.length }}.{% endfor %}`, "2.2."},
{`{% for a in array %}{% if a == 'second' %}{% break %}{% endif %}{{ a }}{% endfor %}`, "first"},
{`{% for a in array %}{% if a == 'second' %}{% continue %}{% endif %}{{ a }}.{% endfor %}`, "first.third."},
}
var loopTestBindings = map[string]interface{}{
"array": []string{"first", "second", "third"},
}
func TestLoopTag(t *testing.T) {
config := render.NewConfig()
AddStandardTags(config)
for i, test := range loopTests {
t.Run(fmt.Sprintf("%02d", i+1), func(t *testing.T) {
ast, err := config.Parse(test.in)
require.NoErrorf(t, err, test.in)
buf := new(bytes.Buffer)
err = render.Render(ast, buf, loopTestBindings, config)
require.NoErrorf(t, err, test.in)
require.Equalf(t, test.expected, buf.String(), test.in)
})
}
}

View File

@ -12,6 +12,7 @@ import (
// AddStandardTags defines the standard Liquid tags.
func AddStandardTags(c render.Config) {
c.AddTag("assign", assignTag)
c.AddTag("include", includeTag)
// blocks
// The parser only recognize the comment and raw tags if they've been defined,

View File

@ -54,58 +54,12 @@ var tagTests = []struct{ in, expected string }{
{`{%unless true%}0{%elsif true%}1{%else%}2{%endunless%}`, "1"},
{`{%unless true%}0{%elsif false%}1{%else%}2{%endunless%}`, "2"},
// loops
{`{%for a in ar%}{{a}} {%endfor%}`, "first second third "},
// loop modifiers
{`{%for a in ar reversed%}{{a}}.{%endfor%}`, "third.second.first."},
{`{%for a in ar limit:2%}{{a}}.{%endfor%}`, "first.second."},
{`{%for a in ar offset:1%}{{a}}.{%endfor%}`, "second.third."},
{`{%for a in ar reversed limit:1%}{{a}}.{%endfor%}`, "third."},
// TODO investigate how these combine; does it depend on the order
// {`{%for a in ar reversed offset:1%}{{a}}.{%endfor%}`, "second.first."},
// {`{%for a in ar limit:1 offset:1%}{{a}}.{%endfor%}`, "second."},
// {`{%for a in ar reversed limit:1 offset:1%}{{a}}.{%endfor%}`, "second."},
// loop variables
{`{%for a in ar%}{{forloop.first}}.{%endfor%}`, "true.false.false."},
{`{%for a in ar%}{{forloop.last}}.{%endfor%}`, "false.false.true."},
{`{%for a in ar%}{{forloop.index}}.{%endfor%}`, "1.2.3."},
{`{%for a in ar%}{{forloop.index0}}.{%endfor%}`, "0.1.2."},
{`{%for a in ar%}{{forloop.rindex}}.{%endfor%}`, "3.2.1."},
{`{%for a in ar%}{{forloop.rindex0}}.{%endfor%}`, "2.1.0."},
{`{%for a in ar%}{{forloop.length}}.{%endfor%}`, "3.3.3."},
{`{%for i in ar%}{{forloop.index}}[{%for j in ar%}{{forloop.index}}{%endfor%}]{{forloop.index}}{%endfor%}`,
"1[123]12[123]23[123]3"},
{`{%for a in ar reversed%}{{forloop.first}}.{%endfor%}`, "true.false.false."},
{`{%for a in ar reversed%}{{forloop.last}}.{%endfor%}`, "false.false.true."},
{`{%for a in ar reversed%}{{forloop.index}}.{%endfor%}`, "1.2.3."},
{`{%for a in ar reversed%}{{forloop.rindex}}.{%endfor%}`, "3.2.1."},
{`{%for a in ar reversed%}{{forloop.length}}.{%endfor%}`, "3.3.3."},
{`{%for a in ar limit:2%}{{forloop.index}}.{%endfor%}`, "1.2."},
{`{%for a in ar limit:2%}{{forloop.rindex}}.{%endfor%}`, "2.1."},
{`{%for a in ar limit:2%}{{forloop.first}}.{%endfor%}`, "true.false."},
{`{%for a in ar limit:2%}{{forloop.last}}.{%endfor%}`, "false.true."},
{`{%for a in ar limit:2%}{{forloop.length}}.{%endfor%}`, "2.2."},
{`{%for a in ar offset:1%}{{forloop.index}}.{%endfor%}`, "1.2."},
{`{%for a in ar offset:1%}{{forloop.rindex}}.{%endfor%}`, "2.1."},
{`{%for a in ar offset:1%}{{forloop.first}}.{%endfor%}`, "true.false."},
{`{%for a in ar offset:1%}{{forloop.last}}.{%endfor%}`, "false.true."},
{`{%for a in ar offset:1%}{{forloop.length}}.{%endfor%}`, "2.2."},
{`{%for a in ar%}{%if a == 'second'%}{%break%}{%endif%}{{a}}{%endfor%}`, "first"},
{`{%for a in ar%}{%if a == 'second'%}{%continue%}{%endif%}{{a}}.{%endfor%}`, "first.third."},
// TODO test whether this requires matching interior tags
{`pre{%raw%}{{a}}{%unknown%}{%endraw%}post`, "pre{{a}}{%unknown%}post"},
{`pre{%raw%}{%if false%}anyway-{%endraw%}post`, "pre{%if false%}anyway-post"},
}
var bindings = map[string]interface{}{
var tagTestBindings = map[string]interface{}{
"x": 123,
"obj": map[string]interface{}{
"a": 1,
@ -126,7 +80,6 @@ var bindings = map[string]interface{}{
{"weight": 3},
{"weight": nil},
},
"ar": []string{"first", "second", "third"},
"page": map[string]interface{}{
"title": "Introduction",
},
@ -144,7 +97,7 @@ func TestParseErrors(t *testing.T) {
})
}
}
func TestRender(t *testing.T) {
func TestTags(t *testing.T) {
config := render.NewConfig()
AddStandardTags(config)
for i, test := range tagTests {
@ -152,7 +105,7 @@ func TestRender(t *testing.T) {
ast, err := config.Parse(test.in)
require.NoErrorf(t, err, test.in)
buf := new(bytes.Buffer)
err = render.Render(ast, buf, bindings, config)
err = render.Render(ast, buf, tagTestBindings, config)
require.NoErrorf(t, err, test.in)
require.Equalf(t, test.expected, buf.String(), test.in)
})

1
tags/testdata/include_target.html vendored Normal file
View File

@ -0,0 +1 @@
include target