~subsetpark/bagatto

38f72d9c086cf844418b749c39c6b207ca0d935a — Zach Smith 10 months ago 91b2c67
Organize and improve naming of stdlib; add filter
6 files changed, 337 insertions(+), 143 deletions(-)

M bagatto-require.janet
M bagatto.janet
M demo/index.janet
M demo/templates/posts.temple
M main.janet
M src/core.janet
M bagatto-require.janet => bagatto-require.janet +1 -0
@@ 1,2 1,3 @@
(import bagatto :export true)
(import path)
(import sh :export true)

M bagatto.janet => bagatto.janet +241 -72
@@ 8,6 8,10 @@
(import src/multimarkdown)
(import src/core)

#
# API
#

(defn set-defaults!
  ``
  Set default values for the attributes specified in data and site


@@ 30,52 34,24 @@
  [attribute]
  (-> (dyn :bagatto-defaults) (in attribute)))

(defn debug! [keys] (setdyn :bagatto-debug keys))

#
# DATA
#

(defmacro defloader [name docstring body]
  ~(defn ,name
     ,docstring
     [pattern]
     (let [filenames (sh/glob pattern)]
       (fiber/new (fn [] (loop [it :in filenames] ,body))))))

(defloader *
  ``
  Generate a fiber that will return all the filenames that match a given
  file blob.
  ``
  (yield it))

(defloader slurp-*
  ``
  Generate a fiber that will slurp all the files that match a given
  file blob.
  ``
  (yield [it (slurp it)]))

#
# Loaders
#

(defn base-attrs [_src attrs] attrs)

(defn mmarkdown-data
  "Get metadata from a markdown string using multimarkdown."
  [md] (multimarkdown/metadata md))
  [md]
  (multimarkdown/metadata md))

(defn mmarkdown-attrs [src attrs] (merge-into attrs (mmarkdown-data src)))
(defn jdn-data
  "Get metadata from a JDN-formatted string."
  [src]
  (jdn/decode src))

(defn jdn-data [src] (jdn/decode src))

(defn jdn-attrs [src attrs] (merge-into attrs (jdn-data src)))

(defn json-data [src] (json/decode src))

(defn json-attrs [src attrs] (merge-into attrs (json-data src)))
(defn json-data
  "Get metadata from a JSON-formatted string."
  [src]
  (json/decode src))

(defn- adjust-for-display [date]
  (-> (fn [[k v]] (if (and (number? v) (index-of k [:month-day :month :year-day :week-day]))


@@ 99,55 75,82 @@
                  (os/date))]
    (if for-display? (adjust-for-display parsed) parsed)))

(defn eval-data [data] (core/load-data data))
#
# Loaders
#

(def- core-* *)

(defmacro- defeach
  [name docstring body]
  ~(defn ,name
     ,docstring
     [pattern]
     (let [filenames (sh/glob pattern)]
       (fiber/new (fn [] (loop [it :in filenames] ,body))))))

(defeach *
  ```
  Generate a fiber that will return all the filenames that match a given
  file blob.
  ```
  (yield it))

(defeach slurp-*
  ``
  Generate a fiber that will slurp all the files that match a given
  file blob.
  ``
  (yield [it (slurp it)]))

#
# SITE
# Attribute Parsers
#

(defn- debug [template attrs keys-to-debug]
  (print "Template: " template "\nArgs:")
  (pp (keys attrs))
  (each key keys-to-debug
    (print key ":")
    (pp (attrs key))))
(defn parse-base
  "Base attribute parser: bypasses the input source and returns the base attributes."
  [_src attrs]
  attrs)

(defn parse-mmarkdown
  "Attribute parser for multimarkdown."
  [src attrs]
  (merge-into attrs (mmarkdown-data src)))

(defn parse-jdn
  "Attribute parser for JDN."
  [src attrs]
  (merge-into attrs (jdn-data src)))

(defn parse-json
  "Attribute parser for JSON."
  [src attrs]
  (merge-into attrs (json-data src)))

#
# SITE
#

(defn render
  "Merge attributes and call a template's render-dict function."
  ``` 
  Given site data and an optional item, render the specified template.

  All the attributes will be available in the template  under `args`.

  If a single item was passed in in addition to the site, that item
  will be available at `(args :_item)`.
  ```
  [template site &opt item]
  (let [res @""
  (comptime (def bufsize (core-* 12 1024)))
  (let [res (buffer/new bufsize)
        env (require template)
        render-dict ((env 'render-dict) :value)
        attrs (merge site {:_item item})]
    
    (match (dyn :bagatto-debug)
      nil :ok
      keys-to-debug (debug template attrs keys-to-debug))
    
    (with-dyns [:out res]
      (render-dict attrs))
    res))

(defn new-path-with [base item &opt key]
  (default key :path)
  (path/join base (path/basename (item key))))

(defn renderer [template-path]
  (fn [site &opt item]
    (if item
      (render template-path site item)
      (render template-path site))))

(defn eval-site [site data] (core/produce-writer-specs site data))

(defn write-site [writer-specs]
  (-> writer-specs
     (core/produce-writers)
     (core/resume-writers)))
#
# TEMPLATE
#

(defn slugify
  "Normalize a string for use as a slug."
  [s]


@@ 162,6 165,163 @@
         (string))
    ""))

(defn slug-from-attr
  ```
  Given an object and a key, get the value of that key in the object
  it make the value into a slug.
  ```
  [item key]
  (string (slugify (item key)))) 

#
# FOFs
#

(defn renderer
  ```
  Return a function, given a template and optional extra attributes,
  that will call `render` with that template on any input.
  ```
  [template-path &opt extra-attrs]
  (default extra-attrs {})
  (fn [site &opt item]
    (let [attrs (table ;(kvs site))]
      (merge-into attrs extra-attrs)
      (render template-path attrs item))))

(defn site-getter
  ```
  Return a getter that, given site data or site and item data, gets
  the value at the given path in the site.
  ```
  [path]
  (fn [site &opt _item] (get-in site path)))

(defn item-getter
  ```
  Return a getter that, given site and item data, gets the value at
  the given path in the item.
  ```
  [path]
  (fn [site item] (get-in item path)))

(defn %p [& elements]
  ```
  Simple DSL for generating paths. 

  Given any number of string or keyword arguments, will return a
  function that takes an item and returns a file path.

  The arguments make up the components of the path. To include an
  element directly in the path, put it as an argument. To indicate
  that an element should be treated as a key in `site`, precede it by
  a `'%s`. To indicate that an element should be treated as a key in
  `item`, precede it by a `'%i`. To join two elements directly,
  without a path separator, put a `'%` between them. For instance,

      (path-builder "web" '%s "author" '%i :seq '% ".html")

  Will return a function that takes in the site data plus a single item, and generate a
  file path like "web/{site["topic"]}/{item[:seq]}.html".
  ```
  (fn [site &opt item]
    (default item {})
    
    (def path-parts @[])
    
    (var item-key? false)
    (var site-key? false)
    (var push-to-last? false)

    (defn reset-flags! []
      (set item-key? false)
      (set site-key? false))

    (defn reset-push! [] (set push-to-last? false))
    
    (defn handle-element [x]
      (cond
        (= x '%) (set push-to-last? true)
        (= x '%i) (set item-key? true)
        (= x '%s) (set site-key? true)
        (let [path-component (cond item-key? (do (reset-flags!) (slug-from-attr item x))
                                   site-key? (do (reset-flags!) (slug-from-attr site x))
                                   x)]
          (if push-to-last?
            (do (reset-push!) (buffer/push-string (last path-parts) path-component))
            (array/push path-parts (buffer path-component))))))
    
    (each element elements (handle-element element))
    (path/join (splice path-parts))))

(defn- make-path-from-base
  ```
  Generate a new path from a base and an item, using the specified
  attribute in the item (or :path by default)
  ```
  [base item key]
  (path/join base (path/basename (item key))))

(defn path-copier [base &opt key]
  ```
  Return a function that will generate a new path with the same
  base, from the same key, for any item.
  ```
  (default key :path)
  (fn [_data item]
    (make-path-from-base base item key)))

(defn attr-sorter
  ```
  Return a sorter function that, given a list of items, sorts
  according to the items' values at the specified key.
  ``` 
  [key &opt descending?]
  (def by (fn [x y] ((if descending? > <) (x key) (y key))))
  (fn [items] (sort items by)))

#
# REPL
#

(defn eval-data
  ```
  Evaluate an object according to the Bagatto *site data
  specification*.

  Not necessary to define a module, but can be useful to debug your
  configuration from within the REPL.
  ```
  [data] (core/load-data data))

(defn eval-site
  ```
  Evaluate an object according to the Bagatto *site generation
  specification*, given a site data object as context.

  Not necessary to define a module, but can be useful to debug your
  configuration from within the REPL.
  ```
  [site data]
  (core/produce-writer-specs site data))

(defn write-site
  ```
  Given the output of a site generation specification, trigger the
  actual file generation.

  Not necessary to define a module, but can be useful to debug your
  configuration from within the REPL.
  ```
  [writer-specs]
  (-> writer-specs
     (core/produce-writers)
     (core/resume-writers)))

#
# TEMPLATE
#

(defn markdown->html
  "Render a markdown string into HTML."
  [md] (moondown/render (string md)))


@@ 178,3 338,12 @@
  ```
  (let [escaped (map |(or $0 "") xs)]
    (string/format templ ;escaped)))

(defn epp
  ```
  Pretty-print to stderr. Since Temple templates operate over stdout,
  we should use stderr instead if we need to print something to the
  console for debugging purposes.
  ```
  [x]
  (eprint (string/format (dyn :pretty-format "%q") x)))

M demo/index.janet => demo/index.janet +81 -61
@@ 17,16 17,16 @@
## Bagatto offers the ability to set default values for any of the
## attributes that it expects to find in a data or site specification
## (which we'll see below). Here we set the `attrs` default to
## `jdn-attrs`, which means that, in our `data` struct, if we don't
## `jdn-parser`, which means that, in our `data` struct, if we don't
## specify an attrs attribute, Bagatto will assume it refers to a jdn
## file.
(bagatto/set-defaults! {:attrs bagatto/jdn-attrs})
(bagatto/set-defaults! {:attrs bagatto/parse-jdn})

#
# Data Helpers
#

## An `attrs` function is called for every source file that's loaded
## A `parse` function is called for every source file that's loaded
## by Bagatto. It takes two arguments: the contents of the file, and
## some pre-generated attributes that are present for every file.
##


@@ 36,35 36,40 @@
##
## (Thus providing the source as the first argument is a little
## unnecessary, but slightly more convenient.)
(defn post-attrs [src attrs]
(defn parse-post [src attrs]
  (let [seq (helpers/int)]
    (put attrs :title (string "Blog Post " seq))
    (put attrs :seq seq))
  (bagatto/mmarkdown-attrs src attrs))
  (bagatto/parse-mmarkdown src attrs))

#
# Data
#

## A Bagatto index module has to define to variables:
## A Bagatto index module has to define two variables:
## * `data` is a struct mapping data entry *names* to data *specifications*.
## * `site` is a struct describing all of the files that are to be
##   generated, given the input from `data`. Each site specification
##   also has a *name*, though in this case the names are just to make
##   it easier to read by a human author.

## Our `data` struct has two entries:
## * `config` is a static *attrs* struct, containing arbitrary values
##   to be used in rendering the site.
## * `posts` is a specification using `bagatto/*`, which accepts a
##   file path wildcard and will stream back all the files that match
##   it. Since it will stream multiple files, `post-attrs` is a
##   function which will be called on each file.
# Our `data` struct has several entries:
# * `config` is a static *attrs* struct, containing arbitrary values
#   to be used in rendering the site.
# * `posts` is a specification using `bagatto/slurp-*`, which accepts a
#   file path wildcard and will stream back all the files that match
#   it. Since it will stream multiple files, `post-attrs` is a
#   function which will be called on each file.
# * `static` uses `bagatto/*`, which operates similarly but just
#   returns the file paths that match the wildcard, and doesn't
#   stream the file contents themselves.
# We also have two examples that specify file paths as :src directly,
# and call different attrs functions on them.
(def data {:config {:attrs {:title "A Demo Bagatto Config"}}
           :config-json {:src "config.json" :attrs bagatto/json-attrs}
           :posts {:src (bagatto/slurp-* "posts/*.md")
                   :attrs post-attrs}
           :static {:src (bagatto/* "static/*") :attrs bagatto/base-attrs}
                   :attrs parse-post}
           :static {:src (bagatto/* "static/*") :attrs bagatto/parse-base}
           :config-json {:src "config.json" :attrs bagatto/parse-json}
           # NB: We don't specify `:attrs` here; we set a default
           # value above.
           :config-file {:src "config.jdn"}}


@@ 86,57 91,72 @@
##
## The *path* function should return the file path that the generated
## file should be placed in.
(defn post-path [_site post]
  (string "site/posts/" (bagatto/slugify (post :title)) ".html"))

# Here we use the bagatto/%p function to generate a path function for
# us. It exposes a simple DSL and returns a function that will take
# the site data and a post, and return the path for that post.
(def make-post-path (bagatto/%p "site" "posts" '%i :title '% ".html" ))

# We can use another generator function, bagatto/path-copier, in the
# case of static paths. This returns a function which will take an
# item and return a new path with the new base (in this case,
# "site/static" followed by the filename of the item.
(def make-static-path (bagatto/path-copier "site/static"))

# The index file doesn't need a function to determine its path
# (there's only one of them, and it won't depend on the attributes of
# the index), so we can specify it as a simple string.
(def index-path "site/index.html")

## The *contents* function has the same argument signature as the
## *path* function. It is also called once for each source file. It
## should return the contents of the new file.
##
## In this case we call `bagatto/render`, which renders a template,
## and takes three arguments:
## * The path to the template
## * The site data
## * (optionally) The attributes of the individual source file, if
##   we're iterating over a data source of multiple files.
(defn post-content [site post]
  (bagatto/render "templates/post" site post))

(def index-path "site/index.html")

(defn static-path [_data item] (bagatto/new-path-with "site/static" item))

## Likewise, our `site` struct has two entries (though they don't map
## cleanly to the two data entries):
## * `post-index` is a single file that lists all the posts. Its path
##   is static; its contents are rendered by the `post-index`
##   function, which takes as a single argument the entire output of
##   the data step, and returns the contents of the new file.
## * `posts` is a site specification which will result in multiple
##   files: one for each element of the `posts` site data. To map it
##   back to that site data, we include an additional attribute,
##   `:each`, which refers to an entry found in the site data table.
##
##   Since we've specified an `:each` attribute, both `post-path` and
##   `post-content` will take two arguments: the overall site data, as
##   well as the attributes of the specific data entry from `posts`
##   being rendered in that call.
# Here we use the bagatto/renderer function to generate a renderer
# function. It returns a function that will take the site data and a
# post and call `bagatto/render` on the specified template, with the
# site and the post as arguments.
(def render-post (bagatto/renderer "templates/post"))
                
# The Posts index is generated from the site data and lists out all
# the posts. Therefore, it isn't rendered out of a specific source
# file. We will only call it once, with the site data as the only
# argument, since there is no series of items that are all being
# rendered.
# 
# The bagatto/renderer function will generate functions that can
# accept only one argument---just the site data---without any specific
# item being rendered. The only difference will be that the `:_item`
# attribute won't be available in the template.
(def render-post-index (bagatto/renderer "templates/posts"))

# Likewise, our `site` struct has three entries---there doesn't have
# to be any clean mapping between entries in `data` and entries in
# `struct`.
#
# * `post-index` is a single file that lists all the posts. Its path
#   is static; its contents are rendered by the `post-index`
#   function, which takes as a single argument the entire output of
#   the data step, and returns the contents of the new file.
# * `posts` is a site specification which will result in multiple
#   files: one for each element of the `posts` site data. To map it
#   back to that site data, we include an additional attribute,
#   `:each`, which refers to an entry found in the site data table.
#
#   Since we've specified an `:each` attribute, both `make-post-path` and
#   `render-post` will take two arguments: the overall site data, as
#   well as the attributes of the specific data entry from `posts`
#   being rendered in that call.
# * `static` includes an `:each` entry, which means it will map over
#   the multiple entries found in `(data :static)`, but no
#   `:contents`. This means that it will simply use `make-static-path`
#   to generate a new path, then copy the file from the original path
#   to the new one.
(def site
  {:post-index {:path index-path
                # The Posts index is generated from the site data and
                # lists out all the posts. Therefore, it isn't
                # rendered out of a specific source file. We will
                # only call it once, with the site data as the only
                # argument, since there is no series of items that
                # are all being rendered.
                #
                # Here we use the `bagatto/renderer` convenience
                # function, which creates a function which receives
                # one or two arguments and then calls `bagatto/render`
                # with the given template.
                :contents (bagatto/renderer "templates/posts")}
                :contents render-post-index}
   :posts {:each :posts
           :path post-path
           :contents post-content}
           :path make-post-path
           :contents render-post}
   :static {:each :static
            :path static-path}})
            :path make-static-path}})

M demo/templates/posts.temple => demo/templates/posts.temple +1 -1
@@ 31,7 31,7 @@
{$ (import hypertext) $}
<ul>
{% (loop [post :in (args :posts)]
     (def dest (-> (post-path args post)))
     (def dest (-> (make-post-path args post)))
     (print (hypertext/markup
            (li (a :href dest
                   [(post :title)]))))) %}

M main.janet => main.janet +0 -1
@@ 50,7 50,6 @@
      (do
        (defn value [sym] ((env sym) :value))
    
        (setdyn :bagatto-debug (env :bagatto-debug))
        (setdyn :bagatto-defaults (env :bagatto-defaults))

        (let [data-spec (value 'data)

M src/core.janet => src/core.janet +13 -8
@@ 83,33 83,38 @@
    (array/push writers [type path contents]))
  
  (loop [[_entry spec] :pairs site]
    (let [with-defaults (set-defaults spec)]
    (let [with-defaults (set-defaults spec)
          filter (spec :filter)]
      (default filter (fn [_site _item] true))
      
      (match with-defaults
        
        {:each site-selector
         :path path-f
         :contents contents-f}
        (loop [item :in (data site-selector)]
          (let [path (maybe-apply path-f [data item])
                contents (maybe-apply contents-f [data item])]
          (if-let [_should-read (filter data item)
                   path (maybe-apply path-f [data item])
                   contents (maybe-apply contents-f [data item])]
            (push-writer :write path contents)))
        
        {:each site-selector
         :path path-f}
        (loop [item :in (data site-selector)]
          (let [path (maybe-apply path-f [data item])]
          (if-let [_should-read (filter data item)
                   path (maybe-apply path-f [data item])]
            (push-writer :copy (item :path) path)))
        
        {:path path-f
         :contents contents-f}
        (let [path (maybe-apply path-f [data])
              contents (maybe-apply contents-f [data])]
        (if-let [path (maybe-apply path-f [data])
                 contents (maybe-apply contents-f [data])]
          (push-writer :write path contents))
        
        {:some site-selector
         :path path-f}
        (let [item (data site-selector)
              path (maybe-apply path-f [data])]
        (if-let [item (data site-selector)
                 path (maybe-apply path-f [data])]
          (push-writer :copy (item :path) path))))) 
  
  writers)