ginger 0.1.1.1 → 0.1.1.2
raw patch · 5 files changed
+230/−134 lines, 5 filesPVP: minor bump suggested
API additions: PVP suggests at least a minor version bump
API changes (from Hackage documentation)
+ Text.Ginger.GVal: instance Text.Ginger.GVal.ToGVal m GHC.Types.Char
Files
- ginger.cabal +6/−6
- src/Text/Ginger.hs +28/−9
- src/Text/Ginger/AST.hs +2/−2
- src/Text/Ginger/GVal.hs +170/−113
- src/Text/Ginger/Run.hs +24/−4
ginger.cabal view
@@ -1,8 +1,8 @@--- Initial ginger.cabal generated by cabal init. For further +-- Initial ginger.cabal generated by cabal init. For further -- documentation, see http://haskell.org/cabal/users-guide/ name: ginger-version: 0.1.1.1+version: 0.1.1.2 synopsis: An implementation of the Jinja2 template language in Haskell description: Ginger is Jinja, minus the most blatant pythonisms. Wants to be feature complete, but isn't quite there yet.@@ -11,10 +11,10 @@ license-file: LICENSE author: Tobias Dammers maintainer: tdammers@gmail.com--- copyright: +-- copyright: category: Text build-type: Simple--- extra-source-files: +-- extra-source-files: cabal-version: >=1.10 library@@ -24,8 +24,8 @@ , Text.Ginger.Html , Text.Ginger.Parse , Text.Ginger.Run- -- other-modules: - -- other-extensions: + -- other-modules:+ -- other-extensions: build-depends: base >=4.5 && <5 , aeson , bytestring
src/Text/Ginger.hs view
@@ -31,13 +31,21 @@ -- > </body> -- > </html> --- There are two kinds of delimiters. `{% ... %}` and `{{ ... }}`. The first+-- | There are two kinds of delimiters: @{% ... %}@ and @{{ ... }}@. The first -- one is used to execute statements such as for-loops or assign values, the -- latter prints the result of an expression to the template.+--+-- Both kinds of delimiters support adding a dash (@-@) on either side or both,+-- putting them in whitespace-eating mode on the respective side.+-- Whitespace-eating means that any whitespace that precedes cq. follows the+-- delimited construct is removed, including newlines. For example,+-- @A {{ "b" }}@ renders as @\"A b\"@, but @A {{- "b" }}@ renders as @\"Ab\"@.+-- Note that whitespace /inside/ delimiters is never printed; the dash only+-- ever removes whitespace on the outside. --- /Not implemented yet/: Jinja2 allows the programmer to override the default--- tags from @{% %}@ and @{{ }}@ to different tokens, e.g. @\<% %\>@ and @\<\<--- \>\>@. Ginger does not currently support this.+-- | /Not implemented yet/: Jinja2 allows the programmer to override the+-- default tags from @{% %}@ and @{{ }}@ to different tokens, e.g. @\<% %\>@+-- and @\<\< \>\>@. Ginger does not currently support this. -- ** Variables -- | You can mess around with the variables in templates provided they are@@ -51,12 +59,12 @@ -- | @ -- {{ foo.bar }}--- {{ foo['bar'] }}+-- {{ foo[\'bar\'] }} -- @ -- | It’s important to know that the curly braces are /not/ part of the -- variable, but the print statement. If you access variables inside tags--- don’t put the braces around them.+-- don't put the braces around them. -- | If a variable or attribute does not exist you will get back an undefined -- value. What you can do with that kind of value depends on the@@ -172,10 +180,16 @@ -- | > {{ append(x, "foobar") }} +-- | A list of available filters can be found in the 'Text.Ginger.Run' module.+ -- | /Deviation from Jinja2:/ Ginger does not distinguish between filters and -- functions at the semantics level; any function can be called as a filter, -- and vv., and the filter syntax is merely a syntactic variant of a function--- call.+-- call. Ginger doesn't really distinguish between functions and values,+-- either: a function is a value that happens to be callable; other than that,+-- functions live in the same namespace as any other value, and if you bind+-- a function to a context value on the host side, you can use it just like+-- any other function / filter. -- * Haskell API -- ** General@@ -212,6 +226,7 @@ -- have pre-loaded all template sources), you can use the pure 'parseGinger' -- flavor, which does not rely on a host monad. module Text.Ginger.Parse+ -- ** Running -- | The core function for running a template is 'runGinger' (or its monadic -- flavor 'runGingerT'); in order to pass an initial context to the template@@ -221,11 +236,15 @@ -- | An example call (for running a template in 'IO') would look something like -- this: --- > runGingerT (makeContextM scopeLookup (putStr . Text.unpack . htmlSource)) tpl+-- | > runGingerT (makeContextM scopeLookup (putStr . Text.unpack . htmlSource)) tpl+ , module Text.Ginger.Run+ -- ** Other concerns--- | Ginger's unitype value+-- *** GVal: Ginger's unitype value , module Text.Ginger.GVal++-- *** AST -- | The data structures used to represent templates, statements and -- expressions internally. , module Text.Ginger.AST
src/Text/Ginger/AST.hs view
@@ -22,12 +22,12 @@ } deriving (Show) --- | A macro definition ( {% macro %}+-- | A macro definition ( @{% macro %}@ ) data Macro = Macro { macroArgs :: [VarName], macroBody :: Statement } deriving (Show) --- | A block definition ( {% block %}+-- | A block definition ( @{% block %}@ ) data Block = Block { blockBody :: Statement } -- TODO: scoped blocks deriving (Show)
src/Text/Ginger/GVal.hs view
@@ -6,7 +6,12 @@ -- Ginger can understand. -- -- Most of the types in this module are parametrized over an 'm' type, which--- is the host monad for template execution, as passed to 'runGingerT'.+-- is the host monad for template execution, as passed to 'runGingerT'. For+-- most kinds of values, 'm' is transparent, and in many cases a 'ToGVal'+-- instance can be written that works for all possible 'm'; the reason we need+-- to parametrize the values themselves over the carrier monad is because we+-- want to support impure functions, which requires access to the underlying+-- carrier monad (e.g. 'IO'). module Text.Ginger.GVal where @@ -53,53 +58,34 @@ import Text.Ginger.Html --- | A function that can be called from within a template execution context.-type Function m = [(Maybe Text, GVal m)] -> m (GVal m)---- | Match arguments passed to a function at runtime against a list of declared--- argument names.--- @matchFuncArgs argNames argsPassed@ returns @(matchedArgs, positionalArgs, namedArgs)@,--- where @matchedArgs@ is a list of arguments matched against declared names--- (by name or by position), @positionalArgs@ are the unused positional--- (unnamed) arguments, and @namedArgs@ are the unused named arguments.-matchFuncArgs :: [Text] -> [(Maybe Text, GVal m)] -> (HashMap Text (GVal m), [GVal m], HashMap Text (GVal m))-matchFuncArgs names args =- (matched, positional, named)- where- positionalRaw = [ v | (Nothing, v) <- args ]- namedRaw = HashMap.fromList [ (n, v) | (Just n, v) <- args ]- fromPositional = Prelude.zip names positionalRaw- numPositional = Prelude.length fromPositional- namesRemaining = Prelude.drop numPositional names- positional = Prelude.drop numPositional positionalRaw- fromNamed = catMaybes $ (List.map lookupName namesRemaining)- lookupName n = do- v <- HashMap.lookup n namedRaw- return (n, v)- matched = HashMap.fromList $ fromPositional ++ fromNamed- named = HashMap.difference namedRaw (HashMap.fromList fromNamed)---- | Ginger value.+-- * The Ginger Value type+--+-- | A variant type designed as the unitype for the template language. Any+-- value referenced in a template, returned from within a template, or used+-- in a template context, will be a 'GVal'.+-- @m@, in most cases, should be a 'Monad'.+--+-- | Some laws apply here, most notably:+-- - when 'isNull' is 'True', then all of 'asFunction', 'asText', 'asNumber',+-- 'asHtml', 'asList', 'asDictItems', and 'length' should produce 'Nothing'+-- - when 'isNull' is 'True', then 'asBoolean' should produce 'False'+-- - when 'asNumber' is not 'Nothing', then 'asBoolean' should only return+-- 'False' for exactly zero+-- - 'Nothing'-ness of 'length' should match one or both of 'asList' / 'asDictItems' data GVal m = GVal- { asList :: Maybe [GVal m]- , asDictItems :: Maybe [(Text, GVal m)]- , asLookup :: Maybe (Text -> Maybe (GVal m))- , asHtml :: Html- , asText :: Text- , asBoolean :: Bool- , asNumber :: Maybe Scientific- , asFunction :: Maybe (Function m)- , length :: Maybe Int- , isNull :: Bool+ { asList :: Maybe [GVal m] -- ^ Convert value to list, if possible+ , asDictItems :: Maybe [(Text, GVal m)] -- ^ Convert value to association list ("dictionary"), if possible+ , asLookup :: Maybe (Text -> Maybe (GVal m)) -- ^ Convert value to a lookup function+ , asHtml :: Html -- ^ Render value as HTML+ , asText :: Text -- ^ Render value as plain-text+ , asBoolean :: Bool -- ^ Get value's truthiness+ , asNumber :: Maybe Scientific -- ^ Convert value to a number, if possible+ , asFunction :: Maybe (Function m) -- ^ Access value as a callable function, if it is one+ , length :: Maybe Int -- ^ Get length of value, if it is a collection (list/dict)+ , isNull :: Bool -- ^ Check if the value is null } -isList :: GVal m -> Bool-isList = isJust . asList--isDict :: GVal m -> Bool-isDict = isJust . asDictItems- -- | The default 'GVal' is equivalent to NULL. instance Default (GVal m) where def = GVal@@ -115,14 +101,6 @@ , length = Nothing } --- | Types that implement conversion to 'GVal'-class ToGVal m a where- toGVal :: a -> GVal m---- | Trivial instance for 'GVal' itself-instance ToGVal m (GVal m) where- toGVal = id- -- | For convenience, 'Show' is implemented in a way that looks similar to -- JavaScript / JSON instance Show (GVal m) where@@ -143,77 +121,51 @@ instance ToHtml (GVal m) where toHtml = asHtml --- | Treat a 'GVal' as a flat list and look up a value by integer index.--- If the value is not a List, or if the index exceeds the list length,--- return 'Nothing'.-lookupIndex :: Int -> GVal m -> Maybe (GVal m)-lookupIndex = lookupIndexMay . Just --- | Helper function; look up a value by an integer index when the index may or--- may not be available. If no index is given, return 'Nothing'.-lookupIndexMay :: Maybe Int -> GVal m -> Maybe (GVal m)-lookupIndexMay i v = do- index <- i- items <- asList v- atMay items index--lookupKey :: Text -> GVal m -> Maybe (GVal m)-lookupKey k v = do- lf <- asLookup v- lf k---- | Loosely-typed lookup: try dictionary-style lookup first (treat index as--- a string, and container as a dictionary), if that doesn't yield anything--- (either because the index is not string-ish, or because the container--- doesn't provide dictionary-style access), try index-based lookup.-lookupLoose :: GVal m -> GVal m -> Maybe (GVal m)-lookupLoose k v =- lookupKey (asText k) v <|> lookupIndexMay (floor <$> asNumber k) v---- | Treat a 'GVal' as a dictionary and list all the keys, with no particular--- ordering.-keys :: GVal m -> Maybe [Text]-keys v = Prelude.map fst <$> asDictItems v---- | Convert a 'GVal' to a number.-toNumber :: GVal m -> Maybe Scientific-toNumber = asNumber+-- * Representing functions as 'GVal's+--+-- | A function that can be called from within a template execution context.+type Function m = [(Maybe Text, GVal m)] -> m (GVal m) --- | Convert a 'GVal' to an 'Int'.--- The conversion will fail when the value is not numeric, and also if--- it is too large to fit in an 'Int'.-toInt :: GVal m -> Maybe Int-toInt x = toNumber x >>= toBoundedInteger+-- | Match arguments passed to a function at runtime against a list of declared+-- argument names.+-- @matchFuncArgs argNames argsPassed@ returns @(matchedArgs, positionalArgs, namedArgs)@,+-- where @matchedArgs@ is a list of arguments matched against declared names+-- (by name or by position), @positionalArgs@ are the unused positional+-- (unnamed) arguments, and @namedArgs@ are the unused named arguments.+matchFuncArgs :: [Text] -> [(Maybe Text, GVal m)] -> (HashMap Text (GVal m), [GVal m], HashMap Text (GVal m))+matchFuncArgs names args =+ (matched, positional, named)+ where+ positionalRaw = [ v | (Nothing, v) <- args ]+ namedRaw = HashMap.fromList [ (n, v) | (Just n, v) <- args ]+ fromPositional = Prelude.zip names positionalRaw+ numPositional = Prelude.length fromPositional+ namesRemaining = Prelude.drop numPositional names+ positional = Prelude.drop numPositional positionalRaw+ fromNamed = catMaybes $ (List.map lookupName namesRemaining)+ lookupName n = do+ v <- HashMap.lookup n namedRaw+ return (n, v)+ matched = HashMap.fromList $ fromPositional ++ fromNamed+ named = HashMap.difference namedRaw (HashMap.fromList fromNamed) --- | Loose cast to boolean.+-- * Marshalling from Haskell to 'GVal' ----- Numeric zero, empty strings, empty lists, empty objects, 'Null', and boolean--- 'False' are considered falsy, anything else (including functions) is--- considered true-ish.-toBoolean :: GVal m -> Bool-toBoolean = asBoolean---- | Dynamically cast to a function.--- This yields 'Just' a 'Function' if the value is a function, 'Nothing' if--- it's not.-toFunction :: GVal m -> Maybe (Function m)-toFunction = asFunction+-- | Types that implement conversion to 'GVal'.+class ToGVal m a where+ toGVal :: a -> GVal m --- | Turn a 'Function' into a 'GVal'-fromFunction :: Function m -> GVal m-fromFunction f =- def- { asHtml = html ""- , asText = ""- , asBoolean = True- , isNull = False- , asFunction = Just f- }+-- | Trivial instance for 'GVal' itself.+instance ToGVal m (GVal m) where+ toGVal = id +-- | 'Nothing' becomes NULL, 'Just' unwraps. instance ToGVal m v => ToGVal m (Maybe v) where toGVal Nothing = def toGVal (Just x) = toGVal x +-- | Haskell lists become list-like 'GVal's instance ToGVal m v => ToGVal m [v] where toGVal xs = helper (Prelude.map toGVal xs) where@@ -228,6 +180,7 @@ , length = Just $ Prelude.length xs } +-- | 'HashMap' of 'Text' becomes a dictionary-like 'GVal' instance ToGVal m v => ToGVal m (HashMap Text v) where toGVal xs = helper (HashMap.map toGVal xs) where@@ -272,12 +225,15 @@ , isNull = False } +-- | Silly helper function, needed to bypass the default 'Show' instance of+-- 'Scientific' in order to make integral 'Scientific's look like integers. scientificToText :: Scientific -> Text scientificToText x = Text.pack $ case floatingOrInteger x of Left x -> show x Right x -> show x +-- | Booleans render as 1 or empty string, and otherwise behave as expected. instance ToGVal m Bool where toGVal x = def@@ -288,6 +244,10 @@ , isNull = False } +-- | 'String' -> 'GVal' conversion uses the 'IsString' class; because 'String'+-- is an alias for '[Char]', there is also a 'ToGVal' instance for 'String',+-- but it marshals strings as lists of characters, i.e., calling 'toGVal' on+-- a string produces a list of characters on the 'GVal' side. instance IsString (GVal m) where fromString x = def@@ -299,6 +259,10 @@ , length = Just . Prelude.length $ x } +-- | Single characters are treated as length-1 'Text's.+instance ToGVal m Char where+ toGVal = toGVal . Text.singleton+ instance ToGVal m Text where toGVal x = def@@ -319,6 +283,19 @@ , isNull = False } +-- | This instance is slightly wrong; the 'asBoolean', 'asNumber', and 'asText'+-- methods all treat the HTML source as plain text. We do this to avoid parsing+-- the HTML back into a 'Text' (and dealing with possible parser errors); the+-- reason this instance exists at all is that we still want to be able to pass+-- pre-rendered HTML around sometimes, and as long as we don't call any numeric+-- or string functions on it, everything is fine. When such HTML values+-- accidentally do get used as strings, the HTML source will bleed into the+-- visible text, but at least this will not introduce an XSS vulnerability.+--+-- It is therefore recommended to avoid passing 'Html' values into templates,+-- and also to avoid calling any string functions on 'Html' values inside+-- templates (e.g. capturing macro output and then passing it through a textual+-- filter). instance ToGVal m Html where toGVal x = def@@ -339,3 +316,83 @@ toGVal (JSON.Null) = def toGVal (JSON.Array a) = toGVal $ Vector.toList a toGVal (JSON.Object o) = toGVal o++-- | Turn a 'Function' into a 'GVal'+fromFunction :: Function m -> GVal m+fromFunction f =+ def+ { asHtml = html ""+ , asText = ""+ , asBoolean = True+ , isNull = False+ , asFunction = Just f+ }+++-- * Inspecting 'GVal's / Marshalling 'GVal' to Haskell++-- | Check if the given GVal is a list-like object+isList :: GVal m -> Bool+isList = isJust . asList++-- | Check if the given GVal is a dictionary-like object+isDict :: GVal m -> Bool+isDict = isJust . asDictItems++-- | Treat a 'GVal' as a flat list and look up a value by integer index.+-- If the value is not a List, or if the index exceeds the list length,+-- return 'Nothing'.+lookupIndex :: Int -> GVal m -> Maybe (GVal m)+lookupIndex = lookupIndexMay . Just++-- | Helper function; look up a value by an integer index when the index may or+-- may not be available. If no index is given, return 'Nothing'.+lookupIndexMay :: Maybe Int -> GVal m -> Maybe (GVal m)+lookupIndexMay i v = do+ index <- i+ items <- asList v+ atMay items index++-- | Strictly-typed lookup: treat value as a dictionary-like object and look+-- up the value at a given key.+lookupKey :: Text -> GVal m -> Maybe (GVal m)+lookupKey k v = do+ lf <- asLookup v+ lf k++-- | Loosely-typed lookup: try dictionary-style lookup first (treat index as+-- a string, and container as a dictionary), if that doesn't yield anything+-- (either because the index is not string-ish, or because the container+-- doesn't provide dictionary-style access), try index-based lookup.+lookupLoose :: GVal m -> GVal m -> Maybe (GVal m)+lookupLoose k v =+ lookupKey (asText k) v <|> lookupIndexMay (floor <$> asNumber k) v++-- | Treat a 'GVal' as a dictionary and list all the keys, with no particular+-- ordering.+keys :: GVal m -> Maybe [Text]+keys v = Prelude.map fst <$> asDictItems v++-- | Convert a 'GVal' to a number.+toNumber :: GVal m -> Maybe Scientific+toNumber = asNumber++-- | Convert a 'GVal' to an 'Int'.+-- The conversion will fail when the value is not numeric, and also if+-- it is too large to fit in an 'Int'.+toInt :: GVal m -> Maybe Int+toInt x = toNumber x >>= toBoundedInteger++-- | Loose cast to boolean.+--+-- Numeric zero, empty strings, empty lists, empty objects, 'Null', and boolean+-- 'False' are considered falsy, anything else (including functions) is+-- considered true-ish.+toBoolean :: GVal m -> Bool+toBoolean = asBoolean++-- | Dynamically cast to a function.+-- This yields 'Just' a 'Function' if the value is a function, 'Nothing' if+-- it's not.+toFunction :: GVal m -> Maybe (Function m)+toFunction = asFunction
src/Text/Ginger/Run.hs view
@@ -6,6 +6,18 @@ {-#LANGUAGE MultiParamTypeClasses #-} {-#LANGUAGE ScopedTypeVariables #-} -- | Execute Ginger templates in an arbitrary monad.+--+-- Usage example:+--+-- > render :: Template -> Text -> Text -> Text+-- > render template -> username imageURL = do+-- > let contextLookup varName =+-- > case varName of+-- > "username" -> toGVal username+-- > "imageURL" -> toGVal imageURL+-- > _ -> def -- def for GVal is equivalent to a NULL value+-- > context = makeContext contextLookup+-- > in htmlSource $ runGinger context template module Text.Ginger.Run ( runGingerT , runGinger@@ -219,19 +231,27 @@ -- | Create an execution context for runGinger. -- The argument is a lookup function that maps top-level context keys to ginger--- values.+-- values. 'makeContext' is a specialized version of 'makeContextM', targeting+-- the 'Writer' 'Html' monad (which is what is used for the non-monadic+-- template interpreter 'runGinger').+--+-- The type of the lookup function may look intimidating, but in most cases,+-- marshalling values from Haskell to Ginger is a matter of calling 'toGVal'+-- on them, so the 'GVal (Run (Writer Html))' part can usually be ignored.+-- See the 'Text.Ginger.GVal' module for details. makeContext :: (VarName -> GVal (Run (Writer Html))) -> GingerContext (Writer Html) makeContext l = makeContextM (return . l) tell --- | Purely expand a Ginger template. @v@ is the type for Ginger values.+-- | Purely expand a Ginger template. The underlying carrier monad is 'Writer'+-- 'Html', which is used to collect the output and render it into a 'Html'+-- value. runGinger :: GingerContext (Writer Html) -> Template -> Html runGinger context template = execWriter $ runGingerT context template --- | Monadically run a Ginger template. The @m@ parameter is the carrier monad,--- the @v@ parameter is the type for Ginger values.+-- | Monadically run a Ginger template. The @m@ parameter is the carrier monad. runGingerT :: (Monad m, Functor m) => GingerContext m -> Template -> m () runGingerT context tpl = runReaderT (evalStateT (runTemplate tpl) (defRunState tpl)) context