├── .github
└── workflows
│ └── go.yaml
├── .gitignore
├── LICENSE
├── README.md
├── TODO
├── ast
├── node.go
└── node_test.go
├── bundle.go
├── data
├── convert.go
├── convert_test.go
├── value.go
└── value_test.go
├── doc.go
├── errortypes
├── filepos.go
└── filepos_test.go
├── features_test.go
├── globals.go
├── go.mod
├── go.sum
├── parse
├── lexer.go
├── lexer_test.go
├── parse.go
├── parse_test.go
├── quote.go
├── quote_test.go
├── rawtext.go
└── rawtext_test.go
├── parsepasses
├── datarefcheck.go
├── datarefcheck_test.go
├── globals.go
└── msgids.go
├── soyhtml
├── directives.go
├── eval.go
├── eval_test.go
├── exec.go
├── exec_test.go
├── funcs.go
├── funcs_test.go
├── renderer.go
├── scope.go
└── tofu.go
├── soyjs
├── directives.go
├── doc.go
├── exec.go
├── exec_test.go
├── formatters.go
├── funcs.go
├── generator.go
├── generator_test.go
├── lib
│ ├── soyutils.js
│ └── soyutils_usegoog.js
└── scope.go
├── soymsg
├── id.go
├── placeholder.go
├── placeholder_test.go
├── pomsg
│ ├── fallback.go
│ ├── fallback_test.go
│ ├── msgid.go
│ ├── msgid_test.go
│ ├── pomsg.go
│ ├── pomsg_test.go
│ ├── testdata
│ │ ├── en.po
│ │ └── zz.po
│ └── xgettext-soy
│ │ └── main.go
├── soymsg.go
└── soymsg_test.go
├── soyweb
└── soyweb.go
├── template
├── registry.go
└── template.go
└── testdata
├── FeaturesUsage_globals.txt
├── features.soy
└── simple.soy
/.github/workflows/go.yaml:
--------------------------------------------------------------------------------
1 | name: "Go"
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | go:
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | go: [ '1.14', '1.13', '1.12' ]
15 | os: [ 'ubuntu-22.04' ]
16 | runs-on: ${{ matrix.os }}
17 | name: Go ${{ matrix.go }} on ${{ matrix.os }}
18 | steps:
19 | - name: Checkout repo
20 | uses: actions/checkout@v4
21 |
22 | - name: Setup Go
23 | uses: actions/setup-go@v5
24 | with:
25 | go-version: ${{ matrix.go }}
26 |
27 | - name: Build
28 | run: go test ./...
29 | env:
30 | GO111MODULE: on
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled Object files, Static and Dynamic libs (Shared Objects)
2 | *.o
3 | *.a
4 | *.so
5 |
6 | # Folders
7 | _obj
8 | _test
9 |
10 | # Architecture specific extensions/prefixes
11 | *.[568vq]
12 | [568vq].out
13 |
14 | *.cgo1.go
15 | *.cgo2.c
16 | _cgo_defun.c
17 | _cgo_gotypes.go
18 | _cgo_export.*
19 |
20 | _testmain.go
21 |
22 | *.exe
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2013 Rob Figueiredo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # soy
2 |
3 | [](http://godoc.org/github.com/robfig/soy)
4 | [](https://github.com/robfig/soy/actions/workflows/go.yaml?query=branch%3Amaster)
5 | [](https://goreportcard.com/report/robfig/soy)
6 |
7 | Go implementation for Soy templates aka [Google Closure
8 | Templates](https://github.com/google/closure-templates). See
9 | [godoc](http://godoc.org/github.com/robfig/soy) for more details and usage
10 | examples.
11 |
12 | This project requires Go 1.12 or higher due to one of the transitive
13 | dependencies requires it as a minimum version; otherwise, Go 1.11 would
14 | suffice for `go mod` support.
15 |
16 | Be sure to set the env var `GO111MODULE=on` to use the `go mod` dependency
17 | versioning when building and testing this project.
18 |
--------------------------------------------------------------------------------
/TODO:
--------------------------------------------------------------------------------
1 | - content "kind": text, html, uri, js (code), js (str chars), html attributes
2 | - Contextual autoescape
3 | - js: figure out / unify print directives / funcs
4 | - js: implement / test all functions
5 | - js: command line tool to compile
6 | - js: combine nodes into expressions for output when possible
7 | - js: generate goog.provide/goog.require
8 | - js: generate jsdoc
9 | - js: goog.getCssName
10 | - {msg}
11 | - Delegates (delpackage, delcall, deltemplate)
12 | - parsepasses (optimizations) (Simplify, CombineConsecutiveRawText, Prerender)
13 | - CSS renaming
14 | - Go code generation
15 | - Bidi
16 | - use xliff message bundles
17 | - use PO messages
18 | - Message extractor (placeholders/phname)
19 | - "private" templates (and optimizations)
20 | - Caching
21 |
--------------------------------------------------------------------------------
/ast/node.go:
--------------------------------------------------------------------------------
1 | // Package ast contains definitions for the in-memory representation of a Soy
2 | // template.
3 | package ast
4 |
5 | import (
6 | "bytes"
7 | "fmt"
8 | "strconv"
9 |
10 | "github.com/robfig/soy/data"
11 | )
12 |
13 | // Node represents any singular piece of a Soy template. For example, a
14 | // sequence of raw text or a print tag.
15 | type Node interface {
16 | String() string // String returns the Soy source representation of this node.
17 | Position() Pos // byte position of start of node in full original input string
18 | }
19 |
20 | // ParentNode is any Node that has descendent nodes. For example, the Children
21 | // of a AddNode are the two nodes that should be added.
22 | type ParentNode interface {
23 | Node
24 | Children() []Node
25 | }
26 |
27 | // Pos represents a byte position in the original input text from which this
28 | // template was parsed. It is useful to construct helpful error messages.
29 | type Pos int
30 |
31 | // Position returns this position. It is implemented as a method so that Nodes
32 | // may embed a Pos and fulfill this part of the Node interface for free.
33 | func (p Pos) Position() Pos {
34 | return p
35 | }
36 |
37 | // SoyFileNode represents a Soy file.
38 | type SoyFileNode struct {
39 | Name string
40 | Text string
41 | Body []Node
42 | }
43 |
44 | func (n SoyFileNode) Position() Pos {
45 | return 0
46 | }
47 |
48 | func (n SoyFileNode) Children() []Node {
49 | return n.Body
50 | }
51 |
52 | func (n SoyFileNode) String() string {
53 | var b bytes.Buffer
54 | for _, n := range n.Body {
55 | fmt.Fprint(&b, n)
56 | }
57 | return b.String()
58 | }
59 |
60 | // ListNode holds a sequence of nodes.
61 | type ListNode struct {
62 | Pos
63 | Nodes []Node // The element nodes in lexical order.
64 | }
65 |
66 | func (l *ListNode) String() string {
67 | b := new(bytes.Buffer)
68 | for _, n := range l.Nodes {
69 | fmt.Fprint(b, n)
70 | }
71 | return b.String()
72 | }
73 |
74 | func (l *ListNode) Children() []Node {
75 | return l.Nodes
76 | }
77 |
78 | type RawTextNode struct {
79 | Pos
80 | Text []byte // The text; may span newlines.
81 | }
82 |
83 | func (t *RawTextNode) String() string {
84 | return string(t.Text)
85 | }
86 |
87 | // NamespaceNode registers the namespace of the Soy file.
88 | type NamespaceNode struct {
89 | Pos
90 | Name string
91 | Autoescape AutoescapeType
92 | }
93 |
94 | func (c *NamespaceNode) String() string {
95 | return "{namespace " + c.Name + "}"
96 | }
97 |
98 | type AutoescapeType int
99 |
100 | const (
101 | AutoescapeUnspecified AutoescapeType = iota
102 | AutoescapeOn
103 | AutoescapeOff
104 | AutoescapeContextual
105 | )
106 |
107 | // TemplateNode holds a template body.
108 | type TemplateNode struct {
109 | Pos
110 | Name string
111 | Body *ListNode
112 | Autoescape AutoescapeType
113 | Private bool
114 | }
115 |
116 | func (n *TemplateNode) String() string {
117 | return fmt.Sprintf("{template %s}\n%s\n{/template}\n", n.Name, n.Body)
118 | }
119 |
120 | func (n *TemplateNode) Children() []Node {
121 | return []Node{n.Body}
122 | }
123 |
124 | // TypeNode holds a type definition for a template parameter.
125 | //
126 | // Presently this is just a string value which is not validated or processed.
127 | // Backwards-incompatible changes in the future may elaborate this data model to
128 | // add functionality.
129 | type TypeNode struct {
130 | Pos
131 | Expr string
132 | }
133 |
134 | func (n TypeNode) String() string {
135 | return n.Expr
136 | }
137 |
138 | // HeaderParamNode holds a parameter declaration.
139 | //
140 | // HeaderParams MUST appear at the beginning of a TemplateNode's Body.
141 | type HeaderParamNode struct {
142 | Pos
143 | Optional bool
144 | Name string
145 | Type TypeNode // empty if inferred from the default value
146 | Default Node // nil if no default was specified
147 | }
148 |
149 | func (n *HeaderParamNode) String() string {
150 | var expr string
151 | if !n.Optional {
152 | expr = "{@param "
153 | } else {
154 | expr = "{@param? "
155 | }
156 | expr += n.Name + ":"
157 | if typ := n.Type.String(); typ != "" {
158 | expr += " " + typ + " "
159 | }
160 | if n.Default != nil {
161 | expr += "= " + n.Default.String()
162 | }
163 | expr += "}\n"
164 | return expr
165 | }
166 |
167 | type SoyDocNode struct {
168 | Pos
169 | Params []*SoyDocParamNode
170 | }
171 |
172 | func (n *SoyDocNode) String() string {
173 | if len(n.Params) == 0 {
174 | return "\n/** */\n"
175 | }
176 | var expr = "\n/**"
177 | for _, param := range n.Params {
178 | expr += "\n * " + param.String()
179 | }
180 | return expr + "\n */\n"
181 | }
182 |
183 | func (n *SoyDocNode) Children() []Node {
184 | var nodes []Node
185 | for _, param := range n.Params {
186 | nodes = append(nodes, param)
187 | }
188 | return nodes
189 | }
190 |
191 | // SoyDocParam represents a parameter to a Soy template.
192 | // e.g.
193 | // /**
194 | // * Says hello to the person
195 | // * @param name The name of the person to say hello to.
196 | // */
197 | type SoyDocParamNode struct {
198 | Pos
199 | Name string // e.g. "name"
200 | Optional bool
201 | }
202 |
203 | func (n *SoyDocParamNode) String() string {
204 | var expr = "@param"
205 | if n.Optional {
206 | expr += "?"
207 | }
208 | return expr + " " + n.Name
209 | }
210 |
211 | type PrintNode struct {
212 | Pos
213 | Arg Node
214 | Directives []*PrintDirectiveNode
215 | }
216 |
217 | func (n *PrintNode) String() string {
218 | var expr = "{" + n.Arg.String()
219 | for _, d := range n.Directives {
220 | expr += d.String()
221 | }
222 | return expr + "}"
223 | }
224 |
225 | func (n *PrintNode) Children() []Node {
226 | var nodes = []Node{n.Arg}
227 | for _, child := range n.Directives {
228 | nodes = append(nodes, child)
229 | }
230 | return nodes
231 | }
232 |
233 | type PrintDirectiveNode struct {
234 | Pos
235 | Name string
236 | Args []Node
237 | }
238 |
239 | func (n *PrintDirectiveNode) String() string {
240 | if len(n.Args) == 0 {
241 | return "|" + n.Name
242 | }
243 | var expr = "|" + n.Name + ":"
244 | var first = true
245 | for _, arg := range n.Args {
246 | if !first {
247 | expr += ","
248 | }
249 | expr += arg.String()
250 | first = false
251 | }
252 | return expr
253 | }
254 |
255 | func (n *PrintDirectiveNode) Children() []Node {
256 | return n.Args
257 | }
258 |
259 | type LiteralNode struct {
260 | Pos
261 | Body string
262 | }
263 |
264 | func (n *LiteralNode) String() string {
265 | return "{literal}" + n.Body + "{/literal}"
266 | }
267 |
268 | type CssNode struct {
269 | Pos
270 | Expr Node
271 | Suffix string
272 | }
273 |
274 | func (n *CssNode) String() string {
275 | var expr = "{css "
276 | if n.Expr != nil {
277 | expr += n.Expr.String() + ", "
278 | }
279 | return expr + n.Suffix + "}"
280 | }
281 |
282 | func (n *CssNode) Children() []Node {
283 | return []Node{n.Expr}
284 | }
285 |
286 | type LogNode struct {
287 | Pos
288 | Body Node
289 | }
290 |
291 | func (n *LogNode) String() string {
292 | return "{log}" + n.Body.String() + "{/log}"
293 | }
294 |
295 | func (n *LogNode) Children() []Node {
296 | return []Node{n.Body}
297 | }
298 |
299 | type DebuggerNode struct {
300 | Pos
301 | }
302 |
303 | func (n *DebuggerNode) String() string {
304 | return "{debugger}"
305 | }
306 |
307 | type LetValueNode struct {
308 | Pos
309 | Name string
310 | Expr Node
311 | }
312 |
313 | func (n *LetValueNode) String() string {
314 | return fmt.Sprintf("{let $%s: %s /}", n.Name, n.Expr)
315 | }
316 |
317 | func (n *LetValueNode) Children() []Node {
318 | return []Node{n.Expr}
319 | }
320 |
321 | type LetContentNode struct {
322 | Pos
323 | Name string
324 | Body Node
325 | }
326 |
327 | func (n *LetContentNode) String() string {
328 | return fmt.Sprintf("{let $%s}%s{/let}", n.Name, n.Body)
329 | }
330 |
331 | func (n *LetContentNode) Children() []Node {
332 | return []Node{n.Body}
333 | }
334 |
335 | type IdentNode struct {
336 | Pos
337 | Ident string // The ident's name.
338 | }
339 |
340 | func (i *IdentNode) String() string {
341 | return i.Ident
342 | }
343 |
344 | // MsgNode represents a localized message.
345 | type MsgNode struct {
346 | Pos
347 | ID uint64
348 | Meaning string
349 | Desc string
350 | Body ParentNode // top-level children: RawTextNode, MsgPlaceholderNode, MsgPluralNode
351 | }
352 |
353 | func (n *MsgNode) String() string {
354 | var meaning = " "
355 | if n.Meaning != "" {
356 | meaning = fmt.Sprintf(" meaning=%q ", n.Meaning)
357 | }
358 | return fmt.Sprintf("{msg%sdesc=%q}%s{/msg}", meaning, n.Desc, n.Body.String())
359 | }
360 |
361 | func (n *MsgNode) Children() []Node {
362 | return n.Body.Children()
363 | }
364 |
365 | // Placeholder returns a placeholder node with the given name within this
366 | // message node. It requires placeholder names to have been calculated.
367 | func (n *MsgNode) Placeholder(name string) *MsgPlaceholderNode {
368 | var q = n.Body.Children()
369 | for len(q) > 0 {
370 | var node Node
371 | node, q = q[0], q[1:]
372 | switch node := node.(type) {
373 | case *MsgPlaceholderNode:
374 | if node.Name == name {
375 | return node
376 | }
377 | default:
378 | if node, ok := node.(ParentNode); ok {
379 | q = append(q, node.Children()...)
380 | }
381 | }
382 | }
383 | return nil
384 | }
385 |
386 | type MsgPlaceholderNode struct {
387 | Pos
388 | Name string
389 | Body Node
390 | }
391 |
392 | func (n *MsgPlaceholderNode) String() string {
393 | return n.Body.String()
394 | }
395 |
396 | func (n *MsgPlaceholderNode) Children() []Node {
397 | return []Node{n.Body}
398 | }
399 |
400 | type MsgHtmlTagNode struct {
401 | Pos
402 | Text []byte
403 | }
404 |
405 | func (n *MsgHtmlTagNode) String() string {
406 | return string(n.Text)
407 | }
408 |
409 | type MsgPluralNode struct {
410 | Pos
411 | VarName string
412 | Value Node
413 | Cases []*MsgPluralCaseNode
414 | Default ParentNode
415 | }
416 |
417 | func (n *MsgPluralNode) String() string {
418 | var expr = "{plural " + n.Value.String() + "}"
419 | for _, caseNode := range n.Cases {
420 | expr += caseNode.String()
421 | }
422 | expr += "{default}" + n.Default.String()
423 | return expr + "{/plural}"
424 | }
425 |
426 | func (n *MsgPluralNode) Children() []Node {
427 | var children []Node
428 | children = append(children, n.Value)
429 | for _, plCase := range n.Cases {
430 | children = append(children, plCase)
431 | }
432 | children = append(children, n.Default)
433 | return children
434 | }
435 |
436 | type MsgPluralCaseNode struct {
437 | Pos
438 | Value int
439 | Body ParentNode // top level children: RawTextNode, MsgPlaceholderNode
440 | }
441 |
442 | func (n *MsgPluralCaseNode) String() string {
443 | return "{case " + strconv.Itoa(n.Value) + "}" + n.Body.String()
444 | }
445 |
446 | func (n *MsgPluralCaseNode) Children() []Node {
447 | return []Node{n.Body}
448 | }
449 |
450 | type CallNode struct {
451 | Pos
452 | Name string
453 | AllData bool
454 | Data Node
455 | Params []Node
456 | }
457 |
458 | func (n *CallNode) String() string {
459 | var expr = fmt.Sprintf("{call %s", n.Name)
460 | if n.AllData {
461 | expr += ` data="all"`
462 | } else if n.Data != nil {
463 | expr += fmt.Sprintf(` data="%s"`, n.Data.String())
464 | }
465 | if n.Params == nil {
466 | return expr + "/}"
467 | }
468 | expr += "}"
469 | for _, param := range n.Params {
470 | expr += param.String()
471 | }
472 | return expr + "{/call}"
473 | }
474 |
475 | func (n *CallNode) Children() []Node {
476 | var nodes []Node
477 | nodes = append(nodes, n.Data)
478 | for _, child := range n.Params {
479 | nodes = append(nodes, child)
480 | }
481 | return nodes
482 | }
483 |
484 | type CallParamValueNode struct {
485 | Pos
486 | Key string
487 | Value Node
488 | }
489 |
490 | func (n *CallParamValueNode) String() string {
491 | return fmt.Sprintf("{param %s: %s/}", n.Key, n.Value.String())
492 | }
493 |
494 | func (n *CallParamValueNode) Children() []Node {
495 | return []Node{n.Value}
496 | }
497 |
498 | type CallParamContentNode struct {
499 | Pos
500 | Key string
501 | Content Node
502 | }
503 |
504 | func (n *CallParamContentNode) String() string {
505 | return fmt.Sprintf("{param %s}%s{/param}", n.Key, n.Content.String())
506 | }
507 |
508 | func (n *CallParamContentNode) Children() []Node {
509 | return []Node{n.Content}
510 | }
511 |
512 | // Control flow ----------
513 |
514 | type IfNode struct {
515 | Pos
516 | Conds []*IfCondNode
517 | }
518 |
519 | func (n *IfNode) String() string {
520 | var expr string
521 | for i, cond := range n.Conds {
522 | if i == 0 {
523 | expr += "{if "
524 | } else if cond.Cond == nil {
525 | expr += "{else}"
526 | } else {
527 | expr += "{elseif "
528 | }
529 | expr += cond.String()
530 | }
531 | return expr + "{/if}"
532 | }
533 |
534 | func (n *IfNode) Children() []Node {
535 | var nodes []Node
536 | for _, child := range n.Conds {
537 | nodes = append(nodes, child)
538 | }
539 | return nodes
540 | }
541 |
542 | type IfCondNode struct {
543 | Pos
544 | Cond Node // nil if "else"
545 | Body Node
546 | }
547 |
548 | func (n *IfCondNode) String() string {
549 | var expr string
550 | if n.Cond != nil {
551 | expr = n.Cond.String() + "}"
552 | }
553 | return expr + n.Body.String()
554 | }
555 |
556 | func (n *IfCondNode) Children() []Node {
557 | return []Node{n.Cond, n.Body}
558 | }
559 |
560 | type SwitchNode struct {
561 | Pos
562 | Value Node
563 | Cases []*SwitchCaseNode
564 | }
565 |
566 | func (n *SwitchNode) String() string {
567 | var expr = "{switch " + n.Value.String() + "}"
568 | for _, caseNode := range n.Cases {
569 | expr += caseNode.String()
570 | }
571 | return expr + "{/switch}"
572 | }
573 |
574 | func (n *SwitchNode) Children() []Node {
575 | var nodes = []Node{n.Value}
576 | for _, child := range n.Cases {
577 | nodes = append(nodes, child)
578 | }
579 | return nodes
580 | }
581 |
582 | type SwitchCaseNode struct {
583 | Pos
584 | Values []Node // len(Values) == 0 => default case
585 | Body Node
586 | }
587 |
588 | func (n *SwitchCaseNode) String() string {
589 | var expr = "{case "
590 | for i, val := range n.Values {
591 | if i > 0 {
592 | expr += ","
593 | }
594 | expr += val.String()
595 | }
596 | return expr + "}" + n.Body.String()
597 | }
598 |
599 | func (n *SwitchCaseNode) Children() []Node {
600 | var nodes = []Node{n.Body}
601 | for _, child := range n.Values {
602 | nodes = append(nodes, child)
603 | }
604 | return nodes
605 | }
606 |
607 | // Note:
608 | // - "For" node may have anything as the List
609 | // - "Foreach" node is required to have a DataRefNode as the List
610 | //
611 | // There is no difference in the parsed representation between them.
612 | // We do not bother to maintain that, because it's not important, as
613 | // upstream has dropped support for {foreach} entirely, so we treat
614 | // this as deprecated.
615 | type ForNode struct {
616 | Pos
617 | Var string // without the leading $
618 | List Node
619 | Body Node
620 | IfEmpty Node
621 | }
622 |
623 | func (n *ForNode) String() string {
624 | var expr = "{for "
625 | expr += "$" + n.Var + " in " + n.List.String() + "}" + n.Body.String()
626 | if n.IfEmpty != nil {
627 | expr += "{ifempty}" + n.IfEmpty.String()
628 | }
629 | return expr + "{/for}"
630 | }
631 |
632 | func (n *ForNode) Children() []Node {
633 | var children = make([]Node, 2, 3)
634 | children[0] = n.List
635 | children[1] = n.Body
636 | if n.IfEmpty != nil {
637 | children = append(children, n.IfEmpty)
638 | }
639 | return children
640 | }
641 |
642 | // Values ----------
643 |
644 | type NullNode struct {
645 | Pos
646 | }
647 |
648 | func (s *NullNode) String() string {
649 | return "null"
650 | }
651 |
652 | type BoolNode struct {
653 | Pos
654 | True bool
655 | }
656 |
657 | func (b *BoolNode) String() string {
658 | if b.True {
659 | return "true"
660 | }
661 | return "false"
662 | }
663 |
664 | type IntNode struct {
665 | Pos
666 | Value int64
667 | }
668 |
669 | func (n *IntNode) String() string {
670 | return strconv.FormatInt(n.Value, 10)
671 | }
672 |
673 | type FloatNode struct {
674 | Pos
675 | Value float64
676 | }
677 |
678 | func (n *FloatNode) String() string {
679 | return strconv.FormatFloat(n.Value, 'g', -1, 64)
680 | }
681 |
682 | type StringNode struct {
683 | Pos
684 | Quoted string // e.g. 'hello\tworld'
685 | Value string // e.g. hello world
686 | }
687 |
688 | func (s *StringNode) String() string {
689 | return s.Quoted
690 | }
691 |
692 | type GlobalNode struct {
693 | Pos
694 | Name string
695 | data.Value
696 | }
697 |
698 | func (n *GlobalNode) String() string {
699 | return n.Name
700 | }
701 |
702 | type FunctionNode struct {
703 | Pos
704 | Name string
705 | Args []Node
706 | }
707 |
708 | func (n *FunctionNode) String() string {
709 | var expr = n.Name + "("
710 | for i, arg := range n.Args {
711 | if i > 0 {
712 | expr += ","
713 | }
714 | expr += arg.String()
715 | }
716 | return expr + ")"
717 | }
718 |
719 | func (n *FunctionNode) Children() []Node {
720 | return n.Args
721 | }
722 |
723 | type ListLiteralNode struct {
724 | Pos
725 | Items []Node
726 | }
727 |
728 | func (n *ListLiteralNode) String() string {
729 | var expr = "["
730 | for i, item := range n.Items {
731 | if i > 0 {
732 | expr += ", "
733 | }
734 | expr += item.String()
735 | }
736 | return expr + "]"
737 | }
738 |
739 | func (n *ListLiteralNode) Children() []Node {
740 | return n.Items
741 | }
742 |
743 | type MapLiteralNode struct {
744 | Pos
745 | Items map[string]Node
746 | }
747 |
748 | func (n *MapLiteralNode) String() string {
749 | if len(n.Items) == 0 {
750 | return "[:]"
751 | }
752 | var expr = "["
753 | var first = true
754 | for k, v := range n.Items {
755 | if !first {
756 | expr += ", "
757 | }
758 | expr += fmt.Sprintf("'%s': %s", k, v.String())
759 | first = false
760 | }
761 | return expr + "]"
762 | }
763 |
764 | func (n *MapLiteralNode) Children() []Node {
765 | var nodes []Node
766 | for _, v := range n.Items {
767 | nodes = append(nodes, v)
768 | }
769 | return nodes
770 | }
771 |
772 | // Data References ----------
773 |
774 | type DataRefNode struct {
775 | Pos
776 | Key string
777 | Access []Node
778 | }
779 |
780 | func (n *DataRefNode) String() string {
781 | var expr = "$" + n.Key
782 | for _, access := range n.Access {
783 | expr += access.String()
784 | }
785 | return expr
786 | }
787 |
788 | func (n *DataRefNode) Children() []Node {
789 | return n.Access
790 | }
791 |
792 | type DataRefIndexNode struct {
793 | Pos
794 | NullSafe bool
795 | Index int
796 | }
797 |
798 | func (n *DataRefIndexNode) String() string {
799 | var expr = "."
800 | if n.NullSafe {
801 | expr = "?" + expr
802 | }
803 | return expr + strconv.Itoa(n.Index)
804 | }
805 |
806 | type DataRefExprNode struct {
807 | Pos
808 | NullSafe bool
809 | Arg Node
810 | }
811 |
812 | func (n *DataRefExprNode) String() string {
813 | var expr = "["
814 | if n.NullSafe {
815 | expr = "?" + expr
816 | }
817 | return expr + n.Arg.String() + "]"
818 | }
819 |
820 | func (n *DataRefExprNode) Children() []Node {
821 | return []Node{n.Arg}
822 | }
823 |
824 | type DataRefKeyNode struct {
825 | Pos
826 | NullSafe bool
827 | Key string
828 | }
829 |
830 | func (n *DataRefKeyNode) String() string {
831 | var expr = "."
832 | if n.NullSafe {
833 | expr = "?" + expr
834 | }
835 | return expr + n.Key
836 | }
837 |
838 | // Operators ----------
839 |
840 | type NotNode struct {
841 | Pos
842 | Arg Node
843 | }
844 |
845 | func (n *NotNode) String() string {
846 | return "not " + n.Arg.String()
847 | }
848 |
849 | func (n *NotNode) Children() []Node {
850 | return []Node{n.Arg}
851 | }
852 |
853 | type NegateNode struct {
854 | Pos
855 | Arg Node
856 | }
857 |
858 | func (n *NegateNode) String() string {
859 | return "-" + n.Arg.String()
860 | }
861 |
862 | func (n *NegateNode) Children() []Node {
863 | return []Node{n.Arg}
864 | }
865 |
866 | type BinaryOpNode struct {
867 | Name string
868 | Pos
869 | Arg1, Arg2 Node
870 | }
871 |
872 | func (n *BinaryOpNode) String() string {
873 | return n.Arg1.String() + " " + n.Name + " " + n.Arg2.String()
874 | }
875 |
876 | func (n *BinaryOpNode) Children() []Node {
877 | return []Node{n.Arg1, n.Arg2}
878 | }
879 |
880 | type (
881 | MulNode struct{ BinaryOpNode }
882 | DivNode struct{ BinaryOpNode }
883 | ModNode struct{ BinaryOpNode }
884 | AddNode struct{ BinaryOpNode }
885 | SubNode struct{ BinaryOpNode }
886 | EqNode struct{ BinaryOpNode }
887 | NotEqNode struct{ BinaryOpNode }
888 | GtNode struct{ BinaryOpNode }
889 | GteNode struct{ BinaryOpNode }
890 | LtNode struct{ BinaryOpNode }
891 | LteNode struct{ BinaryOpNode }
892 | OrNode struct{ BinaryOpNode }
893 | AndNode struct{ BinaryOpNode }
894 | ElvisNode struct{ BinaryOpNode }
895 | )
896 |
897 | type TernNode struct {
898 | Pos
899 | Arg1, Arg2, Arg3 Node
900 | }
901 |
902 | func (n *TernNode) String() string {
903 | return n.Arg1.String() + "?" + n.Arg2.String() + ":" + n.Arg3.String()
904 | }
905 |
906 | func (n *TernNode) Children() []Node {
907 | return []Node{n.Arg1, n.Arg2, n.Arg3}
908 | }
909 |
--------------------------------------------------------------------------------
/ast/node_test.go:
--------------------------------------------------------------------------------
1 | package ast
2 |
3 | import "testing"
4 |
5 | // "[:]"
6 | // "['aaa': 'blah', 'bbb': 123, $boo: $foo]"
7 | func TestMapLiteralNode(t *testing.T) {}
8 |
9 | // "[]"
10 | // "['blah', 123, $foo]"
11 | func TestListLiteralNode(t *testing.T) {}
12 |
--------------------------------------------------------------------------------
/bundle.go:
--------------------------------------------------------------------------------
1 | package soy
2 |
3 | import (
4 | "fmt"
5 | "io/ioutil"
6 | "log"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | "time"
11 |
12 | "github.com/fsnotify/fsnotify"
13 | "github.com/robfig/soy/data"
14 | "github.com/robfig/soy/parse"
15 | "github.com/robfig/soy/parsepasses"
16 | "github.com/robfig/soy/soyhtml"
17 | "github.com/robfig/soy/template"
18 | )
19 |
20 | // Logger is used to print notifications and compile errors when using the
21 | // "WatchFiles" feature.
22 | var Logger = log.New(os.Stderr, "[soy] ", 0)
23 |
24 | type soyFile struct{ name, content string }
25 |
26 | // Bundle is a collection of Soy content (templates and globals). It acts as
27 | // input for the Soy compiler.
28 | type Bundle struct {
29 | files []soyFile
30 | globals data.Map
31 | err error
32 | watcher *fsnotify.Watcher
33 | parsepasses []func(template.Registry) error
34 | recompilationCallback func(*template.Registry)
35 | }
36 |
37 | // NewBundle returns an empty bundle.
38 | func NewBundle() *Bundle {
39 | return &Bundle{globals: make(data.Map)}
40 | }
41 |
42 | // WatchFiles tells Soy to watch any template files added to this bundle,
43 | // re-compile as necessary, and propagate the updates to your tofu. It should
44 | // be called once, before adding any files.
45 | func (b *Bundle) WatchFiles(watch bool) *Bundle {
46 | if watch && b.err == nil && b.watcher == nil {
47 | b.watcher, b.err = fsnotify.NewWatcher()
48 | }
49 | return b
50 | }
51 |
52 | // AddTemplateDir adds all *.soy files found within the given directory
53 | // (including sub-directories) to the bundle.
54 | func (b *Bundle) AddTemplateDir(root string) *Bundle {
55 | var err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
56 | if err != nil {
57 | return err
58 | }
59 | if info.IsDir() {
60 | return nil
61 | }
62 | if !strings.HasSuffix(path, ".soy") {
63 | return nil
64 | }
65 | b.AddTemplateFile(path)
66 | return nil
67 | })
68 | if err != nil {
69 | b.err = err
70 | }
71 | return b
72 | }
73 |
74 | // AddTemplateFile adds the given Soy template file text to this bundle.
75 | // If WatchFiles is on, it will be subsequently watched for updates.
76 | func (b *Bundle) AddTemplateFile(filename string) *Bundle {
77 | content, err := ioutil.ReadFile(filename)
78 | if err != nil {
79 | b.err = err
80 | }
81 | if b.err == nil && b.watcher != nil {
82 | b.err = b.watcher.Add(filename)
83 | }
84 | return b.AddTemplateString(filename, string(content))
85 | }
86 |
87 | // AddTemplateString adds the given template to the bundle. The name is only
88 | // used for error messages - it does not need to be provided nor does it need to
89 | // be a real filename.
90 | func (b *Bundle) AddTemplateString(filename, soyfile string) *Bundle {
91 | b.files = append(b.files, soyFile{filename, soyfile})
92 | return b
93 | }
94 |
95 | // AddGlobalsFile opens and parses the given filename for Soy globals, and adds
96 | // the resulting data map to the bundle.
97 | func (b *Bundle) AddGlobalsFile(filename string) *Bundle {
98 | var f, err = os.Open(filename)
99 | if err != nil {
100 | b.err = err
101 | return b
102 | }
103 | globals, err := ParseGlobals(f)
104 | if err != nil {
105 | b.err = err
106 | }
107 | f.Close()
108 | return b.AddGlobalsMap(globals)
109 | }
110 |
111 | func (b *Bundle) AddGlobalsMap(globals data.Map) *Bundle {
112 | for k, v := range globals {
113 | if existing, ok := b.globals[k]; ok {
114 | b.err = fmt.Errorf("global %q already defined as %q", k, existing)
115 | return b
116 | }
117 | b.globals[k] = v
118 | }
119 | return b
120 | }
121 |
122 | // SetRecompilationCallback assigns the bundle a function to call after
123 | // recompilation. This is called before updating the in-use registry.
124 | func (b *Bundle) SetRecompilationCallback(c func(*template.Registry)) *Bundle {
125 | b.recompilationCallback = c
126 | return b
127 | }
128 |
129 | // AddParsePass adds a function to the bundle that will be called
130 | // after the Soy is parsed.
131 | func (b *Bundle) AddParsePass(f func(template.Registry) error) *Bundle {
132 | b.parsepasses = append(b.parsepasses, f)
133 | return b
134 | }
135 |
136 | // Compile parses all of the Soy files in this bundle, verifies a number of
137 | // rules about data references, and returns the completed template registry.
138 | func (b *Bundle) Compile() (*template.Registry, error) {
139 | if b.err != nil {
140 | return nil, b.err
141 | }
142 |
143 | // Compile all the Soy (globals are already parsed).
144 | var registry = template.Registry{}
145 | for _, soyfile := range b.files {
146 | var tree, err = parse.SoyFile(soyfile.name, soyfile.content)
147 | if err != nil {
148 | return nil, err
149 | }
150 | if err = registry.Add(tree); err != nil {
151 | return nil, err
152 | }
153 | }
154 |
155 | // Apply the post-parse processing
156 | for _, parsepass := range b.parsepasses {
157 | if err := parsepass(registry); err != nil {
158 | return nil, err
159 | }
160 | }
161 | if err := parsepasses.CheckDataRefs(registry); err != nil {
162 | return nil, err
163 | }
164 | if err := parsepasses.SetGlobals(registry, b.globals); err != nil {
165 | return nil, err
166 | }
167 | parsepasses.ProcessMessages(registry)
168 |
169 | if b.watcher != nil {
170 | go b.recompiler(®istry)
171 | }
172 | return ®istry, nil
173 | }
174 |
175 | // CompileToTofu returns a soyhtml.Tofu object that allows you to render soy
176 | // templates to HTML.
177 | func (b *Bundle) CompileToTofu() (*soyhtml.Tofu, error) {
178 | var registry, err = b.Compile()
179 | // TODO: Verify all used funcs exist and have the right # args.
180 | return soyhtml.NewTofu(registry), err
181 | }
182 |
183 | func (b *Bundle) recompiler(reg *template.Registry) {
184 | for {
185 | select {
186 | case ev := <-b.watcher.Events:
187 | // If it's a rename, then fsnotify has removed the watch.
188 | // Add it back, after a delay.
189 | if ev.Op == fsnotify.Rename || ev.Op == fsnotify.Remove {
190 | time.Sleep(10 * time.Millisecond)
191 | if err := b.watcher.Add(ev.Name); err != nil {
192 | Logger.Println(err)
193 | }
194 | }
195 |
196 | // Recompile all the Soy.
197 | var bundle = NewBundle().
198 | AddGlobalsMap(b.globals)
199 | for _, soyfile := range b.files {
200 | bundle.AddTemplateFile(soyfile.name)
201 | }
202 | var registry, err = bundle.Compile()
203 | if err != nil {
204 | Logger.Println(err)
205 | continue
206 | }
207 |
208 | if b.recompilationCallback != nil {
209 | b.recompilationCallback(registry)
210 | }
211 |
212 | // update the existing template registry.
213 | // (this is not goroutine-safe, but that seems ok for a development aid,
214 | // as long as it works in practice)
215 | *reg = *registry
216 | Logger.Printf("update successful (%v)", ev)
217 |
218 | case err := <-b.watcher.Errors:
219 | // Nothing to do with errors
220 | Logger.Println(err)
221 | }
222 | }
223 | }
224 |
--------------------------------------------------------------------------------
/data/convert.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "fmt"
5 | "reflect"
6 | "time"
7 | "unicode"
8 | "unicode/utf8"
9 | )
10 |
11 | var timeType = reflect.TypeOf(time.Time{})
12 |
13 | // New converts the given data into a Soy data value, using
14 | // DefaultStructOptions for structs.
15 | func New(value interface{}) Value {
16 | return NewWith(DefaultStructOptions, value)
17 | }
18 |
19 | // NewWith converts the given data value Soy data value, using the provided
20 | // StructOptions for any structs encountered.
21 | func NewWith(convert StructOptions, value interface{}) Value {
22 | // quick return if we're passed an existing data.Value
23 | if val, ok := value.(Value); ok {
24 | return val
25 | }
26 |
27 | if value == nil {
28 | return Null{}
29 | }
30 |
31 | // see if value implements MarshalValue
32 | if mar, ok := value.(Marshaler); ok {
33 | return mar.MarshalValue()
34 | }
35 |
36 | // drill through pointers and interfaces to the underlying type
37 | var v = reflect.ValueOf(value)
38 | for v.Kind() == reflect.Interface || v.Kind() == reflect.Ptr {
39 | v = v.Elem()
40 | }
41 | if !v.IsValid() {
42 | return Null{}
43 | }
44 |
45 | if v.Type() == timeType {
46 | return String(v.Interface().(time.Time).Format(convert.TimeFormat))
47 | }
48 |
49 | switch v.Kind() {
50 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
51 | return Int(v.Int())
52 | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
53 | return Int(v.Uint())
54 | case reflect.Float32, reflect.Float64:
55 | return Float(v.Float())
56 | case reflect.Bool:
57 | return Bool(v.Bool())
58 | case reflect.String:
59 | return String(v.String())
60 | case reflect.Slice:
61 | if v.IsNil() {
62 | return List(nil)
63 | }
64 | slice := make(List, v.Len())
65 | for i := range slice {
66 | slice[i] = NewWith(convert, v.Index(i).Interface())
67 | }
68 | return slice
69 | case reflect.Map:
70 | var keys = v.MapKeys()
71 | var m = make(Map, len(keys))
72 | for _, key := range keys {
73 | if key.Kind() != reflect.String {
74 | panic("map keys must be strings")
75 | }
76 | m[key.String()] = NewWith(convert, v.MapIndex(key).Interface())
77 | }
78 | return m
79 | case reflect.Struct:
80 | return convert.Data(v.Interface())
81 | default:
82 | panic(fmt.Errorf("unexpected data type: %T (%v)", value, value))
83 | }
84 | }
85 |
86 | var DefaultStructOptions = StructOptions{
87 | LowerCamel: true,
88 | TimeFormat: time.RFC3339,
89 | }
90 |
91 | // StructOptions provides flexibility in conversion of structs to soy's
92 | // data.Map format.
93 | type StructOptions struct {
94 | LowerCamel bool // if true, convert field names to lowerCamel.
95 | TimeFormat string // format string for time.Time. (if empty, use ISO-8601)
96 | }
97 |
98 | func (c StructOptions) Data(obj interface{}) Map {
99 | var v = reflect.ValueOf(obj)
100 | var valType = v.Type()
101 | var n = valType.NumField()
102 | var m = make(Map, n)
103 | for i := 0; i < n; i++ {
104 | if !v.Field(i).CanInterface() {
105 | continue
106 | }
107 | var key = valType.Field(i).Name
108 | if c.LowerCamel {
109 | var firstRune, size = utf8.DecodeRuneInString(key)
110 | key = string(unicode.ToLower(firstRune)) + key[size:]
111 | }
112 | m[key] = NewWith(c, v.Field(i).Interface())
113 | }
114 | return m
115 | }
116 |
117 | // Marshaler is the interface implemented by entities that can marshal
118 | // themselves into a data.Value.
119 | type Marshaler interface {
120 | MarshalValue() Value
121 | }
122 |
--------------------------------------------------------------------------------
/data/convert_test.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | "time"
7 | )
8 |
9 | type AInt struct{ A int }
10 |
11 | var jan1, _ = time.Parse(time.RFC3339, "2014-01-01T00:00:00Z")
12 |
13 | func TestNew(t *testing.T) {
14 | tests := []struct{ input, expected interface{} }{
15 | // basic types
16 | {nil, Null{}},
17 | {true, Bool(true)},
18 | {int(0), Int(0)},
19 | {int64(0), Int(0)},
20 | {uint32(0), Int(0)},
21 | {float32(0), Float(0)},
22 | {"", String("")},
23 | {[]bool(nil), List(nil)},
24 | {[]bool{}, List{}},
25 | {[]string{"a"}, List{String("a")}},
26 | {[]interface{}{"a"}, List{String("a")}},
27 | {map[string]string{}, Map{}},
28 | {map[string]string{"a": "b"}, Map{"a": String("b")}},
29 | {map[string]interface{}{"a": nil}, Map{"a": Null{}}},
30 | {map[string]interface{}{"a": []int{1}}, Map{"a": List{Int(1)}}},
31 |
32 | // type aliases
33 | {[]Int{5}, List{Int(5)}},
34 | {map[string]Value{"a": List{Int(1)}}, Map{"a": List{Int(1)}}},
35 | {Map{"foo": Null{}}, Map{"foo": Null{}}},
36 |
37 | // pointers
38 | {pInt(5), Int(5)},
39 | {&jan1, String(jan1.Format(time.RFC3339))},
40 |
41 | // structs with all of the above, and unexported fields.
42 | // also, structs have their fields lowerCamel and Time's default formatting.
43 | {struct {
44 | A Int
45 | L List
46 | PI *int
47 | no Int
48 | T time.Time
49 | }{Int(5), List{}, pInt(2), 5, jan1},
50 | Map{"a": Int(5), "l": List{}, "pI": Int(2), "t": String(jan1.Format(time.RFC3339))}},
51 | {[]*struct {
52 | PI *AInt
53 | }{{nil}},
54 | List{Map{"pI": Null{}}}},
55 | {testIDURL{1, "https://github.com/robfig/soy"},
56 | Map{"iD": Int(1), "uRL": String("https://github.com/robfig/soy")}},
57 | {testIDURLMarshaler{1, "https://github.com/robfig/soy"},
58 | Map{"id": Int(1), "url": String("https://github.com/robfig/soy")}},
59 | }
60 |
61 | for _, test := range tests {
62 | output := New(test.input)
63 | if !reflect.DeepEqual(test.expected, output) {
64 | t.Errorf("%#v =>\n %#v, expected:\n%#v", test.input, output, test.expected)
65 | }
66 | }
67 | }
68 |
69 | type testIDURL struct {
70 | ID int
71 | URL string
72 | }
73 |
74 | type testIDURLMarshaler testIDURL
75 |
76 | func (t testIDURLMarshaler) MarshalValue() Value {
77 | return Map{
78 | "id": New(t.ID),
79 | "url": New(t.URL),
80 | }
81 | }
82 |
83 | func TestStructOptions(t *testing.T) {
84 | var testStruct = struct {
85 | CaseFormat int
86 | Time time.Time
87 | unexported int
88 | Nested struct {
89 | CaseFormat *bool
90 | Time *time.Time
91 | }
92 | NestedSlice []interface{}
93 | NestedMap map[string]interface{}
94 | }{
95 | CaseFormat: 5,
96 | Time: jan1,
97 | NestedSlice: []interface{}{
98 | "a",
99 | 2,
100 | DefaultStructOptions,
101 | true,
102 | nil,
103 | 5.0,
104 | []uint8{1, 2, 3},
105 | []string{"a", "b", "c"},
106 | map[string]interface{}{
107 | "foo": 1,
108 | "bar": 2,
109 | "baz": 3,
110 | },
111 | },
112 | NestedMap: map[string]interface{}{
113 | "string": "a",
114 | "int": 1,
115 | "float": 5.0,
116 | "nil": nil,
117 | "slice": []*int{pInt(1), pInt(2), pInt(3)},
118 | "Struct": DefaultStructOptions,
119 | },
120 | }
121 |
122 | tests := []struct {
123 | input interface{}
124 | convert StructOptions
125 | expected Map
126 | }{
127 | {testStruct, DefaultStructOptions, Map{
128 | "caseFormat": Int(5),
129 | "time": String(jan1.Format(time.RFC3339)),
130 | "nested": Map{
131 | "caseFormat": Null{},
132 | "time": Null{},
133 | },
134 | "nestedSlice": List{
135 | String("a"),
136 | Int(2),
137 | Map{
138 | "lowerCamel": Bool(true),
139 | "timeFormat": String(time.RFC3339),
140 | },
141 | Bool(true),
142 | Null{},
143 | Float(5.),
144 | List{Int(1), Int(2), Int(3)},
145 | List{String("a"), String("b"), String("c")},
146 | Map{"foo": Int(1), "bar": Int(2), "baz": Int(3)},
147 | },
148 | "nestedMap": Map{
149 | "string": String("a"),
150 | "int": Int(1),
151 | "float": Float(5.0),
152 | "nil": Null{},
153 | "slice": List{Int(1), Int(2), Int(3)},
154 | "Struct": Map{
155 | "lowerCamel": Bool(true),
156 | "timeFormat": String(time.RFC3339),
157 | }},
158 | }},
159 |
160 | {testStruct, StructOptions{false, time.Stamp}, Map{
161 | "CaseFormat": Int(5),
162 | "Time": String(jan1.Format(time.Stamp)),
163 | "Nested": Map{
164 | "CaseFormat": Null{},
165 | "Time": Null{},
166 | },
167 | "NestedSlice": List{
168 | String("a"),
169 | Int(2),
170 | Map{
171 | "LowerCamel": Bool(true),
172 | "TimeFormat": String(time.RFC3339),
173 | },
174 | Bool(true),
175 | Null{},
176 | Float(5.),
177 | List{Int(1), Int(2), Int(3)},
178 | List{String("a"), String("b"), String("c")},
179 | Map{"foo": Int(1), "bar": Int(2), "baz": Int(3)},
180 | },
181 | "NestedMap": Map{
182 | "string": String("a"),
183 | "int": Int(1),
184 | "float": Float(5.0),
185 | "nil": Null{},
186 | "slice": List{Int(1), Int(2), Int(3)},
187 | "Struct": Map{
188 | "LowerCamel": Bool(true),
189 | "TimeFormat": String(time.RFC3339),
190 | }},
191 | }},
192 | }
193 |
194 | for _, test := range tests {
195 | output := test.convert.Data(test.input)
196 | if !reflect.DeepEqual(test.expected, output) {
197 | t.Errorf("%#v =>\n%#v, expected:\n%#v", test.input, output, test.expected)
198 | }
199 | }
200 | }
201 |
202 | func BenchmarkStructOptions(b *testing.B) {
203 | var testStruct = struct {
204 | CaseFormat int
205 | Time time.Time
206 | unexported int
207 | Nested struct {
208 | CaseFormat *bool
209 | Time *time.Time
210 | }
211 | NestedSlice []interface{}
212 | NestedMap map[string]interface{}
213 | }{
214 | CaseFormat: 5,
215 | Time: jan1,
216 | NestedSlice: []interface{}{
217 | "a",
218 | 2,
219 | DefaultStructOptions,
220 | true,
221 | nil,
222 | 5.0,
223 | []uint8{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
224 | []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"},
225 | map[string]interface{}{
226 | "foo": 1,
227 | "bar": 2,
228 | "baz": 3,
229 | "boo": 4,
230 | "poo": 5,
231 | },
232 | },
233 | NestedMap: map[string]interface{}{
234 | "string": "a",
235 | "int": 1,
236 | "float": 5.0,
237 | "nil": nil,
238 | "slice": []*int{pInt(1), pInt(2), pInt(3)},
239 | "Struct": DefaultStructOptions,
240 | },
241 | }
242 |
243 | for i := 0; i < b.N; i++ {
244 | var output = NewWith(DefaultStructOptions, testStruct).(Map)
245 | if len(output) != 5 {
246 | b.Errorf("unexpected output")
247 | }
248 | }
249 | }
250 |
251 | func pInt(i int) *int {
252 | return &i
253 | }
254 |
--------------------------------------------------------------------------------
/data/value.go:
--------------------------------------------------------------------------------
1 | // Package data contains the definitions for the Soy data types.
2 | package data
3 |
4 | import (
5 | "math"
6 | "reflect"
7 | "sort"
8 | "strconv"
9 | "strings"
10 | )
11 |
12 | // Value represents a Soy data value, which may be one of the enumerated types.
13 | type Value interface {
14 | // Truthy returns true according to the Soy definition of truthy and falsy values.
15 | Truthy() bool
16 |
17 | // String formats this value for display in a template.
18 | String() string
19 |
20 | // Equals returns true if the two values are equal. Specifically, if:
21 | // - They are comparable: they have the same Type, or they are Int and Float
22 | // - (Primitives) They have the same value
23 | // - (Lists, Maps) They are the same instance
24 | // Uncomparable types and unequal values return false.
25 | Equals(other Value) bool
26 | }
27 |
28 | type (
29 | Undefined struct{}
30 | Null struct{}
31 | Bool bool
32 | Int int64
33 | Float float64
34 | String string
35 | List []Value
36 | Map map[string]Value
37 | )
38 |
39 | // Index retrieves a value from this list, or Undefined if out of bounds.
40 | func (v List) Index(i int) Value {
41 | if !(0 <= i && i < len(v)) {
42 | return Undefined{}
43 | }
44 | return v[i]
45 | }
46 |
47 | // Key retrieves a value under the named key, or Undefined if it doesn't exist.
48 | func (v Map) Key(k string) Value {
49 | var result, ok = v[k]
50 | if !ok {
51 | return Undefined{}
52 | }
53 | return result
54 | }
55 |
56 | // Marshal ---------
57 |
58 | func (v Undefined) MarshalJSON() ([]byte, error) { return []byte("null"), nil }
59 | func (v Null) MarshalJSON() ([]byte, error) { return []byte("null"), nil }
60 |
61 | // Truthy ----------
62 |
63 | func (v Undefined) Truthy() bool { return false }
64 | func (v Null) Truthy() bool { return false }
65 | func (v Bool) Truthy() bool { return bool(v) }
66 | func (v Int) Truthy() bool { return v != 0 }
67 | func (v Float) Truthy() bool { return v != 0.0 && float64(v) != math.NaN() }
68 | func (v String) Truthy() bool { return v != "" }
69 | func (v List) Truthy() bool { return true }
70 | func (v Map) Truthy() bool { return true }
71 |
72 | // String ----------
73 |
74 | func (v Undefined) String() string { panic("Attempted to coerce undefined value into a string.") }
75 | func (v Null) String() string { return "null" }
76 | func (v Bool) String() string { return strconv.FormatBool(bool(v)) }
77 | func (v Int) String() string { return strconv.FormatInt(int64(v), 10) }
78 | func (v Float) String() string { return strconv.FormatFloat(float64(v), 'g', -1, 64) }
79 | func (v String) String() string { return string(v) }
80 |
81 | func (v List) String() string {
82 | var items = make([]string, len(v))
83 | for i, item := range v {
84 | items[i] = item.String()
85 | }
86 | return "[" + strings.Join(items, ", ") + "]"
87 | }
88 |
89 | func (v Map) String() string {
90 | var items = make([]string, len(v))
91 | var i = 0
92 | for k, v := range v {
93 | var vstr string
94 | if _, ok := v.(Undefined); ok {
95 | vstr = "undefined" // have mercy
96 | } else {
97 | vstr = v.String()
98 | }
99 | items[i] = k + ": " + vstr
100 | i++
101 | }
102 | sort.Strings(items)
103 | return "{" + strings.Join(items, ", ") + "}"
104 | }
105 |
106 | // Equals ----------
107 |
108 | func (v Undefined) Equals(other Value) bool {
109 | _, ok := other.(Undefined)
110 | return ok
111 | }
112 |
113 | func (v Null) Equals(other Value) bool {
114 | _, ok := other.(Null)
115 | return ok
116 | }
117 |
118 | func (v Bool) Equals(other Value) bool {
119 | if o, ok := other.(Bool); ok {
120 | return bool(v) == bool(o)
121 | }
122 | return false
123 | }
124 |
125 | func (v String) Equals(other Value) bool {
126 | if o, ok := other.(String); ok {
127 | return string(v) == string(o)
128 | }
129 | return false
130 | }
131 |
132 | func (v List) Equals(other Value) bool {
133 | if o, ok := other.(List); ok {
134 | return reflect.ValueOf(v).Pointer() == reflect.ValueOf(o).Pointer()
135 | }
136 | return false
137 | }
138 |
139 | func (v Map) Equals(other Value) bool {
140 | if o, ok := other.(Map); ok {
141 | return reflect.ValueOf(v).Pointer() == reflect.ValueOf(o).Pointer()
142 | }
143 | return false
144 | }
145 |
146 | func (v Int) Equals(other Value) bool {
147 | switch o := other.(type) {
148 | case Int:
149 | return v == o
150 | case Float:
151 | return float64(v) == float64(o)
152 | }
153 | return false
154 | }
155 |
156 | func (v Float) Equals(other Value) bool {
157 | switch o := other.(type) {
158 | case Int:
159 | return float64(v) == float64(o)
160 | case Float:
161 | return v == o
162 | }
163 | return false
164 | }
165 |
--------------------------------------------------------------------------------
/data/value_test.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "encoding/json"
5 | "reflect"
6 | "testing"
7 | )
8 |
9 | // Ensure all of the data types implement Value
10 | var (
11 | _ Value = Undefined{}
12 | _ Value = Null{}
13 | _ Value = Bool(false)
14 | _ Value = Int(0)
15 | _ Value = Float(0.0)
16 | _ Value = String("")
17 | _ Value = List{}
18 | _ Value = Map{}
19 | )
20 |
21 | // Ensure custom marshalers are implemented
22 |
23 | var (
24 | _ json.Marshaler = Undefined{}
25 | _ json.Marshaler = Null{}
26 | )
27 |
28 | func TestKey(t *testing.T) {
29 | tests := []struct {
30 | input interface{}
31 | key string
32 | expected interface{}
33 | }{
34 | {map[string]interface{}{}, "foo", Undefined{}},
35 | {map[string]interface{}{"foo": nil}, "foo", Null{}},
36 | }
37 |
38 | for _, test := range tests {
39 | actual := New(test.input).(Map).Key(test.key)
40 | if !reflect.DeepEqual(test.expected, actual) {
41 | t.Errorf("%v => %#v, expected %#v", test.input, actual, test.expected)
42 | }
43 | }
44 | }
45 |
46 | func TestIndex(t *testing.T) {
47 | tests := []struct {
48 | input interface{}
49 | index int
50 | expected interface{}
51 | }{
52 | {[]interface{}{}, 0, Undefined{}},
53 | {[]interface{}{1}, 0, Int(1)},
54 | }
55 |
56 | for _, test := range tests {
57 | actual := New(test.input).(List).Index(test.index)
58 | if !reflect.DeepEqual(test.expected, actual) {
59 | t.Errorf("%v => %#v, expected %#v", test.input, actual, test.expected)
60 | }
61 | }
62 | }
63 |
64 | func TestCustomMarhshaling(t *testing.T) {
65 | tests := []struct {
66 | input interface{}
67 | expected interface{}
68 | }{
69 | {Null{}, []byte("null")},
70 | {Undefined{}, []byte("null")},
71 | }
72 |
73 | for _, test := range tests {
74 | actual, err := json.Marshal(test.input)
75 | if err != nil {
76 | t.Errorf("unexpected error: %#v", err.Error())
77 | }
78 |
79 | if !reflect.DeepEqual(test.expected, actual) {
80 | t.Errorf("%v => %#v, expected %#v", test.input, actual, test.expected)
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/doc.go:
--------------------------------------------------------------------------------
1 | /*
2 | Package soy is an implementation of Google's Closure Templates, which are
3 | data-driven templates for generating HTML.
4 |
5 | Compared to html/template, Closure Templates have a few advantages
6 |
7 | * Intuitive templating language that supports simple control flow, expressions and arithmetic.
8 | * The same templates may be used from Go, Java, and Javascript.
9 | * Internationalization is built in
10 |
11 | and specific to this implementation:
12 |
13 | * High performance (> 3x faster than html/template in BenchmarkSimpleTemplate)
14 | * Hot reload for templates
15 | * Parse a directory tree of templates
16 |
17 | Refer to the official language spec for details:
18 |
19 | https://developers.google.com/closure/templates/
20 |
21 | Template example
22 |
23 | Here is Hello World
24 |
25 | {namespace examples.simple}
26 |
27 | /**
28 | * Says hello to the world.*/
29 | // */
30 | /* {template .helloWorld}
31 | Hello world!
32 | {/template}
33 |
34 |
35 | Here is a more customized version that addresses us by name and can use
36 | greetings other than "Hello".
37 |
38 | /**
39 | * Greets a person using "Hello" by default.
40 | * @param name The name of the person.
41 | * @param? greetingWord Optional greeting word to use instead of "Hello".*/
42 | // */
43 | /* {template .helloName}
44 | {if not $greetingWord}
45 | Hello {$name}!
46 | {else}
47 | {$greetingWord} {$name}!
48 | {/if}
49 | {/template}
50 |
51 | This last example renders a greeting for each person in a list of names.
52 |
53 | It demonstrates a [foreach] loop with an [ifempty] command. It also shows how to
54 | call other templates and insert their output using the [call] command. Note that
55 | the [data="all"] attribute in the call command passes all of the caller's
56 | template data to the callee template.
57 |
58 | /**
59 | * Greets a person and optionally a list of other people.
60 | * @param name The name of the person.
61 | * @param additionalNames The additional names to greet. May be an empty list.*/
62 | // */
63 | /* {template .helloNames}
64 | // Greet the person.
65 | {call .helloName data="all" /}
66 | // Greet the additional people.
67 | {foreach $additionalName in $additionalNames}
68 | {call .helloName}
69 | {param name: $additionalName /}
70 | {/call}
71 | {if not isLast($additionalName)}
72 |
// break after every line except the last
73 | {/if}
74 | {ifempty}
75 | No additional people to greet.
76 | {/foreach}
77 | {/template}
78 |
79 | This example is from
80 | https://developers.google.com/closure/templates/docs/helloworld_java.
81 |
82 | Many more examples of Soy language features/commands may be seen here:
83 | https://github.com/robfig/soy/blob/master/testdata/features.soy
84 |
85 | Usage example
86 |
87 | These are the high level steps:
88 |
89 | * Create a soy.Bundle and add templates to it (the literal template strings,
90 | files, or directories).
91 | * Compile the bundle of templates, resulting in a "Tofu" instance. It provides
92 | access to all your soy.
93 | * Render a HTML template from Tofu by providing the template name and a data
94 | object.
95 |
96 | Typically in a web application you have a directory containing views for all of
97 | your pages. For example:
98 |
99 | app/views/
100 | app/views/account/
101 | app/views/feed/
102 | ...
103 |
104 | This code snippet will parse a file of globals, all Soy templates within
105 | app/views, and provide back a Tofu intance that can be used to render any
106 | declared template. Additionally, if "mode == dev", it will watch the Soy files
107 | for changes and update your compiled templates in the background (or log compile
108 | errors to soy.Logger). Error checking is omitted.
109 |
110 | On startup:
111 |
112 | tofu, _ := soy.NewBundle().
113 | WatchFiles(true). // watch Soy files, reload on changes
114 | AddGlobalsFile("views/globals.txt"). // parse a file of globals
115 | AddTemplateDir("views"). // load *.soy in all sub-directories
116 | CompileToTofu()
117 |
118 | To render a page:
119 |
120 | var obj = map[string]interface{}{
121 | "user": user,
122 | "account": account,
123 | }
124 | tofu.Render(resp, "acme.account.overview", obj)
125 |
126 | Structs may be used as the data context too, but keep in mind that they are
127 | converted to data maps -- unlike html/template, the context is pure data, and
128 | you can not call methods on it.
129 |
130 | var obj = HomepageContext{
131 | User: user,
132 | Account: account,
133 | }
134 | tofu.Render(resp, "acme.account.overview", obj)
135 |
136 | See soyhtml.StructOptions for knobs to control how your structs get converted to
137 | data maps.
138 |
139 | Project Status
140 |
141 | The goal is full compatibility and feature parity with the official Closure
142 | Templates project.
143 |
144 | The server-side templating functionality is well tested and nearly complete,
145 | except for two notable areas: contextual autoescaping and
146 | internationalization/bidi support. Contributions welcome.
147 |
148 | The Javascript generation is early and lacks many generation options, but
149 | it successfully passes the server-side template test suite. Note that it is
150 | possible to run the official Soy compiler to generate your javascript templates
151 | at build time, even if you use this package for server-side templates.
152 |
153 | Please see the TODO file for features that have yet to be implemented.
154 |
155 | Please open a Github Issue for any bugs / problems / comments, or if you find a
156 | template that renders differently than with the official compiler.
157 | */
158 | package soy
159 |
--------------------------------------------------------------------------------
/errortypes/filepos.go:
--------------------------------------------------------------------------------
1 | package errortypes
2 |
3 | import "fmt"
4 |
5 | // ErrFilePos extends the error interface to add details on the file position where the error occurred.
6 | type ErrFilePos interface {
7 | error
8 | File() string
9 | Line() int
10 | Col() int
11 | }
12 |
13 | // NewErrFilePosf creates an error conforming to the ErrFilePos interface.
14 | func NewErrFilePosf(file string, line, col int, format string, args ...interface{}) error {
15 | return &errFilePos{
16 | error: fmt.Errorf(format, args...),
17 | file: file,
18 | line: line,
19 | col: col,
20 | }
21 | }
22 |
23 | // IsErrFilePos identifies whethere or not the root cause of the provided error is of the ErrFilePos type.
24 | // Wrapped errors are unwrapped via the Cause() function.
25 | func IsErrFilePos(err error) bool {
26 | if err == nil {
27 | return false
28 | }
29 | _, isErrFilePos := err.(ErrFilePos)
30 | return isErrFilePos
31 | }
32 |
33 | // ToErrFilePos converts the input error to an ErrFilePos if possible, or nil if not.
34 | // If IsErrFilePos returns true, this will not return nil.
35 | func ToErrFilePos(err error) ErrFilePos {
36 | if err == nil {
37 | return nil
38 | }
39 | if out, isErrFilePos := err.(ErrFilePos); isErrFilePos {
40 | return out
41 | }
42 | return nil
43 | }
44 |
45 | var _ ErrFilePos = &errFilePos{}
46 |
47 | type errFilePos struct {
48 | error
49 | file string
50 | line int
51 | col int
52 | }
53 |
54 | func (e *errFilePos) File() string {
55 | return e.file
56 | }
57 |
58 | func (e *errFilePos) Line() int {
59 | return e.line
60 | }
61 |
62 | func (e *errFilePos) Col() int {
63 | return e.col
64 | }
65 |
--------------------------------------------------------------------------------
/errortypes/filepos_test.go:
--------------------------------------------------------------------------------
1 | package errortypes_test
2 |
3 | import (
4 | "errors"
5 | "testing"
6 |
7 | "github.com/robfig/soy/errortypes"
8 | )
9 |
10 | func TestIsErrFilePos(t *testing.T) {
11 | var tests = []struct {
12 | name string
13 | in error
14 | out bool
15 | }{
16 | {
17 | name: "nil",
18 | out: false,
19 | },
20 | {
21 | name: "errors.New",
22 | in: errors.New("an error"),
23 | out: false,
24 | },
25 | {
26 | name: "new ErrFilePos",
27 | in: errortypes.NewErrFilePosf("file.soy", 1, 2, "message"),
28 | out: true,
29 | },
30 | }
31 | for _, test := range tests {
32 | got := errortypes.IsErrFilePos(test.in)
33 | if got != test.out {
34 | t.Errorf("%s: Expected %v, got %v", test.name, test.out, got)
35 | }
36 | }
37 | }
38 |
39 | func TestToErrFilePos(t *testing.T) {
40 | var tests = []struct {
41 | name string
42 | in error
43 | expectNil bool
44 | expectedFilename string
45 | expectedLine int
46 | expectedCol int
47 | }{
48 | {
49 | name: "nil",
50 | expectNil: true,
51 | },
52 | {
53 | name: "errors.New",
54 | in: errors.New("an error"),
55 | expectNil: true,
56 | },
57 | {
58 | name: "new ErrFilePos",
59 | in: errortypes.NewErrFilePosf("file.soy", 1, 2, "message"),
60 | expectNil: false,
61 | expectedFilename: "file.soy",
62 | expectedLine: 1,
63 | expectedCol: 2,
64 | },
65 | }
66 | for _, test := range tests {
67 | got := errortypes.ToErrFilePos(test.in)
68 | if test.expectNil && got != nil {
69 | t.Errorf("%s: expected ErrFilePos to be nil", test.name)
70 | }
71 | if !test.expectNil {
72 | if got == nil {
73 | t.Errorf("%s: expected ErrFilePos to be non-nil", test.name)
74 | return
75 | }
76 | if got.File() != test.expectedFilename {
77 | t.Errorf("%s: expected file '%s', got '%s'", test.name, test.expectedFilename, got.File())
78 | }
79 | if got.Line() != test.expectedLine {
80 | t.Errorf("%s: expected line %d, got %d", test.name, test.expectedLine, got.Line())
81 | }
82 | if got.Col() != test.expectedCol {
83 | t.Errorf("%s: expected col %d, got %d", test.name, test.expectedCol, got.Col())
84 | }
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/features_test.go:
--------------------------------------------------------------------------------
1 | package soy
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "encoding/json"
7 | "fmt"
8 | "html/template"
9 | "io/ioutil"
10 | "math/rand"
11 | "os"
12 | "reflect"
13 | "testing"
14 |
15 | "github.com/robertkrimen/otto"
16 | "github.com/robfig/soy/data"
17 | "github.com/robfig/soy/soyjs"
18 | )
19 |
20 | type d map[string]interface{}
21 |
22 | type featureTest struct {
23 | name string
24 | data d
25 | output string
26 | }
27 |
28 | var featureTests = []featureTest{
29 | {"demoComments", nil, `blah blah
http://www.google.com
`},
30 |
31 | {"demoLineJoining", nil,
32 | `First second.
` +
33 | `Firstsecond.
` +
34 | `Firstsecond.
` +
35 | `First second.
` +
36 | `Firstsecond.
`},
37 |
38 | {"demoRawTextCommands", nil,
39 | `
Space : AA BB`}, 49 | 50 | {"demoPrint", d{"boo": "Boo!", "two": 2}, 51 | `Boo!
` + 40 | `Empty string: AABB
` + 41 | `New line : AA 42 | BB
` + 43 | "Carriage ret: AA\rBB
" + 44 | `Tab : AA BB
` + 45 | `Left brace : AA{BB
` + 46 | `Right brace : AA}BB
` + 47 | `Literal : AA BB { CC 48 | DD } EE {sp}{\n}{rb} FF
P1
P2
P3
"), 35 | "{START_PARAGRAPH}P1{END_PARAGRAPH}{START_PARAGRAPH}P2{END_PARAGRAPH}{START_PARAGRAPH}P3{END_PARAGRAPH}"}, 36 | 37 | // BUG: Data refs + HTML 38 | // {newMsg("Click"), "{START_LINK}Click{END_LINK}"}, 39 | 40 | // TODO: phname 41 | 42 | // TODO: investigate globals 43 | // {newMsg("{GLOBAL}"), "{GLOBAL}"}, 44 | // {newMsg("{sub.global}"), "{GLOBAL}"}, 45 | } 46 | 47 | for _, test := range tests { 48 | var actual = PlaceholderString(test.node) 49 | if actual != test.phstr { 50 | t.Errorf("(actual) %v != %v (expected)", actual, test.phstr) 51 | } 52 | } 53 | } 54 | 55 | func TestSetPluralVarName(t *testing.T) { 56 | type test struct { 57 | node *ast.MsgNode 58 | varname string 59 | } 60 | 61 | var tests = []test{ 62 | {newMsg("{plural $eggs}{case 1}one{default}other{/plural}"), "EGGS"}, 63 | {newMsg("{plural $eggs}{case 1}one{default}{$eggs}{/plural}"), "EGGS_1"}, 64 | {newMsg("{plural length($eggs)}{case 1}one{default}other{/plural}"), "NUM"}, 65 | } 66 | 67 | for _, test := range tests { 68 | var actual = test.node.Body.Children()[0].(*ast.MsgPluralNode).VarName 69 | if actual != test.varname { 70 | t.Errorf("(actual) %v != %v (expected)", actual, test.varname) 71 | } 72 | } 73 | } 74 | 75 | func newMsg(msg string) *ast.MsgNode { 76 | // TODO: data.Map{"GLOBAL": data.Int(1), "sub.global": data.Int(2)}) 77 | var sf, err = parse.SoyFile("", `{msg desc=""}`+msg+`{/msg}`) 78 | if err != nil { 79 | panic(err) 80 | } 81 | var msgnode = sf.Body[0].(*ast.MsgNode) 82 | SetPlaceholdersAndID(msgnode) 83 | return msgnode 84 | } 85 | 86 | func TestBaseName(t *testing.T) { 87 | type test struct { 88 | expr string 89 | ph string 90 | } 91 | var tests = []test{ 92 | {"$foo", "FOO"}, 93 | {"$foo.boo", "BOO"}, 94 | {"$foo.boo[0].zoo", "ZOO"}, 95 | {"$foo.boo.0.zoo", "ZOO"}, 96 | 97 | // parse.Expr doesn't accept undefined globals. 98 | // {"GLOBAL", "GLOBAL"}, 99 | // {"sub.GLOBAL", "GLOBAL"}, 100 | 101 | {"$foo[0]", "XXX"}, 102 | {"$foo.boo[0]", "XXX"}, 103 | {"$foo.boo.0", "XXX"}, 104 | {"$foo + 1", "XXX"}, 105 | {"'text'", "XXX"}, 106 | {"max(1, 3)", "XXX"}, 107 | } 108 | 109 | for _, test := range tests { 110 | var n, err = parse.Expr(test.expr) 111 | if err != nil { 112 | t.Error(err) 113 | return 114 | } 115 | 116 | var actual = genBasePlaceholderName(&ast.PrintNode{0, n, nil}, "XXX") 117 | if actual != test.ph { 118 | t.Errorf("(actual) %v != %v (expected)", actual, test.ph) 119 | } 120 | } 121 | } 122 | 123 | func TestToUpperUnderscore(t *testing.T) { 124 | var tests = []struct{ in, out string }{ 125 | {"booFoo", "BOO_FOO"}, 126 | {"_booFoo", "BOO_FOO"}, 127 | {"booFoo_", "BOO_FOO"}, 128 | {"BooFoo", "BOO_FOO"}, 129 | {"boo_foo", "BOO_FOO"}, 130 | {"BOO_FOO", "BOO_FOO"}, 131 | {"__BOO__FOO__", "BOO_FOO"}, 132 | {"Boo_Foo", "BOO_FOO"}, 133 | {"boo8Foo", "BOO_8_FOO"}, 134 | {"booFoo88", "BOO_FOO_88"}, 135 | {"boo88_foo", "BOO_88_FOO"}, 136 | {"_boo_8foo", "BOO_8_FOO"}, 137 | {"boo_foo8", "BOO_FOO_8"}, 138 | {"_BOO__8_FOO_", "BOO_8_FOO"}, 139 | } 140 | for _, test := range tests { 141 | var actual = toUpperUnderscore(test.in) 142 | if actual != test.out { 143 | t.Errorf("(actual) %v != %v (expected)", actual, test.out) 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /soymsg/pomsg/fallback.go: -------------------------------------------------------------------------------- 1 | package pomsg 2 | 3 | import ( 4 | "golang.org/x/text/language" 5 | ) 6 | 7 | // fallbacks returns a slice of tags that can be substituted for a tag, ordered by increasing 8 | // generality. 9 | // TODO: potentially support extensions and variants 10 | func fallbacks(tag language.Tag) []language.Tag { 11 | result := []language.Tag{} 12 | lang, script, region := tag.Raw() 13 | // The language package returns ZZ for an unspecified region, similar quirk for script. 14 | if region.String() != "ZZ" { 15 | t, _ := language.Compose(lang, script, region) 16 | result = append(result, t) 17 | } 18 | if script.String() != "Zzzz" { 19 | t, _ := language.Compose(lang, script) 20 | result = append(result, t) 21 | } 22 | t, _ := language.Compose(lang) 23 | result = append(result, t) 24 | return result 25 | } 26 | -------------------------------------------------------------------------------- /soymsg/pomsg/fallback_test.go: -------------------------------------------------------------------------------- 1 | package pomsg 2 | 3 | import ( 4 | "testing" 5 | "golang.org/x/text/language" 6 | ) 7 | 8 | func TestFallback(t *testing.T) { 9 | tests := []struct{ 10 | name string 11 | tag language.Tag 12 | expectedTags []language.Tag 13 | }{ 14 | { 15 | name: "When given a generic locale code, no extra fallbacks are provided", 16 | tag: language.MustParse("en"), 17 | expectedTags: []language.Tag{language.English}, 18 | }, 19 | { 20 | name: "When given a regional locale code, generic fallback is provided", 21 | tag: language.MustParse("en_US"), 22 | expectedTags: []language.Tag{language.AmericanEnglish, language.English}, 23 | }, 24 | { 25 | name: "When given a locale code with script, generic fallback is provided", 26 | tag: language.MustParse("ar_Arab"), 27 | expectedTags: []language.Tag{ 28 | language.MustParse("ar_Arab"), 29 | language.Arabic, 30 | }, 31 | }, 32 | { 33 | name: "When given a locale code with script and region, generic and script fallbacks are provided", 34 | tag: language.MustParse("ar_Arab_EG"), 35 | expectedTags: []language.Tag{ 36 | language.MustParse("ar_Arab_EG"), 37 | language.MustParse("ar_Arab"), 38 | language.Arabic, 39 | }, 40 | }, 41 | { 42 | name: "When given an empty tag, no fallbacks are provided", 43 | tag: language.Tag{}, 44 | expectedTags: []language.Tag{}, 45 | }, 46 | } 47 | 48 | for _, test := range tests { 49 | t.Run(test.name, func(t *testing.T) { 50 | fb := fallbacks(test.tag) 51 | 52 | for i, tag := range test.expectedTags { 53 | if fb[i] != tag { 54 | t.Errorf("Expected tag %+v, got tag %+v", tag, fb[i]) 55 | } 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /soymsg/pomsg/msgid.go: -------------------------------------------------------------------------------- 1 | package pomsg 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | 7 | "github.com/robfig/soy/ast" 8 | ) 9 | 10 | // Validate checks if the given message is representable in a PO file. 11 | // A MsgNode must be validated before trying to caculate its msgid or msgid_plural 12 | // 13 | // Rules: 14 | // - If a message contains a plural, it must be the sole child. 15 | // - A plural contains exactly {case 1} and {default} cases. 16 | func Validate(n *ast.MsgNode) error { 17 | for i, child := range n.Body.Children() { 18 | if n, ok := child.(*ast.MsgPluralNode); ok { 19 | if i != 0 { 20 | return fmt.Errorf("plural node must be the sole child") 21 | } 22 | if len(n.Cases) != 1 || n.Cases[0].Value != 1 { 23 | return fmt.Errorf("PO requires two plural cases [1, default]. found %v", n.Cases) 24 | } 25 | } 26 | } 27 | return nil 28 | } 29 | 30 | // MsgId returns the msgid for the given msg node. 31 | func Msgid(n *ast.MsgNode) string { 32 | return msgidn(n, true) 33 | } 34 | 35 | // MsgidPlural returns the msgid_plural for the given message. 36 | func MsgidPlural(n *ast.MsgNode) string { 37 | return msgidn(n, false) 38 | } 39 | 40 | func msgidn(n *ast.MsgNode, singular bool) string { 41 | var body = n.Body 42 | var children = body.Children() 43 | if len(children) == 0 { 44 | return "" 45 | } 46 | if pluralNode, ok := children[0].(*ast.MsgPluralNode); ok { 47 | body = pluralCase(pluralNode, singular) 48 | } else if !singular { 49 | return "" 50 | } 51 | var buf bytes.Buffer 52 | for _, child := range body.Children() { 53 | writeph(&buf, child) 54 | } 55 | return buf.String() 56 | } 57 | 58 | // pluralCase returns the singular or plural message body. 59 | func pluralCase(n *ast.MsgPluralNode, singular bool) ast.ParentNode { 60 | if singular { 61 | return n.Cases[0].Body 62 | } 63 | return n.Default 64 | } 65 | 66 | // writeph writes the placeholder string for the given node to the given buffer. 67 | func writeph(buf *bytes.Buffer, child ast.Node) { 68 | switch child := child.(type) { 69 | case *ast.RawTextNode: 70 | buf.Write(child.Text) 71 | case *ast.MsgPlaceholderNode: 72 | buf.WriteString("{" + child.Name + "}") 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /soymsg/pomsg/msgid_test.go: -------------------------------------------------------------------------------- 1 | package pomsg 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/robfig/soy/ast" 8 | "github.com/robfig/soy/parse" 9 | "github.com/robfig/soy/soymsg" 10 | ) 11 | 12 | func TestValidate(t *testing.T) { 13 | type test struct { 14 | msg *ast.MsgNode 15 | validates bool 16 | } 17 | var tests = []test{ 18 | {msg(""), true}, 19 | {msg("hello world"), true}, 20 | {msg("{plural $n}{case 1}one{default}other{/plural}"), true}, 21 | {msg("{plural $n}{default}other{/plural}"), false}, 22 | {msg("{plural $n}{case 2}two{default}other{/plural}"), false}, 23 | } 24 | 25 | for _, test := range tests { 26 | var err = Validate(test.msg) 27 | switch { 28 | case test.validates && err != nil: 29 | t.Errorf("should validate, but got %v: %v", err, test.msg) 30 | case !test.validates && err == nil: 31 | t.Errorf("should fail, but didn't: %v", test.msg) 32 | } 33 | } 34 | } 35 | 36 | func TestMsgId(t *testing.T) { 37 | type test struct { 38 | msg *ast.MsgNode 39 | msgid string 40 | msgidPlural string 41 | } 42 | var tests = []test{ 43 | {msg(""), "", ""}, 44 | {msg("hello world"), "hello world", ""}, 45 | {msg("{plural length($users)}{case 1}one{default}other{/plural}"), "one", "other"}, 46 | {msg("{plural length($users)}{case 1}one{default}{length($users)} users{/plural}"), 47 | "one", "{XXX} users"}, 48 | } 49 | 50 | for _, test := range tests { 51 | var ( 52 | msgid = Msgid(test.msg) 53 | msgidPlural = MsgidPlural(test.msg) 54 | ) 55 | if msgid != test.msgid { 56 | t.Errorf("(actual) %v != %v (expected)", msgid, test.msgid) 57 | } 58 | if msgidPlural != test.msgidPlural { 59 | t.Errorf("(actual) %v != %v (expected)", msgidPlural, test.msgidPlural) 60 | } 61 | } 62 | } 63 | 64 | func msg(body string) *ast.MsgNode { 65 | var msgtmpl = fmt.Sprintf(`{msg desc=""}%s{/msg}`, body) 66 | var sf, err = parse.SoyFile("", msgtmpl) 67 | if err != nil { 68 | panic(err) 69 | } 70 | var msgnode = sf.Body[0].(*ast.MsgNode) 71 | soymsg.SetPlaceholdersAndID(msgnode) 72 | return msgnode 73 | } 74 | -------------------------------------------------------------------------------- /soymsg/pomsg/pomsg.go: -------------------------------------------------------------------------------- 1 | // Package pomsg provides a PO file implementation for Soy message bundles 2 | package pomsg 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/robfig/gettext/po" 14 | "github.com/robfig/soy/soymsg" 15 | "golang.org/x/text/language" 16 | ) 17 | 18 | type provider struct { 19 | bundles map[string]soymsg.Bundle 20 | } 21 | 22 | // FileOpener defines an abstraction for opening a po file given a locale 23 | type FileOpener interface { 24 | // Open returns ReadCloser for the po file indicated by locale. It returns 25 | // nil if the file does not exist 26 | Open(locale string) (io.ReadCloser, error) 27 | } 28 | 29 | // Load returns a soymsg.Provider that takes its translations by passing in the 30 | // specified locales to the given PoFileProvider. 31 | // 32 | // Supports fallbacks for when a given locale does not exist, as long as the fallback files are in 33 | // canonical form. 34 | func Load(opener FileOpener, locales []string) (soymsg.Provider, error) { 35 | var prov = provider{make(map[string]soymsg.Bundle)} 36 | for _, locale := range locales { 37 | r, err := opener.Open(locale) 38 | if err != nil { 39 | return nil, err 40 | } else if r == nil { 41 | continue 42 | } 43 | 44 | pofile, err := po.Parse(r) 45 | r.Close() 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | b, err := newBundle(locale, pofile) 51 | if err != nil { 52 | return nil, err 53 | } 54 | prov.bundles[locale] = b 55 | } 56 | return prov, nil 57 | } 58 | 59 | // fsFileOpener is a FileOpener based on the filesystem and rooted at Dirname 60 | type fsFileOpener struct { 61 | Dirname string 62 | } 63 | 64 | func (o fsFileOpener) Open(locale string) (io.ReadCloser, error) { 65 | switch f, err := os.Open(path.Join(o.Dirname, locale+".po")); { 66 | case os.IsNotExist(err): 67 | return nil, nil 68 | case err != nil: 69 | return nil, err 70 | default: 71 | return f, nil 72 | } 73 | } 74 | 75 | // Dir returns a soymsg.Provider that takes translations from the given path. 76 | // For example, if dir is "/usr/local/msgs", po files should be of the form: 77 | // /usr/local/msgs/