Skip to content


Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Update for 0.10 release

  • Loading branch information...
commit c58a7441b801133db725bb791bf15473912f2e4a 1 parent 8399e00
@mightybyte mightybyte authored
65 blogdata/content/2012/12/9/
@@ -0,0 +1,65 @@
+| title: Heist with a >3000x performance improvement
+| author: Doug Beardsley <>
+| published: 2012-12-09T20:55:00+0100
+| updated: 2012-12-09T20:55:00+0100
+| summary: Release notes for Heist 0.10
+The Snap team is excited to announce the release of Heist 0.10. For this
+massively backwards-incompatible release we went back to the drawing board and
+re-wrote Heist from the ground up with performance in mind. But was Heist
+actually slow? Nobody has actually complained about it, but yes, Heist was
+slow. was until now.
+Note that this chart is log scale. The difference is so drastic that if the
+chart was linear scale, the bars for Heist 0.10 wouldn't even show up! The
+three pages benchmarked are taken directly from, so they are
+real-world examples. Heist 0.10 gives an improvement of more than 3000x for
+snaplets.tpl, 2000x for about.tpl, and 700x for faq.tpl. The improvement for
+faq.tpl is smaller because most its content is statically generated by pandoc,
+so Heist only has to traverse a small DOM compared to the other two templates.
+To understand what happened here, we need to go back to the beginning. When
+we originally wrote Heist, speed was not our goal. Instead we wanted to see
+how far a simple concept like binding Haskell code to HTML nodes could take us
+in solving the problem with HTML boilerplate. Now, two and a half years
+later, we've discovered that it's an incredibly powerful and enjoyable model
+for web programming.
+Heist achieves this by traversing the DOM, executing splices to generate
+replacement nodes, and recursively traversing those nodes until all splices
+have been executed. A significant part of Heist's power lies in the fact that
+this transformation happens at runtime every time a template is served, so the
+splice computations are running in your web server monad and have access to
+all the data from the HTTP request. Once this transformation is complete, the
+resulting DOM is rendered to a ByteString and served back in the HTTP
+response. This is really slow when compared with concatenative template
+systems like StringTemplate that just blast out interleaved static and dynamic
+chunks of data. Therefore it should be no surprise that splicing is slow, as
+it allows for much more expressiveness and power.
+However, we realized that a lot of the transformations could be done at load
+time and be preprocessed to an intermediate representation. This consists of
+static ByteStrings interleaved with deferred dynamic computations that still
+give you the power of being able to access the web server's runtime monad.
+This "compiled splice" as we call it works quite a bit differently from the
+old model of splices. It is also strictly less powerful, so we kept the
+ability to use the old "interpreted splice" model if you really need the power
+and can afford the performance penalty.
+While we were making backwards incompatible changes, we decided to clean up
+everything. This included reorganizing the modules and any other changes we
+felt would improve the overall quality and readability of the code base. It
+will be a bit of work to migrate existing code to Heist 0.10, but the old
+interpreted splices are still available and allow you do a gradual transition.
+We also added a new feature called attribute splices that allows you to
+abstract attributes in ways that weren't possible before.
+For more detailed information about the new features, check out the tutorials
+in the [heist section](/docs#heist) of our docs page. After you have read
+those and are ready to upgrade your site, you might want to follow our [migration
+to help you with the transition to 0.10. The migration guide is a github
+wiki, so feel free to help us make it clearer and more complete.
2  snap-website.cabal
@@ -20,10 +20,10 @@ Executable snap-website
- data-lens-template >= 2.1 && < 2.2,
heist >= 0.10 && < 0.11,
+ lens >= 3.7 && < 3.8,
MonadCatchIO-transformers >= 0.2 && < 0.4,
mtl >= 2 && <3,
59 snaplets/heist/templates/docs.tpl
@@ -3,64 +3,67 @@
<div id="about" class="section left">
<div class="inner">
- <h2>Tutorials</h2>
+ <h2 id="snap">Snap</h2>
<div id="docdls">
<dt><a href="docs/quickstart">Quick Start</a></dt>
- <a class="book" href="docs/quickstart">
- <img src="/media/css/book.png" />
- </a>
+ <a class="book" href="docs/quickstart"><img src="/media/css/book.png" /></a>
A guide to getting Snap installed.</dd>
<dt><a href="docs/tutorials/snap-api">Snap API Introduction</a></dt>
- <a class="book" href="docs/tutorials/snap-api">
- <img src="/media/css/book.png" />
- </a>
+ <a class="book" href="docs/tutorials/snap-api"><img src="/media/css/book.png" /></a>
A quick tutorial on the Snap API. Covers installation, the
&ldquo;snap&rdquo; command-line tool, and a walkthough of the
Snap starter application.</dd>
- <dt><a href="docs/tutorials/heist">Heist Template Tutorial</a></dt>
- <dd>
- <a class="book" href="docs/tutorials/heist">
- <img src="/media/css/book.png" />
- </a>
- A tutorial for the Heist HTML templating library.</dd>
<dt><a href="docs/tutorials/snaplets-tutorial">Snaplets Tutorial</a></dt>
- <a class="book" href="docs/tutorials/snaplets-tutorial">
- <img src="/media/css/book.png" />
- </a>
+ <a class="book" href="docs/tutorials/snaplets-tutorial"><img src="/media/css/book.png" /></a>
Guide to using snaplets to build reusable web components.</dd>
<dt><a href="docs/tutorials/snaplets-design">Snaplets Design</a></dt>
- <a class="book" href="docs/tutorials/snaplets-design">
- <img src="/media/css/book.png" />
- </a>
+ <a class="book" href="docs/tutorials/snaplets-design"><img src="/media/css/book.png" /></a>
Description of the snaplets internal design and motivation.</dd>
+ <h2 id="heist">Heist</h2>
+ <div id="docdls">
+ <dl>
+ <dt><a href="docs/tutorials/heist">Heist Template Tutorial</a></dt>
+ <dd>
+ <a class="book" href="docs/tutorials/heist"><img src="/media/css/book.png" /></a>
+ A tutorial for the Heist HTML templating library. This tutorial
+ applies to Heist 0.8 and earlier.</dd>
+ <dt><a href="docs/tutorials/compiled-splices">Compiled Splices Tutorial</a></dt>
+ <dd>
+ <a class="book" href="docs/tutorials/compiled-splices"><img src="/media/css/book.png" /></a>
+ Discusses compiled splices, which were introduced in Heist 0.10.</dd>
+ <dt><a href="docs/tutorials/attribute-splices">Attribute Splices Tutorial</a></dt>
+ <dd>
+ <a class="book" href="docs/tutorials/attribute-splices"><img src="/media/css/book.png" /></a>
+ How to use attribute splices (also introduced in Heist 0.10).</dd>
+ </dl>
+ </div>
<div class="inner">
- <h2>Resources</h2>
+ <h2 id="resources">Resources</h2>
<div id="docdls">
<dt><a href="docs/style-guide">Haskell Style Guide</a></dt>
- <a class="book" href="docs/style-guide">
- <img src="/media/css/book.png" />
- </a>
+ <a class="book" href="docs/style-guide"><img src="/media/css/book.png" /></a>
A guide to the Haskell source style we're using for the project.</dd>
<dt><a href="/benchmarks">Benchmarks</a></dt>
- <a class="book" href="/benchmarks">
- <img src="/media/css/book.png" />
- </a>
+ <a class="book" href="/benchmarks"><img src="/media/css/book.png" /></a>
Some benchmark results comparing Snap to several other
web frameworks.</dd>
@@ -70,7 +73,7 @@
<div id="about" class="section left">
<div class="inner">
- <h2>API Documentation</h2>
+ <h2 id="api">API Documentation</h2>
<div id="docdls">
58 snaplets/heist/templates/docs/tutorials/AttributeSplices.lhs
@@ -0,0 +1,58 @@
+Attribute Splices
+Attribute splices are new in Heist 0.10. They solve the problem of wanting to
+be able to dynamically make empty attributes appear or disappear with a splice
+without binding a splice to the whole tag. This issue comes up most
+frequently when dealing with empty attributes such as HTML's "disabled" or
+> module Heist.Tutorial.AttributeSplices where
+> import Heist.Tutorial.Imports
+Consider a page with several radio buttons. You want the correct one to be
+selected based on the value of a parameter in the HTTP request. The HTML
+would look something like this:
+ <input type="radio" name="color" value="red" checked>Red</input>
+ <input type="radio" name="color" value="green">Green</input>
+ <input type="radio" name="color" value="blue">Blue</input>
+We want to automatically generate the "checked" attribute appropriately. This
+could be done with a splice bound to the input tag, but there might be a
+number of other input tags on the page, so your splice would at best be
+executed on more tags than necessary and at worst not have the granularity
+necessary to work properly. The ${} syntax for splices inside of attribute
+values also won't work because it can only affect an attribute's value. It
+can't make the attribute disappear entirely. This problem can be solved
+nicely with attribute splices that have the following type:
+< type AttrSplice m = Text -> m [(Text, Text)]
+An attribute splice is a computation in the runtime monad that takes the value
+of the attribute it is bound to as its argument and returns a list of
+attributes to substitute back into the tag. Here's how we might implement a
+splice to solve the above problem.
+> autocheckedSplice :: Text -> StateT Text IO [(Text, Text)]
+> autocheckedSplice v = do
+> val <- get -- app-specific retrieval of the appropriate value here
+> let checked = if v == val
+> then [("checked","")]
+> else []
+> return $ ("value", v) : checked
+In this toy example we are using `StateT Text IO` as our "runtime" monad where
+the Text state holds the value of the radio button that should be checked. We
+assume that the current value we're checking against is passed as the bound
+attribute's value, so we compare that against the value to be checked. Then
+we return a list with the appropriate value and the checked attribute if
+necessary. We bind this splice to the "autocheck" attribute by adding it to
+the hcAttributeSplices list in HeistConfig.
+To make everything work we use the following markup for our radio buttons:
+ <input type="radio" name="color" autocheck="red">Red</input>
+ <input type="radio" name="color" autocheck="green">Green</input>
+ <input type="radio" name="color" autocheck="blue">Blue</input>
269 snaplets/heist/templates/docs/tutorials/CompiledSplices.lhs
@@ -0,0 +1,269 @@
+Introduction to Compiled Heist
+Before version 0.10, Heist has essentially been an interpreter. It loads your
+templates and "runs" them whenever a page is served. This is relatively
+inefficient since a lot of document transformations happen every time the
+template is requested. For Heist version 0.10 we completely rethought
+everything with performance in mind. We call it "compiled Heist". The main
+idea is to do most of your splice processing up front at load time. There is
+still a mechanism for rendering dynamic information at runtime, but it is
+faster than the fully interpreted approach that Heist started with.
+It should also be mentioned that the old "interpreted Heist" is not gone. You
+can still use the old approach where all the transformations happen at
+render time. This allows you to upgrade without making sweeping changes to
+your code, and gradually convert your application to the more performant
+compiled approach as you see fit.
+Before we continue it should be mentioned that you are reading real live
+literate Haskell code from our test suite. All the code you see here is
+compiled into our test suite and the results automatically checked by our
+buildbot. So first we need to get some boilerplate and imports out of the way.
+> {-# LANGUAGE NoMonomorphismRestriction #-}
+> module Heist.Tutorial.CompiledSplices where
+> import Heist
+> import qualified Heist.Compiled as C
+> import Heist.Tutorial.Imports
+As a review, normal (interpreted) Heist splices are defined like this.
+< type Splice m = HeistT m m [Node]
+The type parameter `m` is the runtime execution monad (in a Snap application
+this will usually be `Handler` or `Snap`). Don't worry about why the `m` is
+there twice right now. We'll get to that later. The splice's return value is
+a list of nodes that is substituted back into the document wherever the
+spliced node was.
+This kind of splice proccessing involves traversing the DOM, which is
+inefficient. Compiled Heist is designed so that all the DOM traversals happen
+once at load time in the IO monad. This is the "compile" phase. The type
+signature for compiled splices is this.
+< type Splice n = HeistT n IO (DList (Chunk n))
+We see that where Heist splices ran in the m monad, compiled splices run in the
+IO monad. This also explains why HeistT now has two monad type parameters.
+The first parameter is a placeholder for the runtime monad and the second
+parameter is the monad that we're actually running in now.
+But the key point of the compiled splice type signature is the return value.
+They return a DList of Chunks. DList is a list that supports efficient
+insertion to both the front and back of the list. The Chunk type is not
+exposed publicly, but there are three ways to construct a Chunk.
+< yieldPure :: Builder -> DList (Chunk m)
+< yieldRuntime :: RuntimeSplice m Builder -> DList (Chunk m)
+< yieldRuntimeEffect :: Monad m => RuntimeSplice m () -> DList (Chunk m)
+If your splice output can be calculated at load time, then you should use
+`yieldPure` or one of its variants. When you do this, Heist can concatenate
+all adjacent pure chunks into a single precalculated ByteString that can be
+rendered very efficiently. If your template needs a value that has to be
+calculated at runtime, then you should use the `yieldRuntime` constructor and
+supply a computation in the RuntimeSplice monad transformer that is
+parameterized by `m` which we saw above is the runtime monad. Occasionally
+you might want to run a runtime side effect that doesn't actually insert any
+data into your template. The `yieldRuntimeEffect` function gives you that
+An Example
+With that background, let's get to a real example.
+> stateSplice :: C.Splice (StateT Int IO)
+> stateSplice = return $ C.yieldRuntimeText $ do
+> val <- lift get
+> return $ pack $ show (val+1)
+Here we see that our splice's runtime monad is `StateT Int IO`. This makes
+for a simple example that can clearly demonstrate the different contexts that
+we are operating in. To make things more clear, here's a version with some
+print statements that clarify the details of which monad is executed when.
+> stateSplice2 :: C.Splice (StateT Int IO)
+> stateSplice2 = do
+> -- :: C.Splice (StateT Int IO)
+> lift $ putStrLn "This executed at load time"
+> let res = C.yieldRuntimeText $ do
+> -- :: RuntimeSplice (StateT Int IO) a
+> lift $ lift $ putStrLn "This executed at run/render time"
+> val <- lift get
+> return $ pack $ show (val+1)
+> lift $ putStrLn "This also executed at load time"
+> return res
+Note here that even though the type parameter to C.Splice is a monad, it is not
+a monad transformer. RuntimeSplice, however, is. Now let's look at a simple
+load function that sets up a default HeistState and loads templates from a
+directory with compiled splices.
+> load :: MonadIO n
+> => FilePath
+> -> [(Text, C.Splice n)]
+> -> IO (HeistState n)
+> load baseDir splices = do
+> tmap <- runEitherT $ do
+> templates <- loadTemplates baseDir
+> let hc = HeistConfig [] defaultLoadTimeSplices splices [] templates
+> initHeist hc
+> either (error . concat) return tmap
+Here's a function demonstrating all of this in action.
+> runWithStateSplice :: FilePath
+> -> IO ByteString
+> runWithStateSplice baseDir = do
+> hs <- load baseDir [ ("div", stateSplice) ]
+> let runtime = fromJust $ C.renderTemplate hs "index"
+> builder <- evalStateT (fst runtime) 2
+> return $ toByteString builder
+First this function loads the templates with the above compiled splice. You
+have to specify all the compiled splices in the call to loadTemplates because
+loadTemplates takes care of compiling all the templates up front. If you were
+able to bind compiled splices later, then all the templates would have to be
+recompiled, a potentially expensive operation. Next, the function renders the
+template called "index" using a runtime (StateT Int IO) seeded with a value of
+2 and returns the resulting ByteString.
+Now let's look at a more complicated example. We want to render a data
+structure with a compiled splice.
+> data Person = Person
+> { pFirstName :: Text
+> , pLastName :: Text
+> , pAge :: Int
+> }
+> personSplice :: (Monad n)
+> => C.Promise Person
+> -> HeistT n IO (RuntimeSplice n Builder)
+> personSplice = C.promiseChildrenWithText
+> [ ("firstName", pFirstName)
+> , ("lastName", pLastName)
+> , ("age", pack . show . pAge)
+> ]
+> peopleSplice :: (Monad n)
+> => n [Person]
+> -> C.Splice n
+> peopleSplice getPeople = C.mapPromises personSplice getPeople
+> allPeopleSplice :: C.Splice (StateT [Person] IO)
+> allPeopleSplice = peopleSplice get
+> personListTest :: FilePath
+> -> IO ByteString
+> personListTest baseDir = do
+> hs <- load baseDir [ ("people", allPeopleSplice) ]
+> let runtime = fromJust $ C.renderTemplate hs "people"
+> builder <- evalStateT (fst runtime)
+> [ Person "John" "Doe" 42
+> , Person "Jane" "Smith" 21
+> ]
+> return $ toByteString builder
+Disadvantages of Compiled Heist
+Compiled Heist is faster than the original interpreted approach, but as with
+most things in computing there is a tradeoff. Compiled Heist is strictly less
+powerful than interpreted Heist. There are two things that compiled Heist
+loses: the ability to bind new splices on the fly at runtime and splice
+The first point follows immediately from the definition of compiled Heist.
+When you decide to do all your splice DOM traversals once at load time you're
+unavoidably limited to only those splices that you defined at load time. But
+this seems to be a good pattern to use in general because debugging your
+splices will be easier if you don't have to consider the possibility that
+the handler that binds them didn't run.
+The loss of recursion/composability happens because of the change in the type
+signature of splices. Interpreted splices are a essentially function `[Node]
+-> m [Node]`. This means that the output of one splice can be the input of
+another splice (including itself). Compiled splices are a function `[Node] ->
+IO (DList (Chunk m))`. Therefore, once a splice processes some nodes, the
+output is no longer something that can be passed into other splices.
+This composability turns out to be a very powerful feature. Head merging is
+one feature that can't be done without it. Head merging allows you to put
+`<head>` tags anyhere in any template and have them all merged into a single
+`<head>` tag at the top of your HTML document. This is useful because it allows
+you to keep concerns localized. For instance, you can have a template
+represent a small piece of functionality that uses a less common javascript or
+CSS file. Instead of having to depend on that resource being included in the
+top-level `<head>` tag, you can include it in a `<head>` tag right where you're
+using it. Then it will only be included on your pages when you are using the
+markup that needs it.
+Our implementation of head merging uses a splice bound to the `<html>` tag.
+This splice removes all the `<head>` nodes from its children, combines them, and
+inserts them as its first child. This won't work unless the `<html>` splice
+first runs all its children to make sure all `<apply>` and `<bind>` tags have
+happened first. And that is impossible to do with compiled splices.
+To get around this problem we added the concept of load time splices. Load
+time splices are just interpreted splices that are completely executed at load
+time. If interpreted splices have type `[Node] -> m [Node]` where m is the
+runtime monad, then load time splices have type `[Node] -> IO [Node]`, where
+IO is the monad being executed at load time. Load time splices give you the
+power and composability of interpreted splices as long as they are performing
+transformations that don't require runtime data. All of the built-in splices
+that we ship with Heist work as load time splices. So you can still have head
+merging by including our html splice in the load time splice list in your
+A More Involved Example
+The person example above is a very common and useful pattern for using dynamic
+data in splices. But it has the simplification that it always generates
+output the same way. Sometimes you might want a splice's output to have one
+form in some cases and a different form in other cases. A simple example is a
+splice that reads some kind of a key from a request parameter then looks that
+key up in some kind of map. If the key is present the splice uses its child
+nodes as a view for the retrieved value, otherwise it outputs an error message.
+This pattern is a little tricky because you're making decisions about what to
+render based on runtime data, but the actual rendering of child nodes has to
+be done at load time. To bridge the gap and allow communication between load
+time and runtime processing we provide the Promise data type. A Promise is
+kind of like an IORef except that operations on them are restricted to the
+appropriate Heist context. You create a new empty promise in the HeistT n IO
+(load time) monad, and you operate on it in the RuntimeSplice monad.
+Here's an example of how to use a promise manually to render a splice
+differently in the case of failure.
+< failingSplice :: MonadSnap m => C.Splice m
+< failingSplice = do
+< children <- childNodes <$> getParamNode
+< promise <- C.newEmptyPromise
+< outputChildren <- C.promiseChildrenWithNodes splices promise
+< return $ C.yieldRuntime $ do
+< -- :: RuntimeSplice m Builder
+< mname <- lift $ getParam "username"
+< let err = return $ fromByteString "Must supply a username"
+< single name = do
+< euser <- liftIO $ lookupUser $ decodeUtf8 name
+< either (return . fromByteString . encodeUtf8 . T.pack)
+< doUser euser
+< where
+< doUser value = do
+< C.putPromise promise (name, value)
+< outputChildren
+< maybe err single mname
+< splices :: [(Text, (Text, Text) -> [Node])]
+< splices = [ ("user", (:[]) . TextNode . T.pack . fst)
+< , ("value", (:[]) . TextNode . T.pack . snd)
+< ]
6 snaplets/heist/templates/docs/tutorials/attribute-splices.tpl
@@ -0,0 +1,6 @@
+<bind tag="subtitle">: Attribute Splices Tutorial</bind>
+<apply template="page">
+ <div class="singlecolumn">
+ <markdown file="AttributeSplices.lhs"/>
+ </div>
6 snaplets/heist/templates/docs/tutorials/compiled-splices.tpl
@@ -0,0 +1,6 @@
+<bind tag="subtitle">: Compiled Splices Tutorial</bind>
+<apply template="page">
+ <div class="singlecolumn">
+ <markdown file="CompiledSplices.lhs"/>
+ </div>
26 snaplets/heist/templates/docs/tutorials/
@@ -203,8 +203,8 @@ called `default.tpl`:
-The `<apply-content>` tag "pulls in" the page content from the calling
-template and inserts it into the content `<div>`.
+The `<apply-content>` tag "pulls in" the page content from inside the apply
+tag in the calling template and inserts it into the content `<div>`.
Now we have a template for our home page called home.tpl:
@@ -253,9 +253,9 @@ illustrates the power of a simple concept like `apply`.
What if, in the above example, we decided that the contents of the
header div should be different for different pages? To do this, we
need a way to pass multiple parameters into a template. Heist
-provides this capability with the `<bind-content>` tag. Inside the body of a
+provides this capability with the `<bind>` tag. Inside the body of an
`<apply>` tag, you can have multiple bind tags surrounding data to be
-passed as separate parameters. Each `<bind-content>` tag must have a `tag`
+passed as separate parameters. Each `<bind>` tag must have a `tag`
attribute that provides a name for its contents just as described
above. Then, inside the template, those tags will be substituted with
the appropriate data.
@@ -283,33 +283,33 @@ to allow multiple parameters.
-And `home.tpl` uses the `<bind-content>` tag with a name attribute to define
+And `home.tpl` uses the `<bind>` tag with a name attribute to define
values for the `<header/>` and `<main/>` tags:
~~~~~~~~~~~~~~~ {.html}
<apply template="default">
- <bind-content tag="header">
+ <bind tag="header">
<h1>XYZ Inc.</h1>
- </bind-content>
+ </bind>
Some in-between text.
- <bind-content tag="main">
+ <bind tag="main">
<h1>Home Page</h1>
<p>Welcome to XYZ Inc</p>
- </bind-content>
+ </bind>
The result template for this example is the same as the previous
-NOTE: In this example the `<bind-content/>` tag is still bound as described
-above. The `<bind-content/>` tag is always bound to the complete contents
-of the calling `apply` tag. However, any `bind-content` tags inside the apply
+NOTE: In this example the `<bind/>` tag is still bound as described
+above. The `<bind/>` tag is always bound to the complete contents
+of the calling `apply` tag. However, any `bind` tags inside the apply
will disappear. If we changed `default.tpl` to the following:
~~~~~~~~~~~~~~~ {.html}
- <bind-content/>
+ <bind/>
9 src/Main.hs
@@ -7,11 +7,11 @@ module Main where
import Control.Applicative
import Control.Exception (SomeException)
+import Control.Lens
import Control.Monad
import Control.Monad.CatchIO
import Control.Monad.Trans
import qualified Data.ByteString.Char8 as B
-import Data.Lens.Template
import Data.Maybe
import qualified Data.Text as T
import Data.Text.Encoding
@@ -28,7 +28,6 @@ import Snap.Util.FileServe
import Snap.Util.GZip
import Text.Blaze.Html5 (toHtml)
import qualified Text.Blaze.Html5 as H
-import Heist
import Heist.Interpreted
data App = App
@@ -36,7 +35,7 @@ data App = App
, _blog :: Snaplet StaticPages
-makeLenses [''App]
+makeLenses ''App
instance HasHeist App where heistLens = subSnaplet heist
@@ -69,9 +68,9 @@ appInit = makeSnaplet "snap-website" description Nothing $ do
setCache :: MonadSnap m => m a -> m ()
-setCache act = do
+setCache action = do
pinfo <- liftM rqPathInfo getRequest
- act
+ action
when ("media" `B.isPrefixOf` pinfo) $ do
expTime <- liftM (+604800) $ liftIO epochTime
s <- liftIO $ formatHttpTime expTime
BIN  static/media/img/heist-perf.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Please sign in to comment.
Something went wrong with that request. Please try again.