packages feed

pencil 0.1.3 → 1.0.0

raw patch · 36 files changed

+2776/−1883 lines, 36 filesdep ~doctestdep ~hashabledep ~mtl

Dependency ranges changed: doctest, hashable, mtl, semigroups, text, time, unordered-containers

Files

CHANGELOG.md view
@@ -4,10 +4,63 @@  ## [Unreleased] +## [1.0.0]++This is a milestone release! Version 1.0.0. Several breaking changes, but if you+read through the changes below, you should find updating your code to be pretty+easy. Please email me if you are having problems!++### Added+- Added GHC 8.4 and 8.6 support.+- Added `(<<|)` and `coll` to add collection values into structrues.+- Added `useFilePath`, `escapeXml`, `rename`, `to`, `move` to manipulate loaded `Page`s.+- Added `loadAndRender` convenience method. Supports individual files and+  directories. You'll want to use this one to move over static assets quickly+  and easily: `loadAndRender "images/"`.+- Added `toTextRss` and `rfc822DateFormat` to render content ready for an RSS+  template. See the Blog example for details.++### Changed+- All of the `Pencil.Blog` functions are now re-exported in `Pencil`. So you+  only need to `import Pencil` now.+- `load` now automatically figures out the desired final FilePath, so it+  doesn't take a `(FilePath -> FilePath)` as the first argument anymore. You+  can change your code from `load toHtml "foo.markdown"` to `load+  "foo.markdown"`, for the most part. Use `load'` to manually specify the+  FilePath transform. See examples in the Hackage docs for `load'`, `to`,+  `move` and `rename`.+- `loadResources`, like `load`, no longers takes a file path transformer. Use+  `to`, `move` or `rename` to change the file path. But really, you probably can+  use `loadDir` or `loadDir'` or `loadAndRender` instead of `loadResources.`+- Renamed `structure` to `struct`. It's shorter.+- `passthrough` now works with directories too.+- `insertPages` return type changed from `Env` to `PencilApp Env`. We now+  evaluate the given pages (e.g. replace variables) before inserting into+  env. So you'll need to change from `let env' = insertPages "posts" posts env`+  to `env <- insertPages "posts" posts env`.+- Renamed `updateEnvVal` to `adjust`.+- Renamed `insertEnv` to `insert`.+- Renamed `injectTagsEnv` to `injectTags`.+- Renamed `arrayContainsString` to `arrayContainsText`.+- Changed how structures work internally to allow collection values into structures.+- Examples now match the tutorials. This is the start of merging the tutorials into the pencil+  repo itself, instead of living somewhere else.+- Two new errors: `CollectionNotLastInStructure` and+  `CollectionFirstInStructure` to handle collection positions in structures.++### Fixed+- Specify example test files in the pencil.cabal file, so that pencil tests run properly.++### Removed+- `renderCss`. Use `loadAndRender` instead. As in: `loadAndRender "style.scss"`+- Removed `VarNotInEnv` error type, since Pencil no longer throws that.+ ## [0.1.3]  ### Changed - Updated dependencies. Should be able to use with recent versions of Stack LTS releases and Nix channels.+- Pandoc updated to 2.5 from 1.x. Source code renders using `<a>` tags now, so you may have to change your CSS. If you want+  CSS to target only `<a href>` tags, use `a[href] { ... }`.  ## [0.1.2] @@ -42,5 +95,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).  [Unreleased]: https://github.com/elben/pencil/compare/v1.0.0...HEAD+[1.0.0]: https://github.com/elben/pencil/compare/v0.1.3...v1.0.0+[0.1.3]: https://github.com/elben/pencil/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/elben/pencil/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/elben/pencil/compare/cb14e3610aa18dd3c71279bd56231c6bb23bae7b...v0.1.1
README.md view
@@ -2,9 +2,10 @@  # Pencil -Pencil is a static site generator. Use it to generate your personal website!-Pencil comes pre-loaded with goodies such as blogging, tagging, templating,-and Markdown Sass/Scss support. Flexible enough to extend for your own needs.+Pencil is a static site generator. Use Pencil to build a personal website, a+blog, and more. Pencil comes pre-loaded with goodies such as Markdown and+Sass/Scss support, templating, blogging, and tagging. Designed with the+Haskell beginner in mind, but flexible enough to extend for your own needs.  The easiest way to get started is to read the tutorials at [elbenshira.com/pencil](http://elbenshira.com/pencil) and reference the [Haddock@@ -14,35 +15,38 @@ > marble topped tables, the smell of early morning... and luck were all you > needed. — Ernest Hemingway, A Moveable Feast -# Setup+# Examples -First, make sure you have `nix` installed:+Here's an example that shows a personal website with a blog and an RSS feed.+Based off the [this+example](https://github.com/elben/pencil/tree/master/examples/Simple). -```bash-curl https://nixos.org/nix/install | sh-nix-channel --add https://nixos.org/channels/nixos-18.09 nixpkgs-nix-channel --update-```+```haskell+{-# LANGUAGE OverloadedStrings #-} -# Examples+module Main where -Checkout the [examples provided](https://github.com/elben/pencil/tree/master/examples). To run the [Simple](https://github.com/elben/pencil/tree/master/examples/Simple) example:+import Pencil -```-nix-shell --attr env-[nix-shell]$ cabal new-run pencil-example-simple-```+config :: Config+config =+  updateEnv (insertText "title" "My Awesome Website") defaultConfig -Open the `examples/Simple/out/` folder to see the rendered web pages. To serve-the web pages (so that relative URLs work), using `python`'s built in web server-is easiest:+website :: PencilApp ()+website = do+  layout <- load "layout.html"+  index <- load "index.markdown"+  render (layout <|| index) -```-cd examples/Simple/out/-python -m SimpleHTTPServer 8000+  loadAndRender "stylesheet.scss"++main :: IO ()+main = run website config ``` -And go to [localhost:8000](http://localhost:8000).+You can check out other [examples](https://github.com/elben/pencil/tree/master/examples). The [Blog](https://github.com/elben/pencil/tree/master/examples/Blog) is a good one.++My personal website (http://elbenshira.com) uses Pencil ([source here](https://github.com/elben/elben.github.io)). And so does Pencil's website at [elbenshira.com/pencil](http://elbenshira.com/pencil) ([source here](https://github.com/elben/pencil/tree/master/examples/Docs)).  # Development 
− examples/Blog/Main.hs
@@ -1,65 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Main where--import Pencil-import Pencil.Blog-import qualified Data.HashMap.Strict as H-import qualified Data.Text as T--websiteTitle :: T.Text-websiteTitle = "My Blog"--config :: Config-config =-  (updateEnv (insertText "title" websiteTitle) .-   setSourceDir "examples/Blog/site/" .-   setOutputDir "examples/Blog/out/") defaultConfig--website :: PencilApp ()-website = do-  layout <- load toHtml "layout.html"--  -- Load all our blog posts into `posts`, which is of type [Page]-  postLayout <- load toHtml "post-layout.html"-  posts <- loadBlogPosts "blog/"--  -- Build tag index pages. This is a map of a Tag (which is just text)-  -- to a Page which has the environment stuffed with blog posts that has that-  -- tag.-  tagPages <- buildTagPages "tag-list.html" posts--  -- Render all our blog posts. So for each post in posts, we:-  ---  -- - injectTitle - This generates a `title` variable in our env that has the-  --   format of "post title - My Blog"-  ---  -- - injectTagsEnv - This injects some environment variables so that our blog-  --   post page knows what tags it has (via `tags` variable), AND a reference to-  --   the tag index page that we built above (via `this.url`). Look through-  --   post-layout.html to see how to use these tag variables.-  ---  -- - (layout <|| postLayout <|) - We push the generated post Page into this-  --   structure, so that all blog posts share the same postLayout.-  ---  -- - render - Finally we render all of our blog posts into files.-  ---  render $ fmap ((layout <|| postLayout <|) . injectTagsEnv tagPages . injectTitle websiteTitle)-                posts--  -- Build our index page. Insert the blog posts into the env, so that we can-  -- render the list of blog posts. `withEnv` tells Pencil to use the modified-  -- environment when rendering the index page, since that's the env that has-  -- the list of blog posts.-  index <- load toHtml "index.html"-  env <- asks getEnv-  let indexEnv = insertPages "posts" posts env-  withEnv indexEnv (render (layout <|| index))--  -- Render tag list pages. This is so that we can go to /blog/tags/awesome to-  -- see all the blog posts tagged with "awesome".-  render $ fmap (layout <||) (H.elems tagPages)--main :: IO ()-main = run website config-
@@ -0,0 +1,19 @@+<!--PREAMBLE+date: 2018-01-30+postTitle: "Code related stuff"+tags:+  - awesome+-->++This shows code-related stuff! A cool `variable`.++```python+import unittest++class TestSomething(unittest.TestCase):+  def test1(self):+    self.assert_(True)++if __name__ == '__main__':+  unittest.main()+```
+ examples/Blog/site/blog/2018-01-31-ramblings.markdown view
@@ -0,0 +1,11 @@+<!--PREAMBLE+date: 2018-01-31+postTitle: "It's me rambling"+-->++Life is like a *cloud*. Clouds are like, just some **cold water in cloud form**. So+life is like cold water in <em>cloud</em> form.++```+I am <em>more</em> like me. These em tags should be escaped.+```
+ examples/Blog/site/blog/2018-02-01-more-ramblings.markdown view
@@ -0,0 +1,8 @@+<!--PREAMBLE+date: 2018-02-01+postTitle: "Just more rambling"+tags:+  - awesome+-->++These ramblings are still a work-in-progress...
+ examples/Blog/site/index.html view
@@ -0,0 +1,7 @@+<h1>My Blog</h1>++<p>This is my blog. Check out my blog posts:</p>++${partial("post-list.html")}++<p><a href="/rss.xml">RSS Feed</a></p>
+ examples/Blog/site/layout.html view
@@ -0,0 +1,16 @@+<!DOCTYPE html>+<html>++<head>+  <meta charset="utf-8" />+  <title>${title}</title>+  <link rel="stylesheet" type="text/css" href="/assets/style.css" />+</head>++<body>+  <div class="structure">+    ${body}+  </div>+</body>++</html>
+ examples/Blog/site/post-bullet.html view
@@ -0,0 +1,3 @@+<li>+  <a href="${this.url}">${postTitle} - ${date}</a>+</li>
+ examples/Blog/site/post-layout.html view
@@ -0,0 +1,13 @@+<div>+  <h2>${postTitle}</h2>+  <div>${date}</div>+  <div>+    ${if(tags)}+      Tags: ${for(tags)} <a href="${this.url}">${tag}</a> ${end}+    ${end}+  </div>+  <hr />+  <div>+    ${body}+  </div>+</div>
+ examples/Blog/site/post-list.html view
@@ -0,0 +1,5 @@+<ul>+  ${for(posts)}+    ${partial("post-bullet.html")}+  ${end}+</ul>
+ examples/Blog/site/tag-list.html view
@@ -0,0 +1,3 @@+<h1>Posts tagged with <code>${tag}</code></h1>++${partial("post-list.html")}
+ examples/Blog/src/Main.hs view
@@ -0,0 +1,92 @@+{-# LANGUAGE OverloadedStrings #-}++module Main where++import Pencil+import qualified Data.HashMap.Strict as H+import qualified Data.Text as T++websiteTitle :: T.Text+websiteTitle = "My Blog"++config :: Config+config =+  (updateEnv (insertText "title" websiteTitle) .+   setSourceDir "examples/Blog/site/" .+   setOutputDir "examples/Blog/out/"+  ) defaultConfig++website :: PencilApp ()+website = do+  loadAndRender "assets/style.scss"++  layout <- load "layout.html"+  postLayout <- load "post-layout.html"++  -- Load all our blog posts into `posts`, which is of type [Page]+  posts <- loadPosts "blog/"++  -- Build tag index pages. This is a map of a Tag (which is just Text)+  -- to a Page which has the environment stuffed with blog posts that has that+  -- tag.+  tagPages <- buildTagPages "tag-list.html" posts++  -- Render all our blog posts. So for each post in posts, we:+  --+  -- - injectTitle - This generates a `title` variable in our env that has the+  --   format of "post title - My Blog"+  --+  -- - injectTags - This injects some environment variables so that our blog+  --   post page knows what tags it has (via `tags` variable), AND a reference to+  --   the tag index page that we built above (via `this.url`). Look through+  --   post-layout.html to see how to use these tag variables.+  --+  -- - (layout <|| postLayout <|) - We push the generated post Page into this+  --   Structure, so that all blog posts share the same postLayout.+  --+  -- - render - Finally we render all of our blog posts into files.+  --+  render $ fmap ((layout <|| postLayout <|) . injectTags tagPages . injectTitle websiteTitle)+                posts++  -- Load our index page.+  index <- load "index.html"++  -- Build our index page.+  --+  -- - coll "posts" posts - Creates a collection of pages inside the Structure.+  --   The entire structure has access to these pages, via the "posts" variable.+  --+  -- - a <<| c - pushes the collection node c into Structure a.+  --+  render (layout <|| index <<| coll "posts" posts)++  -- Render tag list pages. This is so that we can go to /blog/tags/awesome to+  -- see all the blog posts tagged with "awesome".+  render $ fmap (layout <||) (H.elems tagPages)++  -- Load RSS layout.+  rssLayout <- load "rss.xml"++  -- Build RSS feed of the last 10 posts.+  --+  -- - `struct` converts the Page into a Struct+  --+  -- - `escapeXml` sets the Page to escape the XML/HTML tags in our blog+  --   content, since we're going to render this inside the RSS <description>+  --   tag.+  --+  -- - `coll "posts" ...` creates a collection of pages inside the Structure.+  --   See rss.xml file to see how that's used.+  --+  let rssFeedStruct = struct rssLayout <<| coll "posts" (fmap escapeXml (take 10 posts))++  -- Render the RSS feed. We need to render inside a modified environment, where+  -- @toTextRss@ is used as the render function, so that dates are rendered in+  -- the RFC 822 format, per the RSS specification.+  local (setDisplayValue toTextRss)+        (render rssFeedStruct)++main :: IO ()+main = run website config+
+ examples/Complex/src/Main.hs view
@@ -0,0 +1,99 @@+{-# LANGUAGE OverloadedStrings #-}++module Main where++import Pencil+import qualified Data.HashMap.Strict as H+import qualified Data.Text as T++websiteTitle :: T.Text+websiteTitle = "Complex Test"++config :: Config+config =+  (updateEnv (insertText "title" websiteTitle) .+   updateEnv (insertText "secret" "My global secret") .+   setSourceDir "examples/Complex/site/" .+   setOutputDir "examples/Complex/out/"+  ) defaultConfig++website :: PencilApp ()+website = do+  loadAndRender "assets/style.scss"+  loadAndRender "assets/giraffe.jpg"++  -- Should not convert this file. Rendered as assets/examples/example.markdown.+  move "assets/examples/" <$> passthrough "assets/example.markdown" >>= render++  -- Static files are passed-through. Convertible files are converted.+  loadResources True False "assets/fun/" >>= render++  -- Copy the assets/passthroughs folder to its destination without converting+  -- any of the files in there.+  move "assets/passthroughs-to/" <$> passthrough "assets/passthroughs/" >>= render++  -- Convertible files are converted, and static assets are passed through.+  loadAndRender "assets/conversions/"++  layout <- load "layout.html"++  -- Load all our blog posts into `posts`, which is of type [Page]+  postLayout <- load "post-layout.html"+  posts <- loadPosts "blog/"++  -- Build tag index pages. This is a map of a Tag (which is just text)+  -- to a Page which has the environment stuffed with blog posts that has that+  -- tag.+  tagPages <- buildTagPages "tag-list.html" posts++  -- Render all our blog posts. So for each post in posts, we:+  --+  -- - injectTitle - This generates a `title` variable in our env that has the+  --   format of "post title - My Blog"+  --+  -- - injectTags - This injects some environment variables so that our blog+  --   post page knows what tags it has (via `tags` variable), AND a reference to+  --   the tag index page that we built above (via `this.url`). Look through+  --   post-layout.html to see how to use these tag variables.+  --+  -- - (layout <|| postLayout <|) - We push the generated post Page into this+  --   Structure, so that all blog posts share the same postLayout.+  --+  -- - render - Finally we render all of our blog posts into files.+  --+  render $ fmap ((layout <|| postLayout <|) . injectTags tagPages . injectTitle websiteTitle)+                posts++  -- Build our index page.+  index <- load "index.html"+  render $ layout <|| index <<| coll "posts" posts++  -- Render tag list pages. This is so that we can go to /blog/tags/awesome to+  -- see all the blog posts tagged with "awesome".+  render $ fmap (layout <||) (H.elems tagPages)++  -- Load RSS layout.+  rssLayout <- move "blog/" <$> load "rss.xml"++  -- Build RSS feed of the last 10 posts.+  let rssFeedStruct = struct rssLayout <<| coll "posts" (fmap escapeXml (take 10 posts))++  -- Render the RSS feed. We need to render inside a modified environment, where+  -- @toTextRss@ is used as the render function, so that dates are rendered in+  -- the RFC 822 format, per the RSS specification.+  local (setDisplayValue toTextRss)+        (render rssFeedStruct)++  deep1 <- load "deep/deep1.md"+  deep2 <- load "deep/deep2.html"+  deep3 <- load "deep/deep3.markdown"+  deep4a <- load "deep/deep4a.markdown"+  deep4b <- load "deep/deep4b.md"+  render $ layout <|| deep1 <| deep2 <| move "deep/index.html" deep3 <<| coll "deep4s" [deep4a, deep4b]++  toPage <- to "to/" <$> load "deep/deep1.md"+  render $ struct toPage++main :: IO ()+main = run website config+
+ examples/Docs/src/Main.hs view
@@ -0,0 +1,36 @@+{-# LANGUAGE OverloadedStrings #-}++module Main where++import Pencil++config :: Config+config =+  (updateEnv (insertText "title" "Pencil Documentation") .+   setSourceDir "examples/Docs/site/" .+   setOutputDir "docs/")+   defaultConfig++website :: PencilApp ()+website = do+  loadAndRender "default.scss"+  loadAndRender "tutorials/images/"+  loadAndRender "guides/images/"++  layout <- load "layout.html"++  index <- load "index.markdown"+  render (layout <|| index)++  tuts <- loadDir False "tutorials/"+  renderDir layout tuts++  guides <- loadDir False "guides/"+  renderDir layout guides++  where+    renderDir layout pages = render $ fmap ((layout <||) . rename toDir) pages+++main :: IO ()+main = run website config
− examples/Simple/Main.hs
@@ -1,22 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Main where--import Pencil--config :: Config-config =-  (updateEnv (insertText "title" "My Simple Website") .-   setSourceDir "examples/Simple/site/" .-   setOutputDir "examples/Simple/out/") defaultConfig--website :: PencilApp ()-website = do-  layout <- load toHtml "layout.html"-  index <- load toHtml "index.markdown"-  render (layout <|| index)--  renderCss "style.scss"--main :: IO ()-main = run website config
+ examples/Simple/site/index.markdown view
@@ -0,0 +1,3 @@+# My Awesome Website++Welcome to my *awesome* [website](http://example.com)!
+ examples/Simple/site/layout.html view
@@ -0,0 +1,13 @@+<!DOCTYPE html>+<html>+  <head>+    <meta charset="utf-8" />+    <title>${title}</title>+    <link rel="stylesheet" type="text/css" href="stylesheet.css"/>+  </head>+<body>+  <div class="structure">+    ${body}+  </div>+</body>+</html>
+ examples/Simple/site/stylesheet.scss view
@@ -0,0 +1,13 @@+$bgColor: #ffffff;++body {+  background-color: $bgColor;+  font-family: sans-serif;+  font-size: 18px;+}++.structure {+  margin-left: auto;+  margin-right: auto;+  width: 600px;+}
+ examples/Simple/src/Main.hs view
@@ -0,0 +1,23 @@+{-# LANGUAGE OverloadedStrings #-}++module Main where++import Pencil++config :: Config+config =+  (updateEnv (insertText "title" "My Awesome Website") .+   setSourceDir "examples/Simple/site/" .+   setOutputDir "examples/Simple/out/")+   defaultConfig++website :: PencilApp ()+website = do+  layout <- load "layout.html"+  index <- load "index.markdown"+  render (layout <|| index)++  loadAndRender "stylesheet.scss"++main :: IO ()+main = run website config
pencil.cabal view
@@ -1,43 +1,66 @@ name:                pencil-version:             0.1.3+version:             1.0.0 synopsis: Static site generator description:-  Pencil is a static site generator. Use it to generate your personal website!-  Pencil comes pre-loaded blogging, tagging, templating, and Markdown and-  Sass/Scss support. Flexible enough to extend for your own needs.+  Pencil is a static site generator. Use Pencil to build a personal website, a+  blog, and more. Pencil comes pre-loaded with goodies such as Markdown and+  Sass/Scss support, templating, blogging, and tagging. Designed with the+  Haskell beginner in mind, but flexible enough to extend for your own needs. homepage:            https://github.com/elben/pencil license:             BSD3 license-file:        LICENSE author:              Elben Shira-maintainer:          elbenshira@gmail.com+maintainer:          elben@shira.im copyright:           2018 Elben Shira category:            Web build-type:          Simple-extra-source-files:  README.md, CHANGELOG.md+extra-source-files:  README.md+                   , CHANGELOG.md++                  -- List out test example files so that they are included in the `cabal new-sdist`+                  -- distribution, so that tests can run.+                   , examples/Simple/site/*.html+                   , examples/Simple/site/*.scss+                   , examples/Simple/site/*.markdown+                   , examples/Blog/site/*.html+                   , examples/Blog/site/blog/*.markdown++                  --  Once cabal 2.4 is enforced, we could just list them out as:+                  --  , examples/**/*.html+                  --  , examples/**/*.markdown+                  --  , examples/**/*.scss cabal-version:       >=1.10 tested-with:         GHC == 8.0.2,                      GHC == 8.2.2+                     GHC == 8.4+                     GHC == 8.6  library   hs-source-dirs:      src   exposed-modules:     Pencil+                     , Pencil.App+                     , Pencil.App.Internal                      , Pencil.Blog-                     , Pencil.Internal.Pencil-                     , Pencil.Internal.Env-                     , Pencil.Internal.Parser+                     , Pencil.Config+                     , Pencil.Content+                     , Pencil.Content.Internal+                     , Pencil.Content.Nodes+                     , Pencil.Env+                     , Pencil.Env.Internal+                     , Pencil.Parser   build-depends:       base >= 4.8 && < 5                      , data-default >= 0.7 && < 1                      , directory >= 1.2.5.0 && < 1.4                      , edit-distance >= 0.2.2.1 && < 0.3                      , filepath >= 1.4 && < 1.5-                     , hashable >= 1.2.6.0 && < 1.3+                     , hashable >= 1.2.6.0 && <= 1.3                      , hsass >= 0.8 && < 1                      , mtl >= 2.2 && < 3                      , pandoc >= 2.0 && < 3-                     , semigroups >= 0.18.2 && < 0.19                      , parsec >= 3.1 && < 3.2+                     , semigroups >= 0.18.2 && < 0.20                      , text >= 1.2.2 && < 1.3-                     , time >= 1.5.0.1 && < 1.9+                     , time >= 1.5.0.1 && < 2                      , unordered-containers >= 0.2.7.2 && < 0.3                      , vector >= 0.12.0 && < 0.13                      , xml >= 1.3.10 && < 1.4@@ -50,14 +73,14 @@   main-is:             Spec.hs   build-depends:       base >= 4.8 && < 5                      , pencil-                     , doctest >= 0.16.0.1 && < 1+                     , doctest >= 0.16 && < 1   ghc-options:         -Wall -threaded -rtsopts -with-rtsopts=-N   default-language:    Haskell2010  test-suite pencil-example-simple   type:                exitcode-stdio-1.0   hs-source-dirs:      examples/Simple-  main-is:             Main.hs+  main-is:             src/Main.hs   ghc-options:         -Wall -threaded -rtsopts -with-rtsopts=-N   build-depends:       base >= 4.8 && < 5                      , pencil@@ -66,11 +89,34 @@ test-suite pencil-example-blog   type:                exitcode-stdio-1.0   hs-source-dirs:      examples/Blog-  main-is:             Main.hs+  main-is:             src/Main.hs   ghc-options:         -Wall -threaded -rtsopts -with-rtsopts=-N   build-depends:       base >= 4.8 && < 5                      , pencil+                     , text                      , unordered-containers+                     , mtl+  default-language:    Haskell2010++test-suite pencil-example-complex+  type:                exitcode-stdio-1.0+  hs-source-dirs:      examples/Complex+  main-is:             src/Main.hs+  ghc-options:         -Wall -threaded -rtsopts -with-rtsopts=-N+  build-depends:       base >= 4.8 && < 5+                     , pencil+                     , text+                     , unordered-containers+                     , mtl+  default-language:    Haskell2010++test-suite pencil-docs+  type:                exitcode-stdio-1.0+  hs-source-dirs:      examples/Docs+  main-is:             src/Main.hs+  ghc-options:         -Wall -threaded -rtsopts -with-rtsopts=-N+  build-depends:       base >= 4.8 && < 5+                     , pencil                      , text   default-language:    Haskell2010 
src/Pencil.hs view
@@ -1,82 +1,49 @@+{-|+Main Pencil module. This module re-exports most functions, so you should only need to import this one.+-} module Pencil   (-    -- * Getting started-    ---    -- $gettingstarted--    -- * Templates-    ---    -- $templates+  -- * Getting started+  --+  -- $gettingstarted -    PencilApp-  , run+  -- * Templates+  --+  -- $templates    -- * Pages, Structures and Resources   --   -- $pagesStructuresResources--  , Page-  , getPageEnv, setPageEnv-  , load-  , withEnv-  , renderCss--  , Structure-  , (<||)-  , (<|)-  , structure--  , Resource-  , loadResource-  , loadResources-  , passthrough-  , listDir+    module Pencil.Content -  , Render(..)-  , toHtml-  , toDir-  , toCss-  , toExpected+  -- * Blogging+  --+  -- $blogging+  , module Pencil.Blog    -- * Environment Manipulation--  , merge-  , insertEnv-  , insertText-  , insertPages-  , updateEnvVal-  , sortByVar-  , filterByVar-  , groupByElements+  --+  -- $environment+  , module Pencil.Env    -- * Configuration -  , Config-  , defaultConfig-  , getSourceDir, setSourceDir-  , getOutputDir, setOutputDir-  , getEnv, setEnv, updateEnv-  , getDisplayValue, setDisplayValue-  , getSassOptions, setSassOptions-  , getPandocReaderOptions, setPandocReaderOptions-  , getPandocWriterOptions, setPandocWriterOptions--  -- * Utils and re-exports+  , module Pencil.Config -  , FileType-  , fileType-  , toExtension+  -- * The Pencil Type+  -- | Provides the main Pencil type that everything runs in.+  , module Pencil.App -  -- Re-exports+  -- * Re-exports   , Reader.asks--  -- * Error handling--  , PencilException-+  , Reader.local   ) where -import Pencil.Internal.Pencil+import Pencil.App+import Pencil.Blog+import Pencil.Config+import Pencil.Content+import Pencil.Env  import Control.Monad.Reader as Reader @@ -84,198 +51,55 @@  -- $gettingstarted ----- To get started, let's look at--- <https://github.com/elben/pencil/blob/master/examples/Simple/Main.hs this>--- example, which is a very simple website with only a couple of pages. Browse--- through the--- <https://github.com/elben/pencil/tree/master/examples/Simple/site/ site>--- folder to see the source web pages we'll be using. You can run this example--- by following the instructions found in the--- <https://github.com/elben/pencil/blob/master/README.md#examples README.md>.------ First, we have @layout.html@, which will serve as the layout of all our--- pages. Notice that @layout.html@ contains strings that look like @${title}@--- and @${body}@. These are variable directives that we'll need to fill values--- in for.+-- Pencil helps you build static websites in Haskell. Write your website's+-- content in HTML or Markdown, and use the power of Pencil to compose+-- components together into HTML pages. ----- @index.markdown@ is a pretty basic Markdown file, and @style.scss@ is a--- <http://sass-lang.com Scss> file.+-- The best way to get started is to follow the tutorials and guides found at+-- [elbenshira.com/pencil](https://elbenshira.com/pencil). ----- Now let's look inside @Main.hs@:+-- Here is a simple website of a couple of pages, based off+-- [this](https://github.com/elben/pencil/blob/master/examples/Simple/src/Main.hs)+-- example: --+-- > module Main where+-- >  -- > import Pencil--- >--- > config :: Config--- > config =--- >   (updateEnv (insertText "title" "My Simple Website") .--- >    setSourceDir "examples/Simple/site/" .--- >    setOutputDir "examples/Simple/out/") defaultConfig--- >+-- >  -- > website :: PencilApp () -- > website = do--- >   layout <- load toHtml "layout.html"--- >   index <- load toHtml "index.markdown"+-- >   layout <- load "layout.html"+-- >   index <- load "index.markdown" -- >   render (layout <|| index)--- >--- >   renderCss "style.scss"--- >+-- > +-- >   loadAndRender "stylesheet.scss"+-- >  -- > main :: IO ()--- >   main = run website config------ First, we need to set up a 'Config'. We start with 'defaultConfig', and--- modify it slightly, specifying where the source files live, and where we want--- the output files to go. We also add a @title@ variable with the value @"My--- Simple Website"@ into the environment.------ An 'Env', or environment, is just a mapping of variables to its values. A--- variable can hold a string, number, boolean, date, and so forth. Once a--- variable is defined, we can use that variable in our web pages via a--- variable directive like @${title}@.------ Let's now look at the @website@ function. Note that its type is @PencilApp--- ()@. 'PencilApp' is the monad transformer that web pages are built under.--- Don't worry if you aren't familiar with monad transformers; in simple terms,--- @PencilApp@ is a function that takes a @Config@, and does all the source file--- loading and web page rendering under the @IO@ monad. So @website@ is a--- function that is waiting for a @Config@. We "give" @website@ a @Config@ with--- this code, which is the @main@ function:------ @--- run website config--- @------ Now let's dissect the @website@ function itself. The first thing we do is--- @'load' toHtml "layout.html"@, which loads our layout file into something--- called a 'Page'. In short, a 'Page' holds the contents of the file, plus the--- environment of that file, plus the final output destination of that file if--- it is rendered. The 'toHtml' function tells 'load' that you want the output--- file to have the @.html@ extension.------ It's important to realize that 'toHtml' is not telling 'load' /how/ to load--- @layout.html@; it's telling it what kind of file you want when you spit it--- out. 'load' itself looks at the file extension to figure out that--- @layout.html@ is an HTML file, and @index.markdown@ is a Markdown file. So we--- use 'toHtml' when loading @index.markdown@ because we want the index page to--- be rendered as an @.html@ file.------ Now, what about @render (layout <|| index)@. What the heck is going on here?--- In plain language, you can think of @(layout <|| index)@ as injecting the--- contents of @index@ into @layout@. The way this works is that the contents of--- @index@ is rendered (Markdown is converted to HTML, variable directives are--- resolved through the given environment, etc) and then stuffed into a special--- @body@ variable in @layout@'s environment. When @layout@ is rendered, the--- variable directive @${body}@ in @layout@ is replaced with the contents of--- @index@.------ @(layout <|| index)@ describes what /will/ happen; it forms a 'Structure'.--- Passing it into 'render' is what actually generates the web page.------ Finally, we have @'renderCss' "style.scss"@, which is a helper method to load--- and render CSS files in one step.------ And that's it! If you run this code, it will spit out an @index.html@ file--- and a @style.css@ file in the @examples\/Simple\/out\/@ folder.+-- > main = run website defaultConfig ----- To learn more, read through the documentation found in this module. To build--- a blog, look at the Pencil.Blog module.+-- To learn more, dig into the tutorials and guides found on+-- [elbenshira.com/pencil](elbenshira.com/pencil).  ----------------------------------------------------------------------  -- $templates -- -- Pencil comes with a simple templating engine. Templates allow us to build web--- pages dynamically using Haskell code. This allows us to build modular--- components. Templates can be used for things like shared page layouts,--- navigation and blog post templates.------ Pencil templates are regular text files that can contain a /preamble/ and--- /directives/.------ == Preamble------ Preambles are YAML-formatted environment variable declarations inside your--- source files. They should be declared at the top of the file, and you may--- only have one preamble per source file. Example preamble, in the first part--- of @my-blog-post.markdown@:------ > <!--PREAMBLE--- > postTitle: "Behind Python's unittest.main()"--- > date: 2010-01-30--- > tags:--- >   - python--- > -->------ In the above example, Pencil will intelligently parse the @date@ value as a--- `VDateTime`.------ == Directives------ Directives are rendering /commands/. They are surrounded by @${...}@.------ === Variables------ The simplest directive is the variable directive.------ @--- Hello ${name}!--- @------ The above template will render the value of the variable @name@, which is--- expected to be in the environment at 'render'. If the variable is not found,--- Pencil will throw an exception with some debugging information.------ === If block------ The @if@ directive allows us to render content based off the existence of a--- variable in the current environment.------ > ${if(name)}--- >   Hello ${name}!--- > ${end}------ In this case, we now make sure that @name@ is available before rendering.+-- pages dynamically using Haskell code. Blog posts, for example, can share a+-- common HTML template. ----- === For loop+-- Pencil's templating engine comes with variables, if blocks, for loops, and+-- partials. Read the [templates+-- guide](https://elbenshira.com/pencil/guides/templates/) for a thorough+-- walk-through. ----- The @for@ directive allows us to loop over array type variable. This is--- useful for things like rendering a list of blog post titles, and URLs to the--- individual blog posts.+-- Example: -- -- > <ul> -- > ${for(posts)} -- >   <li><a href="${this.url}">${postTitle}</a> - ${date}</li> -- > ${end} -- > </ul>------ Assuming that @posts@ exists in our environment as an array of @Value@,--- this will render each post's title, publish date, and will link it to--- @this.url@. Note that inside the @for@ block, you have access to the current--- environment's variables. This is why we're able to simply request--- @${postTitle}@—it is the current post's @postTitle@ that will be rendered.------ @this.url@ is a special variable that is automatically inserted for you--- inside a loaded @Page@. It points to the final file path destination of that--- current @Page@.------ === Partials------ The @partial@ directive injects another template file into the current file.--- The directives inside the partial are rendered in the same environmental--- context as the @partial@ directive.------ Think of partials as just copy-and-pasting snippet from one file to another.--- Unlike 'Structure's, partials cannot define environment variables.------ In the example below, the first @partial@ is rendered with the current--- environment. The @partial@ inside the @for@ loop receives the same--- environemnt as any other snippet inside the loop, and thus has access to--- the environment inside each post.------ > ${partial("partials/nav-bar.html")}--- >--- > ${for(posts)}--- >   ${partial("partials/nav-bar.html")}--- > ${end}  ---------------------------------------------------------------------- @@ -283,5 +107,29 @@ -- -- 'Page', 'Structure' and 'Resource' are the "big three" data types you need to -- know to effectively use Pencil.+--+-- The [Pages and+-- Structures](https://elbenshira.com/pencil/guides/pages-and-structures/) guide+-- introduces these important data types. The documentation found here goes into+-- further detail.++----------------------------------------------------------------------++-- $blogging+--+-- This module provides out-of-the-box blogging functionality. Read through the+-- [blogging tutorial](http://elbenshira.com/pencil/tutorials/03-blogging/) to+-- learn how to use it.++----------------------------------------------------------------------++-- $environment+--+-- The environment is where variables live. Composing web pages together+-- implies composing environments. This is where Pencil's power lies: in helping+-- you easily build the proper environment to render your web pages.+--+-- To get started, read the [environment+-- guide](https://elbenshira.com/pencil/guides/environment/).  ----------------------------------------------------------------------
+ src/Pencil/App.hs view
@@ -0,0 +1,62 @@++{-|+Pencil's run and error types.+-}+module Pencil.App+  ( PencilApp+  , run+  ) where++import Pencil.App.Internal+import Pencil.Content+import Pencil.Config++import Control.Monad.Except+import Control.Monad.Reader++import qualified Data.List as L+import qualified Data.Text as T+import qualified Text.EditDistance as EditDistance++-- | Run the Pencil app.+--+-- Note that this can throw a fatal exception.+run :: PencilApp a -> Config -> IO ()+run app config = do+  e <- runExceptT $ runReaderT app config+  case e of+    Left (FileNotFound (Just fp)) -> do+      e2 <- runExceptT $ runReaderT (mostSimilarFiles fp) config+      case e2 of+        Right closestFiles ->+          if not (null closestFiles)+          then do+            putStrLn ("File " ++ fp ++ " not found. Maybe you meant: ")+            printAsList (take 3 closestFiles)+          else putStrLn ("File " ++ fp ++ " not found.")+        _ -> return ()+    Left (CollectionNotLastInStructure name) ->+      putStrLn ("Collections must be last in a Structure. But the collection named " +++               T.unpack name ++ " was not the last in the Structure.")+    Left (CollectionFirstInStructure name) ->+      putStrLn ("Collections cannot be first in a Structure. But the collection named " +++               T.unpack name ++ " first in the Structure.")+    Left (NotTextFile fp) ->+      putStrLn ("Tried to load " ++ maybe "UNKNOWNFILE" id fp ++ " as text file, but either it's not a text file or the file is corrupted.")+    _ -> return ()++  case e of+    -- Force program to exit with error state+    Left _ -> fail "Exception when running program!"+    _ -> return ()++-- | Given a file path, look at all file paths and find the one that seems most+-- similar.+mostSimilarFiles :: FilePath -> PencilApp [FilePath]+mostSimilarFiles fp = do+  sitePrefix <- asks getSourceDir+  fps <- listDir True ""+  let fps' = map (sitePrefix ++) fps -- add site prefix for distance search+  let costs = map (\f -> (f, EditDistance.levenshteinDistance EditDistance.defaultEditCosts fp f)) fps'+  let sorted = L.sortBy (\(_, d1) (_, d2) -> compare d1 d2) costs+  return $ map fst sorted
+ src/Pencil/App/Internal.hs view
@@ -0,0 +1,74 @@+{-|+Internal implementation of Pencil's main functionality.+-}+module Pencil.App.Internal where++import Pencil.Config++import Control.Monad.Except+import Control.Monad.Reader (ReaderT(..))+import Data.Typeable (Typeable)+import GHC.IO.Exception (IOException(ioe_description, ioe_filename, ioe_type), IOErrorType(NoSuchThing))++import qualified Data.Text as T++-- | The primary monad transformer stack for a Pencil application.+--+-- This unrolls to:+--+-- > PencilApp a = Config -> IO (Except PencilException a)+--+-- The @ExceptT@ monad allows us to catch "checked" exceptions; errors that we+-- know how to handle, in PencilException. Note that Unknown "unchecked"+-- exceptions can still go through IO.+--+type PencilApp = ReaderT Config (ExceptT PencilException IO)++-- | Known Pencil errors that we know how to either recover from or quit+-- gracefully.+data PencilException+  = NotTextFile (Maybe FilePath)+  -- ^ Failed to read a file as a text file.+  | FileNotFound (Maybe FilePath)+  -- ^ File not found. We may or may not know the file we were looking for.+  | CollectionNotLastInStructure T.Text+  -- ^ The collection in the structure was not the last element in the+  -- structure.+  | CollectionFirstInStructure T.Text+  -- ^ A collection cannot be the first element in the structure (it's useless+  -- there, as nothing can reference the pages in the collection).+  deriving (Typeable, Show)++-- | Converts the IOError to a known 'PencilException'.+--+-- How to test errors:+--+-- @+-- import Control.Exception+-- import qualified Data.Text.IO as TIO+--+-- (\e -> print ('GHC.IO.ioe_description' (e :: IOError)) >> return "") 'Control.Exception.handle' (TIO.readFile "foo")+-- @+--+toPencilException :: IOError -> Maybe PencilException+toPencilException e+  | isInvalidByteSequence e = Just (NotTextFile (ioe_filename e))+  | isNoSuchFile e = Just (FileNotFound (ioe_filename e))+  | otherwise = Nothing++-- | Returns true if the IOError is an invalid byte sequence error. This+-- suggests that the file is a binary file.+isInvalidByteSequence :: IOError -> Bool+isInvalidByteSequence e = ioe_description e == "invalid byte sequence"++-- | Returns true if the IOError is due to missing file.+isNoSuchFile :: IOError -> Bool+isNoSuchFile e = ioe_type e == NoSuchThing++-- | Print the list of Strings, one line at a time, prefixed with "-".+printAsList :: [String] -> IO ()+printAsList [] = return ()+printAsList (a:as) = do+  putStr "- "+  putStrLn a+  printAsList as
src/Pencil/Blog.hs view
@@ -1,88 +1,96 @@ {-# LANGUAGE OverloadedStrings #-} +{-|+This module provides a standard way of building and generating blog posts.+Check out the Blog example+<https://github.com/elben/pencil/blob/master/examples/Blog/ here>. You can+also follow the [blogging tutorial+here](https://elbenshira.com/pencil/tutorials/03-blogging/).++To generate a blog for your website, first create a @blog/@ directory in+your web page source directory.++Then, name your blog posts in this format:++> yyyy-mm-dd-title-of-blog-post.markdown++Where @yyyy-mm-dd@ should be something like @2019-12-30@. This isn't used for+anything other than to keep each post ordered in the directory, for your ease+of viewing.++Each post is expected to have a preamble that has at least @postTitle@ and+@date@ defined. The date set in the preamble is used as the sort order of the+blog posts. The other variables are optional.++> <!--PREAMBLE+> postTitle: "The Meaning of Life"+> date: 2010-01-30+> draft: true+> tags:+>   - philosophy+> -->++You can mark a post as a draft via the @draft@ variable, so that it won't be+loaded when you call 'loadPosts'. You can also set the post's tags using,+as seen above in @tags@. Then, use 'loadPosts' to load the entire @blog/@+directory.++In the example below, @layout.html@ defines the outer HTML structure (with+global components like navigation), and @blog-post.html@ is a generic blog+post container that renders @${postTitle}@ as a header, @${date}@, and+@${body}@ for the post body.++@+layout <- 'load' "layout.html"+postLayout <- 'load' "blog-post.html"+posts <- 'loadPosts' "blog/"+render (fmap (layout <|| postLayout <|) posts)+@+-} module Pencil.Blog-  (-    -- * Getting started-    ---    -- $gettingstarted-    ---    loadBlogPosts-  , blogPostUrl+  ( loadPosts+  , postUrl   , injectTitle-  , buildTagPagesWith++  -- * Tags+  -- | You can add tags to blog posts.+  , Tag   , buildTagPages-  , injectTagsEnv+  , buildTagPagesWith+  , injectTags   ) where -import Pencil-import Pencil.Internal.Env-import Control.Monad (liftM, foldM)+import Pencil.Config+import Pencil.Env+import Pencil.App+import Pencil.Content+import Control.Monad (foldM) import Control.Monad.Reader (asks) import qualified Data.HashMap.Strict as H import qualified Data.List as L import qualified Data.Text as T import qualified System.FilePath as FP --- $gettingstarted------ This module provides a standard way of building and generating blog posts.--- Check out the Blog example--- <https://github.com/elben/pencil/blob/master/examples/Blog/ here>.------ To generate a blog for your website, first create a @blog/@ directory in--- your web page source directory.------ Then, name your blog posts in this format:------ > yyyy-mm-dd-title-of-blog-post.markdown------ The files in that directory are expected to have preambles that have at--- least @postTitle@ and @date@ defined. The other ones are optional.------ > <!--PREAMBLE--- > postTitle: "Behind Python's unittest.main()"--- > date: 2010-01-30--- > draft: true--- > tags:--- >   - python--- > -->------ You can mark a post as a draft via the @draft@ variable (it won't be--- loaded when you call 'loadBlogPosts'), and add tagging (see below) via--- @tags@. Then, use 'loadBlogPosts' to load the entire @blog/@ directory.------ In the example below, @layout.html@ defines the outer HTML structure (with--- global components like navigation), and @blog-post.html@ is a generic blog--- post container that renders @${postTitle}@ as a header, @${date}@, and--- @${body}@ for the post body.------ @--- layout <- 'load' toHtml "layout.html"--- postLayout <- 'load' toHtml "blog-post.html"--- posts <- 'loadBlogPosts' "blog/"--- render (fmap (layout <|| postLayout <|) posts)--- @---- -- | Loads the given directory as a series of blog posts, sorted by the @date@--- PREAMBLE environment variable. Posts with @draft: true@ are filtered out.+-- preamble environment variable. Posts with @draft: true@ are filtered out. -- -- @--- posts <- loadBlogPosts "blog/"+-- posts <- loadPosts "blog/" -- @-loadBlogPosts :: FilePath -> PencilApp [Page]-loadBlogPosts fp = do-  -- Load posts-  postFps <- listDir False fp--  -- Sort by date (newest first) and filter out drafts-  liftM (filterByVar True "draft" (VBool True /=) . sortByVar "date" dateOrdering)-        (mapM (load blogPostUrl) postFps)+loadPosts :: FilePath -> PencilApp [Page]+loadPosts fp = do+  posts <- loadDir False fp+  return $+    (filterByVar True "draft" (VBool True /=) . sortByVar "date" dateOrdering)+    (fmap (rename postUrl) posts)  -- | Rewrites file path for blog posts.--- @\/blog\/2011-01-01-the-post-title.html@ => @\/blog\/the-post-title\/@-blogPostUrl :: FilePath -> FilePath-blogPostUrl fp = FP.replaceFileName fp (drop 11 (FP.takeBaseName fp)) ++ "/"+--+-- > postUrl "/blog/2011-01-01-post-title.html"+-- > -- "/blog/1-post-title.html/"+--+postUrl :: FilePath -> FilePath+postUrl fp = FP.replaceFileName fp (drop 11 (FP.takeBaseName fp)) ++ "/"  -- | Given that the current @Page@ has a @postTitle@ in the environment, inject -- the post title into the @title@ environment variable, prefixed with the given@@ -91,15 +99,16 @@ -- This is useful for generating the @\<title\>${title}\</title\>@ tags in your -- container layout. --+-- For example, if the page's preamble has @postTitle: "The Meaning of Life"@,+-- then the snippet below will insert a @title@ variable with the value @"The+-- Meaning of Life - My Awesome Website"@:+-- -- @ -- injectTitle "My Awesome Website" post -- @ ----- The above example may insert a @title@ variable with the value @"How to do X--- - My Awesome Website"@.--- injectTitle :: T.Text-            -- ^ Title prefix.+            -- ^ Title prefix             -> Page             -> Page injectTitle titlePrefix page =@@ -109,9 +118,14 @@       env' = insertText "title" title (getPageEnv page)   in setPageEnv env' page +-- | Like, you know, a hashtag. Wraps a text. type Tag = T.Text --- | Helper of 'buildTagPagesWith' defaulting to the variable name @posts@, and+-- | Finds all the tags from the given pages, and generates a page for each tag+-- found. Each tag page has a variable "posts" containing all pages that have+-- the tag.+--+-- Helper of 'buildTagPagesWith' defaulting to the variable name @posts@, and -- the tag index page file path @blog\/tags\/my-tag-name\/@. -- -- @@@ -138,7 +152,7 @@ -- tagPages <- buildTagPagesWith --               "tag-list.html" --               "posts"---               (\tag _ -> "blog/tags/" ++ 'Data.Text.unpack' tag ++ "/")+--               (\\tag _ -> "blog\/tags\/" ++ 'Data.Text.unpack' tag ++ "\/") --               posts -- @ buildTagPagesWith :: FilePath@@ -147,7 +161,7 @@                   -- ^ Variable name inserted into Tag index pages for the list of                   -- Pages tagged with the specified tag                   -> (Tag -> FilePath -> FilePath)-                  -- ^ Function to generate the URL of the tag pages.+                  -- ^ Function to generate the URL of the tag pages                   -> [Page]                   -> PencilApp (H.HashMap Tag Page) buildTagPagesWith tagPageFp pagesVar fpf pages = do@@ -158,17 +172,20 @@    foldM     (\acc (tag, taggedPosts) -> do-      tagPage <- load (fpf tag) tagPageFp-      let tagEnv = (insertPages pagesVar taggedPosts . insertText "tag" tag . merge (getPageEnv tagPage)) env+      tagPage <- rename (fpf tag) <$> load tagPageFp+      -- Generate the URL that this tag page will use.+      let url = T.pack $ "/" ++ getFilePath tagPage+      tagEnv <- (insertPages pagesVar taggedPosts . insertText "tag" tag . insertText "this.url" url . merge (getPageEnv tagPage)) env       return $ H.insert tag (setPageEnv tagEnv tagPage) acc     )     H.empty     (H.toList tagMap) --- | Inject the given tagging map into the given @Page@'s environment, as the--- @tags@ variable, whose value is a @VEnvList@.-injectTagsEnv :: H.HashMap Tag Page -> Page -> Page-injectTagsEnv tagMap page =+-- | Injects the tag map (usually generated by 'buildTagPages' or+-- 'buildTagPagesWith') into the page's environment as the variable @tags@,+-- which is an @VEnvList@.+injectTags :: H.HashMap Tag Page -> Page -> Page+injectTags tagMap page =   -- Build up an env list of tag to that tag page's env. This is so that we can   -- have access to the URL of the tag index pages.   let envs =@@ -190,6 +207,5 @@       -- have access to the URL of the Tag index.       env' = if null envs                then getPageEnv page-               else insertEnv "tags" (VEnvList envs) (getPageEnv page)+               else insert "tags" (VEnvList envs) (getPageEnv page)   in setPageEnv env' page-
+ src/Pencil/Config.hs view
@@ -0,0 +1,181 @@+{-|+Pencil Config.+-}+module Pencil.Config+  ( Config(..)+  , defaultConfig+  , getSourceDir , setSourceDir+  , getOutputDir , setOutputDir+  , getEnv , setEnv , updateEnv+  , getDisplayValue , setDisplayValue+  , getSassOptions , setSassOptions+  , getPandocReaderOptions , setPandocReaderOptions+  , getPandocWriterOptions , setPandocWriterOptions+  )where++import Data.Default (Default)+import Pencil.Env.Internal+import Text.Pandoc.Extensions (disableExtension, Extension(..))+import Text.Sass.Options (defaultSassOptions)+import qualified Data.HashMap.Strict as H+import qualified Data.Text as T+import qualified Text.Pandoc as P+import qualified Text.Pandoc.Highlighting+import qualified Text.Sass as Sass++-- | The main @Config@ needed to build your website. Your app's @Config@ is+-- passed into the 'Pencil.App.PencilApp' monad transformer.+--+-- Use 'defaultConfig' as a starting point, along with the config-modification+-- helpers such as 'setSourceDir'.+--+data Config = Config+  { configSourceDir :: FilePath+  , configOutputDir :: FilePath+  , configEnv :: Env+  , configDisplayValue :: Value -> T.Text+  , configSassOptions :: Sass.SassOptions+  , configPandocReaderOptions :: P.ReaderOptions+  , configPandocWriterOptions :: P.WriterOptions+  }++-- 'Data.Default.Default' instance for 'Config'.+instance Default Config where+  def = defaultConfig++-- | This default @Config@ gives you everything you need to start.+--+-- Default values:+--+-- @+-- Config+--  { 'configSourceDir' = "site/"+--  , 'configOutputDir' = "out/"+--  , 'configEnv' = HashMap.empty+--  , 'configDisplayValue' = 'toText'+--  , 'configSassOptions' = Text.Sass.Options.defaultSassOptions+--  , 'configPandocReaderOptions' = Text.Pandoc.def {+--       Text.Pandoc.readerExtensions = 'Text.Pandoc.Extensions.disableExtension' 'Text.Pandoc.Extensions.Ext_tex_math_dollars' ('Text.Pandoc.Extensions.getDefaultExtensions' "markdown")+--    }+--  , 'configPandocWriterOptions' = Text.Pandoc.def { Text.Pandoc.writerHighlightStyle = Just Text.Pandoc.Highlighting.monochrome }+--  , 'configDisplayValue = 'toText'+--  }+-- @+--+-- @Ext_tex_math_dollars@ is disabled because it messes with parsing template+-- variable directives. If you want TeX math, the better option is drop in a+-- JavaScript library like KaTeX (https://katex.org).+--+defaultConfig :: Config+defaultConfig = Config+  { configSourceDir = "site/"+  , configOutputDir = "out/"+  , configEnv = H.empty+  , configSassOptions = Text.Sass.Options.defaultSassOptions++  -- For markdown reader. We use getDefaultExtensions "markdown" here to get default Markdown extensions.+  -- See+  -- https://hackage.haskell.org/package/pandoc/docs/Text-Pandoc-Extensions.html#v:getDefaultExtensions+  --+  -- Ext_text_math_dollars is disabled because it messes with variable+  -- directives. For example, this renders weird (as of Pandoc 2.7.2):+  -- > **${name}** and **${age}**+  , configPandocReaderOptions = P.def {+      P.readerExtensions = disableExtension Ext_tex_math_dollars (P.getDefaultExtensions "markdown")+  }+  , configPandocWriterOptions = P.def {+      P.writerHighlightStyle = Just Text.Pandoc.Highlighting.monochrome+  }+  , configDisplayValue = toText+  }++-- | Gets the source directory of your web page source files.+getSourceDir :: Config -> FilePath+getSourceDir = configSourceDir++-- | Sets the source directory of your web page source files.+setSourceDir :: FilePath -> Config -> Config+setSourceDir fp c = c { configSourceDir = fp }++-- | Gets the output directory of your rendered web pages.+getOutputDir :: Config -> FilePath+getOutputDir = configOutputDir++-- | Sets the output directory of your rendered web pages.+setOutputDir :: FilePath -> Config -> Config+setOutputDir fp c = c { configOutputDir = fp }++-- | Gets environment of the @Config@, which is what the @PencilApp@ monad+-- transformer uses. This is where variables are set for rendering template+-- directives.+getEnv :: Config -> Env+getEnv = configEnv++-- | Sets the current environment. You may also want to look at 'Pencil.App.withEnv' if you+-- want to 'Pencil.Content.render' things in a modified environment.+setEnv :: Env -> Config -> Config+setEnv env c = c { configEnv = env }++-- | Updates the Env inside the 'Config'.+updateEnv :: (Env -> Env) -> Config -> Config+updateEnv f c = c { configEnv = f (getEnv c) }++-- | Gets the 'Sass.SassOptions' for rendering Sass/Scss files.+getSassOptions :: Config -> Sass.SassOptions+getSassOptions = configSassOptions++-- | Sets the 'Sass.SassOptions' for rendering Sass/Scss files.+setSassOptions :: Sass.SassOptions -> Config -> Config+setSassOptions env c = c { configSassOptions = env }++-- | Gets the 'Text.Pandoc.ReaderOptions' for reading files that use Pandoc.+-- Supported formats:+--+-- * Markdown+-- * Open a GitHub issue if you'd like to see more options!+--+getPandocReaderOptions :: Config -> P.ReaderOptions+getPandocReaderOptions = configPandocReaderOptions++-- | Sets the 'Text.Pandoc.ReaderOptions'. For example, you may want to enable+-- some Pandoc extensions like 'Text.Pandoc.Extensions.Ext_literate_haskell':+--+-- @+-- setPandocReaderOptions+--   (Text.Pandoc.def { 'Text.Pandoc.Options.readerExtensions' = extensionsFromList [Ext_literate_haskell] })+--   config+-- @+setPandocReaderOptions :: P.ReaderOptions -> Config -> Config+setPandocReaderOptions o c = c { configPandocReaderOptions = o }++-- | Gets the 'Text.Pandoc.WriterOptions' for rendering files that use Pandoc.+getPandocWriterOptions :: Config -> P.WriterOptions+getPandocWriterOptions = configPandocWriterOptions++-- | Sets the 'Text.Pandoc.WriterOptions'.+setPandocWriterOptions :: P.WriterOptions -> Config -> Config+setPandocWriterOptions o c = c { configPandocWriterOptions = o }++-- | Gets the function that renders 'Value' to text.+getDisplayValue :: Config -> Value -> T.Text+getDisplayValue = configDisplayValue++-- | Sets the function that renders 'Value' to text. Overwrite this with your+-- own function if you would like to change how certain 'Value's are rendered+-- (e.g. 'Pencil.Env.Internal.VDateTime').+--+-- @+-- myRender :: Value -> T.Text+-- myRender (VDateTime dt) = 'T.pack' $ 'TF.formatTime' 'TF.defaultTimeLocale' "%e %B %Y" dt+-- myRender t = 'toText' t+--+-- ...+--+-- setDisplayValue myRender config+-- @+--+-- In the above example, we change the @VDateTime@ rendering to show @25+-- December 2017@. Leave everything else unchanged.+--+setDisplayValue :: (Value -> T.Text) -> Config -> Config+setDisplayValue f c = c { configDisplayValue = f }
+ src/Pencil/Content.hs view
@@ -0,0 +1,640 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}++{-|+Load, compose and render content.+-}+module Pencil.Content+  (+  -- ** Page++    Page+  , load+  , load'+  , loadDir+  , loadDir'+  , loadAndRender+  , rename+  , to+  , move+  , useFilePath+  , escapeXml+  , getPageEnv, setPageEnv+  , filterByVar+  , sortByVar+  , groupByElements+  , insertPages++  -- ** Structure++  , Structure+  , struct+  , (<||)+  , (<|)+  , (<<|)+  , coll++  -- ** Resource++  , Resource+  , passthrough+  , loadResource+  , loadResources++  -- ** Render++  , Render(..)++  -- ** File paths and types++  , listDir+  , toExpected+  , toHtml+  , toCss+  , toDir++  , FileType+  , fileType+  , toExtension++  , HasFilePath(..)+  ) where++import Pencil.App.Internal+import Pencil.Config+import Pencil.Content.Internal+import Pencil.Content.Nodes+import Pencil.Env+import Pencil.Env.Internal+import Pencil.Parser++import Control.Monad (forM_, foldM, filterM)+import Control.Monad.Except+import Control.Monad.Reader+import Data.List.NonEmpty (NonEmpty(..)) -- Import the NonEmpty data constructor, (:|)+import qualified Data.HashMap.Strict as H+import qualified Data.List as L+import qualified Data.List.NonEmpty as NE+import qualified Data.Maybe as M+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import qualified System.Directory as D+import qualified System.FilePath as FP+import qualified Text.Pandoc.XML as XML+++-- | Lists files in given directory. The file paths returned is prefixed with the+-- given directory.+listDir :: Bool+        -- ^ Recursive if @True@.+        -> FilePath+        -> PencilApp [FilePath]+listDir recursive dir = do+  let dir' = if null dir then dir else FP.addTrailingPathSeparator dir+  fps <- listDir_ recursive dir'+  return $ map (dir' ++) fps++-- | 'listDir' helper.+listDir_ :: Bool -> FilePath -> PencilApp [FilePath]+listDir_ recursive dir = do+  sitePrefix <- asks getSourceDir+  -- List files (just the filename, without the fp directory prefix)+  listing <- liftIO $ D.listDirectory (sitePrefix ++ dir)+  -- Filter only for files (we have to add the right directory prefixes to the+  -- file check)+  files <- liftIO $ filterM (\f -> D.doesFileExist (sitePrefix ++ dir ++ f)) listing+  dirs <- liftIO $ filterM (\f -> D.doesDirectoryExist (sitePrefix ++ dir ++ f)) listing++  innerFiles <- if recursive+                  then mapM+                         (\d -> do+                           ff <- listDir_ recursive (dir ++ d ++ "/")+                           -- Add the inner directory as a prefix+                           return (map (\f -> d ++ "/" ++ f) ff))+                         dirs+                  else return []++  return $ files ++ concat innerFiles++----------------------------------------------------------------------+-- Page+----------------------------------------------------------------------++-- | Applies the environment variables on the given pages.+--+-- The 'Structure' is expected to be ordered by inner-most content first (such+-- that the final, HTML structure layout is last in the list).+--+-- The returned Page contains the fully-applied environment and the nodes of the+-- fully rendered page in this.nodes. The `this.url` variable is set to the Page+-- in the Structure that specified 'useFilePath'; if no Page specified this,+-- then it defaults to the URL of the first (inner-most) Page.+--+-- Variable application. The outer environment's variables are applied down into+-- the inner environments. Once it hits the lowest environment, that page is+-- rendered (and has access to all variables defined in the parent). The page's+-- rendered content is now set as the @${body}@ variable in the environment.+-- The parent page now gets rendered with this new environment, and so on.+--+-- As an example, there is the common scenario where we have a default layout+-- (e.g. @default.html@), with the full HTML structure, but no body. It has only+-- a @${body}@ template variable inside. This is the parent layout. There is a+-- child layout, the partial called "blog-post.html", which has HTML for+-- rendering a blog post, like usage of ${postTitle} and ${postDate}. Inside+-- this, there is another child layout, the blog post content itself, which+-- defines the variables @postTitle@ and @postDate@, and may renderer parent+-- variables such as @websiteTitle@.+--+-- > +--------------++-- > |              | <--- default.html+-- > |              |      Defines websiteTitle+-- > |  +---------+ |+-- > |  |         |<+----- blog-post.html+-- > |  | +-----+ | |      Renders ${postTitle}, ${postDate}+-- > |  | |     | | |+-- > |  | |     | | |+-- > |  | |     |<+-+----- blog-article-content.markdown+-- > |  | |     | | |      Renders ${websiteTitle}+-- > |  | +-----+ | |      Defines postTitle, postDate+-- > |  +---------+ |+-- > +--------------++--+-- In this case, we want to accumulate the environment variables, starting from+-- default.html, to blog-post.html, and blog-article-content.markdown variables.+-- Combine all of that, then render the blog post content. This content is then+-- injected into the parent's environment as a @${body}@ variable, for use in+-- blog-post.html. Now /that/ content is injected into the parent environment's+-- @${body}@ variable, which is then used to render the full-blown HTML page.+--+apply :: Structure -> PencilApp Page+apply structure = do+  let reversed = NE.reverse (structureNodes structure)++  -- Collections cannot be the first element in the structure. It would be+  -- useless there, since nothing can reference it.+  let h = NE.head reversed+  when (isColl h) $ throwError (CollectionFirstInStructure (nodeName h))++  setFilePath (getFilePath structure) <$> apply_ reversed++-- | Apply list of @Page@s and convert to @Page@.+--+-- It's simpler to implement if NonEmpty is ordered outer-structure first (e.g.+-- HTML layout).+--+apply_ :: NE.NonEmpty Node -> PencilApp Page+apply_ (Node _ page :| []) = do+  env <- asks getEnv++  -- Evaluate this page's nodes using the combined env+  applyPage env page++apply_ (Nodes name pages :| rest) = do+  -- Collection nodes must be the last element in the Structure. Before, you+  -- could attach more Pages after a collection (such that each page in the+  -- collection received the rest of the structure's env.)+  --+  -- But we now disallow this for a couple of reasons:+  --+  -- - Most use-cases of collections involve it being the last item anyways+  --   (e.g. list of blog posts)+  -- - Simplifes logic of choosing with Page becomes the default file path to be+  --   used in the Structure render. The rule is now: the last non-collection+  --   Page becomes the default file name. It was complicated with collections,+  --   since a collection had N pages to choose from. And most use-cases (e.g.+  --   list of blog posts) would then require you to call `useFilePath` for the+  --   last Page to specify.+  when (not (null rest)) $ throwError (CollectionNotLastInStructure name)++  env <- asks getEnv++  -- Apply the inner pages against the rest of the Structure.+  pages' <- mapM (\p -> apply_ (Node "body" p :| rest)) pages++  return $+    Page ((H.insert name (VEnvList (map getPageEnv pages')) env)) "UNSPECIFIED_FILE_PATH" False False++apply_ (Node name page :| (h : rest)) = do+  -- Apply the inner Pages to accumulate the inner environments and pages. Do it+  -- in a local env where this Page's env is merged with the current env.+  pageInner <- local (\c -> setEnv (merge (getPageEnv page) (getEnv c)) c) (apply_ (h :| rest))++  -- At this point, pageInner's env is the accumulate of this page's env and all+  -- the inner pages' envs.+  --+  -- Get the inner page's rendered content, and insert into a new env using the+  -- specified `name`. This assumes that the inner Page's content was rendered,+  -- above. Some pages don't have this.content (e.g. 'Nodes' pages).+  let env' = maybe (getPageEnv pageInner)+              (\content -> insertText name content (getPageEnv pageInner))+              (getContent (getPageEnv pageInner))++  -- Apply this current Page's nodes with the accumulated environment of all the+  -- inner Pages.+  applyPage env' page++-- | Applies the Page by merging the given env with the Page's env, evaluating+-- the nodes with the combined env, and generating a new env for the Page,+-- containing @this.url@, @this.nodes@ and @this.content@ in the env.+applyPage :: Env -> Page -> PencilApp Page+applyPage env page = do+  let env' = merge (getPageEnv page) env++  -- Evaluate the Page's nodes with the specified environment.+  nodes <- evalNodes env' (getNodes (getPageEnv page))++  -- Generate this Page's final file path.+  -- Insert the nodes and rendered content into the env.+  let env'' = (insertText "this.url" (T.pack ("/" ++ pageFilePath page)) .+               insert "this.nodes" (VNodes nodes) .+               insertText "this.content" (nodesToText (pageEscapeXml page) nodes))+              env'++  -- Use inner-most Page's file path, as this will be the destination of the+  -- accumluated, final, rendered page.+  return $ page { pageEnv = env'' }++-- | Render nodes. XML/HTML tags are escaped if @escpXml@ is True.+nodesToText :: Bool+            -- ^ XML/HTML tags escaped if True.+            -> [PNode]+            -> T.Text+nodesToText escXml nodes =+  (if escXml then escapeForXml else id) (renderNodes nodes)++-- | Escape XML tags in the given Text.+escapeForXml :: T.Text -> T.Text+escapeForXml text = T.pack (XML.escapeStringForXML (T.unpack text))++-- | Sorts pages by an ordering function.+sortByVar :: T.Text+          -- ^ Environment variable name.+          -> (Value -> Value -> Ordering)+          -- ^ Ordering function to compare Value against. If the variable is+          -- not in the Env, the Page will be placed at the bottom of the order.+          -> [Page]+          -> [Page]+sortByVar var ordering =+  L.sortBy+    (\a b ->+      maybeOrdering ordering (H.lookup var (getPageEnv a)) (H.lookup var (getPageEnv b)))++-- | Filters pages by a variable's value in the environment.+filterByVar :: Bool+            -- ^ If true, include pages without the specified variable.+            -> T.Text+            -- ^ Environment variable name.+            -> (Value -> Bool)+            -> [Page]+            -> [Page]+filterByVar includeMissing var f =+  L.filter+   (\p -> M.fromMaybe includeMissing (H.lookup var (getPageEnv p) >>= (Just . f)))++-- | Given a variable (whose value is assumed to be an array of VText) and list+-- of pages, groups the pages by the VText found in the variable.+--+-- For example, say each Page has a variable "tags" that is a list of tags. The+-- first Page has a "tags" variable that is an @VArray [VText "a"]@, and the+-- second Page has a "tags" variable that is an @VArray [VText "a", VText "b"]@.+-- The final output would be a map @fromList [("a", [page1, page2]), ("b",+-- [page2])]@.+groupByElements :: T.Text+                -- ^ Environment variable name.+                -> [Page]+                -> H.HashMap T.Text [Page]+groupByElements var pages =+  -- This outer fold takes the list of pages, and accumulates the giant HashMap.+  L.foldl'+    (\acc page ->+      let x = H.lookup var (getPageEnv page)+      in case x of+           Just (VArray values) ->+             -- This fold takes each of the found values (each is a key in the+             -- hash map), and adds the current page (from the outer fold) into+             -- each of the key.+             L.foldl'+               (\hashmap envData ->+                 case envData of+                   -- Only insert Pages into the map if the variable is an VArray of+                   -- VText. Alter the map to either (1) insert this current+                   -- page into the existing list, or (2) create a new list (the+                   -- key has never been seen) with just this page.+                   VText val -> H.alter (\mv -> Just (page : M.fromMaybe [] mv)) val hashmap+                   _ -> hashmap)+               acc values+           _ -> acc+    )+    H.empty+    -- Reverse to keep ordering consistent inside hash map, since the fold+    -- prepends into accumulated list.+    (reverse pages)++-- | Copy file from source to output dir. If both the input and output file+-- paths are directories, recursively copy the contents from one to the other.+copyFile :: FilePath -> FilePath -> PencilApp ()+copyFile fpIn fpOut = do+  sitePrefix <- asks getSourceDir+  outPrefix <- asks getOutputDir+  liftIO $ D.createDirectoryIfMissing True (FP.takeDirectory (outPrefix ++ fpOut))++  if isDir fpIn && isDir fpOut+    -- Copying directories+    then do+      fps <- listDir True fpIn+      forM_ fps (\fp -> copyFile fp (fpOut ++ (FP.takeFileName fp)))+    else+      liftIO $ D.copyFile (sitePrefix ++ fpIn) (outPrefix ++ fpOut)+++-- | Loads a file as a 'Resource'. Use this for binary files (e.g. images) and+-- for files without template directives that may still need conversion (e.g.+-- Markdown to HTML, SASS to CSS).+--+-- Generally, you can just use `loadAndRender` instead of this method.+--+-- Loads and renders the image as-is. Underneath the hood this is just a file+-- copy:+--+-- > loadResource "images/profile.jpg" >>= render+--+-- Loads and renders to @about.html@:+--+-- > loadResource "about.markdown" >>= render+--+loadResource :: FilePath -> PencilApp Resource+loadResource fp =+  -- If we can load the Page as text file, convert to a Single. Otherwise if it+  -- wasn't a text file, then return a Passthroguh resource. This is where we+  -- finally handle the "checked" exception; that is, converting the Left error+  -- case (NotTextFile) into a Right case (Passthrough).+  fmap Single (load fp)+    `catchError` handle+  -- 'handle' requires FlexibleContexts+  where handle e = case e of+                     NotTextFile _ -> return (Passthrough fp fp)+                     _ -> throwError e++-- | Loads file in given directory as 'Resource's.+--+-- Generally, you can just use `loadAndRender` instead of this method.+--+-- Load everything inside the @assets/@ folder, renaming converted files as+-- expected (e.g. SCSS to CSS):+--+-- > loadResources True True "assets/"+--+loadResources :: Bool+              -- ^ Recursive if @True@.+              -> Bool+              -- ^ Handle as pass-throughs (file copy) if @True@.+              -> FilePath+              -> PencilApp [Resource]+loadResources recursive pass dir = do+  fps <- listDir recursive dir+  if pass+    then return $ map (\fp -> Passthrough fp fp) fps+    else mapM loadResource fps++-- | Loads file as a pass-through. There is no content conversion, and template+-- directives are ignored. In essence this is a file copy.+--+-- @+-- passthrough "robots.txt" >>= render+--+-- render (move "images\/profile.jpg" \<$\> passthrough "images\/myProfile.jpg")+-- @+--+passthrough :: FilePath -> PencilApp Resource+passthrough fp = return $ Passthrough fp fp++-- | Loads a file as a page, rendering the file (as determined by the file+-- extension) into the proper output format (e.g. Markdown rendered to+-- HTML, SCSS to CSS). Parses the template directives and preamble variables+-- into its environment.+--+-- This loads @index.markdown@ with the destination file path set to @index.html@:+--+-- > load "index.markdown"+--+-- Because this is already an HTML file, the file path is kept as @about.html@:+--+-- > load "about.html"+--+-- Using 'rename' and 'toDir', the destination file path becomes @pages\/about\/index.html@:+--+-- @+-- 'render' $ 'rename' 'toDir' \<$\> load "pages/about.markdown"+-- @+--+load :: FilePath -> PencilApp Page+load fp = rename toExpected <$> load' fp++-- | Like 'load', loads a file as a page. Unlike 'load', the source file path+-- is used as the destination file path (i.e. the extension name is not changed).+--+load' :: FilePath -> PencilApp Page+load' fp = do+  (_, nodes) <- parseAndConvertTextFiles fp+  -- Filter out preamble nodes, since we've already injected preamble into the+  -- env.+  let env' = H.insert "this.nodes" (VNodes (filter (not . isPreamble) nodes)) (findEnv nodes)+  return $ Page env' fp False False++-- | A version of 'load' for directories.+--+-- @+-- layout <- load "layout.html"+-- tutorials <- loadDir False "tutorials/"+-- render $ fmap ((layout '<||') . 'rename' 'toDir') tutorials+-- @+loadDir :: Bool+        -- ^ If True, recursively load files in the directory+        -> FilePath+        -> PencilApp [Page]+loadDir = loadDirWith load++-- | A version of load' for directories. Loads the files in the specified+-- directory as pages. Keeps the original file path.+--+-- @+-- tutorials <- loadDir' False "tutorials/"+-- render $ fmap ((layout <||) . rename toDir) pages+-- @+loadDir' :: Bool+         -- ^ Recursive if true+         -> FilePath+         -> PencilApp [Page]+loadDir' = loadDirWith load'++-- | Internal implemenation for loading directories to pgaes.+loadDirWith :: (FilePath -> PencilApp Page)+            -> Bool+            -- ^ Recursive if true+            -> FilePath+            -> PencilApp [Page]+loadDirWith loadF recur fp = do+  fps <- listDir recur fp+  foldM+    (\acc fpath -> do+      mp <- (loadF fpath >>= return . Just) `catchError` handle+      return (maybe acc (\p -> p : acc) mp))+    []+    (reverse fps)+  where handle e = case e of+                    NotTextFile _ -> return Nothing+                    _ -> throwError e++-- | Loads and renders file, converting content if it's convertible (e.g.+-- Markdown to HTML). The final file path is the "default conversion", if Pencil+-- knows how to convert the file (e.g. .markdown to .html). Otherwise, the same+-- file name is kept (e.g. .txt).+--+-- Load @style.sass@, convert to CSS, and render as @style.css@:+--+-- > loadAndRender "style.sass"+--+-- Load, convert and render everything in the @assets/@ folder. Binary files are+-- copied as-is without any further processing:+--+-- > loadAndRender "assets/"+--+loadAndRender :: FilePath -> PencilApp ()+loadAndRender fp =+  if isDir fp+    then loadResources True False fp >>= render+    else loadResource fp >>= render++-- | Returns True if the given Node is a collection node.+isColl :: Node -> Bool+isColl (Nodes _ _) = True+isColl _ = False++-- | Get the node's name.+nodeName :: Node -> T.Text+nodeName (Node n _) = n+nodeName (Nodes n _) = n++-- | Creates a new structure from two pages. Pronounced "smash".+--+-- @+-- layout <- load "layout.html"+-- index <- load "index.markdown"+-- render (layout <|| index)+-- @+(<||) :: Page -> Page -> Structure+(<||) x y = Structure+  { structureNodes = Node "body" y :| [Node "body" x]+  , structureFilePath = getFilePath y+  , structureFilePathFrozen = False+  }++-- | Pushes @Page@ into @Structure@. Pronounced "push".+--+-- @+-- layout <- load "layout.html"+-- blogLayout <- load "blog-layout.html"+-- blogPost <- load "myblogpost.markdown"+-- render (layout <|| blogLayout <| blogPost)+-- @+(<|) :: Structure -> Page -> Structure+(<|) s p = s { structureNodes = NE.cons (Node "body" p) (structureNodes s)+             , structureFilePath = if structureFilePathFrozen s then structureFilePath s else getFilePath p+             , structureFilePathFrozen = structureFilePathFrozen s || pageUseFilePath p+             }++-- | Pushes @Node@ into the @Structure@. Usually used in conjunction with 'coll'.+--+-- @+-- blogLayout <- load "blog-layout.html"+-- blogPosts <- 'Pencil.Blog.loadPosts' "posts/"+-- render (struct blogLayout <<| coll "posts" blogPosts)+-- @+(<<|) :: Structure -> Node -> Structure+(<<|) s node =+  let (fp, frozen) = if structureFilePathFrozen s+      then+        (structureFilePath s, True)+      else+        case node of+          -- If collection, use the existing file path (ignore file paths in+          -- collection). This keeps with the rule that the default file path+          -- is the last non-collection page.+          Nodes _ _ -> (structureFilePath s, False)+          Node _ p -> (getFilePath p, pageUseFilePath p)+  in s { structureNodes = NE.cons node (structureNodes s)+       , structureFilePath = fp+       , structureFilePathFrozen = frozen+       }++-- | Creates a collection 'Node'. Usually used in conjunction with '<<|'.+coll :: T.Text -> [Page] -> Node+coll = Nodes++-- | Converts a @Page@ into a @Structure@. This is a "singleton" structure.+struct :: Page -> Structure+struct p = Structure+  { structureNodes = Node "body" p :| []+  , structureFilePath = getFilePath p+  , structureFilePathFrozen = pageUseFilePath p+  }+++----------------------------------------------------------------------+-- Render class+----------------------------------------------------------------------++-- | To render something is to create the output web pages, evaluating template+-- directives into their final form using the current environment.+class Render a where+  -- | Renders @a@ as web page(s).+  render :: a -> PencilApp ()++instance Render Resource where+  render (Single page) = render page+  render (Passthrough fpIn fpOut) = copyFile fpIn fpOut++-- This requires FlexibleInstances.+instance Render Structure where+  render s = apply s >>= render++instance Render Page where+  render page = do+    let fpOut = pageFilePath page+    outPrefix <- asks getOutputDir+    let fpOut' = outPrefix ++ if isDir fpOut then fpOut ++ "index.html" else fpOut+    liftIO $ D.createDirectoryIfMissing True (FP.takeDirectory fpOut')+    liftIO $ TIO.writeFile fpOut' (renderNodes (getNodes (getPageEnv page)))++-- This requires FlexibleInstances.+instance Render r => Render [r] where+  render rs = forM_ rs render++----------------------------------------------------------------------+-- Environment modifications+----------------------------------------------------------------------++-- | Inserts pages into the environment. The pages are evaluated and applied before insertion.+--+-- @+-- posts <- 'Pencil.Blog.loadPosts' "blog/"+-- env <- asks 'getEnv'+-- env' <- insertPages "posts" posts env+-- @+insertPages :: T.Text+            -- ^ Environment variable name.+            -> [Page]+            -- ^ @Page@s to insert.+            -> Env+            -- ^ Environment to modify.+            -> PencilApp Env+insertPages var pages env = do+  envs <- mapM+               (\p -> do+                 p' <- apply (struct p)+                 let text = renderNodes (getNodes (getPageEnv p'))+                 let penv = insertText "this.content" text (getPageEnv p')+                 return penv)+               pages+  return $ H.insert var (VEnvList envs) env
+ src/Pencil/Content/Internal.hs view
@@ -0,0 +1,375 @@+{-# LANGUAGE DeriveGeneric #-}++{-|+Internal implementation for functions that content.+-}+module Pencil.Content.Internal where++import Data.Hashable (Hashable)+import Data.List.NonEmpty (NonEmpty(..)) -- Import the NonEmpty data constructor, (:|)+import GHC.Generics (Generic)+import Pencil.Env.Internal+import qualified Data.Char as Char+import qualified Data.HashMap.Strict as H+import qualified Data.Maybe as M+import qualified Data.Text as T+import qualified System.FilePath as FP++-- | The @Page@ is an important data type in Pencil.+--+-- Source files like Markdown and HTML are loaded (e.g. via 'Pencil.Content.load') as a @Page@.+-- A page contains the contents of the file, their un-evaluated [template+-- directives](https://elbenshira.com/pencil/guides/templates/) (e.g.+-- @${body}@), the variables defined in the preamble, and the destination file+-- path.+--+-- The contents /may/ be in its converted form. 'Pencil.Content.load' will convert Markdown to+-- HTML, for example.+--+-- Pages can be /combined/ together into a 'Structure', or inserted into the+-- environment (see 'Pencil.Content.insertPages'). But at the end of the day, even a structure+-- is converted back into a page on 'Pencil.Content.render'. This is because it is the page+-- that is finally rendered into an actual web page when you run your program.+--+data Page = Page+  { pageEnv        :: Env++  , pageFilePath :: FilePath+  -- ^ The rendered output path of this page. Defaults to the input file path.+  -- This file path is used to generate the self URL that is injected into the+  -- environment.++  , pageUseFilePath :: Bool+  -- ^ Whether or not this Page's URL should be used as the final URL in the+  -- render.++  , pageEscapeXml :: Bool+  -- ^ Whether or not XML/HTML tags should be escaped when rendered.+  } deriving (Eq, Show)++-- | Gets the Env of a 'Page'.+getPageEnv :: Page -> Env+getPageEnv = pageEnv++-- | Sets the Env of a 'Page'.+setPageEnv :: Env -> Page -> Page+setPageEnv env p = p { pageEnv = env }++-- | Sets this 'Page' as the designated final 'FilePath'.+--+-- This is useful when you are building a 'Structure' but don't want the file+-- path of the last 'Page' in the structure to be the destination file path on+-- render.+--+-- The [Pages and+-- Structures](https://elbenshira.com/pencil/guides/pages-and-structures/) guide+-- describes this in detail.+--+-- @+-- a <- load "a.html"+-- b <- load "b.html"+-- c <- load "c.html"+--+-- -- Rendered file path is "c.html"+-- render $ a <|| b <| c+--+-- -- Rendered file path is "b.html"+-- render $ a <|| useFilePath b <| c+-- @+--+useFilePath :: Page -> Page+useFilePath p = p { pageUseFilePath = True }++-- | Sets this 'Page' to render with escaped XML/HTML tags.+--+-- This is useful when you are building an RSS feed, and you need the /contents/+-- of each item in the feed to HTML-escaped.+--+-- @+-- rss <- load "rss.xml"+-- item1 <- load "item1.html"+--+-- render $ rss <|| escapeXml item1+-- @+--+escapeXml :: Page -> Page+escapeXml p = p { pageEscapeXml = True }+++-- | A @Structure@ is a list of 'Page's, defining a nesting order. Think of them+-- like <https://en.wikipedia.org/wiki/Matryoshka_doll Russian nesting dolls>.+-- The first element defines the outer-most container, and subsequent elements+-- are /inside/ the previous element.+--+-- You commonly use @Structure@s to insert a page containing content (e.g. a blog+-- post) into a container (e.g. a layout shared across all your web pages).+--+-- Build structures using 'Pencil.Content.struct', 'Pencil.Content.<||' and 'Pencil.Content.<|'.+--+-- @+-- layout <- load "layout.html"+-- index <- load "index.markdown"+-- about <- load "about.markdown"+-- render (layout <|| index)+-- render (layout <|| about)+-- @+--+-- In the example above we load a layout page, which defines the outer HTML+-- structure like @\<html\>\<\/html\>@. We then "push" the index page and the+-- about page into the layout.+--+-- When we 'Pencil.Content.render' @layout <|| index@, the contents of the index (and about)+-- page is injected into the layout page through the variable @${body}@. So+-- @layout.html@ must use @${body}@ somewhere in its own body.+--+-- Structures also control the closure of variables. Variables defined in a+-- page are accessible both by pages above and below. This allows inner+-- pages to define variables like the blog post title, which may be used in+-- the outer page to, say, set the @\<title\>@ tag.+--+-- In this way, structures allows efficient page reuse. See the private function+-- 'Pencil.Content.Internal.apply' to learn more about how structures are evaluated.+--+-- /The Default File Path Rule/. When a structure is rendered, the /last/+-- non-collection page in the structure is used as the destination file path.+-- You can select a different page via 'useFilePath'.+--+-- The [Pages and+-- Structures](https://elbenshira.com/pencil/guides/pages-and-structures/) guide+-- also describes structures in detail.+--+-- Note that structures differ from the @${partial(...)}@ directive, which has no+-- such variable closures. The partial directive is much simpler—think of them+-- as copy-and-pasting snippets from one file to another. A partial has+-- the same environment as the context in which the partial directive appears.+--+--+data Structure = Structure+  { structureNodes :: NonEmpty Node+  , structureFilePath :: FilePath+  , structureFilePathFrozen :: Bool+  -- ^ True if the file path should no longer be changed. This happens when a+  -- page with `useFilePath = True` was pushed into the structure.+  }++-- | An inner element in the Structure. Either a singular Page, or a collection+-- of Pages. The Text element is the variable name that the inner page's content+-- is injected as. Defaults to @"body"@.+data Node =+    Node T.Text Page+  | Nodes T.Text [Page]++-- | @Resource@ is used to copy static binary files to the destination, and to+-- load and render files that just needs conversion without template directives+-- or structures.+--+-- This is how Pencil handles files like images, compiled JavaScript, or text+-- files that require only a straight-forward conversion.+--+-- Use 'Pencil.Content.passthrough', 'Pencil.Content.loadResource' and+-- 'Pencil.Content.loadResources' to build a @Resource@ from a file.+--+-- In the example below, @robots.txt@ and everything in the @images/@ directory+-- will be rendered as-is.+--+-- @+-- passthrough "robots.txt" >>= render+-- passthrough "images/" >>= render+-- @+--+data Resource+  = Single Page+  | Passthrough FilePath FilePath+  -- ^ in and out file paths (can be dir or files)+++-- | Enum for file types that can be parsed and converted by Pencil.+data FileType = Html+              | Markdown+              | Css+              | Sass+              | Other+  deriving (Eq, Generic)++-- | 'Hashable' instance of @FileType@.+instance Hashable FileType++-- | A 'H.HashMap' of file extensions (e.g. @markdown@) to 'FileType'.+--+-- * 'Html': @html, htm@+-- * 'Markdown': @markdown, md@+-- * 'Css': @css@+-- * 'Sass': @sass, scss@+--+fileTypeMap :: H.HashMap String FileType+fileTypeMap = H.fromList+  [ ("html", Html)+  , ("htm", Html)+  , ("markdown", Markdown)+  , ("md", Markdown)+  , ("css", Css)+  , ("sass", Sass)+  , ("scss", Sass)+  ]++-- | Mapping of 'FileType' to the final converted format. Only contains+-- 'FileType's that Pencil will convert.+--+-- * 'Markdown': @html@+-- * 'Sass': @css@+--+extensionMap :: H.HashMap FileType String+extensionMap = H.fromList+  [ (Markdown, "html")+  , (Sass, "css")]++-- | Converts a 'FileType' into its converted webpage extension, if Pencil would+-- convert it (e.g. Markdown to HTML).+--+-- >>> toExtension Markdown+-- Just "html"+--+toExtension :: FileType -> Maybe String+toExtension ft = H.lookup ft extensionMap++-- | Takes a file path and returns the 'FileType', defaulting to 'Other' if it's+-- not a supported extension.+fileType :: FilePath -> FileType+fileType fp =+  -- takeExtension returns ".markdown", so drop the "."+  M.fromMaybe Other (H.lookup (map Char.toLower (drop 1 (FP.takeExtension fp))) fileTypeMap)++-- | Returns True if the file path is a directory.+-- Examples: foo/bar/+-- Examples of not directories: /foo, foo/bar, foo/bar.baz+isDir :: FilePath -> Bool+isDir fp = null (FP.takeBaseName fp)++-- | Replaces the file path's extension with @.html@.+--+-- @+-- rename toHtml \<$\> 'Pencil.Content.load' "about.htm"+-- @+--+toHtml :: FilePath -> FilePath+toHtml fp = FP.dropExtension fp ++ ".html"++-- | Converts a file path into a directory name, dropping the extension.+-- Pages with a directory as its file path is rendered as an index file in that+-- directory.+--+-- For example, @pages/about.html@ is transformed into @pages\/about\/@, which+-- upon 'Pencil.Content.render' results in the destination file path @pages\/about\/index.html@:+--+-- @+-- toDir "pages/about.html"+-- @+--+-- Load and render as @pages\/about\/@:+--+-- @+-- render $ 'rename' toDir \<$\> 'Pencil.Content.load' "pages/about.html"+-- @+--+toDir :: FilePath -> FilePath+toDir fp = FP.replaceFileName fp (FP.takeBaseName fp) ++ "/"++-- | Replaces the file path's extension with @.css@.+--+-- @+-- rename toCss \<$\> 'Pencil.Content.load' "style.sass"+-- @+--+toCss :: FilePath -> FilePath+toCss fp = FP.dropExtension fp ++ ".css"++-- | Converts file path into the expected extensions. This means @.markdown@+-- become @.html@, @.sass@ becomes @.css@, and so forth. See 'extensionMap' for+-- conversion table.+toExpected :: FilePath -> FilePath+toExpected fp = maybe fp ((FP.dropExtension fp ++ ".") ++) (toExtension (fileType fp))++-- | Transforms the file path.+--+-- @+-- about <- load "about.htm"+-- render $ struct (rename 'toHtml' about)+-- @+rename :: HasFilePath a => (FilePath -> FilePath) -> a -> a+rename f a = setFilePath (f (getFilePath a)) a++-- | Sets the target file path to the specified file path. If the given file path+-- is a directory, the file name set to @index.html@. If the file path is a file+-- name, then the file is renamed.+--+-- Move @stuff/about.html@ to @about/blah.html@ on render:+--+-- > about <- to "about/blah.html" <$> load "stuff/about.htm"+--+-- Convert the destination file path to @about/index.html@:+--+-- > about <- to "about/" <$> load "stuff/about.htm"+-- > render about+--+-- Equivalent to the above example:+--+-- > about <- load "stuff/about.htm"+-- > render $ to "about/" about+--+to :: HasFilePath a => FilePath -> a -> a+to = move' "index.html"++-- | Moves the target file path to the specified file path. Behaves similar to+-- the UNIX @mv@ command: if the given file path is a directory, the file name+-- is kept the same. If the file path is a file name, then the file is renamed.+--+-- Move @assets/style.css@ to @stylesheets/style.css@:+--+-- > move "stylesheets/" <$> load "assets/style.css"+--+-- Move @assets/style.css@ to @stylesheets/base.css@.+--+-- > move "stylesheets/base.css" <$> load "assets/style.css"+--+move :: HasFilePath a => FilePath -> a -> a+move fp a = move' (FP.takeFileName (getFilePath a)) fp a++-- | Internal implemenation for 'move' and 'to'.+--+-- Moves the target file path to the specified FilePath. If the given FilePath+-- is a directory, the file name is kept the same. If the FilePath is a file+-- name, then @fromFileName@ is used as the file name.+move' :: HasFilePath a => FilePath -> FilePath -> a -> a+move' fromFileName fp a =+  let dir = FP.takeDirectory fp+      fp' = if isDir fp+              then dir ++ "/" ++ fromFileName+              else dir ++ "/" ++ FP.takeFileName fp+  in setFilePath fp' a++----------------------------------------------------------------------+-- HasFilePath class+----------------------------------------------------------------------++-- | Class for types that has a final file path for rendering.+--+-- This allows file-path-changing methods to be re-used across 'Pencil.Content.Internal.Page',+-- 'Pencil.Content.Internal.Structure' and 'Pencil.Content.Internal.Resource' types.+class HasFilePath a where+  getFilePath :: a -> FilePath+  setFilePath :: FilePath -> a -> a++instance HasFilePath Page where+  getFilePath = pageFilePath+  setFilePath fp p = p { pageFilePath = fp }++instance HasFilePath Resource where+  getFilePath (Single p) = getFilePath p+  getFilePath (Passthrough _ fp) = fp++  setFilePath fp (Single p) = Single $ setFilePath fp p+  setFilePath fp (Passthrough ofp _) = Passthrough ofp fp++instance HasFilePath Structure where+  getFilePath = structureFilePath+  setFilePath fp s = s { structureFilePath = fp }
+ src/Pencil/Content/Nodes.hs view
@@ -0,0 +1,120 @@+{-|+Internal functions that deal with nodes.+-}+module Pencil.Content.Nodes where++import Pencil.App.Internal+import Pencil.Env.Internal+import Pencil.Parser+import Pencil.Content.Internal+import Pencil.Config++import Control.Monad.Reader+import Control.Monad.Except+import Control.Exception (tryJust)+import Data.Text.Encoding (decodeUtf8)++import qualified Data.HashMap.Strict as H+import qualified Data.Text as T+import qualified Text.Pandoc as P+import qualified Text.Sass as Sass+import qualified Data.Text.IO as TIO++-- | Evaluate the nodes in the given environment. Note that it returns an IO+-- because of @${partial(..)}@ calls that requires us to load a file.+evalNodes :: Env -> [PNode] -> PencilApp [PNode]+evalNodes _ [] = return []+evalNodes env (PVar var : rest) = do+  nodes <- evalNodes env rest+  case H.lookup var env of+    Nothing ->+      -- Can't find var in env. Skip over it for now and we'll just render the+      -- directive to help user debug missing variables. Later on we'll do some+      -- nice error handling w/o crashing the system (throw warnings instead of+      -- errors).+      return $ PVar var : nodes+    Just envData -> do+      displayValue <- asks getDisplayValue+      return $ PText (displayValue envData) : nodes+evalNodes env (PIf var nodes : rest) = do+  rest' <- evalNodes env rest+  case H.lookup var env of+    Nothing ->+      -- Can't find var in env; everything inside the if-statement is thrown away+      return rest'+    Just _ -> do+      -- Render nodes inside the if-statement+      nodes' <- evalNodes env nodes+      return $ nodes' ++ rest'+evalNodes env (PFor var nodes : rest) = do+  rest' <- evalNodes env rest+  case H.lookup var env of+    Nothing ->+      -- Can't find var in env; everything inside the for-statement is throw away+      return rest'+    Just (VEnvList envs) -> do+      -- Render the for nodes once for each given env, and append them together+      forNodes <-+        foldM+          (\accNodes e -> do+              nodes' <- evalNodes (H.union e env) nodes+              return $ accNodes ++ nodes')+          [] envs+      return $ forNodes ++ rest'+    -- Var is not an VEnvList; everything inside the for-statement is thrown away+    Just _ -> return rest'+evalNodes env (PPartial fp : rest) = do+  (_, nodes) <- parseAndConvertTextFiles (T.unpack fp)+  nodes' <- evalNodes env nodes+  rest' <- evalNodes env rest+  return $ nodes' ++ rest'+evalNodes env (n : rest) = do+  rest' <- evalNodes env rest+  return $ n : rest'++-- | Loads and parses the given file path. Converts 'Markdown' files to HTML,+-- compiles 'Sass' files into CSS, and leaves everything else alone.+parseAndConvertTextFiles :: FilePath -> PencilApp (T.Text, [PNode])+parseAndConvertTextFiles fp = do+  content <- loadTextFile fp+  content' <-+    case fileType fp of+      Markdown -> do+        pandocReaderOptions <- asks getPandocReaderOptions+        pandocWriterOptions <- asks getPandocWriterOptions+        result <- liftIO $ P.runIO (readMarkdownWriteHtml pandocReaderOptions pandocWriterOptions content)+        case result of+          Left _ -> return content+          Right text -> return text+      Sass -> do+        sassOptions <- asks getSassOptions+        sitePrefix <- asks getSourceDir+        -- Use compileFile so that SASS @import works+        result <- liftIO $ Sass.compileFile (sitePrefix ++ fp) sassOptions+        case result of+          Left _ -> return content+          Right byteStr -> return $ decodeUtf8 byteStr+      _ -> return content+  let nodes = case parseText content' of+                Left _ -> []+                Right n -> n+  return (content', nodes)++-- | Loads the given file as a text file. Throws an exception into the ExceptT+-- monad transformer if the file is not a text file.+loadTextFile :: FilePath -> PencilApp T.Text+loadTextFile fp = do+  sitePrefix <- asks getSourceDir+  -- Try to read the file. If it fails because it's not a text file, capture the+  -- exception and convert it to a "checked" exception in the ExceptT stack via+  -- 'throwError'.+  eitherContent <- liftIO $ tryJust toPencilException (TIO.readFile (sitePrefix ++ fp))+  case eitherContent of+    Left e -> throwError e+    Right a -> return a++-- | Converts Markdown to HTML using the given options.+readMarkdownWriteHtml :: P.PandocMonad m => P.ReaderOptions -> P.WriterOptions -> T.Text -> m T.Text+readMarkdownWriteHtml readerOptions writerOptions content = do+  pandoc <- P.readMarkdown readerOptions content+  P.writeHtml5String writerOptions pandoc
+ src/Pencil/Env.hs view
@@ -0,0 +1,98 @@+{-|+Functions that manipulate the environment.+-}+module Pencil.Env+  ( Value(..)+  , withEnv+  , insert+  , insertText+  -- , insertPages+  , adjust+  , merge+  , toText+  , toTextRss++  , arrayContainsText+  , dateOrdering+  , maybeOrdering+  ) where++import Pencil.Env.Internal+import Pencil.App.Internal+import Pencil.Config++import qualified Control.Monad.Reader as Reader+import qualified Data.HashMap.Strict as H+import qualified Data.Text as T++-- | Inserts @Value@ into the given @Env@.+insert :: T.Text+          -- ^ Environment variable name.+          -> Value+          -- ^ @Value@ to insert.+          -> Env+          -- ^ Environment to modify.+          -> Env+insert = H.insert++-- | Modifies a variable in the given environment.+adjust :: (Value -> Value)+       -> T.Text+       -- ^ Environment variable name.+       -> Env+       -> Env+adjust = H.adjust++-- | Merges two @Env@s together, biased towards the left-hand @Env@ on duplicates.+merge :: Env -> Env -> Env+merge = H.union++-- | Inserts text into the given @Env@.+--+-- @+-- env <- asks getEnv+-- insertText "title" "My Awesome Website" env+-- @+insertText :: T.Text+           -- ^ Environment variable name.+           -> T.Text+           -- ^ Text to insert.+           -> Env+           -- ^ Environment to modify.+           -> Env+insertText var val = H.insert var (VText val)++-- | Runs the computation with the given environment. This is useful when you+-- want to render a 'Pencil.Content.Internal.Page' or 'Pencil.Content.Internal.Structure' with a modified environment.+--+-- @+-- withEnv ('Pencil.Env.insertText' "newvar" "newval" env) ('Pencil.Content.render' page)+-- @+--+-- Alternatively, use 'Reader.local', which is re-exported in the Pencil module.+--+withEnv :: Env -> PencilApp a -> PencilApp a+withEnv env = Reader.local (setEnv env)++-- | Returns true if the given @Value@ is a @VArray@ that contains the given+-- text.+arrayContainsText :: T.Text -> Value -> Bool+arrayContainsText t (VArray arr) =+  any (\d -> case d of+               VText t' -> t == t'+               _ -> False)+      arr+arrayContainsText _ _ = False++-- | Defines an ordering for possibly-missing Value. Nothings are ordered last.+maybeOrdering :: (Value -> Value -> Ordering)+              -> Maybe Value -> Maybe Value -> Ordering+maybeOrdering _ Nothing Nothing = EQ+maybeOrdering _ (Just _) Nothing = GT+maybeOrdering _ Nothing (Just _) = LT+maybeOrdering o (Just a) (Just b) = o a b++-- | Sort by newest first.+dateOrdering :: Value -> Value -> Ordering+dateOrdering (VDateTime a) (VDateTime b) = compare b a+dateOrdering _ _ = EQ
+ src/Pencil/Env/Internal.hs view
@@ -0,0 +1,143 @@+{-# LANGUAGE OverloadedStrings #-}++{-|+Internal implementation of Pencil's environment.+-}+module Pencil.Env.Internal where++import qualified Pencil.Parser as P++import Data.Text.Encoding (encodeUtf8)++import qualified Data.HashMap.Strict as H+import qualified Data.Maybe as M+import qualified Data.Text as T+import qualified Data.Time.Clock as TC+import qualified Data.Time.Format as TF+import qualified Data.Vector as V+import qualified Data.Yaml as A++-- | Represents the data types found in an environment.+--+-- This includes at least 'Data.Aeson' 'Data.Aeson.Types.Value' types, plus other+-- useful ones.+data Value =+    VNull -- JSON null+  | VText T.Text+  | VBool Bool+  | VDateTime TC.UTCTime+  | VArray [Value]+  | VEnvList [Env]+  | VNodes [P.PNode]+  deriving (Eq, Show)++-- | Environment map of variables to 'Value's.+type Env = H.HashMap T.Text Value++-- | Converts an Aeson 'Aeson.Value' to a Pencil 'Value'.+toValue :: A.Value -> Maybe Value+toValue A.Null = Just VNull+toValue (A.Bool b) = Just $ VBool b+toValue (A.String s) =+  -- See if coercible to datetime+  case toDateTime (T.unpack s) of+    Nothing -> Just $ VText s+    Just dt -> Just $ VDateTime dt+toValue (A.Array arr) =+  Just $ VArray (V.toList (V.mapMaybe toValue arr))+toValue _ = Nothing++-- | Accepted format is ISO 8601 (YYYY-MM-DD), optionally with an appended "THH:MM:SS".+-- Examples:+--+-- * 2010-01-30+-- * 2010-01-30T09:08:00+--+toDateTime :: String -> Maybe TC.UTCTime+toDateTime s =+  -- Try to parse "YYYY-MM-DD"+  case parseIso8601 Nothing s of+    -- Try to parse "YYYY-MM-DDTHH:MM:SS"+    Nothing -> parseIso8601 (Just "%H:%M:%S") s+    Just dt -> Just dt++-- | Helper for 'TF.parseTimeM' using ISO 8601. YYYY-MM-DDTHH:MM:SS and+-- YYYY-MM-DD formats.+--+-- https://hackage.haskell.org/package/time-1.9/docs/Data-Time-Format.html#v:iso8601DateFormat+parseIso8601 :: Maybe String -> String -> Maybe TC.UTCTime+parseIso8601 f = TF.parseTimeM True TF.defaultTimeLocale (TF.iso8601DateFormat f)++-- | Gets the nodes from the env, from the @this.nodes@ variable. Returns empty+-- list if this variable is missing.+getNodes :: Env -> [P.PNode]+getNodes env =+  case H.lookup "this.nodes" env of+    Just (VNodes nodes) -> nodes+    _ -> []++-- | Find preamble node, and load as an Env. If no preamble is found, return a+-- blank Env.+findEnv :: [P.PNode] -> Env+findEnv nodes =+  aesonToEnv $ M.fromMaybe H.empty (P.findPreambleText nodes >>= (A.decodeThrow . encodeUtf8 . T.strip))++-- | Converts an Aeson Object to an Env.+aesonToEnv :: A.Object -> Env+aesonToEnv = H.foldlWithKey' maybeInsertIntoEnv H.empty++-- | Convert known Aeson 'Aeson.Value' into a Pencil+-- 'Pencil.Env.Internal.Value', and insert into the env. If there is no+-- conversion possible, the env is not modified.+maybeInsertIntoEnv :: Env -> T.Text -> A.Value -> Env+maybeInsertIntoEnv env k v =+  case toValue v of+    Nothing -> env+    Just d -> H.insert k d env+++-- | Gets the rendered content from the env, from the @this.content@ variable.+-- Returns empty is the variable is missing (e.g. has not been rendered).+getContent :: Env -> Maybe T.Text+getContent env =+  case H.lookup "this.content" env of+    Just (VText content) -> return content+    _ -> Nothing++-- | Renders environment value for human consumption. This is the default one.+toText :: Value -> T.Text+toText VNull = "null"+toText (VText t) = t+toText (VArray arr) = T.unwords $ map toText arr+toText (VBool b) = if b then "true" else "false"+toText (VEnvList envs) = T.unwords $ map (T.unwords . map toText . H.elems) envs+toText (VDateTime dt) =+  -- December 30, 2017+  T.pack $ TF.formatTime TF.defaultTimeLocale "%B %e, %Y" dt+toText (VNodes nodes) = P.renderNodes nodes++-- | A version of 'toText' that renders 'Value' acceptable for an RSS feed.+--+-- * Dates are rendered in the RFC 822 format.+-- * Everything else defaults to the 'toText' implementation.+--+-- You'll probably want to also use 'Pencil.Content.Internal.escapeXml' to render an RSS feed.+--+toTextRss :: Value -> T.Text+toTextRss (VDateTime dt) = T.pack $ TF.formatTime TF.defaultTimeLocale rfc822DateFormat dt+toTextRss v = toText v++-- | RFC 822 date format.+--+-- Helps to pass https://validator.w3.org/feed/check.cgi.+--+-- Same as https://hackage.haskell.org/package/time/docs/Data-Time-Format.html#v:rfc822DateFormat+-- but no padding for the day section, so that single-digit days only has one space preceeding it.+--+-- Also changed to spit out the offset timezone (+0000) because the default was spitting out "UTC"+-- which is not valid RFC 822. Weird, since the defaultTimeLocal source and docs show that it won't+-- use "UTC":+-- https://hackage.haskell.org/package/time/docs/Data-Time-Format.html#v:defaultTimeLocale+--+rfc822DateFormat :: String+rfc822DateFormat = "%a, %d %b %Y %H:%M:%S %z"
− src/Pencil/Internal/Env.hs
@@ -1,93 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Pencil.Internal.Env where--import qualified Data.HashMap.Strict as H-import qualified Data.Text as T-import qualified Data.Time.Clock as TC-import qualified Data.Time.Format as TF-import qualified Data.Vector as V-import qualified Data.Yaml as A---- | Represents the data types found in an environment.------ This includes at least Data.Aeson Value type--- (https://hackage.haskell.org/package/aeson-1.2.3.0/docs/Data-Aeson.html#t:Value),--- plus other useful ones.-data Value =-    VNull -- JSON null-  | VText T.Text-  | VBool Bool-  | VDateTime TC.UTCTime-  | VArray [Value]-  | VEnvList [Env]-  deriving (Eq, Show)---- | Environment map of variables to 'Value's.-type Env = H.HashMap T.Text Value---- | Converts an Aeson @Value@ to a Pencil 'Value'.-toValue :: A.Value -> Maybe Value-toValue A.Null = Just VNull-toValue (A.Bool b) = Just $ VBool b-toValue (A.String s) =-  -- See if coercible to datetime-  case toDateTime (T.unpack s) of-    Nothing -> Just $ VText s-    Just dt -> Just $ VDateTime dt-toValue (A.Array arr) =-  Just $ VArray (V.toList (V.mapMaybe toValue arr))-toValue _ = Nothing---- | Render for human consumption. This is the default one. Pass into Config as--- part of the Reader?-toText :: Value -> T.Text-toText VNull = "null"-toText (VText t) = t-toText (VArray arr) = T.unwords $ map toText arr-toText (VBool b) = if b then "true" else "false"-toText (VEnvList envs) = T.unwords $ map (T.unwords . map toText . H.elems) envs-toText (VDateTime dt) =-  -- December 30, 2017-  T.pack $ TF.formatTime TF.defaultTimeLocale "%B %e, %Y" dt---- | Accepted format is ISO 8601 (YYYY-MM-DD), optionally with an appended "THH:MM:SS".--- Example: 2010-01-30, 2010-01-30T09:08:00-toDateTime :: String -> Maybe TC.UTCTime-toDateTime s =-  -- Try to parse "YYYY-MM-DD"-  case maybeParseIso8601 Nothing s of-    -- Try to parse "YYYY-MM-DDTHH:MM:SS"-    Nothing -> maybeParseIso8601 (Just "%H:%M:%S") s-    Just dt -> Just dt---- | Helper for 'TF.parseTimeM' using ISO 8601. YYYY-MM-DDTHH:MM:SS and--- YYYY-MM-DD formats.------ https://hackage.haskell.org/package/time-1.9/docs/Data-Time-Format.html#v:iso8601DateFormat-maybeParseIso8601 :: Maybe String -> String -> Maybe TC.UTCTime-maybeParseIso8601 f = TF.parseTimeM True TF.defaultTimeLocale (TF.iso8601DateFormat f)---- | Define an ordering for possibly-missing Value. Nothings are ordered last.-maybeOrdering :: (Value -> Value -> Ordering)-              -> Maybe Value -> Maybe Value -> Ordering-maybeOrdering _ Nothing Nothing = EQ-maybeOrdering _ (Just _) Nothing = GT-maybeOrdering _ Nothing (Just _) = LT-maybeOrdering o (Just a) (Just b) = o a b---- | Sort by newest first.-dateOrdering :: Value -> Value -> Ordering-dateOrdering (VDateTime a) (VDateTime b) = compare b a-dateOrdering _ _ = EQ---- | Returns true if the given @Value@ is a @VArray@ that contains the given--- string.-arrayContainsString :: T.Text -> Value -> Bool-arrayContainsString t (VArray arr) =-  any (\d -> case d of-               VText t' -> t == t'-               _ -> False)-      arr-arrayContainsString _ _ = False-
− src/Pencil/Internal/Parser.hs
@@ -1,397 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Pencil.Internal.Parser where--import Text.ParserCombinators.Parsec-import qualified Data.List as DL-import qualified Data.Text as T-import qualified Text.Parsec as P---- Doctest setup.------ $setup--- >>> :set -XOverloadedStrings--- >>> import Data.Either (isLeft)---- | Pencil's @Page@ AST.-data PNode =-    PText T.Text-  | PVar T.Text-  | PFor T.Text [PNode]-  | PIf T.Text [PNode]-  | PPartial T.Text-  | PPreamble T.Text--  -- Signals a If/For expression in the stack waiting for expressions. So that we-  -- can find the next unused open if/for-statement in nested if/for-statements.-  | PMetaIf T.Text-  | PMetaFor T.Text--  -- A terminating node that represents the end of the program, to help with AST-  -- converstion-  | PMetaEnd-  deriving (Show, Eq)---- | Pencil's tokens for content.-data Token =-    TokText T.Text-  | TokVar T.Text-  | TokFor T.Text-  | TokIf T.Text-  | TokPartial T.Text-  | TokPreamble T.Text-  | TokEnd-  deriving (Show, Eq)---- | Convert Tokens to PNode AST.------ >>> transform [TokText "hello", TokText "world"]--- [PText "hello",PText "world"]------ >>> transform [TokIf "title", TokEnd]--- [PIf "title" []]------ >>> transform [TokIf "title", TokText "hello", TokText "world", TokEnd]--- [PIf "title" [PText "hello",PText "world"]]------ > ${if(title)}--- >   ${for(posts)}--- >     world--- >   ${end}--- > ${end}------ >>> transform [TokIf "title", TokFor "posts", TokText "world", TokEnd, TokEnd]--- [PIf "title" [PFor "posts" [PText "world"]]]------ > begin--- > now--- > ${if(title)}--- >   hello--- >   world--- >   ${if(body)}--- >     ${body}--- >     ${someothervar}--- >     wahh--- >   ${end}--- >   final--- >   thing--- > ${end}--- > the--- > lastline------ >>> transform [TokText "begin", TokText "now", TokIf "title", TokText "hello", TokText "world", TokIf "body", TokVar "body", TokVar "someothervar", TokText "wahh", TokEnd, TokText "final", TokText "thing", TokEnd, TokText "the", TokText "lastline"]--- [PText "begin",PText "now",PIf "title" [PText "hello",PText "world",PIf "body" [PVar "body",PVar "someothervar",PText "wahh"],PText "final",PText "thing"],PText "the",PText "lastline"]------ > <!--PREAMBLE--- > foo: bar--- > do:--- >   - re--- >   - me--- > -->--- > Hello world ${foo}------ >>> transform [TokPreamble "foo: bar\ndo:\n  - re\n  -me", TokText "Hello world ", TokVar "foo"]--- [PPreamble "foo: bar\ndo:\n  - re\n  -me",PText "Hello world ",PVar "foo"]----transform :: [Token] -> [PNode]-transform toks =-  let stack = ast [] toks-  in reverse stack---- | Converts Tokens, which is just the raw list of parsed tokens, into PNodes--- which are the tree-structure expressions (i.e. if/for nesting)------ This function works by using a stack to keep track of where we are for nested--- expressions such as if and for statements. When a token that starts a nesting--- is found (like a TokIf), a "meta" expression (PMetaIf) is pushed into the--- stack. When we finally see an end token (TokEnd), we pop all the expressions--- off the stack until the first meta tag (e.g PMetaIf) is reached. All the--- expressions popped off are now known to be nested inside that if statement.----ast :: [PNode] -- stack-    -> [Token] -- remaining-    -> [PNode] -- (AST, remaining)-ast stack [] = stack-ast stack (TokText t : toks) = ast (PText t : stack) toks-ast stack (TokVar t : toks)  = ast (PVar t : stack) toks-ast stack (TokPartial fp : toks) = ast (PPartial fp : stack) toks-ast stack (TokPreamble t : toks) = ast (PPreamble t : stack) toks-ast stack (TokIf t : toks)   = ast (PMetaIf t : stack) toks-ast stack (TokFor t : toks)  = ast (PMetaFor t : stack) toks-ast stack (TokEnd : toks) =-  let (node, popped, remaining) = popNodes stack-      -- ^ Find the last unused if/for statement, and grab all the expressions-      -- in-between this TokEnd and the opening if/for keyword.-      n = case node of-            PMetaIf t -> PIf t popped-            PMetaFor t -> PFor t popped-            _ -> PMetaEnd-  -- Push the statement into the stack-  in ast (n : remaining) toks---- | Pop nodes until we hit a If/For statement.--- Return pair (constructor found, nodes popped, remaining stack)-popNodes :: [PNode] -> (PNode, [PNode], [PNode])-popNodes = popNodes_ []---- | Helper for 'popNodes'.-popNodes_ :: [PNode] -> [PNode] -> (PNode, [PNode], [PNode])-popNodes_ popped [] = (PMetaEnd, popped, [])-popNodes_ popped (PMetaIf t : rest) = (PMetaIf t, popped, rest)-popNodes_ popped (PMetaFor t : rest) = (PMetaFor t, popped, rest)-popNodes_ popped (t : rest) = popNodes_ (t : popped) rest---- | Render nodes as string.-renderNodes :: [PNode] -> T.Text-renderNodes = DL.foldl' (\str n -> (T.append str (renderNode n))) ""---- | Render node as string.-renderNode :: PNode -> T.Text-renderNode (PText t) = t-renderNode (PVar t) = T.append (T.append "${" t) "}"-renderNode (PFor t nodes) =-  let for = T.append (T.append "${for(" t) ")}"-      body = renderNodes nodes-      end = "${end}"-  in T.append (T.append for body) end-renderNode (PIf t nodes) =-  let for = T.append (T.append "${if(" t) ")}"-      body = renderNodes nodes-      end = "${end}"-  in T.append (T.append for body) end-renderNode (PPartial file) = T.append (T.append "${partial(" file) ")}"-renderNode (PMetaIf v) = renderNode (PIf v [])-renderNode (PMetaFor v) = renderNode (PFor v [])-renderNode PMetaEnd = ""-renderNode (PPreamble _) = "" -- Don't render the PREAMBLE---- | Render tokens.-renderTokens :: [Token] -> T.Text-renderTokens = DL.foldl' (\str n -> (T.append str (renderToken n))) ""---- | Render token.-renderToken :: Token -> T.Text-renderToken (TokText t) = t-renderToken (TokVar t) = T.append (T.append "${" t) "}"-renderToken (TokPartial fp) = T.append (T.append "${partial(\"" fp) "\"}"-renderToken (TokFor t) = T.append (T.append "${for(" t) ")}"-renderToken (TokEnd) = "${end}"-renderToken (TokIf t) = T.append (T.append "${if(" t) ")}"-renderToken (TokPreamble _) = "" -- Hide preamble content---- | Parse text.-parseText :: T.Text -> Either ParseError [PNode]-parseText text = do-  toks <- parse parseEverything (T.unpack "") (T.unpack text)-  return $ transform toks---- | Parse everything.------ >>> parse parseEverything "" "Hello ${man} and ${woman}."--- Right [TokText "Hello ",TokVar "man",TokText " and ",TokVar "woman",TokText "."]------ >>> parse parseEverything "" "Hello ${man} and ${if(woman)} text here ${end}."--- Right [TokText "Hello ",TokVar "man",TokText " and ",TokIf "woman",TokText " text here ",TokEnd,TokText "."]------ >>> parse parseEverything "" "Hi ${for(people)} ${name}, ${end} everyone!"--- Right [TokText "Hi ",TokFor "people",TokText " ",TokVar "name",TokText ", ",TokEnd,TokText " everyone!"]------ >>> parse parseEverything "" "${realvar} $.get(javascript) $$ $$$ $} $( $45.50 $$escape $${escape2} wonderful life! ${truth}"--- Right [TokVar "realvar",TokText " $.get(javascript) $$ $$$ $} $( $45.50 $$escape ",TokText "${",TokText "escape2} wonderful life! ",TokVar "truth"]------ >>> parse parseEverything "" "<!--PREAMBLE  \n  foo: bar\ndo:\n  - re\n  -me\n  -->waffle house ${lyfe}"--- Right [TokPreamble "  \n  foo: bar\ndo:\n  - re\n  -me\n  ",TokText "waffle house ",TokVar "lyfe"]------ >>> parse parseEverything "" "YO ${foo} <!--PREAMBLE  \n  ${foo}: bar\ndo:\n  - re\n  -me\n  -->waffle house ${lyfe}"--- Right [TokText "YO ",TokVar "foo",TokText " ",TokPreamble "  \n  ${foo}: bar\ndo:\n  - re\n  -me\n  ",TokText "waffle house ",TokVar "lyfe"]------ This is a degenerate case that we will just allow (for now) to go sideways:--- >>> parse parseEverything "" "<b>this ${var never closes</b> ${realvar}"--- Right [TokText "<b>this ",TokVar "var never closes</b> ${realvar"]----parseEverything :: Parser [Token]-parseEverything =-  -- Note that order matters here. We want "most general" to be last (variable-  -- names).-  many1 (try parsePreamble-     <|> try parseEscape-     <|> try parseContent-     <|> try parseEnd-     <|> try parseFor-     <|> try parseIf-     <|> try parseEnd-     <|> try parsePartial-     <|> parseVar)---- >>> parse parseVar "" "${ffwe} yep"--- Right (TokVar "ffwe")------ >>> parse parseVar "" "${spaces technically allowed}"--- Right (TokVar "spaces technically allowed")------ >>> isLeft $ parse parseVar "" "Hello ${name}"--- True------ >>> isLeft $ parse parseVar "" "${}"--- True------ | Parse variables.-parseVar :: Parser Token-parseVar = try $ do-  _ <- char '$'-  _ <- char '{'-  varName <- many1 (noneOf "}")-  _ <- char '}'-  return $ TokVar (T.pack varName)---- | Parse preamble.-parsePreamble :: Parser Token-parsePreamble = do-  _ <- parsePreambleStart--  -- "Note the overlapping parsers anyChar and string "-->", and therefore the-  -- use of the try combinator."-  -- (https://hackage.haskell.org/package/parsec-3.1.11/docs/Text-Parsec.html)-  content <- manyTill anyChar (try (string "-->"))-  return $ TokPreamble (T.pack content)---- | Parse the start of a PREAMBLE.-parsePreambleStart :: Parser String-parsePreambleStart = string "<!--PREAMBLE"----- | Parse partial commands.------ >>> parse parsePartial "" "${partial(\"my/file/name.html\")}"--- Right (TokPartial "my/file/name.html")----parsePartial :: Parser Token-parsePartial = do-  _ <- string "${partial(\""-  filename <- many (noneOf "\"")-  _ <- string "\")}"-  return $ TokPartial (T.pack filename)---- | Parse escape sequence "$${"------ >>> parse parseEscape "" "$${example}"--- Right (TokText "${")----parseEscape :: Parser Token-parseEscape = do-  _ <- try $ string "$${"-  return (TokText "${")---- | Parse boring, boring text.------ >>> parse parseContent "" "hello ${ffwe} you!"--- Right (TokText "hello ")------ >>> parse parseContent "" "hello $.get() $ $( $$ you!"--- Right (TokText "hello $.get() $ $( $$ you!")------ Because of our first parser to grab a character that is not a $, we can't--- grab strings that start with a $, even if it's text. It's a bug, just deal--- with it for now.--- >>> isLeft $ parse parseContent "" "$$$ what"--- True------ >>> isLeft $ parse parseContent "" "${name}!!"--- True----parseContent :: Parser Token-parseContent = do-  -- The manyTill big parser below will accept an empty string, which is bad. So-  -- grab a single character to start things off.-  h <- noneOf "$"--  -- Grab chars until we see something that looks like a ${...}, or eof. Use-  -- both lookAhead (does not consume successful "${" found) and try (does not-  -- consume failure to find "${"). Not having both produces bugs, so.-  ---  -- Also grab "$${", which should be captured as an escape (parseEscape).-  ---  -- https://stackoverflow.com/questions/20020350/parsec-difference-between-try-and-lookahead-  stuff <- manyTill anyChar (try (lookAhead (string "$${")) <|>-                             try (lookAhead (string "${")) <|>-                             try (lookAhead parsePreambleStart) <|>-                             (eof >> return " "))-  return $ TokText (T.pack (h : stuff))---- | Parse for loop declaration.------ >>> parse parseFor "" "${for(posts)}"--- Right (TokFor "posts")------ >>> parse parseFor "" "${for(variable name with spaces technically allowed)}"--- Right (TokFor "variable name with spaces technically allowed")------ >>> isLeft $ parse parseFor "" "${for()}"--- True------ >>> isLeft $ parse parseFor "" "${for foo}"--- True----parseFor :: Parser Token-parseFor = parseFunction "for" TokFor---- | Parse if directive.-parseIf :: Parser Token-parseIf = parseFunction "if" TokIf---- | General parse template functions.-parseFunction :: String -> (T.Text -> Token) -> Parser Token-parseFunction keyword ctor = do-  _ <- char '$'-  _ <- char '{'-  _ <- try $ string (keyword ++ "(")-  varName <- many1 (noneOf ")")-  _ <- char ')'-  _ <- char '}'-  return $ ctor (T.pack varName)---- | Parse end keyword.------ >>> parse parseEnd "" "${end}"--- Right TokEnd------ >>> isLeft $ parse parseEnd "" "${enddd}"--- True----parseEnd :: Parser Token-parseEnd = do-  _ <- try $ string "${end}"-  return TokEnd---- | A hack to capture strings that "almost" are templates. I couldn't figure--- out another way.-parseFakeVar :: Parser Token-parseFakeVar = do-  _ <- char '$'-  n <- noneOf "{"-  rest <- many1 (noneOf "$")-  return $ TokText (T.pack ("$" ++ [n] ++ rest))---- | @many1Till p end@ will parse one or more @p@ until @end.------ From https://hackage.haskell.org/package/pandoc-1.10.0.4/docs/Text-Pandoc-Parsing.html-many1Till :: P.Stream s m t => P.ParsecT s u m a -> P.ParsecT s u m end -> P.ParsecT s u m [a]-many1Till p end = do-  first <- p-  rest <- manyTill p end-  return (first : rest)---- | Find the preamble content from the given @PNode@s.-findPreambleText :: [PNode] -> Maybe T.Text-findPreambleText nodes = DL.find isPreamble nodes >>= preambleText---- | Returns @True@ if the @PNode@ is a @PPreamble@.-isPreamble :: PNode -> Bool-isPreamble (PPreamble _) = True-isPreamble _ = False---- | Gets the content of the @PPreamble@-preambleText :: PNode -> Maybe T.Text-preambleText (PPreamble t) = Just t-preambleText _ = Nothing-
− src/Pencil/Internal/Pencil.hs
@@ -1,956 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}--module Pencil.Internal.Pencil where--import Pencil.Internal.Env-import Pencil.Internal.Parser--import Control.Exception (tryJust)-import Control.Monad (forM_, foldM, filterM, liftM)-import Control.Monad.Except-import Control.Monad.Reader-import Data.Char (toLower)-import Data.Default (Default)-import Data.List.NonEmpty (NonEmpty(..)) -- Import the NonEmpty data constructor, (:|)-import Data.Text.Encoding (encodeUtf8, decodeUtf8)-import Data.Typeable (Typeable)-import GHC.Generics (Generic)-import GHC.IO.Exception (IOException(ioe_description, ioe_filename, ioe_type), IOErrorType(NoSuchThing))-import Text.EditDistance (levenshteinDistance, defaultEditCosts)-import Text.Sass.Options (defaultSassOptions)-import Data.Hashable (Hashable)-import qualified Data.HashMap.Strict as H-import qualified Data.List as L-import qualified Data.List.NonEmpty as NE-import qualified Data.Maybe as M-import qualified Data.Text as T-import qualified Data.Text.IO as TIO-import qualified Data.Yaml as A-import qualified System.Directory as D-import qualified System.FilePath as FP-import qualified Text.Pandoc as P-import qualified Text.Pandoc.Highlighting-import Text.Pandoc.Extensions (enableExtension, Extension(..))-import qualified Text.Sass as Sass---- | The main monad transformer stack for a Pencil application.------ This unrolls to:------ > PencilApp a = Config -> IO (Except PencilException a)------ The @ExceptT@ monad allows us to catch "checked" exceptions; errors that we--- know how to handle, in PencilException. Note that Unknown "unchecked"--- exceptions can still go through IO.----type PencilApp = ReaderT Config (ExceptT PencilException IO)---- | The main @Config@ needed to build your website. Your app's @Config@ is--- passed into the 'PencilApp' monad transformer.------ Use 'defaultConfig' as a starting point, along with the config-modification--- helpers such as 'setSourceDir'.-data Config = Config-  { configSourceDir :: FilePath-  , configOutputDir :: FilePath-  , configEnv :: Env-  , configDisplayValue :: Value -> T.Text-  , configSassOptions :: Sass.SassOptions-  , configPandocReaderOptions :: P.ReaderOptions-  , configPandocWriterOptions :: P.WriterOptions-  }---- 'Data.Default.Default' instance for 'Config'.-instance Default Config where-  def = defaultConfig---- | This default @Config@ gives you everything you need to start.------ Default values:------ @--- Config---  { 'configSourceDir' = "site/"---  , 'configOutputDir' = "out/"---  , 'configEnv' = HashMap.empty---  , 'configDisplayValue' = 'toText'---  , 'configSassOptions' = Text.Sass.Options.defaultSassOptions---  , 'configPandocReaderOptions' = Text.Pandoc.def {---       Text.Pandoc.readerExtensions = Text.Pandoc.Extensions.getDefaultExtensions "markdown"---    }---  , 'configPandocWriterOptions' = Text.Pandoc.def { Text.Pandoc.writerHighlightStyle = Just Text.Pandoc.Highlighting.monochrome }---  , 'configDisplayValue = 'toText'---  }--- @----defaultConfig :: Config-defaultConfig = Config-  { configSourceDir = "site/"-  , configOutputDir = "out/"-  , configEnv = H.empty-  , configSassOptions = Text.Sass.Options.defaultSassOptions--  -- For markdown reader. We use getDefaultExtensions "markdown" here to get default Markdown extensions.-  -- See https://hackage.haskell.org/package/pandoc-2.5/docs/Text-Pandoc-Extensions.html#v:getDefaultExtensions-  , configPandocReaderOptions = P.def {-      P.readerExtensions = P.getDefaultExtensions "markdown"-    }-  , configPandocWriterOptions = P.def {-      P.writerHighlightStyle = Just Text.Pandoc.Highlighting.monochrome-    }-  , configDisplayValue = toText-  }---- | The directory path of your web page source files.-getSourceDir :: Config -> FilePath-getSourceDir = configSourceDir---- | Sets the source directory of your web page source files.-setSourceDir :: FilePath -> Config -> Config-setSourceDir fp c = c { configSourceDir = fp }---- | The directory path of your rendered web pages.-getOutputDir :: Config -> FilePath-getOutputDir = configOutputDir---- | Sets the output directory of your rendered web pages.-setOutputDir :: FilePath -> Config -> Config-setOutputDir fp c = c { configOutputDir = fp }---- | The environment of the @Config@, which is what the @PencilApp@ monad--- transformer uses. This is where variables are set for rendering template--- directives.-getEnv :: Config -> Env-getEnv = configEnv---- | Sets the current environment. You may also want to look at 'withEnv' if you--- want to 'render' things in a modified environment.-setEnv :: Env -> Config -> Config-setEnv env c = c { configEnv = env }---- | Update the 'Env' inside the 'Config'.-updateEnv :: (Env -> Env) -> Config -> Config-updateEnv f c = c { configEnv = f (getEnv c) }---- | The 'Sass.SassOptions' for rendering Sass/Scss files.-getSassOptions :: Config -> Sass.SassOptions-getSassOptions = configSassOptions---- | Sets the 'Sass.SassOptions'.-setSassOptions :: Sass.SassOptions -> Config -> Config-setSassOptions env c = c { configSassOptions = env }---- | The 'Text.Pandoc.ReaderOptions' for reading files that use Pandoc.--- Supported formats by Pencil are: Markdown.-getPandocReaderOptions :: Config -> P.ReaderOptions-getPandocReaderOptions = configPandocReaderOptions---- | Sets the 'Text.Pandoc.ReaderOptions'. For example, you may want to enable--- some Pandoc extensions like 'Text.Pandoc.Extensions.Ext_literate_haskell':------ @--- setPandocReaderOptions---   (Text.Pandoc.def { 'Text.Pandoc.Options.readerExtensions' = extensionsFromList [Ext_literate_haskell] })---   config--- @-setPandocReaderOptions :: P.ReaderOptions -> Config -> Config-setPandocReaderOptions o c = c { configPandocReaderOptions = o }---- | The 'Text.Pandoc.WriterOptions' for rendering files that use Pandoc.--- Supported formats by Pencil are: Markdown.-getPandocWriterOptions :: Config -> P.WriterOptions-getPandocWriterOptions = configPandocWriterOptions---- | Sets the 'Text.Pandoc.WriterOptions'.-setPandocWriterOptions :: P.WriterOptions -> Config -> Config-setPandocWriterOptions o c = c { configPandocWriterOptions = o }---- | The function that renders 'Value' to text.-getDisplayValue :: Config -> Value -> T.Text-getDisplayValue = configDisplayValue---- | Sets the function that renders 'Value' to text. Overwrite this with your--- own function if you would like to change how certain 'Value's are rendered--- (e.g. 'VDateTime').------ @--- myRender :: Value -> T.Text--- myRender ('VDateTime' dt) = 'T.pack' $ 'TF.formatTime' 'TF.defaultTimeLocale' "%e %B %Y" dt--- myRender t = 'toText' t------ ...------ setDisplayValue myRender config--- @------ In the above example, we change the @VDateTime@ rendering to show @25--- December 2017@. Leave everything else unchanged.----setDisplayValue :: (Value -> T.Text) -> Config -> Config-setDisplayValue f c = c { configDisplayValue = f }---- | Run the Pencil app.------ Note that this can throw a fatal exception.-run :: PencilApp a -> Config -> IO ()-run app config = do-  e <- runExceptT $ runReaderT app config-  case e of-    Left (VarNotInEnv var fp) ->-      putStrLn ("Variable ${" ++ T.unpack var ++ "}" ++ " not found in the environment when rendering file " ++ fp ++ ".")-    Left (FileNotFound (Just fp)) -> do-      e2 <- runExceptT $ runReaderT (mostSimilarFiles fp) config-      case e2 of-        Right closestFiles ->-          if not (null closestFiles)-          then do-            putStrLn ("File " ++ fp ++ " not found. Maybe you meant: ")-            printAsList (take 3 closestFiles)-          else putStrLn ("File " ++ fp ++ " not found.")-        _ -> return ()-    _ -> return ()---- Print the list of Strings, one line at a time, prefixed with "-".-printAsList :: [String] -> IO ()-printAsList [] = return ()-printAsList (a:as) = do-  putStr "- "-  putStrLn a-  printAsList as---- | Given a file path, look at all file paths and find the one that seems most--- similar.-mostSimilarFiles :: FilePath -> PencilApp [FilePath]-mostSimilarFiles fp = do-  sitePrefix <- asks getSourceDir-  fps <- listDir True ""-  let fps' = map (sitePrefix ++) fps -- add site prefix for distance search-  let costs = map (\f -> (f, levenshteinDistance defaultEditCosts fp f)) fps'-  let sorted = L.sortBy (\(_, d1) (_, d2) -> compare d1 d2) costs-  return $ map fst sorted---- | Known Pencil errors that we know how to either recover from or quit--- gracefully.-data PencilException-  = NotTextFile IOError-  -- ^ Failed to read a file as a text file.-  | FileNotFound (Maybe FilePath)-  -- ^ File not found. We may or may not know the file we were looking for.-  | VarNotInEnv T.Text FilePath-  -- ^ Variable is not in the environment. Variable name, and file where the-  -- variable was reference.-  deriving (Typeable, Show)---- | Enum for file types that can be parsed and converted by Pencil.-data FileType = Html-              | Markdown-              | Css-              | Sass-              | Other-  deriving (Eq, Generic)---- | 'Hashable' instance of @FileType@.-instance Hashable FileType---- | A 'H.HashMap' of file extensions (e.g. @markdown@) to 'FileType'.------ * 'Html': @html, htm@--- * 'Markdown': @markdown, md@--- * 'Css': @css@--- * 'Sass': @sass, scss@----fileTypeMap :: H.HashMap String FileType-fileTypeMap = H.fromList-  [ ("html", Html)-  , ("htm", Html)-  , ("markdown", Markdown)-  , ("md", Markdown)-  , ("css", Css)-  , ("sass", Sass)-  , ("scss", Sass)]---- | Mapping of 'FileType' to the final converted format. Only contains--- 'FileType's that Pencil will convert.------ * 'Markdown': @html@--- * 'Sass': @css@----extensionMap :: H.HashMap FileType String-extensionMap = H.fromList-  [ (Markdown, "html")-  , (Sass, "css")]---- | Converts a 'FileType' into its converted webpage extension, if Pencil would--- convert it (e.g. Markdown to HTML).------ >>> toExtension Markdown--- Just "html"----toExtension :: FileType -> Maybe String-toExtension ft = H.lookup ft extensionMap---- | Takes a file path and returns the 'FileType', defaulting to 'Other' if it's--- not a supported extension.-fileType :: FilePath -> FileType-fileType fp =-  -- takeExtension returns ".markdown", so drop the "."-  M.fromMaybe Other (H.lookup (map toLower (drop 1 (FP.takeExtension fp))) fileTypeMap)---- | The Page is an important data type in Pencil. It contains the parsed--- template of a file (e.g. of Markdown or HTML files). It may have template--- directives (e.g. @${body}@) that has not yet been rendered, and an--- environment loaded from the preamble section of the file. A Page also--- contains 'pageFilePath', which is the output file path.-data Page = Page-  { pageNodes     :: [PNode]-  , pageEnv       :: Env-  , pageFilePath  :: FilePath-  -- ^ The rendered output path of this page. Defaults to the input file path.-  -- This file path is used to generate the self URL that is injected into the-  -- environment.-  } deriving (Eq, Show)---- | Returns the 'Env' from a 'Page'.-getPageEnv :: Page -> Env-getPageEnv = pageEnv---- | Sets the 'Env' in a 'Page'.-setPageEnv :: Env -> Page -> Page-setPageEnv env p = p { pageEnv = env }---- | Applies the environment variables on the given pages.------ The 'Structure' is expected to be ordered by inner-most content first (such--- that the final, HTML structure layout is last in the list).------ The returned Page contains the Nodes of the fully rendered page, the--- fully-applied environment, and the URL of the last (inner-most) Page.------ The variable application works by applying the outer environments down into--- the inner environments, until it hits the lowest environment, in which the--- page is rendered. Once done, this rendered content is saved as the @${body}@--- variable for the parent structure, which is then applied, and so on.------ As an example, there is the common scenario where we have a default layout--- (e.g. @default.html@), with the full HTML structure, but no body. It has only--- a @${body}@ template variable inside. This is the parent layout. There is a--- child layout, the partial called "blog-post.html", which has HTML for--- rendering a blog post, like usage of ${postTitle} and ${postDate}. Inside--- this, there is another child layout, the blog post content itself, which--- defines the variables @postTitle@ and @postDate@, and may renderer parent--- variables such as @websiteTitle@.------ > +--------------+--- > |              | <--- default.html--- > |              |      Defines websiteTitle--- > |  +---------+ |--- > |  |         |<+----- blog-post.html--- > |  | +-----+ | |      Renders ${postTitle}, ${postDate}--- > |  | |     | | |--- > |  | |     | | |--- > |  | |     |<+-+----- blog-article-content.markdown--- > |  | |     | | |      Renders ${websiteTitle}--- > |  | +-----+ | |      Defines postTitle, postDate--- > |  +---------+ |--- > +--------------+------ In this case, we want to accumulate the environment variables, starting from--- default.html, to blog-post.html, and the markdown file's variables. Combine--- all of that, then render the blog post content. This content is then injected--- into the parent's environment as a @${body}@ variable, for use in blog-post.html.--- Now /that/ content is injected into the parent environment's @${body}@ variable,--- which is then used to render the full-blown HTML page.----apply :: Structure -> PencilApp Page-apply pages = apply_ (NE.reverse pages)---- | Apply @Structure@ and convert to @Page@.------ It's simpler to implement if NonEmpty is ordered outer-structure first (e.g.--- HTML layout).-apply_ :: Structure -> PencilApp Page-apply_ (Page nodes penv fp :| []) = do-  env <- asks getEnv-  let env' = merge penv env-  nodes' <- evalNodes env' nodes `catchError` setVarNotInEnv fp-  return $ Page nodes' env' fp-apply_ (Page nodes penv _ :| (headp : rest)) = do-  -- Modify the current env (in the ReaderT) with the one in the page (penv)-  -- Then call apply_ on the inner Pages to accumulate the inner Page-  -- environments.-  Page nodesInner envInner fpInner <- local (\c -> setEnv (merge penv (getEnv c)) c)-                                    (apply_ (headp :| rest))--  -- Render the inner nodes, and inject into this environment's "body" var.-  let env' = insertEnv "body" (VText (renderNodes nodesInner)) envInner--  -- Evaluate this current Page's nodes with the accumualted environemnt of all-  -- the inner Pages.-  nodes' <- evalNodes env' nodes `catchError` setVarNotInEnv fpInner--  -- Use inner-most Page's file path, as this will be the destination of the-  -- accumluated, final, rendered page.-  return $ Page nodes' env' fpInner---- | Helper to inject a file path into a VarNotInEnv exception. Rethrow the--- exception afterwards.-setVarNotInEnv :: FilePath -> PencilException -> PencilApp a-setVarNotInEnv fp (VarNotInEnv var _) = throwError $ VarNotInEnv var fp-setVarNotInEnv _ e = throwError e---- | Loads the given file as a text file. Throws an exception into the ExceptT--- monad transformer if the file is not a text file.-loadTextFile :: FilePath -> PencilApp T.Text-loadTextFile fp = do-  sitePrefix <- asks getSourceDir-  -- Try to read the file. If it fails because it's not a text file, capture the-  -- exception and convert it to a "checked" exception in the ExceptT stack via-  -- 'throwError'.-  eitherContent <- liftIO $ tryJust toPencilException (TIO.readFile (sitePrefix ++ fp))-  case eitherContent of-    Left e -> throwError e-    Right a -> return a---- | Converts the IOError to a known 'PencilException'.------ How to test errors:------ @--- import Control.Exception--- import qualified Data.Text.IO as TIO------ (\e -> print (ioe_description (e :: IOError)) >> return "") `handle` (TIO.readFile "foo")--- @----toPencilException :: IOError -> Maybe PencilException-toPencilException e-  | isInvalidByteSequence e = Just (NotTextFile e)-  | isNoSuchFile e = Just (FileNotFound (ioe_filename e))-  | otherwise = Nothing---- | Returns true if the IOError is an invalid byte sequence error. This--- suggests that the file is a binary file.-isInvalidByteSequence :: IOError -> Bool-isInvalidByteSequence e = ioe_description e == "invalid byte sequence"---- | Returns true if the IOError is due to missing file.-isNoSuchFile :: IOError -> Bool-isNoSuchFile e = ioe_type e == NoSuchThing--readMarkdownWriteHtml :: P.PandocMonad m => P.ReaderOptions -> P.WriterOptions -> T.Text -> m T.Text-readMarkdownWriteHtml readerOptions writerOptions content  = do-  pandoc <- P.readMarkdown readerOptions content-  P.writeHtml5String writerOptions pandoc---- | Loads and parses the given file path. Converts 'Markdown' files to HTML,--- compiles 'Sass' files into CSS, and leaves everything else alone.-parseAndConvertTextFiles :: FilePath -> PencilApp (T.Text, [PNode])-parseAndConvertTextFiles fp = do-  content <- loadTextFile fp-  content' <--    case fileType fp of-      Markdown -> do-        pandocReaderOptions <- asks getPandocReaderOptions-        pandocWriterOptions <- asks getPandocWriterOptions-        result <- liftIO $ P.runIO (readMarkdownWriteHtml pandocReaderOptions pandocWriterOptions content)-        case result of-          Left _ -> return content-          Right text -> return text-      Sass -> do-        sassOptions <- asks getSassOptions-        sitePrefix <- asks getSourceDir-        -- Use compileFile so that SASS @import works-        result <- liftIO $ Sass.compileFile (sitePrefix ++ fp) sassOptions-        case result of-          Left _ -> return content-          Right byteStr -> return $ decodeUtf8 byteStr-      _ -> return content-  let nodes = case parseText content' of-                Left _ -> []-                Right n -> n-  return (content', nodes)---- | Evaluate the nodes in the given environment. Note that it returns an IO--- because of @${partial(..)}@ calls that requires us to load a file.-evalNodes :: Env -> [PNode] -> PencilApp [PNode]-evalNodes _ [] = return []-evalNodes env (PVar var : rest) = do-  nodes <- evalNodes env rest-  case H.lookup var env of-    Nothing ->-      -- Can't find var in env. Skip over it for now and we'll just render the-      -- directive to help user debug missing variables. Later on we'll do some-      -- nice error handling w/o crashing the system (throw warnings instead of-      -- errors).-      return $ PVar var : nodes-    Just envData -> do-      displayValue <- asks getDisplayValue-      return $ PText (displayValue envData) : nodes-evalNodes env (PIf var nodes : rest) = do-  rest' <- evalNodes env rest-  case H.lookup var env of-    Nothing ->-      -- Can't find var in env; everything inside the if-statement is thrown away-      return rest'-    Just _ -> do-      -- Render nodes inside the if-statement-      nodes' <- evalNodes env nodes-      return $ nodes' ++ rest'-evalNodes env (PFor var nodes : rest) = do-  rest' <- evalNodes env rest-  case H.lookup var env of-    Nothing ->-      -- Can't find var in env; everything inside the for-statement is throw away-      return rest'-    Just (VEnvList envs) -> do-      -- Render the for nodes once for each given env, and append them together-      forNodes <--        foldM-          (\accNodes e -> do-              nodes' <- evalNodes (H.union e env) nodes-              return $ accNodes ++ nodes')-          [] envs-      return $ forNodes ++ rest'-    -- Var is not an VEnvList; everything inside the for-statement is thrown away-    Just _ -> return rest'-evalNodes env (PPartial fp : rest) = do-  (_, nodes) <- parseAndConvertTextFiles (T.unpack fp)-  nodes' <- evalNodes env nodes-  rest' <- evalNodes env rest-  return $ nodes' ++ rest'-evalNodes env (n : rest) = do-  rest' <- evalNodes env rest-  return $ n : rest'---- | Sort given @Page@s by the specified ordering function.-sortByVar :: T.Text-          -- ^ Environment variable name.-          -> (Value -> Value -> Ordering)-          -- ^ Ordering function to compare Value against. If the variable is-          -- not in the Env, the Page will be placed at the bottom of the order.-          -> [Page]-          -> [Page]-sortByVar var ordering =-  L.sortBy-    (\(Page _ enva _) (Page _ envb _) ->-      maybeOrdering ordering (H.lookup var enva) (H.lookup var envb))---- | Filter by a variable's value in the environment.-filterByVar :: Bool-            -- ^ If true, include pages without the specified variable.-            -> T.Text-            -- ^ Environment variable name.-            -> (Value -> Bool)-            -> [Page]-            -> [Page]-filterByVar includeMissing var f =-  L.filter-   (\(Page _ env _) -> M.fromMaybe includeMissing (H.lookup var env >>= (Just . f)))---- | Given a variable (whose value is assumed to be an array of VText) and list--- of pages, group the pages by the VText found in the variable.------ For example, say each Page has a variable "tags" that is a list of tags. The--- first Page has a "tags" variable that is an VArray [VText "a"], and the--- second Page has a "tags" variable that is an VArray [VText "a", VText "b"].--- The final output would be a map fromList [("a", [page1, page2]), ("b",--- [page2])].-groupByElements :: T.Text-                -- ^ Environment variable name.-                -> [Page]-                -> H.HashMap T.Text [Page]-groupByElements var pages =-  -- This outer fold takes the list of pages, and accumulates the giant HashMap.-  L.foldl'-    (\acc page@(Page _ env _) ->-      let x = H.lookup var env-      in case x of-           Just (VArray values) ->-             -- This fold takes each of the found values (each is a key in the-             -- hash map), and adds the current page (from the outer fold) into-             -- each of the key.-             L.foldl'-               (\hashmap envData ->-                 case envData of-                   -- Only insert Pages into the map if the variable is an VArray of-                   -- VText. Alter the map to either (1) insert this current-                   -- page into the existing list, or (2) create a new list (the-                   -- key has never been seen) with just this page.-                   VText val -> H.alter (\mv -> Just (page : M.fromMaybe [] mv)) val hashmap-                   _ -> hashmap)-               acc values-           _ -> acc-    )-    H.empty-    -- Reverse to keep ordering consistent inside hash map, since the fold-    -- prepends into accumulated list.-    (reverse pages)---- | Loads file in given directory as 'Resource's.-loadResources :: (FilePath -> FilePath)-              -> Bool-              -- ^ Recursive if @True@.-              -> Bool-              -- ^ Handle as pass-throughs (file copy) if @True@.-              -> FilePath-              -> PencilApp [Resource]-loadResources fpf recursive pass dir = do-  fps <- listDir recursive dir-  if pass-    then return $ map (\fp -> Passthrough fp fp) fps-    else mapM (loadResource fpf) fps---- | Lists files in given directory. The file paths returned is prefixed with the--- given directory.-listDir :: Bool-        -- ^ Recursive if @True@.-        -> FilePath-        -> PencilApp [FilePath]-listDir recursive dir = do-  let dir' = if null dir then dir else FP.addTrailingPathSeparator dir-  fps <- listDir_ recursive dir'-  return $ map (dir' ++) fps---- | 'listDir' helper.-listDir_ :: Bool -> FilePath -> PencilApp [FilePath]-listDir_ recursive dir = do-  sitePrefix <- asks getSourceDir-  -- List files (just the filename, without the fp directory prefix)-  listing <- liftIO $ D.listDirectory (sitePrefix ++ dir)-  -- Filter only for files (we have to add the right directory prefixes to the-  -- file check)-  files <- liftIO $ filterM (\f -> D.doesFileExist (sitePrefix ++ dir ++ f)) listing-  dirs <- liftIO $ filterM (\f -> D.doesDirectoryExist (sitePrefix ++ dir ++ f)) listing--  innerFiles <- if recursive-                  then mapM-                         (\d -> do-                           ff <- listDir_ recursive (dir ++ d ++ "/")-                           -- Add the inner directory as a prefix-                           return (map (\f -> d ++ "/" ++ f) ff))-                         dirs-                  else return []--  return $ files ++ concat innerFiles--------------------------------------------------------------------------- Environment modifications--------------------------------------------------------------------------- | Merges two @Env@s together, biased towards the left-hand @Env@ on duplicates.-merge :: Env -> Env -> Env-merge = H.union---- | Insert text into the given @Env@.------ @--- env <- asks getEnv--- insertText "title" "My Awesome Website" env--- @-insertText :: T.Text-           -- ^ Environment variable name.-           -> T.Text-           -- ^ Text to insert.-           -> Env-           -- ^ Environment to modify.-           -> Env-insertText var val = H.insert var (VText val)---- | Insert @Page@s into the given @Env@.------ @--- posts <- 'Pencil.Blog.loadBlogPosts' "blog/"--- env <- asks 'getEnv'--- insertPages "posts" posts env--- @-insertPages :: T.Text-            -- ^ Environment variable name.-            -> [Page]-            -- ^ @Page@s to insert.-            -> Env-            -- ^ Environment to modify.-            -> Env-insertPages var posts = H.insert var (VEnvList (map getPageEnv posts))---- | Modify a variable in the given environment.-updateEnvVal :: (Value -> Value)-          -> T.Text-          -- ^ Environment variable name.-          -> Env-          -> Env-updateEnvVal = H.adjust---- | Insert @Value@ into the given @Env@.-insertEnv :: T.Text-          -- ^ Environment variable name.-          -> Value-          -- ^ @Value@ to insert.-          -> Env-          -- ^ Environment to modify.-          -> Env-insertEnv = H.insert---- | Convert known Aeson types into known Env types.-maybeInsertIntoEnv :: Env -> T.Text -> A.Value -> Env-maybeInsertIntoEnv env k v =-  case toValue v of-    Nothing -> env-    Just d -> H.insert k d env---- | Converts an Aeson Object to an Env.-aesonToEnv :: A.Object -> Env-aesonToEnv = H.foldlWithKey' maybeInsertIntoEnv H.empty----- | Use @Resource@ to load and render files that don't need any manipulation--- other than conversion (e.g. Sass to CSS), or for static files that you want--- to copy as-is (e.g. binary files like images, or text files that require no--- other processing).------ Use 'passthrough', 'loadResource' and 'loadResources' to build a @Resource@--- from a file.------ In the example below, @robots.txt@ and everything in the @images/@ directory--- will be rendered as-is.------ @--- passthrough "robots.txt" >> render--- loadResources id True True "images/" >> render--- @----data Resource-  = Single Page-  | Passthrough FilePath FilePath-  -- ^ in and out file paths---- | Copy file from source to output dir.-copyFile :: FilePath -> FilePath -> PencilApp ()-copyFile fpIn fpOut = do-  sitePrefix <- asks getSourceDir-  outPrefix <- asks getOutputDir-  liftIO $ D.createDirectoryIfMissing True (FP.takeDirectory (outPrefix ++ fpOut))-  liftIO $ D.copyFile (sitePrefix ++ fpIn) (outPrefix ++ fpOut)---- | Replaces the file path's extension with @.html@.------ @--- 'load' toHtml "about.markdown"--- @----toHtml :: FilePath -> FilePath-toHtml fp = FP.dropExtension fp ++ ".html"---- | Converts a file path into a directory name, dropping the extension.--- Pages with a directory as its FilePath is rendered as an index file in that--- directory. For example, the @pages/about.html@ is transformed into--- @pages\/about\/@, which 'render' would render the 'Page' to the file path--- @pages\/about\/index.html@.----toDir :: FilePath -> FilePath-toDir fp = FP.replaceFileName fp (FP.takeBaseName fp) ++ "/"---- | Replaces the file path's extension with @.css@.------ @--- 'load' toCss "style.sass"--- @----toCss :: FilePath -> FilePath-toCss fp = FP.dropExtension fp ++ ".css"---- | Converts file path into the expected extensions. This means @.markdown@--- become @.html@, @.sass@ becomes @.css@, and so forth. See 'extensionMap' for--- conversion table.------ @--- -- Load everything inside the "assets/" folder, renaming converted files as--- -- expected, and leaving everything else alone.--- 'loadResources' toExpected True True "assets/"--- @-toExpected :: FilePath -> FilePath-toExpected fp = maybe fp ((FP.dropExtension fp ++ ".") ++) (toExtension (fileType fp))---- | Loads a file as a 'Resource'. Use this for binary files (e.g. images) and--- for files without template directives. Regular files are still converted to--- their web page formats (e.g. Markdown to HTML, SASS to CSS).------ @--- -- Loads and renders the image as-is. Underneath the hood--- -- this is just a file copy.--- loadResource id "images/profile.jpg" >> render------ -- Loads and renders to about.index--- loadResource toHtml "about.markdown" >> render--- @-loadResource :: (FilePath -> FilePath) -> FilePath -> PencilApp Resource-loadResource fpf fp =-  -- If we can load the Page as text file, convert to a Single. Otherwise if it-  -- wasn't a text file, then return a Passthroguh resource. This is where we-  -- finally handle the "checked" exception; that is, converting the Left error-  -- case (NotTextFile) into a Right case (Passthrough).-  fmap Single (load fpf fp)-    `catchError` handle-  -- 'handle' requires FlexibleContexts-  where handle e = case e of-                     NotTextFile _ -> return (Passthrough fp (fpf fp))-                     _ -> throwError e---- | Loads file as a pass-through. There is no content conversion, and template--- directives are ignored. In essence this is a file copy.------ @--- passthrough "robots.txt" >> render--- @----passthrough :: FilePath -> PencilApp Resource-passthrough fp = return $ Passthrough fp fp---- | Loads a file into a Page, rendering the file (as determined by the file--- extension) into the proper output format (e.g. Markdown rendered to--- HTML, SCSS to CSS). Parses the template directives and preamble variables--- into its environment. The 'Page''s 'pageFilePath' is determined by the given--- function, which expects the original file path, and returns the designated file--- path.------ The Page's designated file path is calculated and stored in the Page's--- environment in the variable @this.url@. This allows the template to use--- @${this.url}@ to refer to the designated file path.------ Example:------ @--- -- Loads index.markdown with the designated file path of index.html--- load 'toHtml' "index.markdown"------ -- Keep the file path as-is--- load id "about.html"--- @----load :: (FilePath -> FilePath) -> FilePath -> PencilApp Page-load fpf fp = do-  (_, nodes) <- parseAndConvertTextFiles fp-  let env = findEnv nodes-  let fp' = "/" ++ fpf fp-  let env' = H.insert "this.url" (VText (T.pack fp')) env-  return $ Page nodes env' fp'---- | Find preamble node, and load as an Env. If no preamble is found, return a--- blank Env.-findEnv :: [PNode] -> Env-findEnv nodes =-  aesonToEnv $ M.fromMaybe H.empty (findPreambleText nodes >>= (A.decodeThrow . encodeUtf8 . T.strip))---- | Loads and renders file as CSS.------ @--- -- Load, convert and render as style.css.--- renderCss "style.sass"--- @-renderCss :: FilePath -> PencilApp ()-renderCss fp =-  -- Drop .scss/sass extension and replace with .css.-  load toCss fp >>= render---- | A @Structure@ is a list of 'Page's, defining a nesting order. Think of them--- like <https://en.wikipedia.org/wiki/Matryoshka_doll Russian nesting dolls>.--- The first element defines the outer-most container, and subsequent elements--- are /inside/ the previous element.------ You commonly use @Structure@s to insert a @Page@ containing content (e.g. a blog--- post) into a container (e.g. a layout shared across all your web pages).------ Build structures using 'structure', '<||' and '<|'.------ @--- layout <- load toHtml "layout.html"--- index <- load toHtml "index.markdown"--- about <- load toHtml "about.markdown"--- render (layout <|| index)--- render (layout <|| about)--- @------ In the example above we load a layout @Page@, which can be an HTML page--- defining the outer structures like @\<html\>\<\/html\>@. Assuming @layout.html@--- has the template directive @${body}@ (note that @body@ is a special variable--- generated during structure-building), @layout <|| index@--- tells 'render' that you want the rendered body of @index@ to be injected into--- the @${body}@ directive inside of @layout@.------ @Structure@s also control the closure of variables. Variables defined in a--- @Page@s are accessible both by @Page@s above and below. This allows inner--- @Page@s to define variables like the blog post title, which may be used in--- the outer @Page@ to set the @\<title\>@ tag.------ In this way, @Structure@ allows efficient @Page@ reuse. See the private--- function 'apply' to learn more about how @Structure@s are--- evaluated.------ Note that this differs than the @${partial(...)}@ directive, which has no--- such variable closures. The partial directive is much simpler—think of them--- as copy-and-pasting snippets from one file to another. The partial has has--- the same environment as the parent context.-type Structure = NonEmpty Page---- | Creates a new @Structure@ from two @Page@s.------ @--- layout <- load toHtml "layout.html"--- index <- load toHtml "index.markdown"--- render (layout <|| index)--- @-(<||) :: Page -> Page -> Structure-(<||) x y = y :| [x]---- | Pushes @Page@ into @Structure@.------ @--- layout <- load toHtml "layout.html"--- blogLayout <- load toHtml "blog-layout.html"--- blogPost <- load toHtml "myblogpost.markdown"--- render (layout <|| blogLayout <| blogPost)--- @-(<|) :: Structure -> Page -> Structure-(<|) ne x = NE.cons x ne---- | Converts a @Page@ into a @Structure@.-structure :: Page -> Structure-structure p = p :| []---- | Runs the computation with the given environment. This is useful when you--- want to render a 'Page' or 'Structure' with a modified environment.------ @--- withEnv ('insertText' "newvar" "newval" env) ('render' page)--- @----withEnv :: Env -> PencilApp a -> PencilApp a-withEnv env = local (setEnv env)---- | To render something is to create the output web pages, rendering template--- directives into their final form using the current environment.-class Render a where-  -- | Renders 'a' as web page(s).-  render :: a -> PencilApp ()--instance Render Resource where-  render (Single page) = render page-  render (Passthrough fpIn fpOut) = copyFile fpIn fpOut---- This requires FlexibleInstances.-instance Render Structure where-  render s = apply s >>= render--instance Render Page where-  render (Page nodes _ fpOut) = do-    outPrefix <- asks getOutputDir-    let noFileName = FP.takeBaseName fpOut == ""-    let fpOut' = outPrefix ++ if noFileName then fpOut ++ "index.html" else fpOut-    liftIO $ D.createDirectoryIfMissing True (FP.takeDirectory fpOut')-    liftIO $ TIO.writeFile fpOut' (renderNodes nodes)---- This requires FlexibleInstances.-instance Render r => Render [r] where-  render rs = forM_ rs render
+ src/Pencil/Parser.hs view
@@ -0,0 +1,400 @@+{-# LANGUAGE OverloadedStrings #-}++{-|+Internal implementation of Pencil's template directive parser.+-}+module Pencil.Parser where++import Text.ParserCombinators.Parsec+import qualified Data.List as DL+import qualified Data.Text as T+import qualified Text.Parsec as P++-- Doctest setup.+--+-- $setup+-- >>> :set -XOverloadedStrings+-- >>> import Data.Either (isLeft)++-- | Pencil's @Page@ AST.+data PNode =+    PText T.Text+  | PVar T.Text+  | PFor T.Text [PNode]+  | PIf T.Text [PNode]+  | PPartial T.Text+  | PPreamble T.Text++  -- Signals an If/For expression in the stack waiting for expressions. So that we+  -- can find the next unused open if/for-statement in nested if/for-statements.+  | PMetaIf T.Text+  | PMetaFor T.Text++  -- A terminating node that represents the end of the program, to help with AST+  -- converstion+  | PMetaEnd+  deriving (Show, Eq)++-- | Pencil's tokens for content.+data Token =+    TokText T.Text+  | TokVar T.Text+  | TokFor T.Text+  | TokIf T.Text+  | TokPartial T.Text+  | TokPreamble T.Text+  | TokEnd+  deriving (Show, Eq)++-- | Convert Tokens to PNode AST.+--+-- >>> transform [TokText "hello", TokText "world"]+-- [PText "hello",PText "world"]+--+-- >>> transform [TokIf "title", TokEnd]+-- [PIf "title" []]+--+-- >>> transform [TokIf "title", TokText "hello", TokText "world", TokEnd]+-- [PIf "title" [PText "hello",PText "world"]]+--+-- > ${if(title)}+-- >   ${for(posts)}+-- >     world+-- >   ${end}+-- > ${end}+--+-- >>> transform [TokIf "title", TokFor "posts", TokText "world", TokEnd, TokEnd]+-- [PIf "title" [PFor "posts" [PText "world"]]]+--+-- > begin+-- > now+-- > ${if(title)}+-- >   hello+-- >   world+-- >   ${if(body)}+-- >     ${body}+-- >     ${someothervar}+-- >     wahh+-- >   ${end}+-- >   final+-- >   thing+-- > ${end}+-- > the+-- > lastline+--+-- >>> transform [TokText "begin", TokText "now", TokIf "title", TokText "hello", TokText "world", TokIf "body", TokVar "body", TokVar "someothervar", TokText "wahh", TokEnd, TokText "final", TokText "thing", TokEnd, TokText "the", TokText "lastline"]+-- [PText "begin",PText "now",PIf "title" [PText "hello",PText "world",PIf "body" [PVar "body",PVar "someothervar",PText "wahh"],PText "final",PText "thing"],PText "the",PText "lastline"]+--+-- > <!--PREAMBLE+-- > foo: bar+-- > do:+-- >   - re+-- >   - me+-- > -->+-- > Hello world ${foo}+--+-- >>> transform [TokPreamble "foo: bar\ndo:\n  - re\n  -me", TokText "Hello world ", TokVar "foo"]+-- [PPreamble "foo: bar\ndo:\n  - re\n  -me",PText "Hello world ",PVar "foo"]+--+transform :: [Token] -> [PNode]+transform toks =+  let stack = ast [] toks+  in reverse stack++-- | Converts Tokens, which is just the raw list of parsed tokens, into PNodes+-- which are the tree-structure expressions (i.e. if/for nesting)+--+-- This function works by using a stack to keep track of where we are for nested+-- expressions such as if and for statements. When a token that starts a nesting+-- is found (like a TokIf), a "meta" expression (PMetaIf) is pushed into the+-- stack. When we finally see an end token (TokEnd), we pop all the expressions+-- off the stack until the first meta tag (e.g PMetaIf) is reached. All the+-- expressions popped off are now known to be nested inside that if statement.+--+ast :: [PNode] -- stack+    -> [Token] -- remaining+    -> [PNode] -- (AST, remaining)+ast stack [] = stack+ast stack (TokText t : toks) = ast (PText t : stack) toks+ast stack (TokVar t : toks)  = ast (PVar t : stack) toks+ast stack (TokPartial fp : toks) = ast (PPartial fp : stack) toks+ast stack (TokPreamble t : toks) = ast (PPreamble t : stack) toks+ast stack (TokIf t : toks)   = ast (PMetaIf t : stack) toks+ast stack (TokFor t : toks)  = ast (PMetaFor t : stack) toks+ast stack (TokEnd : toks) =+  let (node, popped, remaining) = popNodes stack+      -- ^ Find the last unused if/for statement, and grab all the expressions+      -- in-between this TokEnd and the opening if/for keyword.+      n = case node of+            PMetaIf t -> PIf t popped+            PMetaFor t -> PFor t popped+            _ -> PMetaEnd+  -- Push the statement into the stack+  in ast (n : remaining) toks++-- | Pop nodes until we hit a If/For statement.+-- Return pair (constructor found, nodes popped, remaining stack)+popNodes :: [PNode] -> (PNode, [PNode], [PNode])+popNodes = popNodes_ []++-- | Helper for 'popNodes'.+popNodes_ :: [PNode] -> [PNode] -> (PNode, [PNode], [PNode])+popNodes_ popped [] = (PMetaEnd, popped, [])+popNodes_ popped (PMetaIf t : rest) = (PMetaIf t, popped, rest)+popNodes_ popped (PMetaFor t : rest) = (PMetaFor t, popped, rest)+popNodes_ popped (t : rest) = popNodes_ (t : popped) rest++-- | Render nodes as string.+renderNodes :: [PNode] -> T.Text+renderNodes = DL.foldl' (\str n -> (T.append str (renderNode n))) ""++-- | Render node as string.+renderNode :: PNode -> T.Text+renderNode (PText t) = t+renderNode (PVar t) = T.append (T.append "${" t) "}"+renderNode (PFor t nodes) =+  let for = T.append (T.append "${for(" t) ")}"+      body = renderNodes nodes+      end = "${end}"+  in T.append (T.append for body) end+renderNode (PIf t nodes) =+  let for = T.append (T.append "${if(" t) ")}"+      body = renderNodes nodes+      end = "${end}"+  in T.append (T.append for body) end+renderNode (PPartial file) = T.append (T.append "${partial(" file) ")}"+renderNode (PMetaIf v) = renderNode (PIf v [])+renderNode (PMetaFor v) = renderNode (PFor v [])+renderNode PMetaEnd = ""+renderNode (PPreamble _) = "" -- Don't render the PREAMBLE++-- | Render tokens.+renderTokens :: [Token] -> T.Text+renderTokens = DL.foldl' (\str n -> (T.append str (renderToken n))) ""++-- | Render token.+renderToken :: Token -> T.Text+renderToken (TokText t) = t+renderToken (TokVar t) = T.append (T.append "${" t) "}"+renderToken (TokPartial fp) = T.append (T.append "${partial(\"" fp) "\"}"+renderToken (TokFor t) = T.append (T.append "${for(" t) ")}"+renderToken (TokEnd) = "${end}"+renderToken (TokIf t) = T.append (T.append "${if(" t) ")}"+renderToken (TokPreamble _) = "" -- Hide preamble content++-- | Parse text.+parseText :: T.Text -> Either ParseError [PNode]+parseText text = do+  toks <- parse parseEverything (T.unpack "") (T.unpack text)+  return $ transform toks++-- | Parse everything.+--+-- >>> parse parseEverything "" "Hello ${man} and ${woman}."+-- Right [TokText "Hello ",TokVar "man",TokText " and ",TokVar "woman",TokText "."]+--+-- >>> parse parseEverything "" "Hello ${man} and ${if(woman)} text here ${end}."+-- Right [TokText "Hello ",TokVar "man",TokText " and ",TokIf "woman",TokText " text here ",TokEnd,TokText "."]+--+-- >>> parse parseEverything "" "Hi ${for(people)} ${name}, ${end} everyone!"+-- Right [TokText "Hi ",TokFor "people",TokText " ",TokVar "name",TokText ", ",TokEnd,TokText " everyone!"]+--+-- >>> parse parseEverything "" "${realvar} $.get(javascript) $$ $$$ $} $( $45.50 $$escape $${escape2} wonderful life! ${truth}"+-- Right [TokVar "realvar",TokText " $.get(javascript) $$ $$$ $} $( $45.50 $$escape ",TokText "${",TokText "escape2} wonderful life! ",TokVar "truth"]+--+-- >>> parse parseEverything "" "<!--PREAMBLE  \n  foo: bar\ndo:\n  - re\n  -me\n  -->waffle house ${lyfe}"+-- Right [TokPreamble "  \n  foo: bar\ndo:\n  - re\n  -me\n  ",TokText "waffle house ",TokVar "lyfe"]+--+-- >>> parse parseEverything "" "YO ${foo} <!--PREAMBLE  \n  ${foo}: bar\ndo:\n  - re\n  -me\n  -->waffle house ${lyfe}"+-- Right [TokText "YO ",TokVar "foo",TokText " ",TokPreamble "  \n  ${foo}: bar\ndo:\n  - re\n  -me\n  ",TokText "waffle house ",TokVar "lyfe"]+--+-- This is a degenerate case that we will just allow (for now) to go sideways:+-- >>> parse parseEverything "" "<b>this ${var never closes</b> ${realvar}"+-- Right [TokText "<b>this ",TokVar "var never closes</b> ${realvar"]+--+parseEverything :: Parser [Token]+parseEverything =+  -- Note that order matters here. We want "most general" to be last (variable+  -- names).+  many1 (try parsePreamble+     <|> try parseEscape+     <|> try parseContent+     <|> try parseEnd+     <|> try parseFor+     <|> try parseIf+     <|> try parseEnd+     <|> try parsePartial+     <|> parseVar)++-- >>> parse parseVar "" "${ffwe} yep"+-- Right (TokVar "ffwe")+--+-- >>> parse parseVar "" "${spaces technically allowed}"+-- Right (TokVar "spaces technically allowed")+--+-- >>> isLeft $ parse parseVar "" "Hello ${name}"+-- True+--+-- >>> isLeft $ parse parseVar "" "${}"+-- True+--+-- | Parse variables.+parseVar :: Parser Token+parseVar = try $ do+  _ <- char '$'+  _ <- char '{'+  varName <- many1 (noneOf "}")+  _ <- char '}'+  return $ TokVar (T.pack varName)++-- | Parse preamble.+parsePreamble :: Parser Token+parsePreamble = do+  _ <- parsePreambleStart++  -- "Note the overlapping parsers anyChar and string "-->", and therefore the+  -- use of the try combinator."+  -- (https://hackage.haskell.org/package/parsec-3.1.11/docs/Text-Parsec.html)+  content <- manyTill anyChar (try (string "-->"))+  return $ TokPreamble (T.pack content)++-- | Parse the start of a PREAMBLE.+parsePreambleStart :: Parser String+parsePreambleStart = string "<!--PREAMBLE"+++-- | Parse partial commands.+--+-- >>> parse parsePartial "" "${partial(\"my/file/name.html\")}"+-- Right (TokPartial "my/file/name.html")+--+parsePartial :: Parser Token+parsePartial = do+  _ <- string "${partial(\""+  filename <- many (noneOf "\"")+  _ <- string "\")}"+  return $ TokPartial (T.pack filename)++-- | Parse escape sequence "$${"+--+-- >>> parse parseEscape "" "$${example}"+-- Right (TokText "${")+--+parseEscape :: Parser Token+parseEscape = do+  _ <- try $ string "$${"+  return (TokText "${")++-- | Parse boring, boring text.+--+-- >>> parse parseContent "" "hello ${ffwe} you!"+-- Right (TokText "hello ")+--+-- >>> parse parseContent "" "hello $.get() $ $( $$ you!"+-- Right (TokText "hello $.get() $ $( $$ you!")+--+-- Because of our first parser to grab a character that is not a $, we can't+-- grab strings that start with a $, even if it's text. It's a bug, just deal+-- with it for now.+-- >>> isLeft $ parse parseContent "" "$$$ what"+-- True+--+-- >>> isLeft $ parse parseContent "" "${name}!!"+-- True+--+parseContent :: Parser Token+parseContent = do+  -- The manyTill big parser below will accept an empty string, which is bad. So+  -- grab a single character to start things off.+  h <- noneOf "$"++  -- Grab chars until we see something that looks like a ${...}, or eof. Use+  -- both lookAhead (does not consume successful "${" found) and try (does not+  -- consume failure to find "${"). Not having both produces bugs, so.+  --+  -- Also grab "$${", which should be captured as an escape (parseEscape).+  --+  -- https://stackoverflow.com/questions/20020350/parsec-difference-between-try-and-lookahead+  stuff <- manyTill anyChar (try (lookAhead (string "$${")) <|>+                             try (lookAhead (string "${")) <|>+                             try (lookAhead parsePreambleStart) <|>+                             (eof >> return " "))+  return $ TokText (T.pack (h : stuff))++-- | Parse for loop declaration.+--+-- >>> parse parseFor "" "${for(posts)}"+-- Right (TokFor "posts")+--+-- >>> parse parseFor "" "${for(variable name with spaces technically allowed)}"+-- Right (TokFor "variable name with spaces technically allowed")+--+-- >>> isLeft $ parse parseFor "" "${for()}"+-- True+--+-- >>> isLeft $ parse parseFor "" "${for foo}"+-- True+--+parseFor :: Parser Token+parseFor = parseFunction "for" TokFor++-- | Parse if directive.+parseIf :: Parser Token+parseIf = parseFunction "if" TokIf++-- | General parse template functions.+parseFunction :: String -> (T.Text -> Token) -> Parser Token+parseFunction keyword ctor = do+  _ <- char '$'+  _ <- char '{'+  _ <- try $ string (keyword ++ "(")+  varName <- many1 (noneOf ")")+  _ <- char ')'+  _ <- char '}'+  return $ ctor (T.pack varName)++-- | Parse end keyword.+--+-- >>> parse parseEnd "" "${end}"+-- Right TokEnd+--+-- >>> isLeft $ parse parseEnd "" "${enddd}"+-- True+--+parseEnd :: Parser Token+parseEnd = do+  _ <- try $ string "${end}"+  return TokEnd++-- | A hack to capture strings that "almost" are templates. I couldn't figure+-- out another way.+parseFakeVar :: Parser Token+parseFakeVar = do+  _ <- char '$'+  n <- noneOf "{"+  rest <- many1 (noneOf "$")+  return $ TokText (T.pack ("$" ++ [n] ++ rest))++-- | @many1Till p end@ will parse one or more @p@ until @end.+--+-- From https://hackage.haskell.org/package/pandoc-1.10.0.4/docs/Text-Pandoc-Parsing.html+many1Till :: P.Stream s m t => P.ParsecT s u m a -> P.ParsecT s u m end -> P.ParsecT s u m [a]+many1Till p end = do+  first <- p+  rest <- manyTill p end+  return (first : rest)++-- | Find the preamble content from the given @PNode@s.+findPreambleText :: [PNode] -> Maybe T.Text+findPreambleText nodes = DL.find isPreamble nodes >>= preambleText++-- | Returns @True@ if the @PNode@ is a @PPreamble@.+isPreamble :: PNode -> Bool+isPreamble (PPreamble _) = True+isPreamble _ = False++-- | Gets the content of the @PPreamble@.+preambleText :: PNode -> Maybe T.Text+preambleText (PPreamble t) = Just t+preambleText _ = Nothing+
test/Spec.hs view
@@ -2,4 +2,4 @@  main :: IO () main =-  doctest ["-isrc", "src/Pencil/Internal/Parser.hs"]+  doctest ["-isrc", "src/Pencil/Parser.hs"]