doctemplates 0.1.0.2 → 0.11.0.1
raw patch · 41 files changed
Files
- LICENSE +1/−1
- README.md +424/−24
- bench/bench.hs +38/−0
- changelog.md +341/−0
- doctemplates.cabal +49/−10
- src/Text/DocTemplates.hs +376/−231
- src/Text/DocTemplates/Internal.hs +469/−0
- src/Text/DocTemplates/Parser.hs +441/−0
- test/Spec.hs +0/−41
- test/bad.txt +2/−0
- test/basic-with-braces.test +16/−0
- test/basic-with-it.test +16/−0
- test/basic.test +16/−0
- test/boilerplate.txt +3/−0
- test/boolean.test +13/−0
- test/comments.test +9/−0
- test/conditionals.test +37/−0
- test/elseif.test +30/−0
- test/empty.test +16/−0
- test/enum.txt +2/−0
- test/final-newline.test +15/−0
- test/forloop.test +21/−0
- test/indent.test +30/−0
- test/inparens.txt +1/−0
- test/loop-in-object.test +19/−0
- test/loop-in-partial.test +6/−0
- test/loop1.txt +1/−0
- test/loop2.txt +1/−0
- test/name.tex +1/−0
- test/name.txt +1/−0
- test/nest.test +67/−0
- test/nested-loop.test +41/−0
- test/numbers.test +7/−0
- test/pad.test +53/−0
- test/partial_foo.txt +4/−0
- test/partials.test +39/−0
- test/pipe-and-partial.test +16/−0
- test/pipes.test +109/−0
- test/space-in-loop.test +27/−0
- test/test.hs +116/−0
- test/values.test +20/−0
LICENSE view
@@ -1,4 +1,4 @@-Copyright John MacFarlane (c) 2016+Copyright John MacFarlane (c) 2009-2019 All rights reserved.
README.md view
@@ -1,16 +1,40 @@ # doctemplates -This is the templating system used by pandoc. It was formerly-be a module in pandoc. It has been split off to make it easier-to use independently.+[](https://github.com/jgm/doctemplates/actions) -Example: +This is the text templating system used by pandoc. Its basic function is to+fill variables in a template. Variables are provided by a "context."+Any instance of the `ToContext` typeclass (such as an aeson `Value`)+can serve as the context, or a `Context` value can be constructed manually.++Control structures are provided to test whether a variable has a non-blank+value and to iterate over the items of a list. Partials---that is,+subtemplates defined in different files---are supported. Pipes+can be used to transform the values of variables or partials.+The provided pipes make it possible to do list enumeration and+tabular layout in templates.++Templates are rendered to a doclayout `Doc` (which is polymorphic+in the underlying string type). If `Doc` values are used in the+context, rendered documents will be able to wrap flexibly on breaking+spaces. This feature makes doctemplates more suitable than other+template engines for plain-text formats (like Markdown).++Unlike the various HTML-centered template engines, doctemplates is+output-format agnostic, so no automatic escaping is done on interpolated+values. Values are assumed to be escaped properly in the Context.++## Example of use+ ``` haskell {-# LANGUAGE OverloadedStrings #-}-import Data.Text+import Data.Text (Text)+import qualified Data.Text.IO as T import Data.Aeson import Text.DocTemplates+import Text.DocLayout (render) data Employee = Employee { firstName :: String , lastName :: String@@ -23,9 +47,12 @@ template :: Text template = "$for(employee)$Hi, $employee.name.first$. $if(employee.salary)$You make $employee.salary$.$else$No salary data.$endif$$sep$\n$endfor$" -main = case compileTemplate template of+main :: IO ()+main = do+ res <- compileTemplate "mytemplate.txt" template+ case res of Left e -> error e- Right t -> putStrLn $ renderTemplate t $ object+ Right t -> T.putStrLn $ render Nothing $ renderTemplate t $ object ["employee" .= [ Employee "John" "Doe" Nothing , Employee "Omar" "Smith" (Just 30000)@@ -33,32 +60,405 @@ ] ``` +## Delimiters++To mark variables and control structures in the template,+either `$`...`$` or `${`...`}` may be used as delimiters.+The styles may also be mixed in the same template, but the+opening and closing delimiter must match in each case. The+opening delimiter may be followed by one or more spaces+or tabs, which will be ignored. The closing delimiter may+be followed by one or more spaces or tabs, which will be+ignored.++To include a literal `$` in the document, use `$$`.++## Comments++Anything between the sequence `$--` and the end of the+line will be treated as a comment and omitted from the output.++## Interpolated variables+ A slot for an interpolated variable is a variable name surrounded-by dollar signs. To include a literal `$` in your template, use-`$$`. Variable names must begin with a letter and can contain letters,-numbers, `_`, `-`, and `.`.+by matched delimiters. Variable names must begin with a letter+and can contain letters, numbers, `_`, `-`, and `.`. The+keywords `it`, `if`, `else`, `endif`, `for`, `sep`, and `endfor` may+not be used as variable names. Examples: -The values of variables are determined by a JSON object that is+```+$foo$+$foo.bar.baz$+$foo_bar.baz-bim$+$ foo $+${foo}+${foo.bar.baz}+${foo_bar.baz-bim}+${ foo }+```++The values of variables are determined by the `Context` that is passed as a parameter to `renderTemplate`. So, for example, `title` will return the value of the `title` field, and `employee.salary` will return the value of the `salary` field of the object that is the value of the `employee` field. -The value of a variable will be indented to the same level as the-variable.+- If the value of the variable is simple value, it will be+ rendered verbatim. (Note that no escaping is done;+ the assumption is that the calling program will escape+ the strings appropriately for the output format.)+- If the value of the variable is a boolean value, it+ will be rendered as `true` if true, or as empty if false.+- If the value is a list, the values will be concatenated.+- If the value is a map, the string `true` will be rendered.+- Every other value will be rendered as the empty string. -A conditional begins with `$if(variable_name)$` and ends with `$endif$`.-It may optionally contain an `$else$` section. The if section is-used if `variable_name` has a non-null value, otherwise the else section-is used.+When a `Context` is derived from an aeson (JSON) `Value`,+the following conversions are done: -Conditional keywords should not be indented, or unexpected spacing-problems may occur.+- If the value is a number, it will be rendered as an+ integer if possible, otherwise as a floating-point number. -The `$for$` keyword can be used to iterate over an array. If-the value of the associated variable is not an array, a single-iteration will be performed on its value. -You may optionally specify separators using `$sep$`, as in the-example above.+## Conditionals++A conditional begins with `if(variable)` (enclosed in+matched delimiters) and ends with `endif` (enclosed in matched+delimiters). It may optionally contain an `else` (enclosed in+matched delimiters). The `if` section is used if+`variable` has a true value, otherwise the `else`+section is used (if present). The following values+count as true:++- any map+- any array containing at least one true value+- any nonempty string (even `false`)+- boolean True++Examples:++```+$if(foo)$bar$endif$++$if(foo)$+ $foo$+$endif$++$if(foo)$+part one+$else$+part two+$endif$++${if(foo)}bar${endif}++${if(foo)}+ ${foo}+${endif}++${if(foo)}+${ foo.bar }+${else}+no foo!+${endif}+```++The keyword `elseif` may be used to simplify complex nested+conditionals. Thus++```+$if(foo)$+XXX+$elseif(bar)$+YYY+$else$+ZZZ+$endif$+```++is equivalent to++```+$if(foo)$+XXX+$else$+$if(bar)$+YYY+$else$+ZZZ+$endif$+$endif$+```++## For loops++A for loop begins with `for(variable)` (enclosed in+matched delimiters) and ends with `endfor` (enclosed in matched+delimiters).++- If `variable` is an array, the material inside the loop will+ be evaluated repeatedly, with `variable` being set to each+ value of the array in turn, and concatenated.+- If `variable` is a map, the material inside will be set to+ the map.+- If the value of the associated variable is not an array or+ a map, a single iteration will be performed on its value.++Examples:++```+$for(foo)$$foo$$sep$, $endfor$++$for(foo)$+ - $foo.last$, $foo.first$+$endfor$++${ for(foo.bar) }+ - ${ foo.bar.last }, ${ foo.bar.first }+${ endfor }++$for(mymap)$+$it.name$: $it.office$+$endfor$+```++You may optionally specify a separator between consecutive+values using `sep` (enclosed in matched delimiters). The+material between `sep` and the `endfor` is the separator.++```+${ for(foo) }${ foo }${ sep }, ${ endfor }+```++Instead of using `variable` inside the loop, the special+anaphoric keyword `it` may be used.++```+${ for(foo.bar) }+ - ${ it.last }, ${ it.first }+${ endfor }+```++## Partials++Partials (subtemplates stored in different files) may be+included using the syntax++```+${ boilerplate() }+```++The partials are obtained using `getPartial` from+the `TemplateMonad` class. This may be implemented+differently in different monads. The path passed+to `getPartial` is computed on the basis of the+original template path (a parameter to `compileTemplate`)+and the partial's name. The partial's name is substituted+for the *base name* of the original template path+(leaving the original template's extension), unless+the partial has an explicit extension, in which case+this is kept. So, with the `TemplateMonad` instance+for IO, partials will be sought in the directory+containing the main template, and will be assumed+to have the extension of the main template.++Partials may optionally be applied to variables using+a colon:++```+${ date:fancy() }++${ articles:bibentry() }+```++If `articles` is an array, this will iterate over its+values, applying the partial `bibentry()` to each one. So the+second example above is equivalent to++```+${ for(articles) }+${ it:bibentry() }+${ endfor }+```++Note that the anaphoric keyword `it` must be used when+iterating over partials. In the above examples,+the `bibentry` partial should contain `it.title`+(and so on) instead of `articles.title`.++Final newlines are omitted from included partials.++Partials may include other partials. If you exceed+a nesting level of 50, though, in resolving partials,+the literal `(loop)` will be returned, to avoid infinite loops.++A separator between values of an array may be specified+in square brackets, immediately after the variable name+or partial:++```+${months[, ]}$++${articles:bibentry()[; ]$+```++The separator in this case is literal and (unlike with `sep`+in an explicit `for` loop) cannot contain interpolated+variables or other template directives.++## Nesting++To ensure that content is "nested," that is, subsequent lines+indented, use the `^` directive:++```+$item.number$ $^$$item.description$ ($item.price$)+```++In this example, if `item.description` has multiple lines,+they will all be indented to line up with the first line:++```+00123 A fine bottle of 18-year old+ Oban whiskey. ($148)+```++To nest multiple lines to the same level, align them+with the `^` directive in the template. For example:++```+$item.number$ $^$$item.description$ ($item.price$)+ (Available til $item.sellby$.)+```++will produce++```+00123 A fine bottle of 18-year old+ Oban whiskey. ($148)+ (Available til March 30, 2020.)+```++If a variable occurs by itself on a line, preceded by whitespace+and not followed by further text or directives on the same line,+and the variable's value contains multiple lines, it will be+nested automatically.++## Breakable spaces++When rendering to a `Doc`, a distinction can be made between+breakable and unbreakable spaces. Normally, spaces in the+template itself (as opposed to values of the interpolated+variables) are not breakable, but they can be made breakable+in part of the template by using the `~` keyword (ended+with another `~`).++```+$~$This long line may break if the document is rendered+with a short line length.$~$+```++The `~` keyword has no effect when rendering to `Text`+or `String`.++## Pipes++A pipe transforms the value of a variable or partial. Pipes+are specified using a slash (`/`) between the variable name (or+partial) and the pipe name. Example:++```+$for(name)$+$name/uppercase$+$endfor$++$for(metadata/pairs)$+- $it.key$: $it.value$+$endfor$++$employee:name()/uppercase$+```++Pipes may be chained:++```+$for(employees/pairs)$+$it.key/alpha/uppercase$. $it.name$+$endfor$+```++Some pipes take parameters:++```+|----------------------|------------|+$for(employee)$+$it.name.first/uppercase/left 20 "| "$$it.name.salary/right 10 " | " " |"$+$endfor$+|----------------------|------------|+```++Currently the following pipes are predefined:++- `pairs`: Converts a map or array to an array of maps,+ each with `key` and `value` fields. If the original+ value was an array, the `key` will be the array index,+ starting with 1.++- `first`: Returns the first value of an array, if+ applied to a non-empty array; otherwise returns+ the original value.++- `last`: Returns the last value of an array, if+ applied to a non-empty array; otherwise returns+ the original value.++- `rest`: Returns all but the first value of an array, if+ applied to a non-empty array; otherwise returns+ the original value.++- `allbutlast`: Returns all but the last value of an array, if+ applied to a non-empty array; otherwise returns+ the original value.++- `uppercase`: Converts text to uppercase.++- `lowercase`: Converts text to lowercase.++- `length`: Returns the length of the value: number+ of characters for a textual value, number of elements+ for a map or array.++- `reverse`: Reverses a textual value or array,+ and has no effect on other values.++- `chomp`: Removes trailing newlines (and breakable space).++- `nowrap`: Disables line wrapping on breakable spaces.++- `alpha`: Converts textual values that can be+ read as an integer into lowercase alphabetic+ characters `a..z` (mod 26). This can be used to get lettered+ enumeration from array indices. To get uppercase+ letters, chain with `uppercase`.++- `roman`: Converts textual values that can be+ read as an integer into lowercase roman numerials.+ This can be used to get lettered enumeration from array indices.+ To get uppercase roman, chain with `uppercase`.++- `left n "leftborder" "rightborder"`: Renders a textual value+ in a block of width `n`, aligned to the left, with an optional+ left and right border. Has no effect on other values. This+ can be used to align material in tables. Widths are positive+ integers indicating the number of characters. Borders+ are strings inside double quotes; literal `"` and `\` characters+ must be backslash-escaped.++- `right n "leftborder" "rightborder"`: Renders a textual value+ in a block of width `n`, aligned to the right, and has no+ effect on other values.++- `center n "leftborder" "rightborder"`: Renders a textual+ value in a block of width `n`, aligned to the center, and has+ no effect on other values.
+ bench/bench.hs view
@@ -0,0 +1,38 @@+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE OverloadedStrings #-}+import Text.DocTemplates+import qualified Data.Text as T+import Data.Text (Text)+import Criterion.Main+import Criterion.Types (Config (..))+import Control.Monad.Identity+import Data.Semigroup ((<>))+import Data.Aeson (object, (.=), Value)+import Text.DocLayout (render)++main :: IO ()+main = do+ Right bigtextTemplate <- compileTemplate "bigtext.txt" bigtext+ defaultMainWith defaultConfig{ timeLimit = 5.0 } $+ [ bench "applyTemplate" $+ nf (fmap (render Nothing)+ . runIdentity . applyTemplate "bigtext" bigtext+ :: Value -> Either String Text)+ val+ , bench "renderTemplate" $+ nf (render Nothing . renderTemplate bigtextTemplate :: Value -> Text)+ val+ ]++bigtext :: Text+bigtext = T.replicate 150 $+ "Hello there $foo$. This is a big text.\n$for(bar)$$bar.baz$$endfor$\n"+ <> "$if(foo)$Hi $foo$.$endif$\n"++val :: Value+val = object [ "foo" .= (22 :: Int)+ , "bar" .= [ object [ "baz" .= ("Hello"::Text) ]+ , object [ "baz" .= ("Bye"::Text) ]+ ]+ ]+
+ changelog.md view
@@ -0,0 +1,341 @@+# doctemplates++# 0.11.0.1++ * Bump version bounds for doclayout.++ * Fix typos.++# 0.11++ * Remove HsYAML depenedency.++ * Remove upper bound for criterion.++## 0.10.0.2++ * Use doclayout 0.4.++## 0.10.0.1++ * Don't rely on aeson Object being implemented as a HashMap.+ This change is needed for doctemplates to compile against aeson 2.0.0.0.++## 0.10++ * Change rendering and conditional behavior with booleans.+ Previously, `$if(foo)$` evaluated to false iff `foo`+ would render as the empty string. This forced us to render+ a boolean False value as an empty string, rather than `false`.+ And this has caused various problems with templates+ (#16, jgm/pandoc#7402). Now, boolean False values render as+ `false` -- just as True values render as `true`. And conditionals+ are now sensitive to booleans, so `$if(foo)$` evaluates to false+ when `foo` is a boolean False value, even though it would render+ as the nonempty string `false`.++## 0.9++ * Add BoolVal constructor to Val. This gives a smoother+ interface with JSON and YAML. [API change]++ * Remove overlapping instances by generalizing+ `ToContext String String` and `FromContext String String`+ to `TemplateTarget [a] => ToContext [a] [a]` and+ `TemplateTarget [a] => FromContext [a] [a]`.+ Remove the instance `ToContext String (Doc String)`.+ Remove redundant constraints. (#9, favonia) [API change]++## 0.8.3++ * Properly handle nested loops (#15). Previously "it" was+ always used for the variable in a loop, and in a nested loop+ there was no way to distinguish the value of the inner+ iteration from the value of the outer one. Now we assign+ the iterated value to both "it" and to the original variable+ name (e.g. "foo.bar"). This probably has a small negative+ performance impact. Note that this change also affects+ the output of the template parser: original variable+ names are now retained instead of being replaced by "it".++ * Remove duplicate IsString constraint (#14, Mario Lang).++ * Update haddocks from README (#10).++ * Minor code clean-ups (#7, favonia).++ * Add hsyaml >= 0.2 constraint (#6).+++## 0.8.2++ * Add filters: first, rest, last, allbutlast.++ * New constructors for Filter: FirstItem, LastItem, Rest, AllButLast+ [API change].++## 0.8.1++ * Depend on doclayout 0.3, which adds an additional method+ on the HasChars class. This fixes some stack overflows+ in rendering very long lines.++## 0.8++ * Change `Filter` data type to `Pipe`. Use the nomenclature of+ "pipe" instead of "filter" to avoid confusion in pandoc between+ two notions of filter. Otherwise everything works the same.++## 0.7.2++ * Add `nowrap` filter.++ * Improve `alpha`, `roman`, `uppercase`, `lowercase` filters so they+ apply recursively within a list or map.++ * Allow `for` loops to bind map value. In this case there is no+ iteration, but the anophoric variable 'it' is assigned, which may+ help in using filters that destructure a string into a map (if+ we add any).++## 0.7.1++ * Add `chomp` filter.++ * Allow filters to be applied to output of partials.++## 0.7++ * Add haddock Makefile target, which regenerates haddocks from README+ and tests the code example.++ * Remove `BreakingSpace` constructor on `Template`.+ Now we use doclayout `BreakingSpace` inside a `Literal`.++ * Add instance for `ToContext a (Doc a)`.++ * Get benchmarks compiling again.++ * Use (doclayout) `Doc` internally and for rendered output.++ + `TemplateTarget` is now a type constraint synonym, not a regular+ typeclass.+ + Constraint on `compileTemplate` and `applyTemplate`+ simplified using TemplateTarget.+ + DocTemplates reexports Text.DocLayout.Doc.+ + The `Literal` costructor of `Template` now takes a `Doc a`+ rather than an `a`.+ + The `SimpleVal` constructor of `Val` now takes a `Doc a`+ rather than an `a`.+ + `renderTemplate` now returns a `Doc a` rather than an `a`.+ (This value can be converted to an a using `render Nothing`.)++ * Remove fromText from `TemplateTarget`. Now we use `fromString`+ from Data.String.++ * Parameterize `Template` on underlying stringlike type.++ * Improved behavior of partials.++ * Improve indent functions: don't drop final newline.++ * Allow blank lines in nested section.++ * Indent for Text/String: don't indent empty lines.++ * Additional tests and documentation about nesting.++ * Render items in for loop before separator.+ Otherwise we throw off column calculation.++ * Remove `+-reflow`; replace with toggle `$~$`.++ * Remove pNewline parser; it isn't needed now.++ * Remove `+-nest`.++ * Fix nest parsing bug.++ * Improve nesting.++ + Change `Nested` constructor for `Template` so it doesn't take+ a parameter.+ + Nesting level is now determined dynamically at render time+ rather than at compile time. This gives much better results+ when nesting occurs after template directives.+ Benchmarks show a slight penalty in performance (from 3.5ms to 3.1ms+ in rendering), but it's not too much.++ * Add filters. Filters transform the value of a variable, e.g. changing+ a map into an array of key/value pairs. Closes #5.++ + Internal: Add `Filter` type and `[Filter]` parameter on `Variable`.+ + Remove `unVariable`; now we have `varParts` and `varFilters`.+ + Document filters in README.md.+ + Implement filters.+ + Add tests.++ * Add `ToYAML`, `FromYAML` instances for `Context`, `Val`.++## 0.6.2++ * Remove unnecessary `TemplateTarget` constraints on+ `ToContext` instances.++ * Add `ToContext` instance for `Map Text a`.++ * Add `Data`, `Typeable` instances for `Context` and `Val`.++## 0.6.1++ * Indent bare partials.++## 0.6++ * Add `+nest`/`-nest` keywords.++ * Add `+reflow`/`-reflow` keywords.++ * Add Nested constructor to Template, remove Indented+ and Indented parameter for Interpolate.++ * More expansive description of library.++## 0.5.1++ * Add elseif keyword.++ * Improve compile error source locations with partials.++ * Handle templates that don't end in newlines. Previously+ this caused problems in some cases.++## 0.5++ * Add toText method to TemplateTarget class.++ * Add String and Lazy Text instances for TemplateTarget.++ * Swap Parameters in ToContext (so that the first parameter+ for both ToContext and FromContext refers to the parameter+ of Context).++ * Add toVal method to ToContext.++ * Default instance definition for toContext in terms of toVal,+ so that defining toVal is sufficient.++ * Add instances for ToContext and FromContext.++ * Remove valueToContext. Add ToJSON, FromJSON instances+ for Context and Val instead.++ * isEmpty: For Doc, treat `Text 0 _` as empty.+ Also `Concat x y` when x and y are empty.+ This differs from isEmpty in DocLayout itself, which only+ applies to Empty.++ * Code cleanup.++## 0.4++ * Split into three modules. Main module only exports an+ opaque version of the Template type. Import Internal if you+ need to manipulate a Template.++ * Add Context type, parameterized on the underlying content's type.++ * Add Val type.++ * Add valueToContext for converting an Aeson Value to a Context.++ * Make renderTemplate and applyTemplate polymorphic in both+ context and target. Context parameter is now any instance+ of ToContext (instead of ToJSON). Result is now any+ instance of TemplateTarget.++ * Change type of getPartial in TemplateMonad so it runs in the+ TemplateMonad instance, not the Parser. Return a simple+ value rather than an Either; error handling can vary with+ the monad.++ * Remove TemplatePart. Template is now an algebraci data type,+ not a list of TemplateParts.++ * Add an Indented type to indicate indentation for+ interpolated variables.++ * Improve architecture, doing more at compile time.++ * Depend on doclayout. Context can be parameterized on a doclayout+ Doc type, allowing intelligent reflowing of content.++ * Remove single final newline in interpolated variable.++ * Remove final newline from partial.++ * Don't iterate when the variable evaluates to NullVal.++ * Only indented interpolated variables if by themselves on line.++ * Add Indented parameter to Interpolate constructor.++ * Update documentation and haddocks.++ * Add benchmark.++## 0.3.0.1++* Bump lower bound on base to 4.9, drop support for ghc 7.10.++* Add needed import for older base versions.++* Add test.hs to repository.++## 0.3++* Note that all of the changes to template syntax described+ below are backwards compatible, and all old pandoc templates+ should continue to work as before.++* Allow `${...}` style delimiters around variables and+ directives, in addition to `$...$`. Allow space around+ the delimiters.++* Support `$it$` as a variable for the current value in+ an iteration. (The old method, where the containing+ variable name is used, still works.)++* Support partials (subtemplates defined in different files).++* Interpolated array variables now have all elements rendered,+ concatenated, with an optional separator that can be+ specified using a new bracketed syntax.++* Remove `TemplateTarget` class. It was pointless; the+ calling program can just do these trivial transformations.+ Avoids dependencies on bytestring, blaze-html, blaze-markup.++* Change type of `renderTemplate` and `applyTemplate` to produce+ a `Text`, instead of being polymorphic.++* Changed type of `compileTemplate`: it now takes a+ template path and the template contents, and returns+ either a template or an error. It runs in an instance+ of `TemplateMonad`, which is an abstraction around different+ ways of getting partials. (For example, in IO we can get+ partials by reading them from a file system, but in a+ web application one might want to obtain them from the+ database or have a set of them baked in.)++* Remove `varListToJSON`.++* Changed the architecture: `Template` is no longer just+ a newtype around a function, but a list of `TemplatePart`s.++* Added a newtype for `Variable`.++* Improved documentation in README.md.++* Added a new test framework and much more extensive tests.+
doctemplates.cabal view
@@ -1,47 +1,86 @@ name: doctemplates-version: 0.1.0.2+version: 0.11.0.1 synopsis: Pandoc-style document templates-description: Please see README.md+description: This is the text templating system used by pandoc.+ It supports variable interpolation, iteration,+ tests for non-blank values, pipes, and partials.+ Templates are rendered to doclayout Docs,+ and variable values may come from a variety of+ different sources, including aeson Values. homepage: https://github.com/jgm/doctemplates#readme license: BSD3 license-file: LICENSE author: John MacFarlane maintainer: jgm@berkeley.edu-copyright: 2016 John MacFarlane+copyright: 2016-19 John MacFarlane category: Text build-type: Simple -- extra-source-files: data-files: README.md+ changelog.md+extra-source-files: test/*.test+ test/*.txt+ test/*.tex cabal-version: >=1.10 library hs-source-dirs: src exposed-modules: Text.DocTemplates- build-depends: base >= 4.7 && < 5,+ Text.DocTemplates.Parser+ Text.DocTemplates.Internal+ build-depends: base >= 4.9 && < 5,+ safe,+ text-conversions, aeson,- bytestring,- blaze-markup,- blaze-html, text,+ doclayout >= 0.4 && < 0.6, containers, vector,+ filepath, parsec,- unordered-containers,+ mtl, scientific+ if !impl(ghc >= 8.0)+ build-depends: semigroups == 0.18.* default-language: Haskell2010 ghc-options: -Wall -fno-warn-unused-do-bind test-suite doctemplates-test type: exitcode-stdio-1.0 hs-source-dirs: test- main-is: Spec.hs+ main-is: test.hs build-depends: base, doctemplates,+ doclayout >= 0.4 && < 0.6,+ containers, aeson,- hspec,+ Glob,+ tasty,+ tasty-golden,+ tasty-hunit,+ filepath,+ temporary,+ bytestring, text ghc-options: -threaded -rtsopts -with-rtsopts=-N default-language: Haskell2010++benchmark doctemplates-bench+ Type: exitcode-stdio-1.0+ Main-Is: bench.hs+ Hs-Source-Dirs: bench+ Build-Depends: doctemplates,+ doclayout >= 0.4 && < 0.6,+ base >= 4.8 && < 5,+ criterion >= 1.0,+ filepath,+ aeson,+ text,+ containers,+ mtl+ Ghc-Options: -rtsopts -Wall -fno-warn-unused-do-bind+ Default-Language: Haskell2010+ source-repository head type: git
src/Text/DocTemplates.hs view
@@ -1,22 +1,42 @@-{-# LANGUAGE TypeSynonymInstances, FlexibleInstances,- OverloadedStrings, GeneralizedNewtypeDeriving, ScopedTypeVariables #-} {- |- Module : Text.Pandoc.Templates- Copyright : Copyright (C) 2009-2016 John MacFarlane+ Module : Text.DocTemplates+ Copyright : Copyright (C) 2009-2019 John MacFarlane License : BSD3 Maintainer : John MacFarlane <jgm@berkeley.edu> Stability : alpha Portability : portable -A simple templating system with variable substitution and conditionals.-This module was formerly part of pandoc and is used for pandoc's-templates. The following program illustrates its use:+This is the text templating system used by pandoc. Its basic function is+to fill variables in a template. Variables are provided by a “context.”+Any instance of the @ToContext@ typeclass (such as an aeson @Value@) can+serve as the context, or a @Context@ value can be constructed manually. +Control structures are provided to test whether a variable has a+non-blank value and to iterate over the items of a list. Partials—that+is, subtemplates defined in different files—are supported. Pipes can be+used to transform the values of variables or partials. The provided+pipes make it possible to do list enumeration and tabular layout in+templates.++Templates are rendered to a doclayout @Doc@ (which is polymorphic in the+underlying string type). If @Doc@ values are used in the context,+rendered documents will be able to wrap flexibly on breaking spaces.+This feature makes doctemplates more suitable than other template+engines for plain-text formats (like Markdown).++Unlike the various HTML-centered template engines, doctemplates is+output-format agnostic, so no automatic escaping is done on interpolated+values. Values are assumed to be escaped properly in the Context.++== Example of use+ > {-# LANGUAGE OverloadedStrings #-}-> import Data.Text+> import Data.Text (Text)+> import qualified Data.Text.IO as T > import Data.Aeson > import Text.DocTemplates+> import Text.DocLayout (render) > > data Employee = Employee { firstName :: String > , lastName :: String@@ -29,277 +49,402 @@ > template :: Text > template = "$for(employee)$Hi, $employee.name.first$. $if(employee.salary)$You make $employee.salary$.$else$No salary data.$endif$$sep$\n$endfor$" >-> main = case compileTemplate template of+> main :: IO ()+> main = do+> res <- compileTemplate "mytemplate.txt" template+> case res of > Left e -> error e-> Right t -> putStrLn $ renderTemplate t $ object+> Right t -> T.putStrLn $ render Nothing $ renderTemplate t $ object > ["employee" .= > [ Employee "John" "Doe" Nothing > , Employee "Omar" "Smith" (Just 30000) > , Employee "Sara" "Chen" (Just 60000) ] > ] -A slot for an interpolated variable is a variable name surrounded-by dollar signs. To include a literal @$@ in your template, use-@$$@. Variable names must begin with a letter and can contain letters,-numbers, @_@, @-@, and @.@.+== Delimiters -The values of variables are determined by a JSON object that is-passed as a parameter to @renderTemplate@. So, for example,-@title@ will return the value of the @title@ field, and-@employee.salary@ will return the value of the @salary@ field-of the object that is the value of the @employee@ field.+To mark variables and control structures in the template, either @$@…@$@+or @${@…@}@ may be used as delimiters. The styles may also be mixed in+the same template, but the opening and closing delimiter must match in+each case. The opening delimiter may be followed by one or more spaces+or tabs, which will be ignored. The closing delimiter may be followed by+one or more spaces or tabs, which will be ignored. -The value of a variable will be indented to the same level as the-variable.+To include a literal @$@ in the document, use @$$@. -A conditional begins with @$if(variable_name)$@ and ends with @$endif$@.-It may optionally contain an @$else$@ section. The if section is-used if @variable_name@ has a non-null value, otherwise the else section-is used.+== Comments -Conditional keywords should not be indented, or unexpected spacing-problems may occur.+Anything between the sequence @$--@ and the end of the line will be+treated as a comment and omitted from the output. -The @$for$@ keyword can be used to iterate over an array. If-the value of the associated variable is not an array, a single-iteration will be performed on its value.+== Interpolated variables -You may optionally specify separators using @$sep$@, as in the-example above.+A slot for an interpolated variable is a variable name surrounded by+matched delimiters. Variable names must begin with a letter and can+contain letters, numbers, @_@, @-@, and @.@. The keywords @it@, @if@,+@else@, @endif@, @for@, @sep@, and @endfor@ may not be used as variable+names. Examples: --}+> $foo$+> $foo.bar.baz$+> $foo_bar.baz-bim$+> $ foo $+> ${foo}+> ${foo.bar.baz}+> ${foo_bar.baz-bim}+> ${ foo } -module Text.DocTemplates ( renderTemplate- , applyTemplate- , TemplateTarget(..)- , varListToJSON- , compileTemplate- , Template- ) where+The values of variables are determined by the @Context@ that is passed+as a parameter to @renderTemplate@. So, for example, @title@ will return+the value of the @title@ field, and @employee.salary@ will return the+value of the @salary@ field of the object that is the value of the+@employee@ field. -import Data.Char (isAlphaNum)-import Control.Monad (guard, when)-import Data.Aeson (ToJSON(..), Value(..))-import qualified Text.Parsec as P-import Text.Parsec.Text (Parser)-import qualified Data.Set as Set-import Data.Monoid-import Control.Applicative-import qualified Data.Text as T-import Data.Text (Text)-import Data.Text.Encoding (encodeUtf8)-import Data.List (intersperse)-import qualified Data.Map as M-import qualified Data.HashMap.Strict as H-import Data.Foldable (toList)-import Text.Blaze.Html (Html)-import Text.Blaze.Internal (preEscapedText)-import Data.ByteString.Lazy (ByteString, fromChunks)-import Data.Vector ((!?))-import Data.Scientific (floatingOrInteger)+- If the value of the variable is simple value, it will be rendered+ verbatim. (Note that no escaping is done; the assumption is that the+ calling program will escape the strings appropriately for the output+ format.)+- If the value of the variable is a boolean value, it will be rendered+ as @true@ if true, or as empty if false.+- If the value is a list, the values will be concatenated.+- If the value is a map, the string @true@ will be rendered.+- Every other value will be rendered as the empty string. --- | A 'Template' is essentially a function that takes--- a JSON 'Value' and produces 'Text'.-newtype Template = Template { unTemplate :: Value -> Text }- deriving Monoid+When a @Context@ is derived from an aeson (JSON) @Value@, the following+conversions are done: -type Variable = [Text]+- If the value is a number, it will be rendered as an integer if+ possible, otherwise as a floating-point number. -class TemplateTarget a where- toTarget :: Text -> a+== Conditionals -instance TemplateTarget Text where- toTarget = id+A conditional begins with @if(variable)@ (enclosed in matched+delimiters) and ends with @endif@ (enclosed in matched delimiters). It+may optionally contain an @else@ (enclosed in matched delimiters). The+@if@ section is used if @variable@ has a true value, otherwise the+@else@ section is used (if present). The following values count as true: -instance TemplateTarget String where- toTarget = T.unpack+- any map+- any array containing at least one true value+- any nonempty string (even @false@)+- boolean True -instance TemplateTarget ByteString where- toTarget = fromChunks . (:[]) . encodeUtf8+Examples: -instance TemplateTarget Html where- toTarget = preEscapedText+> $if(foo)$bar$endif$+>+> $if(foo)$+> $foo$+> $endif$+>+> $if(foo)$+> part one+> $else$+> part two+> $endif$+>+> ${if(foo)}bar${endif}+>+> ${if(foo)}+> ${foo}+> ${endif}+>+> ${if(foo)}+> ${ foo.bar }+> ${else}+> no foo!+> ${endif} --- | A convenience function for passing in an association--- list of string values instead of a JSON 'Value'.-varListToJSON :: [(String, String)] -> Value-varListToJSON assoc = toJSON $ M.fromList assoc'- where assoc' = [(T.pack k, toVal [T.pack z | (y,z) <- assoc,- not (null z),- y == k])- | k <- ordNub $ map fst assoc ]- toVal [x] = toJSON x- toVal [] = Null- toVal xs = toJSON xs+The keyword @elseif@ may be used to simplify complex nested+conditionals. Thus --- An efficient specialization of nub.-ordNub :: (Ord a) => [a] -> [a]-ordNub l = go Set.empty l- where- go _ [] = []- go s (x:xs) = if x `Set.member` s then go s xs- else x : go (Set.insert x s) xs+> $if(foo)$+> XXX+> $elseif(bar)$+> YYY+> $else$+> ZZZ+> $endif$ --- | Compile a template.-compileTemplate :: Text -> Either String Template-compileTemplate template =- case P.parse (pTemplate <* P.eof) "template" template of- Left e -> Left (show e)- Right x -> Right x+is equivalent to --- | Render a compiled template using @context@ to resolve variables.-renderTemplate :: (ToJSON a, TemplateTarget b) => Template -> a -> b-renderTemplate (Template f) context = toTarget $ f $ toJSON context+> $if(foo)$+> XXX+> $else$+> $if(bar)$+> YYY+> $else$+> ZZZ+> $endif$+> $endif$ --- | Combines `renderTemplate` and `compileTemplate`.-applyTemplate :: (ToJSON a, TemplateTarget b) => Text -> a -> Either String b-applyTemplate t context =- case compileTemplate t of- Left e -> Left e- Right f -> Right $ renderTemplate f context+== For loops -var :: Variable -> Template-var = Template . resolveVar+A for loop begins with @for(variable)@ (enclosed in matched delimiters)+and ends with @endfor@ (enclosed in matched delimiters. -resolveVar :: Variable -> Value -> Text-resolveVar var' val =- case multiLookup var' val of- Just (Array vec) -> maybe mempty (resolveVar []) $ vec !? 0- Just (String t) -> T.stripEnd t- Just (Number n) -> case floatingOrInteger n of- Left (r :: Double) -> T.pack $ show r- Right (i :: Integer) -> T.pack $ show i- Just (Bool True) -> "true"- Just (Object _) -> "true"- Just _ -> mempty- Nothing -> mempty+- If @variable@ is an array, the material inside the loop will be+ evaluated repeatedly, with @variable@ being set to each value of the+ array in turn, and concatenated.+- If @variable@ is a map, the material inside will be set to the map.+- If the value of the associated variable is not an array or a map, a+ single iteration will be performed on its value. -multiLookup :: [Text] -> Value -> Maybe Value-multiLookup [] x = Just x-multiLookup (v:vs) (Object o) = H.lookup v o >>= multiLookup vs-multiLookup _ _ = Nothing+Examples: -lit :: Text -> Template-lit = Template . const+> $for(foo)$$foo$$sep$, $endfor$+>+> $for(foo)$+> - $foo.last$, $foo.first$+> $endfor$+>+> ${ for(foo.bar) }+> - ${ foo.bar.last }, ${ foo.bar.first }+> ${ endfor }+>+> $for(mymap)$+> $it.name$: $it.office$+> $endfor$ -cond :: Variable -> Template -> Template -> Template-cond var' (Template ifyes) (Template ifno) = Template $ \val ->- case resolveVar var' val of- "" -> ifno val- _ -> ifyes val+You may optionally specify a separator between consecutive values using+@sep@ (enclosed in matched delimiters). The material between @sep@ and+the @endfor@ is the separator. -iter :: Variable -> Template -> Template -> Template-iter var' template sep = Template $ \val -> unTemplate- (case multiLookup var' val of- Just (Array vec) -> mconcat $ intersperse sep- $ map (setVar template var')- $ toList vec- Just x -> cond var' (setVar template var' x) mempty- Nothing -> mempty) val+> ${ for(foo) }${ foo }${ sep }, ${ endfor } -setVar :: Template -> Variable -> Value -> Template-setVar (Template f) var' val = Template $ f . replaceVar var' val+Instead of using @variable@ inside the loop, the special anaphoric+keyword @it@ may be used. -replaceVar :: Variable -> Value -> Value -> Value-replaceVar [] new _ = new-replaceVar (v:vs) new (Object o) =- Object $ H.adjust (\x -> replaceVar vs new x) v o-replaceVar _ _ old = old+> ${ for(foo.bar) }+> - ${ it.last }, ${ it.first }+> ${ endfor } ---- parsing+== Partials -pTemplate :: Parser Template-pTemplate = do- sp <- P.option mempty pInitialSpace- rest <- mconcat <$> many (pConditional <|>- pFor <|>- pNewline <|>- pVar <|>- pLit <|>- pEscapedDollar)- return $ sp <> rest+Partials (subtemplates stored in different files) may be included using+the syntax -takeWhile1 :: (Char -> Bool) -> Parser Text-takeWhile1 f = T.pack <$> P.many1 (P.satisfy f)+> ${ boilerplate() } -pLit :: Parser Template-pLit = lit <$> takeWhile1 (\x -> x /='$' && x /= '\n')+The partials are obtained using @getPartial@ from the @TemplateMonad@+class. This may be implemented differently in different monads. The path+passed to @getPartial@ is computed on the basis of the original template+path (a parameter to @compileTemplate@) and the partial’s name. The+partial’s name is substituted for the /base name/ of the original+template path (leaving the original template’s extension), unless the+partial has an explicit extension, in which case this is kept. So, with+the @TemplateMonad@ instance for IO, partials will be sought in the+directory containing the main template, and will be assumed to have the+extension of the main template. -pNewline :: Parser Template-pNewline = do- P.char '\n'- sp <- P.option mempty pInitialSpace- return $ lit "\n" <> sp+Partials may optionally be applied to variables using a colon: -pInitialSpace :: Parser Template-pInitialSpace = do- sps <- takeWhile1 (==' ')- let indentVar = if T.null sps- then id- else indent (T.length sps)- v <- P.option mempty $ indentVar <$> pVar- return $ lit sps <> v+> ${ date:fancy() }+>+> ${ articles:bibentry() } -pEscapedDollar :: Parser Template-pEscapedDollar = lit "$" <$ P.try (P.string "$$")+If @articles@ is an array, this will iterate over its values, applying+the partial @bibentry()@ to each one. So the second example above is+equivalent to -pVar :: Parser Template-pVar = var <$> (P.try $ P.char '$' *> pIdent <* P.char '$')+> ${ for(articles) }+> ${ it:bibentry() }+> ${ endfor } -pIdent :: Parser [Text]-pIdent = do- first <- pIdentPart- rest <- many (P.char '.' *> pIdentPart)- return (first:rest)+Note that the anaphoric keyword @it@ must be used when iterating over+partials. In the above examples, the @bibentry@ partial should contain+@it.title@ (and so on) instead of @articles.title@. -pIdentPart :: Parser Text-pIdentPart = P.try $ do- first <- P.letter- rest <- T.pack <$> P.many (P.satisfy (\c -> isAlphaNum c || c == '_' || c == '-'))- let id' = T.singleton first <> rest- guard $ id' `notElem` reservedWords- return id'+Final newlines are omitted from included partials. -reservedWords :: [Text]-reservedWords = ["else","endif","for","endfor","sep"]+Partials may include other partials. If you exceed a nesting level of+50, though, in resolving partials, the literal @(loop)@ will be+returned, to avoid infinite loops. -skipEndline :: Parser ()-skipEndline = P.try $ P.skipMany (P.satisfy (`elem` (" \t" :: String))) >> P.char '\n' >> return ()+A separator between values of an array may be specified in square+brackets, immediately after the variable name or partial: -pConditional :: Parser Template-pConditional = do- P.try $ P.string "$if("- id' <- pIdent- P.string ")$"- -- if newline after the "if", then a newline after "endif" will be swallowed- multiline <- P.option False (True <$ skipEndline)- ifContents <- pTemplate- elseContents <- P.option mempty $ P.try $- do P.string "$else$"- when multiline $ P.option () skipEndline- pTemplate- P.string "$endif$"- when multiline $ P.option () skipEndline- return $ cond id' ifContents elseContents+> ${months[, ]}$+>+> ${articles:bibentry()[; ]$ -pFor :: Parser Template-pFor = do- P.try $ P.string "$for("- id' <- pIdent- P.string ")$"- -- if newline after the "for", then a newline after "endfor" will be swallowed- multiline <- P.option False $ skipEndline >> return True- contents <- pTemplate- sep <- P.option mempty $- do P.try $ P.string "$sep$"- when multiline $ P.option () skipEndline- pTemplate- P.string "$endfor$"- when multiline $ P.option () skipEndline- return $ iter id' contents sep+The separator in this case is literal and (unlike with @sep@ in an+explicit @for@ loop) cannot contain interpolated variables or other+template directives. -indent :: Int -> Template -> Template-indent 0 x = x-indent ind (Template f) = Template $ \val -> indent' (f val)- where indent' t = T.concat- $ intersperse ("\n" <> T.replicate ind " ") $ T.lines t+== Nesting++To ensure that content is “nested,” that is, subsequent lines indented,+use the @^@ directive:++> $item.number$ $^$$item.description$ ($item.price$)++In this example, if @item.description@ has multiple lines, they will all+be indented to line up with the first line:++> 00123 A fine bottle of 18-year old+> Oban whiskey. ($148)++To nest multiple lines to the same level, align them with the @^@+directive in the template. For example:++> $item.number$ $^$$item.description$ ($item.price$)+> (Available til $item.sellby$.)++will produce++> 00123 A fine bottle of 18-year old+> Oban whiskey. ($148)+> (Available til March 30, 2020.)++If a variable occurs by itself on a line, preceded by whitespace and not+followed by further text or directives on the same line, and the+variable’s value contains multiple lines, it will be nested+automatically.++== Breakable spaces++When rendering to a @Doc@, a distinction can be made between breakable+and unbreakable spaces. Normally, spaces in the template itself (as+opposed to values of the interpolated variables) are not breakable, but+they can be made breakable in part of the template by using the @~@+keyword (ended with another @~@).++> $~$This long line may break if the document is rendered+> with a short line length.$~$++The @~@ keyword has no effect when rendering to @Text@ or @String@.++== Pipes++A pipe transforms the value of a variable or partial. Pipes are+specified using a slash (@\/@) between the variable name (or partial)+and the pipe name. Example:++> $for(name)$+> $name/uppercase$+> $endfor$+>+> $for(metadata/pairs)$+> - $it.key$: $it.value$+> $endfor$+>+> $employee:name()/uppercase$++Pipes may be chained:++> $for(employees/pairs)$+> $it.key/alpha/uppercase$. $it.name$+> $endfor$++Some pipes take parameters:++> |----------------------|------------|+> $for(employee)$+> $it.name.first/uppercase/left 20 "| "$$it.name.salary/right 10 " | " " |"$+> $endfor$+> |----------------------|------------|++Currently the following pipes are predefined:++- @pairs@: Converts a map or array to an array of maps, each with+ @key@ and @value@ fields. If the original value was an array, the+ @key@ will be the array index, starting with 1.++- @first@: Returns the first value of an array, if applied to a+ non-empty array; otherwise returns the original value.++- @last@: Returns the last value of an array, if applied to a+ non-empty array; otherwise returns the original value.++- @rest@: Returns all but the first value of an array, if applied to a+ non-empty array; otherwise returns the original value.++- @allbutlast@: Returns all but the last value of an array, if applied+ to a non-empty array; otherwise returns the original value.++- @uppercase@: Converts text to uppercase.++- @lowercase@: Converts text to lowercase.++- @length@: Returns the length of the value: number of characters for+ a textual value, number of elements for a map or array.++- @reverse@: Reverses a textual value or array, and has no effect on+ other values.++- @chomp@: Removes trailing newlines (and breakable space).++- @nowrap@: Disables line wrapping on breakable spaces.++- @alpha@: Converts textual values that can be read as an integer into+ lowercase alphabetic characters @a..z@ (mod 26). This can be used to+ get lettered enumeration from array indices. To get uppercase+ letters, chain with @uppercase@.++- @roman@: Converts textual values that can be read as an integer into+ lowercase roman numerials. This can be used to get lettered+ enumeration from array indices. To get uppercase roman, chain with+ @uppercase@.++- @left n \"leftborder\" \"rightborder\"@: Renders a textual value in+ a block of width @n@, aligned to the left, with an optional left and+ right border. Has no effect on other values. This can be used to+ align material in tables. Widths are positive integers indicating+ the number of characters. Borders are strings inside double quotes;+ literal @\"@ and @\\@ characters must be backslash-escaped.++- @right n \"leftborder\" \"rightborder\"@: Renders a textual value in+ a block of width @n@, aligned to the right, and has no effect on+ other values.++- @center n \"leftborder\" \"rightborder\"@: Renders a textual value+ in a block of width @n@, aligned to the center, and has no effect on+ other values.++-}++module Text.DocTemplates ( renderTemplate+ , compileTemplate+ , compileTemplateFile+ , applyTemplate+ , TemplateMonad(..)+ , TemplateTarget+ , Context(..)+ , Val(..)+ , ToContext(..)+ , FromContext(..)+ , Template -- export opaque type+ , Doc(..)+ ) where++import qualified Data.Text.IO as TIO+import Text.DocLayout (Doc(..))+import Data.Text (Text)+import Text.DocTemplates.Parser (compileTemplate)+import Text.DocTemplates.Internal ( TemplateMonad(..), Context(..),+ Val(..), ToContext(..), FromContext(..), TemplateTarget,+ Template, renderTemplate )++-- | Compile a template from a file. IO errors will be+-- raised as exceptions; template parsing errors result in+-- Left return values.+compileTemplateFile :: TemplateTarget a+ => FilePath -> IO (Either String (Template a))+compileTemplateFile templPath = do+ templateText <- TIO.readFile templPath+ compileTemplate templPath templateText++-- | Compile a template and apply it to a context. This is+-- just a convenience function composing 'compileTemplate'+-- and 'renderTemplate'. If a template will be rendered+-- more than once in the same process, compile it separately+-- for better performance.+applyTemplate :: (TemplateMonad m, TemplateTarget a, ToContext a b)+ => FilePath -> Text -> b -> m (Either String (Doc a))+applyTemplate fp t val = do+ res <- compileTemplate fp t+ case res of+ Left s -> return $ Left s+ Right t' -> return $ Right $ renderTemplate t' val+
+ src/Text/DocTemplates/Internal.hs view
@@ -0,0 +1,469 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE ConstraintKinds #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DeriveTraversable #-}++{- |+ Module : Text.DocTemplates.Internal+ Copyright : Copyright (C) 2009-2019 John MacFarlane+ License : BSD3++ Maintainer : John MacFarlane <jgm@berkeley.edu>+ Stability : alpha+ Portability : portable+-}++module Text.DocTemplates.Internal+ ( renderTemplate+ , TemplateMonad(..)+ , Context(..)+ , Val(..)+ , ToContext(..)+ , FromContext(..)+ , TemplateTarget+ , Template(..)+ , Variable(..)+ , Pipe(..)+ , Alignment(..)+ , Border(..)+ ) where++import Data.Text.Conversions (FromText(..), ToText(..))+import Data.Aeson (Value(..), ToJSON(..), FromJSON(..), Result(..), fromJSON)+import Control.Monad.Identity+import qualified Control.Monad.State.Strict as S+import Data.Char (chr, ord)+import Data.Maybe (fromMaybe)+import qualified Data.Text.Read as T+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import Text.DocLayout (Doc, HasChars)+import qualified Text.DocLayout as DL+import Data.String (IsString(..))+import Data.Data (Data)+import Data.Typeable (Typeable)+import GHC.Generics (Generic)+import Data.Text (Text)+import qualified Data.Map as M+import qualified Data.Vector as V+import Data.Scientific (floatingOrInteger)+import Data.List (intersperse)+#if MIN_VERSION_base(4,11,0)+#else+import Data.Semigroup+#endif++-- | A template.+data Template a =+ Interpolate Variable+ | Conditional Variable (Template a) (Template a)+ | Iterate Variable (Template a) (Template a)+ | Nested (Template a)+ | Partial [Pipe] (Template a)+ | Literal (Doc a)+ | Concat (Template a) (Template a)+ | Empty+ deriving (Show, Read, Data, Typeable, Generic, Eq, Ord,+ Foldable, Traversable, Functor)++instance Semigroup a => Semigroup (Template a) where+ x <> Empty = x+ Empty <> x = x+ x <> y = Concat x y++instance Semigroup a => Monoid (Template a) where+ mappend = (<>)+ mempty = Empty++data Pipe =+ ToPairs+ | ToUppercase+ | ToLowercase+ | ToLength+ | Reverse+ | FirstItem+ | LastItem+ | Rest+ | AllButLast+ | Chomp+ | ToAlpha+ | ToRoman+ | NoWrap+ | Block Alignment Int Border+ deriving (Show, Read, Data, Typeable, Generic, Eq, Ord)++data Alignment =+ LeftAligned+ | Centered+ | RightAligned+ deriving (Show, Read, Data, Typeable, Generic, Eq, Ord)++data Border = Border+ { borderLeft :: Text+ , borderRight :: Text+ }+ deriving (Show, Read, Data, Typeable, Generic, Eq, Ord)++-- | A variable which may have several parts (@foo.bar.baz@).+data Variable =+ Variable+ { varParts :: [Text]+ , varPipes :: [Pipe]+ }+ deriving (Show, Read, Data, Typeable, Generic, Eq, Ord)++instance Semigroup Variable where+ Variable xs fs <> Variable ys gs = Variable (xs <> ys) (fs <> gs)++instance Monoid Variable where+ mempty = Variable mempty mempty+ mappend = (<>)++type TemplateTarget a =+ (HasChars a, ToText a, FromText a)++-- | A 'Context' defines values for template's variables.+newtype Context a = Context { unContext :: M.Map Text (Val a) }+ deriving (Show, Semigroup, Monoid, Traversable, Foldable, Functor,+ Data, Typeable)++-- | A variable value.+data Val a =+ SimpleVal (Doc a)+ | ListVal [Val a]+ | MapVal (Context a)+ | BoolVal Bool+ | NullVal+ deriving (Show, Traversable, Foldable, Functor, Data, Typeable)++-- | The 'ToContext' class provides automatic conversion to+-- a 'Context' or 'Val'.+class ToContext a b where+ toContext :: b -> Context a+ toContext x = case toVal x of+ MapVal c -> c+ _ -> mempty+ toVal :: b -> Val a++instance ToContext a (Context a) where+ toContext = id+ toVal = MapVal++instance ToContext a (Val a) where+ toVal = id++instance TemplateTarget a => ToContext a a where+ toVal = SimpleVal . DL.literal++instance ToContext a a => ToContext a (Doc a) where+ toVal = SimpleVal++instance ToContext a b => ToContext a [b] where+ toVal = ListVal . map toVal++instance {-# OVERLAPPING #-} TemplateTarget [a] => ToContext [a] [a] where+ toVal = SimpleVal . DL.literal++instance ToContext a b => ToContext a (M.Map Text b) where+ toVal = MapVal . toContext+ toContext = Context . M.map toVal++instance TemplateTarget a => ToContext a Bool where+ toVal True = BoolVal True+ toVal False = BoolVal False++instance TemplateTarget a => ToContext a Value where+ toContext x = case fromJSON x of+ Success y -> y+ Error _ -> mempty+ toVal x = case fromJSON x of+ Success y -> y+ Error _ -> NullVal++-- | The 'FromContext' class provides functions for extracting+-- values from 'Val' and 'Context'.+class FromContext a b where+ fromVal :: Val a -> Maybe b+ lookupContext :: Text -> Context a -> Maybe b+ lookupContext t (Context m) = M.lookup t m >>= fromVal++instance TemplateTarget a => FromContext a (Val a) where+ fromVal = Just++instance TemplateTarget a => FromContext a (Doc a) where+ fromVal (SimpleVal x) = Just x+ fromVal _ = Nothing++instance TemplateTarget a => FromContext a a where+ fromVal (SimpleVal x) = Just (DL.render Nothing x)+ fromVal _ = Nothing++instance {-# OVERLAPPING #-} TemplateTarget [a] => FromContext [a] [a] where+ fromVal (SimpleVal x) = Just (DL.render Nothing x)+ fromVal _ = Nothing++instance FromContext a b => FromContext a [b] where+ fromVal (ListVal xs) = mapM fromVal xs+ fromVal x = sequence [fromVal x]++instance TemplateTarget a => FromJSON (Val a) where+ parseJSON v =+ case v of+ Array vec -> ListVal <$> mapM parseJSON (V.toList vec)+ String t -> return $ SimpleVal $ DL.literal $ fromText t+ Number n -> return $ SimpleVal $ fromString $+ case floatingOrInteger n of+ Left (r :: Double) -> show r+ Right (i :: Integer) -> show i+ Bool b -> return $ BoolVal b+ Object _ -> MapVal . Context <$> parseJSON v+ _ -> return NullVal++instance TemplateTarget a => FromJSON (Context a) where+ parseJSON v = do+ val <- parseJSON v+ case val of+ MapVal o -> return o+ _ -> fail "Expecting MapVal"++instance TemplateTarget a => ToJSON (Context a) where+ toJSON (Context m) = toJSON m++instance TemplateTarget a => ToJSON (Val a) where+ toJSON NullVal = Null+ toJSON (MapVal m) = toJSON m+ toJSON (ListVal xs) = toJSON xs+ toJSON (SimpleVal d) = toJSON $ toText $ DL.render Nothing d+ toJSON (BoolVal b) = toJSON b++mapDoc :: TemplateTarget a => (Doc a -> Doc a) -> Val a -> Val a+mapDoc f val =+ case val of+ SimpleVal d -> SimpleVal (f d)+ MapVal (Context m) -> MapVal (Context $ M.map (mapDoc f) m)+ ListVal xs -> ListVal $ map (mapDoc f) xs+ BoolVal b -> BoolVal b+ NullVal -> NullVal++mapText :: TemplateTarget a => (Text -> Text) -> Val a -> Val a+mapText f val =+ runIdentity (traverse (return . fromText . f . toText) val)++applyPipe :: TemplateTarget a => Pipe -> Val a -> Val a+applyPipe ToLength val = SimpleVal $ fromString . show $ len+ where+ len = case val of+ SimpleVal d -> T.length . toText $ DL.render Nothing d+ MapVal (Context m) -> M.size m+ ListVal xs -> length xs+ BoolVal _ -> 0+ NullVal -> 0+applyPipe ToUppercase val = mapText T.toUpper val+applyPipe ToLowercase val = mapText T.toLower val+applyPipe ToPairs val =+ case val of+ MapVal (Context m) ->+ ListVal $ map toPair $ M.toList m+ ListVal xs ->+ ListVal $ map toPair $ zip (map (fromString . show) [(1::Int)..]) xs+ _ -> val+ where+ toPair (k, v) = MapVal $ Context $ M.fromList+ [ ("key", SimpleVal $ fromString . T.unpack $ k)+ , ("value", v) ]+applyPipe FirstItem val =+ case val of+ ListVal (x:_) -> x+ _ -> val+applyPipe LastItem val =+ case val of+ ListVal xs@(_:_) -> last xs+ _ -> val+applyPipe Rest val =+ case val of+ ListVal (_:xs) -> ListVal xs+ _ -> val+applyPipe AllButLast val =+ case val of+ ListVal xs@(_:_) -> ListVal (init xs)+ _ -> val+applyPipe Reverse val =+ case val of+ ListVal xs -> ListVal (reverse xs)+ SimpleVal{} -> mapText T.reverse val+ _ -> val+applyPipe Chomp val = mapDoc DL.chomp val+applyPipe ToAlpha val = mapText toAlpha val+ where toAlpha t =+ case T.decimal t of+ Right (y,"") -> fromString [chr (ord 'a' + (y `mod` 26) - 1)]+ _ -> t+applyPipe ToRoman val = mapText toRoman' val+ where toRoman' t =+ case T.decimal t of+ Right (y,"") -> fromMaybe t (toRoman y)+ _ -> t+applyPipe NoWrap val = mapDoc DL.nowrap val+applyPipe (Block align n border) val =+ let constructor = case align of+ LeftAligned -> DL.lblock+ Centered -> DL.cblock+ RightAligned -> DL.rblock+ toBorder y = if T.null y+ then mempty+ else DL.vfill (fromText y)+ in case nullToSimple val of+ SimpleVal d -> SimpleVal $+ toBorder (borderLeft border) <>+ constructor n d <>+ toBorder (borderRight border)+ _ -> val++nullToSimple :: Monoid a => Val a -> Val a+nullToSimple NullVal = SimpleVal mempty+nullToSimple x = x++-- | Convert number 0 < x < 4000 to lowercase roman numeral.+toRoman :: Int -> Maybe Text+toRoman x+ | x >= 1000+ , x < 4000 = ("m" <>) <$> toRoman (x - 1000)+ | x >= 900 = ("cm" <>) <$> toRoman (x - 900)+ | x >= 500 = ("d" <>) <$> toRoman (x - 500)+ | x >= 400 = ("cd" <>) <$> toRoman (x - 400)+ | x >= 100 = ("c" <>) <$> toRoman (x - 100)+ | x >= 90 = ("xc" <>) <$> toRoman (x - 90)+ | x >= 50 = ("l" <>) <$> toRoman (x - 50)+ | x >= 40 = ("xl" <>) <$> toRoman (x - 40)+ | x >= 10 = ("x" <>) <$> toRoman (x - 10)+ | x == 9 = return "ix"+ | x >= 5 = ("v" <>) <$> toRoman (x - 5)+ | x == 4 = return "iv"+ | x >= 1 = ("i" <>) <$> toRoman (x - 1)+ | x == 0 = return ""+ | otherwise = Nothing++applyPipes :: TemplateTarget a => [Pipe] -> Val a -> Val a+applyPipes fs x = foldr applyPipe x $ reverse fs++multiLookup :: TemplateTarget a => [Text] -> Val a -> Val a+multiLookup [] x = x+multiLookup (t:vs) (MapVal (Context o)) =+ case M.lookup t o of+ Nothing -> NullVal+ Just v' -> multiLookup vs v'+multiLookup _ _ = NullVal++-- The Bool indicates whether it's a true or false value.+data Resolved a = Resolved Bool [Doc a]+ deriving (Show, Read, Data, Typeable, Generic, Eq, Ord,+ Foldable, Traversable, Functor)++instance Semigroup (Resolved a) where+ Resolved b1 x1 <> Resolved b2 x2 = Resolved (b1 || b2) (x1 <> x2)++instance Monoid (Resolved a) where+ mappend = (<>)+ mempty = Resolved False []++resolveVariable :: TemplateTarget a+ => Variable -> Context a -> Resolved a+resolveVariable v ctx = resolveVariable' v (MapVal ctx)++resolveVariable' :: TemplateTarget a+ => Variable -> Val a -> Resolved a+resolveVariable' v val =+ case applyPipes (varPipes v) $ multiLookup (varParts v) val of+ ListVal xs -> mconcat $ map (resolveVariable' mempty) xs+ SimpleVal d+ | DL.isEmpty d -> Resolved False []+ | otherwise -> Resolved True [removeFinalNl d]+ MapVal _ -> Resolved True ["true"]+ BoolVal True -> Resolved True ["true"]+ BoolVal False -> Resolved False ["false"]+ NullVal -> Resolved False []++removeFinalNl :: Doc a -> Doc a+removeFinalNl DL.NewLine = mempty+removeFinalNl DL.CarriageReturn = mempty+removeFinalNl (DL.Concat d1 d2) = d1 <> removeFinalNl d2+removeFinalNl x = x++withVariable :: (Monad m, TemplateTarget a)+ => Variable -> Context a -> (Context a -> m (Doc a))+ -> m [Doc a]+withVariable var ctx f =+ case applyPipes (varPipes var) $ multiLookup (varParts var) (MapVal ctx) of+ NullVal -> return mempty+ ListVal xs -> mapM (\iterval -> f $ setVarVal iterval) xs+ MapVal ctx' -> (:[]) <$> f (setVarVal (MapVal ctx'))+ val' -> (:[]) <$> f (setVarVal val')+ where+ setVarVal x =+ addToContext var x $ Context $ M.insert "it" x $ unContext ctx+ addToContext (Variable [] _) _ (Context ctx') = Context ctx'+ addToContext (Variable (v:vs) fs) x (Context ctx') =+ Context $ M.adjust+ (\z -> case z of+ _ | null vs -> x+ MapVal m ->+ MapVal $ addToContext (Variable vs fs) x m+ _ -> z) v ctx'++type RenderState = S.State Int++-- | Render a compiled template in a "context" which provides+-- values for the template's variables.+renderTemplate :: (TemplateTarget a, ToContext a b)+ => Template a -> b -> Doc a+renderTemplate t x = S.evalState (renderTemp t (toContext x)) 0++updateColumn :: TemplateTarget a => Doc a -> RenderState (Doc a)+updateColumn x = do+ S.modify $ DL.updateColumn x+ return x++renderTemp :: forall a . TemplateTarget a+ => Template a -> Context a -> RenderState (Doc a)+renderTemp (Literal t) _ = updateColumn t+renderTemp (Interpolate v) ctx =+ case resolveVariable v ctx of+ Resolved _ xs -> updateColumn (mconcat xs)+renderTemp (Conditional v ift elset) ctx =+ case resolveVariable v ctx of+ Resolved False _ -> renderTemp elset ctx+ Resolved True _ -> renderTemp ift ctx+renderTemp (Iterate v t sep) ctx = do+ xs <- withVariable v ctx (renderTemp t)+ sep' <- renderTemp sep ctx+ return . mconcat . intersperse sep' $ xs+renderTemp (Nested t) ctx = do+ n <- S.get+ DL.nest n <$> renderTemp t ctx+renderTemp (Partial fs t) ctx = do+ val' <- renderTemp t ctx+ return $ case applyPipes fs (SimpleVal val') of+ SimpleVal x -> x+ _ -> mempty+renderTemp (Concat t1 t2) ctx =+ mappend <$> renderTemp t1 ctx <*> renderTemp t2 ctx+renderTemp Empty _ = return mempty++-- | A 'TemplateMonad' defines a function to retrieve a partial+-- (from the file system, from a database, or using a default+-- value).+class Monad m => TemplateMonad m where+ getPartial :: FilePath -> m Text++instance TemplateMonad Identity where+ getPartial _ = return mempty++instance TemplateMonad IO where+ getPartial = TIO.readFile
+ src/Text/DocTemplates/Parser.hs view
@@ -0,0 +1,441 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE OverloadedStrings #-}+{- |+ Module : Text.DocTemplates.Parser+ Copyright : Copyright (C) 2009-2019 John MacFarlane+ License : BSD3++ Maintainer : John MacFarlane <jgm@berkeley.edu>+ Stability : alpha+ Portability : portable+-}++module Text.DocTemplates.Parser+ ( compileTemplate ) where++import Data.Char (isAlphaNum)+import Control.Monad (guard, when)+import Control.Monad.Trans (lift)+import qualified Text.Parsec as P+import qualified Text.Parsec.Pos as P+import Control.Applicative+import Data.String (IsString(..))+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Read as T+import System.FilePath+import Text.DocTemplates.Internal+import qualified Text.DocLayout as DL+#if MIN_VERSION_base(4,11,0)+#else+import Data.Semigroup ((<>), Semigroup)+#endif++-- | Compile a template. The FilePath parameter is used+-- to determine a default path and extension for partials+-- and may be left empty if partials are not used.+compileTemplate :: (TemplateMonad m, TemplateTarget a)+ => FilePath -> Text -> m (Either String (Template a))+compileTemplate templPath template = do+ res <- P.runParserT (pTemplate <* P.eof)+ PState{ templatePath = templPath+ , partialNesting = 1+ , breakingSpaces = False+ , firstNonspace = P.initialPos templPath+ , nestedCol = Nothing+ , insideDirective = False+ } templPath template+ case res of+ Left e -> return $ Left $ show e+ Right x -> return $ Right x+++data PState =+ PState { templatePath :: FilePath+ , partialNesting :: !Int+ , breakingSpaces :: !Bool+ , firstNonspace :: P.SourcePos+ , nestedCol :: Maybe Int+ , insideDirective :: Bool+ }++type Parser = P.ParsecT Text PState++pTemplate :: (TemplateMonad m, TemplateTarget a) => Parser m (Template a)+pTemplate = do+ P.skipMany pComment+ mconcat <$> many+ ((pLit <|> pNewline <|> pDirective <|>+ pEscape) <* P.skipMany pComment)++pEndline :: Monad m => Parser m String+pEndline = P.try $ do+ nls <- pLineEnding+ mbNested <- nestedCol <$> P.getState+ inside <- insideDirective <$> P.getState+ case mbNested of+ Just col -> do+ P.skipMany $ do+ P.getPosition >>= guard . (< col) . P.sourceColumn+ P.char ' ' <|> P.char '\t'+ curcol <- P.sourceColumn <$> P.getPosition+ guard $ inside || curcol >= col+ Nothing -> return ()+ return nls++pBlankLine :: (TemplateTarget a, Monad m) => Parser m (Template a)+pBlankLine =+ P.try $ Literal . fromString <$> pLineEnding <* P.lookAhead pNewlineOrEof++pNewline :: (TemplateTarget a, Monad m) => Parser m (Template a)+pNewline = P.try $ do+ nls <- pEndline+ sps <- P.many (P.char ' ' <|> P.char '\t')+ breakspaces <- breakingSpaces <$> P.getState+ pos <- P.getPosition+ P.updateState $ \st -> st{ firstNonspace = pos }+ return $ Literal $+ if breakspaces+ then DL.BreakingSpace+ else fromString $ nls <> sps++pLit :: (TemplateTarget a, Monad m) => Parser m (Template a)+pLit = do+ cs <- P.many1 (P.satisfy (\c -> c /= '$' && c /= '\n' && c /= '\r'))+ when (all (\c -> c == ' ' || c == '\t') cs) $ do+ pos <- P.getPosition+ when (P.sourceLine pos == 1) $+ P.updateState $ \st -> st{ firstNonspace = pos }+ breakspaces <- breakingSpaces <$> P.getState+ if breakspaces+ then return $ toBreakable cs+ else return $ Literal $ fromString cs++toBreakable :: TemplateTarget a => String -> Template a+toBreakable [] = Empty+toBreakable xs =+ case break isSpacy xs of+ ([], []) -> Empty+ ([], zs) -> Literal DL.BreakingSpace <>+ toBreakable (dropWhile isSpacy zs)+ (ys, []) -> Literal (fromString ys)+ (ys, zs) -> Literal (fromString ys) <> toBreakable zs++isSpacy :: Char -> Bool+isSpacy ' ' = True+isSpacy '\n' = True+isSpacy '\r' = True+isSpacy '\t' = True+isSpacy _ = False++backupSourcePos :: Monad m => Int -> Parser m ()+backupSourcePos n = do+ pos <- P.getPosition+ P.setPosition $ P.incSourceColumn pos (- n)++pEscape :: (TemplateTarget a, Monad m) => Parser m (Template a)+pEscape = Literal "$" <$ P.try (P.string "$$" <* backupSourcePos 1)++pDirective :: (TemplateTarget a, TemplateMonad m)+ => Parser m (Template a)+pDirective =+ pConditional <|> pForLoop <|> pReflowToggle <|> pNested <|>+ pInterpolate <|> pBarePartial++pEnclosed :: Monad m => Parser m a -> Parser m a+pEnclosed parser = P.try $ do+ closer <- pOpen+ P.skipMany pSpaceOrTab+ result <- parser+ P.skipMany pSpaceOrTab+ closer+ return result++pParens :: Monad m => Parser m a -> Parser m a+pParens parser = do+ P.char '('+ result <- parser+ P.char ')'+ return result++pInside :: Monad m+ => Parser m (Template a)+ -> Parser m (Template a)+pInside parser = do+ oldInside <- insideDirective <$> P.getState+ P.updateState $ \st -> st{ insideDirective = True }+ res <- parser+ P.updateState $ \st -> st{ insideDirective = oldInside }+ return res++pConditional :: (TemplateTarget a, TemplateMonad m)+ => Parser m (Template a)+pConditional = do+ v <- pEnclosed $ P.try $ P.string "if" *> pParens pVar+ pInside $ do+ multiline <- P.option False (True <$ skipEndline)+ -- if newline after the "if", then a newline after "endif" will be swallowed+ ifContents <- pTemplate+ elseContents <- P.option mempty (pElse multiline <|> pElseIf)+ pEnclosed (P.string "endif")+ when multiline $ P.option () skipEndline+ return $ Conditional v ifContents elseContents++pElse :: (TemplateTarget a, TemplateMonad m)+ => Bool -> Parser m (Template a)+pElse multiline = do+ pEnclosed (P.string "else")+ when multiline $ P.option () skipEndline+ pTemplate++pElseIf :: (TemplateTarget a, TemplateMonad m) => Parser m (Template a)+pElseIf = do+ v <- pEnclosed $ P.try $ P.string "elseif" *> pParens pVar+ multiline <- P.option False (True <$ skipEndline)+ ifContents <- pTemplate+ elseContents <- P.option mempty (pElse multiline <|> pElseIf)+ return $ Conditional v ifContents elseContents++skipEndline :: Monad m => Parser m ()+skipEndline = do+ pEndline+ pos <- P.lookAhead $ do+ P.skipMany (P.char ' ' <|> P.char '\t')+ P.getPosition+ P.updateState $ \st -> st{ firstNonspace = pos }++pReflowToggle :: (Monoid a, Semigroup a, TemplateMonad m)+ => Parser m (Template a)+pReflowToggle = do+ pEnclosed $ P.char '~'+ P.modifyState $ \st -> st{ breakingSpaces = not (breakingSpaces st) }+ return mempty++pNested :: (TemplateTarget a, TemplateMonad m) => Parser m (Template a)+pNested = do+ col <- P.sourceColumn <$> P.getPosition+ pEnclosed $ P.char '^'+ oldNested <- nestedCol <$> P.getState+ P.updateState $ \st -> st{ nestedCol = Just col }+ x <- pTemplate+ xs <- P.many $ P.try $ do+ y <- mconcat <$> P.many1 pBlankLine+ z <- pTemplate+ return (y <> z)+ let contents = x <> mconcat xs+ P.updateState $ \st -> st{ nestedCol = oldNested }+ return $ Nested contents++pForLoop :: (TemplateTarget a, TemplateMonad m) => Parser m (Template a)+pForLoop = do+ v <- pEnclosed $ P.try $ P.string "for" *> pParens pVar+ -- if newline after the "for", then a newline after "endfor" will be swallowed+ pInside $ do+ multiline <- P.option False $ skipEndline >> return True+ contents <- pTemplate+ sep <- P.option mempty $+ do pEnclosed (P.string "sep")+ when multiline $ P.option () skipEndline+ pTemplate+ pEnclosed (P.string "endfor")+ when multiline $ P.option () skipEndline+ return $ Iterate v contents sep++pInterpolate :: (TemplateTarget a, TemplateMonad m)+ => Parser m (Template a)+pInterpolate = do+ pos <- P.getPosition+ -- we don't used pEnclosed here, to get better error messages:+ (closer, var) <- P.try $ do+ cl <- pOpen+ P.skipMany pSpaceOrTab+ v <- pVar+ P.notFollowedBy (P.char '(') -- bare partial+ return (cl, v)+ res <- (P.char ':' *> (pPartialName >>= pPartial (Just var)))+ <|> Iterate var (Interpolate (Variable ["it"] [])) <$> pSep+ <|> return (Interpolate var)+ P.skipMany pSpaceOrTab+ closer+ handleNesting False pos res++pLineEnding :: Monad m => Parser m String+pLineEnding = P.string "\n" <|> P.try (P.string "\r\n") <|> P.string "\r"++pNewlineOrEof :: Monad m => Parser m ()+pNewlineOrEof = () <$ pLineEnding <|> P.eof++handleNesting :: TemplateMonad m+ => Bool -> P.SourcePos -> Template a -> Parser m (Template a)+handleNesting eatEndline pos templ = do+ firstNonspacePos <- firstNonspace <$> P.getState+ let beginline = firstNonspacePos == pos+ endofline <- (True <$ P.lookAhead pNewlineOrEof) <|> pure False+ when (eatEndline && beginline) $ P.optional skipEndline+ mbNested <- nestedCol <$> P.getState+ let toNested t@(Nested{}) = t+ toNested t = case P.sourceColumn pos of+ 1 -> t+ n | Just n == mbNested -> t+ | otherwise -> Nested t+ return $ if beginline && endofline+ then toNested templ+ else templ++pBarePartial :: (TemplateTarget a, TemplateMonad m)+ => Parser m (Template a)+pBarePartial = do+ pos <- P.getPosition+ (closer, fp) <- P.try $ do+ closer <- pOpen+ P.skipMany pSpaceOrTab+ fp <- pPartialName+ return (closer, fp)+ res <- pPartial Nothing fp+ P.skipMany pSpaceOrTab+ closer+ handleNesting True pos res++pPartialName :: TemplateMonad m+ => Parser m FilePath+pPartialName = P.try $ do+ fp <- P.many1 (P.alphaNum <|> P.oneOf ['_','-','.','/','\\'])+ P.string "()"+ return fp++pPartial :: (TemplateTarget a, TemplateMonad m)+ => Maybe Variable -> FilePath -> Parser m (Template a)+pPartial mbvar fp = do+ oldst <- P.getState+ separ <- P.option mempty pSep+ tp <- templatePath <$> P.getState+ let fp' = case takeExtension fp of+ "" -> replaceBaseName tp fp+ _ -> replaceFileName tp fp+ partial <- lift $ removeFinalNewline <$> getPartial fp'+ nesting <- partialNesting <$> P.getState+ t <- if nesting > 50+ then return $ Literal "(loop)"+ else do+ oldInput <- P.getInput+ oldPos <- P.getPosition+ P.setPosition $ P.initialPos fp'+ P.setInput partial+ P.updateState $ \st -> st{ partialNesting = nesting + 1 }+ P.updateState $ \st -> st{ nestedCol = Nothing }+ res' <- pTemplate <* P.eof+ P.updateState $ \st -> st{ partialNesting = nesting }+ P.setInput oldInput+ P.setPosition oldPos+ return res'+ P.putState oldst+ fs <- many pPipe+ case mbvar of+ Just var -> return $ Iterate var (Partial fs t) separ+ Nothing -> return $ Partial fs t++removeFinalNewline :: Text -> Text+removeFinalNewline t =+ case T.unsnoc t of+ Just (t', '\n') -> t'+ _ -> t++pSep :: (TemplateTarget a, Monad m) => Parser m (Template a)+pSep = do+ P.char '['+ xs <- P.many (P.satisfy (/= ']'))+ P.char ']'+ return $ Literal (fromString xs)++pSpaceOrTab :: Monad m => Parser m Char+pSpaceOrTab = P.satisfy (\c -> c == ' ' || c == '\t')++pComment :: Monad m => Parser m ()+pComment = do+ pos <- P.getPosition+ P.try (P.string "$--")+ P.skipMany (P.satisfy (/='\n'))+ -- If the comment begins in the first column, the line ending+ -- will be consumed; otherwise not.+ when (P.sourceColumn pos == 1) $ () <$ pNewlineOrEof++pOpenDollar :: Monad m => Parser m (Parser m ())+pOpenDollar =+ pCloseDollar <$ P.try (P.char '$' <*+ P.notFollowedBy (P.char '$' <|> P.char '{'))+ where+ pCloseDollar = () <$ P.char '$'++pOpenBraces :: Monad m => Parser m (Parser m ())+pOpenBraces =+ pCloseBraces <$ P.try (P.string "${" <* P.notFollowedBy (P.char '}'))+ where+ pCloseBraces = () <$ P.try (P.char '}')++pOpen :: Monad m => Parser m (Parser m ())+pOpen = pOpenDollar <|> pOpenBraces++pVar :: Monad m => Parser m Variable+pVar = do+ first <- pIdentPart <|> pIt+ rest <- P.many (P.char '.' *> pIdentPart)+ pipes <- P.many pPipe+ return $ Variable (first:rest) pipes++pPipe :: Monad m => Parser m Pipe+pPipe = do+ P.char '/'+ pipeName <- P.many1 P.letter+ P.notFollowedBy P.letter+ case pipeName of+ "uppercase" -> return ToUppercase+ "lowercase" -> return ToLowercase+ "pairs" -> return ToPairs+ "length" -> return ToLength+ "alpha" -> return ToAlpha+ "roman" -> return ToRoman+ "reverse" -> return Reverse+ "first" -> return FirstItem+ "rest" -> return Rest+ "last" -> return LastItem+ "allbutlast" -> return AllButLast+ "chomp" -> return Chomp+ "nowrap" -> return NoWrap+ "left" -> Block LeftAligned <$> pBlockWidth <*> pBlockBorders+ "right" -> Block RightAligned <$> pBlockWidth <*> pBlockBorders+ "center" -> Block Centered <$> pBlockWidth <*> pBlockBorders+ _ -> fail $ "Unknown pipe " ++ pipeName++pBlockWidth :: Monad m => Parser m Int+pBlockWidth = P.try (do+ _ <- P.many1 P.space+ ds <- P.many1 P.digit+ case T.decimal (T.pack ds) of+ Right (n,"") -> return n+ _ -> fail "Expected integer parameter for pipe") P.<?>+ "integer parameter for pipe"++pBlockBorders :: Monad m => Parser m Border+pBlockBorders = do+ P.skipMany P.space+ let pBorder = do+ P.char '"'+ cs <- P.many $ (P.noneOf ['"','\\']) <|> (P.char '\\' >> P.anyChar)+ P.char '"'+ P.skipMany P.space+ return $ T.pack cs+ Border <$> P.option mempty pBorder <*> P.option mempty pBorder++pIt :: Monad m => Parser m Text+pIt = fromString <$> P.try (P.string "it")++pIdentPart :: Monad m => Parser m Text+pIdentPart = P.try $ do+ first <- P.letter+ rest <- P.many (P.satisfy (\c -> isAlphaNum c || c == '_' || c == '-'))+ let part = first : rest+ guard $ part `notElem` reservedWords+ return $ fromString part++reservedWords :: [String]+reservedWords = ["if","else","endif","elseif","for","endfor","sep","it"]
− test/Spec.hs
@@ -1,41 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--import Text.DocTemplates-import Test.Hspec-import Data.Text-import Data.Aeson--data Employee = Employee { firstName :: String- , lastName :: String- , salary :: Maybe Integer }-instance ToJSON Employee where- toJSON e = object [ "name" .= object [ "first" .= firstName e- , "last" .= lastName e ]- , "salary" .= salary e ]--employees :: [Employee]-employees = [ Employee "John" "Doe" Nothing- , Employee "Omar" "Smith" (Just 30000)- , Employee "Sara" "Chen" (Just 60000) ]--template :: Text-template =- "$for(employee)$Hi, $employee.name.first$. $if(employee.salary)$You make $$$employee.salary$.$else$No salary data.$endif$$sep$\n$endfor$"--main :: IO ()-main = hspec $ do- describe "applyTemplate" $ do- it "works" $ do- applyTemplate template (object ["employee" .= employees])- `shouldBe`- (Right "Hi, John. No salary data.\nHi, Omar. You make $30000.\nHi, Sara. You make $60000." :: Either String Text)- it "renders numbers appropriately as integer or floating" $ do- applyTemplate "$m$ and $n$"- (object ["m" .= (5 :: Integer), "n" .= (7.3 :: Double)])- `shouldBe`- (Right "5 and 7.3" :: Either String Text)- it "fails with an incorrect template" $ do- applyTemplate "$if(x$and$endif$" (object [])- `shouldBe`- (Left "\"template\" (line 1, column 6):\nunexpected \"$\"\nexpecting \".\" or \")$\"" :: Either String Text)-
+ test/bad.txt view
@@ -0,0 +1,2 @@+partial+$with syntax error
+ test/basic-with-braces.test view
@@ -0,0 +1,16 @@+{ "employee":+ [ { "name": { "first": "John", "last": "Doe" } }+ , { "name": { "first": "Omar", "last": "Smith" }+ , "salary": "30000" }+ , { "name": { "first": "Sara", "last": "Chen" }+ , "salary": "60000" }+ ]+}+.+${ for(employee) }+Hi, ${employee.name.first}. ${ if(employee.salary) }You make $$${ employee.salary }.${ else }No salary data.${ endif }+${ endfor }+.+Hi, John. No salary data.+Hi, Omar. You make $30000.+Hi, Sara. You make $60000.
+ test/basic-with-it.test view
@@ -0,0 +1,16 @@+{ "employee":+ [ { "name": { "first": "John", "last": "Doe" } }+ , { "name": { "first": "Omar", "last": "Smith" }+ , "salary": "30000" }+ , { "name": { "first": "Sara", "last": "Chen" }+ , "salary": "60000" }+ ]+}+.+$for(employee)$+Hi, $it.name.first$. $if(it.salary)$You make $$$it.salary$.$else$No salary data.$endif$+$endfor$+.+Hi, John. No salary data.+Hi, Omar. You make $30000.+Hi, Sara. You make $60000.
+ test/basic.test view
@@ -0,0 +1,16 @@+{ "employee":+ [ { "name": { "first": "John", "last": "Doe" } }+ , { "name": { "first": "Omar", "last": "Smith" }+ , "salary": "30000" }+ , { "name": { "first": "Sara", "last": "Chen" }+ , "salary": "60000" }+ ]+}+.+$for(employee)$+Hi, $employee.name.first$. $if(employee.salary)$You make $$$employee.salary$.$else$No salary data.$endif$+$ endfor $+.+Hi, John. No salary data.+Hi, Omar. You make $30000.+Hi, Sara. You make $60000.
+ test/boilerplate.txt view
@@ -0,0 +1,3 @@+BOILERPLATE+HERE+
+ test/boolean.test view
@@ -0,0 +1,13 @@+{ "foo": true,+ "bar": false+}+.+$foo$+$bar$+$if(foo)$XXX$else$YYY$endif$+$if(bar)$XXX$else$YYY$endif$+.+true+false+XXX+YYY
+ test/comments.test view
@@ -0,0 +1,9 @@+{ "foo": 3 }+.+$-- a comment+$--${foo} more comment+$$-- not a comment+a$-- comment+.+$-- not a comment+a
+ test/conditionals.test view
@@ -0,0 +1,37 @@+{ "foo": 1,+ "bar": null,+ "baz": ["a", "b"],+ "bim": { "zub": "sim" },+ "sup": [ { "biz": "qux" }+ , { "sax": "" }+ ]+}+.+${if(sup.sax)}+XXX+${else}+YYY+${endif}+${if(bar)}+BAR+${endif}+${if(bar)}BAR${endif}+${if(foo)}+FOO+${endif}+${if(baz)}+BAZ+${endif}+${if(bim)}+BIM+${endif}+${if(sup)}+SUP+${endif}+.+YYY++FOO+BAZ+BIM+SUP
+ test/elseif.test view
@@ -0,0 +1,30 @@+{ "foo": 1,+ "bar": null,+ "baz": ["a", "b"],+ "bim": { "zub": "sim" },+ "sup": [ { "biz": "qux" }+ , { "sax": "" }+ ]+}+.+$if(sup.sax)$+XXX+$elseif(baz)$+YYY+$else$+ZZZ+$endif$++$if(sup.sax)$+XXX+$elseif(baz.nonexist)$+YYY+$elseif(sup.sax)$+ZZZ+$else$+WWW+$endif$+.+YYY++WWW
+ test/empty.test view
@@ -0,0 +1,16 @@+{ "employee":+ [ { "name": { "first": "John", "last": "Doe" } }+ , { "name": { "first": "Omar", "last": "Smith" }+ , "salary": "" }+ , { "name": { "first": "Sara", "last": "Chen" }+ , "salary": [] }+ ]+}+.+$for(employee)$+Hi, $employee.name.first$. $if(employee.salary)$You make $$$employee.salary$.$else$No salary data.$endif$+$ endfor $+.+Hi, John. No salary data.+Hi, Omar. No salary data.+Hi, Sara. No salary data.
+ test/enum.txt view
@@ -0,0 +1,2 @@+$it.key/alpha/uppercase$. $^$$it.value$+
+ test/final-newline.test view
@@ -0,0 +1,15 @@+{ "employee":+ [ { "name": "John\n" }+ , { "name": "Sara\n\n" }+ , { "name": "Omar" }+ ]+}+.+$for(employee)$+$employee.name$+$ endfor $+.+John+Sara++Omar
+ test/forloop.test view
@@ -0,0 +1,21 @@+{ "employee":+ [ { "name": { "first": "John", "last": "Doe" }+ , "salary": "30000" }+ , { "name": { "first": "Omar", "last": "Smith" }+ , "salary": "60000" }+ , { "name": { "first": "Sara", "last": "Chen" } }+ ]+}+.+$for(employee)$+$employee.name.first$ $employee.name.last$$sep$;+$endfor$+++$for(employee)$$employee.salary$$sep$; $endfor$+.+John Doe;+Omar Smith;+Sara Chen++30000; 60000;
+ test/indent.test view
@@ -0,0 +1,30 @@+{ "foo": "FOO1\nFOO2"+, "bar": [ "LINE1\n LINE2"+ , "LINE3\n LINE4"+ ]+}+.+$if(foo)$+ $foo$+$endif$+$for(bar)$+ $bar$+$endfor$++- $foo$+ $foo$ -+ $if(foo)$$foo$$endif$+.+ FOO1+ FOO2+ LINE1+ LINE2+ LINE3+ LINE4++- FOO1+FOO2+ FOO1+FOO2 -+ FOO1+FOO2
+ test/inparens.txt view
@@ -0,0 +1,1 @@+($it$)
+ test/loop-in-object.test view
@@ -0,0 +1,19 @@+{ "worksite":+ { "name": "canyon"+ , "workers":+ [ { "name": { "first": "John", "last": "Doe" } }+ , { "name": { "first": "Omar", "last": "Smith" }+ , "salary": "30000" }+ , { "name": { "first": "Sara", "last": "Chen" }+ , "salary": "60000" }+ ]+ }+}+.+${ for(worksite.workers) }+${it.name.last}, ${it.name.first}+${ endfor }+.+Doe, John+Smith, Omar+Chen, Sara
+ test/loop-in-partial.test view
@@ -0,0 +1,6 @@+{}+.+$loop1()$++.+(loop)
+ test/loop1.txt view
@@ -0,0 +1,1 @@+$loop2()$
+ test/loop2.txt view
@@ -0,0 +1,1 @@+$loop1()$
+ test/name.tex view
@@ -0,0 +1,1 @@+\name{$it.name.first$}{$it.name.last$}
+ test/name.txt view
@@ -0,0 +1,1 @@+$it.name.first:inparens()$ $it.name.last$
+ test/nest.test view
@@ -0,0 +1,67 @@+{ "foo": 1,+ "baz": ["a", "b"],+ "bim": { "zub": "sim" },+ "sup": "a multiline\nstring"+}+.+ $sup$+ $sup$++ $^$$sup$++$bim.zub$ $^$$sup$++$bim.zub$ $^$$foo$+ bar $sup$++$for(baz)$+1. $^$Hello+ $if(it)$+ $it$+ $endif$+$endfor$++ $^$hey $sup$+ hey $sup$++ hey $sup$++ hey+ $if(foo)$++ $foo$+ $endif$+ hey+.+ a multiline+ string+ a multiline+ string++ a multiline+ string++sim a multiline+ string++sim 1+ bar a multiline+ string++1. Hello+ a+1. Hello+ b++ hey a multiline+ string+ hey a multiline+ string++ hey a multiline+ string++ hey++ 1+ hey
+ test/nested-loop.test view
@@ -0,0 +1,41 @@+{+ "pages": [+ {+ "subpages": [+ {+ "slug": "subpage-1"+ },+ {+ "slug": "subpage-2"+ }+ ],+ "slug": "page-1"+ },+ {+ "subpages": [+ {+ "slug": "subpage-1"+ },+ {+ "slug": "subpage-2"+ }+ ],+ "slug": "page-2"+ }+ ]+}+.+$-- see #15+$for(pages)$+/$pages.slug$+$for(pages.subpages)$+ /$pages.slug$/$pages.subpages.slug$+$endfor$+$endfor$+.+/page-1+ /page-1/subpage-1+ /page-1/subpage-2+/page-2+ /page-2/subpage-1+ /page-2/subpage-2
+ test/numbers.test view
@@ -0,0 +1,7 @@+{ "m": 5+, "n": 7.3+}+.+$m$ and $n$+.+5 and 7.3
+ test/pad.test view
@@ -0,0 +1,53 @@+{ "sup": "a multiline\nstring",+ "baz": ["a\nb", "b\nc\nd"],+ "employee":+ [ { "name": { "first": "John", "last": "Doe" } }+ , { "name": { "first": "Omar", "last": "Smith" }+ , "salary": "30000" }+ , { "name": { "first": "Sara", "last": "Chen" }+ , "salary": "60000" }+ ]+}+.+$sup/right 15$$sup/center 15$$sup/left 15$++$for(baz/pairs)$+$it.key/alpha/right 4$. $^$$it.value$+$endfor$+++------+-----------++$for(baz/pairs)$+$it.key/right 4 "| " " | "$$it.value/left 10 "" "|"$++------+-----------++$endfor$++|------------|------------|+$for(employee)$+$it.name.first/uppercase/left 10 "| "$$it.salary/right 10 " | " " |"$+$endfor$+|------------|------------|++.+ a multiline a multiline a multiline+ string string string++ a. a+ b+ b. b+ c+ d+++------+-----------++| 1 | a |+| | b |++------+-----------++| 2 | b |+| | c |+| | d |++------+-----------+++|------------|------------|+| JOHN | |+| OMAR | 30000 |+| SARA | 60000 |+|------------|------------|
+ test/partial_foo.txt view
@@ -0,0 +1,4 @@+Hello+$if(foo)$+$foo$+$endif$
+ test/partials.test view
@@ -0,0 +1,39 @@+{ "employee":+ [ { "name": { "first": "John", "last": "Doe" } }+ , { "name": { "first": "Omar", "last": "Smith" }+ , "salary": "30000" }+ , { "name": { "first": "Sara", "last": "Chen" }+ , "salary": "60000" }+ ]+}+.+$for(employee)$+$it:name()$+$endfor$++$employee:name()[, ]$++$employee:name()$++$employee:name.tex()[; ]$++---+ $boilerplate()$+---+$partial_foo()$+.+(John) Doe+(Omar) Smith+(Sara) Chen++(John) Doe, (Omar) Smith, (Sara) Chen++(John) Doe(Omar) Smith(Sara) Chen++\name{John}{Doe}; \name{Omar}{Smith}; \name{Sara}{Chen}++---+ BOILERPLATE+ HERE+---+Hello
+ test/pipe-and-partial.test view
@@ -0,0 +1,16 @@+{ "employee":+ [ { "name": { "first": "John", "last": "Doe" } }+ , { "name": { "first": "Omar", "last": "Smith" }+ , "salary": "30000" }+ , { "name": { "first": "Sara", "last": "Chen" }+ , "salary": "60000" }+ ]+}+.+$for(employee)$+$it:name()/uppercase$+$endfor$+.+(JOHN) DOE+(OMAR) SMITH+(SARA) CHEN
+ test/pipes.test view
@@ -0,0 +1,109 @@+{ "foo": 1,+ "bar": null,+ "baz": ["a", "b"],+ "bim": { "Zub": "Sim" },+ "sup": [ { "biz": "qux" },+ { "sax": 2 } ],+ "items": [ "one with\na line break",+ "two",+ "three with\na line break" ],+ "hasblanks": "hello\n\n",+ "hasblanksmap": { "a": "hello\n\n",+ "b": "there\n\n" },+ "digits": [1, 5, 20]+}+.+$bar/length$+$baz/length$+$bim.Zub/length$+$bim/length$+$sup/length$++$baz/uppercase[, ]$++$for(baz)$+$it$+$it/uppercase$+$baz$+$baz/uppercase$+$endfor$++$for(bim/pairs)$+$it.key$: $it.value$+$bim.key$: $bim.value/lowercase$+$endfor$++$for(baz/pairs)$+$it.key/roman/uppercase/right 4$. $it.value$+$endfor$++$items/pairs/reverse:enum()$++($hasblanks/chomp$)++$for(hasblanksmap/chomp/pairs/uppercase)$+$it.key$ ($it.value$)+$endfor$++$digits/roman[ ]$++$for(bim/uppercase)$+$it.Zub$+$endfor$++$digits/first$+$digits/last$+$for(digits/rest)$+$it$+$endfor$+$for(digits/allbutlast)$+$it$+$endfor$+$foo/first$+.+0+2+3+1+2++A, B++a+A+a+A+b+B+b+B++Zub: Sim+Zub: sim++ I. a+ II. b++C. three with+ a line break+B. two+A. one with+ a line break+++(hello)++A (HELLO)+B (THERE)++i v xx++SIM++1+20+5+20+1+5+1
+ test/space-in-loop.test view
@@ -0,0 +1,27 @@+{ "employee":+ [ { "name": { "first": "John", "last": "Doe" } }+ , { "name": { "first": "Omar", "last": "Smith" }+ , "salary": "30000" }+ , { "name": { "first": "Sara", "last": "Chen" }+ , "salary": "60000" }+ ]+}+.+$for(employee)$+$employee.name.first$++$endfor$+---+$for(nonexistent)$++$endfor$+---+.+John++Omar++Sara++---+---
+ test/test.hs view
@@ -0,0 +1,116 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++import Text.DocLayout (render)+import qualified Text.DocLayout as DL+import qualified Data.Map as M+import Text.DocTemplates+import Test.Tasty.Golden+import Test.Tasty+import Test.Tasty.HUnit+import qualified Data.Text as T+import qualified Data.Text.IO as T+import qualified Data.Text.Encoding as T+import System.FilePath+import System.IO.Temp+import Data.Aeson+import System.FilePath.Glob+import qualified Data.ByteString.Lazy as BL+import Data.Semigroup ((<>))+import Data.Maybe++main :: IO ()+main = withTempDirectory "test" "out." $ \tmpdir -> do+ testFiles <- glob "test/*.test"+ goldenTests <- mapM (getTest tmpdir) testFiles+ defaultMain $ testGroup "Tests"+ [ testGroup "Golden tests" goldenTests+ , testGroup "Unit tests" unitTests+ ]++unitTests :: [TestTree]+unitTests = [+ testCase "compile failure" $ do+ (res :: Either String (Template T.Text)) <-+ compileTemplate "" "$if(x$and$endif$"+ res @?= Left "(line 1, column 6):\nunexpected \"$\"\nexpecting \".\", \"/\" or \")\""+ , testCase "compile failure (keyword as variable)" $ do+ (res :: Either String (Template T.Text)) <-+ compileTemplate "foobar.txt" "$sep$"+ res @?= Left "\"foobar.txt\" (line 1, column 5):\nunexpected \"$\"\nexpecting letter or digit or \"()\""+ , testCase "compile failure (unknown pipe)" $ do+ (res :: Either String (Template T.Text)) <-+ compileTemplate "foobar.txt" "$foo/nope$"+ res @?= Left "\"foobar.txt\" (line 1, column 10):\nunexpected \"$\"\nexpecting letter, letter or digit or \"()\"\nUnknown pipe nope"+ , testCase "compile failure (missing parameter for pipe)" $ do+ (res :: Either String (Template T.Text)) <-+ compileTemplate "foobar.txt" "$foo/left$"+ res @?= Left "\"foobar.txt\" (line 1, column 10):\nunexpected \"$\"\nexpecting letter, integer parameter for pipe, letter or digit or \"()\""+ , testCase "compile failure (unexpected parameter for pipe)" $ do+ (res :: Either String (Template T.Text)) <-+ compileTemplate "foobar.txt" "$foo/left a$"+ res @?= Left "\"foobar.txt\" (line 1, column 11):\nunexpected \"a\"\nexpecting integer parameter for pipe"+ , testCase "compile failure (error in partial)" $ do+ (res :: Either String (Template T.Text)) <-+ compileTemplate "test/foobar.txt" "$bad()$"+ res @?= Left "\"test/bad.txt\" (line 2, column 7):\nunexpected \"s\"\nexpecting \"$\""+ , testCase "comment with no newline" $ do+ (res :: Either String (Template T.Text)) <-+ compileTemplate "foo" "$-- hi"+ res @?= Right (mempty :: Template T.Text)+ , testCase "reflow" $ do+ (templ :: Either String (Template T.Text)) <-+ compileTemplate "foo" "not breakable and$~$ this is breakable\nok? $foo$$~$"+ let res :: T.Text+ res = case templ of+ Right t -> render (Just 10)+ (renderTemplate t (object ["foo" .= ("42" :: T.Text)]))+ Left e -> T.pack e+ res @?= "not breakable and\nthis is\nbreakable\nok? 42"+ , testCase "nowrap pipe" $ do+ (templ :: Either String (Template T.Text)) <-+ compileTemplate "foo" "$foo/nowrap$\n$foo$"+ let res :: T.Text+ res = case templ of+ Right t -> render (Just 10)+ (renderTemplate t (Context $ M.insert "foo"+ (SimpleVal $+ DL.hsep ["hello", "this", "is", "a",+ "test", "of", "the", "wrapping"]+ :: Val T.Text) mempty))+ Left e -> T.pack e+ res @?= "hello this is a test of the wrapping\nhello this\nis a test\nof the\nwrapping"+ ]++{- The test "golden" files are structured as follows:++{ "foo": ["bar", "baz"] }+.+A template with $foo$.+.+A template with bar, baz.++-}++diff :: FilePath -> FilePath -> [String]+diff ref new = ["diff", "-u", "--minimal", ref, new]++getTest :: FilePath -> FilePath -> IO TestTree+getTest tmpdir fp = do+ let actual = tmpdir </> takeFileName fp+ return $ goldenVsFileDiff fp diff fp actual $ do+ inp <- T.readFile fp+ let (j, template', _expected) =+ case T.splitOn "\n.\n" inp of+ [x,y,z] -> (x,y,z)+ _ -> error $ "Error parsing " ++ fp+ let j' = j <> "\n"+ let template = template' <> "\n"+ let templatePath = replaceExtension fp ".txt"+ let (context :: Value) = fromMaybe Null $+ decode' . BL.fromStrict . T.encodeUtf8 $ j'+ res <- applyTemplate templatePath template context+ case res of+ Left e -> error e+ Right x -> T.writeFile actual $ j' <> ".\n" <> template <> ".\n" <>+ render Nothing x
+ test/values.test view
@@ -0,0 +1,20 @@+{ "foo": 1,+ "bar": null,+ "baz": ["a", "b"],+ "bim": { "zub": "sim" },+ "sup": [ { "biz": "qux" }+ , { "sax": 2 }+ ]+}+.+$foo$+$bar$+$baz$+$bim$+$sup$+.+1++ab+true+truetrue