diff --git a/examples/blog/build.boot b/examples/blog/build.boot index 02dbd4d3..a31f7b30 100644 --- a/examples/blog/build.boot +++ b/examples/blog/build.boot @@ -5,7 +5,8 @@ [hiccup "1.0.5"] [pandeiro/boot-http "0.6.3-SNAPSHOT"]]) -(require '[io.perun :refer :all] +(require '[clojure.string :as str] + '[io.perun :refer :all] '[io.perun.example.index :as index-view] '[io.perun.example.post :as post-view] '[pandeiro.boot-http :refer [serve]]) @@ -26,6 +27,20 @@ (gravatar :source-key :author-email :target-key :author-gravatar) (render :renderer 'io.perun.example.post/render) (collection :renderer 'io.perun.example.index/render :page "index.html") + (paginate :renderer 'io.perun.example.paginate/render) + (assortment :renderer 'io.perun.example.assortment/render + :grouper (fn [entries] + (->> entries + (mapcat (fn [entry] + (if-let [kws (:keywords entry)] + (map #(-> [% entry]) (str/split kws #"\s*,\s*")) + []))) + (reduce (fn [result [kw entry]] + (let [path (str kw ".html")] + (-> result + (update-in [path :entries] conj entry) + (assoc-in [path :entry :keyword] kw)))) + {})))) (static :renderer 'io.perun.example.about/render :page "about.html") (inject-scripts :scripts #{"start.js"}) (sitemap) diff --git a/examples/blog/src/io/perun/example/assortment.clj b/examples/blog/src/io/perun/example/assortment.clj new file mode 100644 index 00000000..15f87f43 --- /dev/null +++ b/examples/blog/src/io/perun/example/assortment.clj @@ -0,0 +1,15 @@ +(ns io.perun.example.assortment + (:require [hiccup.page :refer [html5]])) + +(defn render [{global-meta :meta posts :entries entry :entry}] + (html5 {:lang "en" :itemtype "http://schema.org/Blog"} + [:head + [:title (str (:site-title global-meta) "|" (:keyword entry))] + [:meta {:charset "utf-8"}] + [:meta {:http-equiv "X-UA-Compatible" :content "IE=edge,chrome=1"}] + [:meta {:name "viewport" :content "width=device-width, initial-scale=1.0, user-scalable=no"}]] + [:body + [:h1 (str "Page " (:page entry))] + [:ul.items.columns.small-12 + (for [post posts] + [:li (:title post)])]])) diff --git a/examples/blog/src/io/perun/example/paginate.clj b/examples/blog/src/io/perun/example/paginate.clj new file mode 100644 index 00000000..ac86b5e2 --- /dev/null +++ b/examples/blog/src/io/perun/example/paginate.clj @@ -0,0 +1,15 @@ +(ns io.perun.example.paginate + (:require [hiccup.page :refer [html5]])) + +(defn render [{global-meta :meta posts :entries entry :entry}] + (html5 {:lang "en" :itemtype "http://schema.org/Blog"} + [:head + [:title (str (:site-title global-meta) "|" (:tag entry))] + [:meta {:charset "utf-8"}] + [:meta {:http-equiv "X-UA-Compatible" :content "IE=edge,chrome=1"}] + [:meta {:name "viewport" :content "width=device-width, initial-scale=1.0, user-scalable=no"}]] + [:body + [:h1 (str "Page " (:page entry))] + [:ul.items.columns.small-12 + (for [post posts] + [:li (:title post)])]])) diff --git a/src/io/perun.clj b/src/io/perun.clj index c5168644..8e74df3d 100644 --- a/src/io/perun.clj +++ b/src/io/perun.clj @@ -753,7 +753,7 @@ paths (grouper (filter-meta-by-ext fileset options))] (if (seq paths) (reduce - (fn [result [path {:keys [entries group-meta]}]] + (fn [result [path {:keys [entry entries]}]] (let [sorted (->> entries (sort-by sortby comparator) (map #(assoc % :content (->> (:path %) @@ -761,8 +761,7 @@ boot/tmp-file slurp)))) new-path (perun/create-filepath out-dir path) - new-entry (assoc group-meta :out-dir out-dir)] - (perun/report-info task-name (str "rendered " task-name " " path)) + new-entry (assoc entry :out-dir out-dir)] (assoc result new-path {:meta global-meta :entry new-entry :entries (vec sorted)}))) @@ -772,11 +771,84 @@ (perun/report-info task-name (str task-name " found nothing to render")) [])))) -(def ^:private +collection-defaults+ +(defn assortment-pre-wrap + "Handles common assortment task orchestration + + `task-name` is used for log messages. `tracer` is a keyword that gets added + to the `:io.perun/trace` metadata. `grouper` is a function that takes a seq + of entries and returns a map of paths to render data (see docstring for + `assortment` for more info) + + Returns a boot `with-pre-wrap` result" + [{:keys [task-name tracer grouper options]}] + (cond (not (fn? (:comparator options))) + (u/fail (str task-name " task :comparator option should implement Fn\n")) + (not (ifn? (:filterer options))) + (u/fail (str task-name " task :filterer option value should implement IFn\n")) + (not (ifn? (:sortby options))) + (u/fail (str task-name " task :sortby option value should implement IFn\n")) + (not (ifn? grouper)) + (u/fail (str task-name " task :grouper option value should implement IFn\n")) + :else + (let [;; Make sure task-level metadata gets added to each entry + meta-grouper (fn [entries] + (->> entries + grouper + (map (fn [[path data]] + [path (update-in data [:entry] #(merge (:meta options) %))])) + (into {}))) + options (assoc options :grouper meta-grouper)] + (render-pre-wrap {:task-name task-name + :render-paths-fn (partial grouped-paths task-name) + :options options + :tracer tracer})))) + +(def ^:private +assortment-defaults+ {:out-dir "public" :filterer identity :extensions [".html"] :sortby (fn [file] (:date-published file)) + :comparator (fn [i1 i2] (compare i2 i1)) + :grouper #(-> {"index.html" {:entries %}})}) + +(deftask assortment + "Render multiple collections + The symbol supplied as `renderer` should resolve to a function + which will be called with a map containing the following keys: + - `:meta`, global perun metadata + - `:entry`, the metadata for this collection + - `:entries`, all entries + + The `grouper` function will be called with a seq containing the + entries to be grouped, and it should return a map with keys that + are filenames and values that are maps with the keys: + - `:entries`: the entries for each collection + - `:entry`: (optional) page metadata for this collection + + Entries can optionally be filtered by supplying a function + to the `filterer` option. + + The `sortby` function can be used for ordering entries before rendering." + [o out-dir OUTDIR str "the output directory" + r renderer RENDERER sym "page renderer (fully qualified symbol resolving to a function)" + g grouper GROUPER code "group posts function, keys are filenames, values are to-be-rendered entries" + _ filterer FILTER code "predicate to use for selecting entries (default: `identity`)" + e extensions EXTENSIONS [str] "extensions of files to include" + s sortby SORTBY code "sort entries by function" + c comparator COMPARATOR code "sort by comparator function" + m meta META edn "metadata to set on each collection entry"] + (let [grouper (or grouper #(-> {"index.html" {:entries %}})) + options (merge +assortment-defaults+ (dissoc *opts* :grouper))] + (assortment-pre-wrap {:task-name "assortment" + :tracer :io.perun/assortment + :grouper grouper + :options options}))) + +(def ^:private +collection-defaults+ + {:out-dir "public" + :filterer identity + :extensions [".html"] + :sortby :date-published :comparator (fn [i1 i2] (compare i2 i1))}) (deftask collection @@ -790,51 +862,69 @@ Entries can optionally be filtered by supplying a function to the `filterer` option. - The `sortby` and `groupby` functions can be used for ordering entries + The `sortby` function can be used for ordering entries before rendering as well as rendering groups of entries to different pages." [o out-dir OUTDIR str "the output directory" r renderer RENDERER sym "page renderer (fully qualified symbol resolving to a function)" _ filterer FILTER code "predicate to use for selecting entries (default: `identity`)" e extensions EXTENSIONS [str] "extensions of files to include" s sortby SORTBY code "sort entries by function" - g groupby GROUPBY code "group posts by function, keys are filenames, values are to-be-rendered entries" c comparator COMPARATOR code "sort by comparator function" p page PAGE str "collection result page path" m meta META edn "metadata to set on each collection entry"] - (let [options (merge +collection-defaults+ - (dissoc *opts* :page) - (if page - {:grouper #(-> {page {:entries % - :group-meta meta}})} - (if groupby - {:grouper #(->> % - (group-by groupby) - (map (fn [[page entries]] - [page {:entries entries - :group-meta meta}])) - (into {}))} - {:grouper #(-> {"index.html" {:entries % - :group-meta meta}})})))] - (cond (not (fn? (:comparator options))) - (u/fail "collection task :comparator option should implement Fn\n") - (not (ifn? (:filterer options))) - (u/fail "collection task :filterer option value should implement IFn\n") - (and (:page options) groupby) - (u/fail "using the :page option will render any :groupby option setting effectless\n") - (and (:groupby options) (not (ifn? (:groupby options)))) - (u/fail "collection task :groupby option value should implement IFn\n") - (not (ifn? (:sortby options))) - (u/fail "collection task :sortby option value should implement IFn\n") - :else - (let [collection-paths (partial grouped-paths "collection")] - (render-pre-wrap {:task-name"collection" - :render-paths-fn collection-paths - :options options - :tracer :io.perun/collection}))))) + (let [p (or page "index.html")] + (assortment-pre-wrap {:task-name "collection" + :tracer :io.perun/collection + :grouper #(-> {p {:entries %}}) + :options (merge +collection-defaults+ (dissoc *opts* :page))}))) (def +inject-scripts-defaults+ {:extensions [".html"]}) +(def ^:private +paginate-defaults+ + {:out-dir "public" + :prefix "page-" + :page-size 10 + :filterer identity + :extensions [".html"] + :sortby (fn [file] (:date-published file)) + :comparator (fn [i1 i2] (compare i2 i1))}) + +(deftask paginate + "Render multiple collections + The symbol supplied as `renderer` should resolve to a function + which will be called with a map containing the following keys: + - `:meta`, global perun metadata + - `:entry`, the metadata for this collection + - `:entries`, all entries + + Entries can optionally be filtered by supplying a function + to the `filterer` option. + + The `sortby` function can be used for ordering entries before rendering." + [o out-dir OUTDIR str "the output directory" + f prefix PREFIX str "the prefix for each html file, eg prefix-1.html, prefix-2.html (default: `\"page-\"`)" + p page-size PAGESIZE int "the number of entries to include in each page (default: `10`)" + r renderer RENDERER sym "page renderer (fully qualified symbol resolving to a function)" + _ filterer FILTER code "predicate to use for selecting entries (default: `identity`)" + e extensions EXTENSIONS [str] "extensions of files to include" + s sortby SORTBY code "sort entries by function" + c comparator COMPARATOR code "sort by comparator function" + m meta META edn "metadata to set on each collection entry"] + (let [{:keys [sortby comparator page-size prefix] :as options} (merge +paginate-defaults+ *opts*) + grouper (fn [entries] + (->> entries + (sort-by sortby comparator) + (partition-all page-size) + (map-indexed #(-> [(str prefix (inc %1) ".html") + {:entry {:page (inc %1)} + :entries %2}])) + (into {})))] + (assortment-pre-wrap {:task-name "paginate" + :tracer :io.perun/paginate + :grouper grouper + :options options}))) + (deftask inject-scripts "Inject JavaScript scripts into html files. Use either filter to include only files matching or remove to diff --git a/test/io/perun_test.clj b/test/io/perun_test.clj index b41b4f81..55777aba 100644 --- a/test/io/perun_test.clj +++ b/test/io/perun_test.clj @@ -2,6 +2,7 @@ (:require [boot.core :as boot :refer [deftask]] [boot.task.built-in :refer [sift]] [boot.test :as boot-test :refer [deftesttask]] + [clj-yaml.core :as yaml] [clojure.java.io :as io] [clojure.string :as str] [clojure.test :refer [deftest testing is]] @@ -141,17 +142,48 @@ (boot/add-resource tmp) boot/commit!)))) +(def base-meta {:email "brent.hagany@gmail.com" + :author "Testy McTesterson"}) +(def yamls [(yaml/generate-string (assoc base-meta + :uuid "2078a34d-1b1a-4257-9eff-ffe215d90bcd" + :draft true)) + (yaml/generate-string (assoc base-meta + :uuid "2078a34d-1b1a-4257-9eff-ffe215d90bcd" + :draft false)) + (yaml/generate-string (assoc base-meta + :uuid "2078a34d-1b1a-4257-9eff-ffe215d90bcd" + :draft true + :order 4 + :foo "bar")) + + (yaml/generate-string (assoc base-meta + :uuid "e98ae98f-a621-47f3-a4be-de8b06961f41" + :tags ["tag1" "tag2" "tag3"] + :order 3 + :baz true)) + (yaml/generate-string (assoc base-meta + :uuid "2d4f8006-4a3b-4099-9f7b-3b8c9349a3dc" + :tags ["tag1" "tag2"] + :order 2 + :baz false)) + (yaml/generate-string (assoc base-meta + ;; :uuid + :tags ["tag1" "tag3"] + :order 1 + :baz false)) + (yaml/generate-string (assoc base-meta + ;; :uuid + :tags ["tag2" "tag3"] + :order 0 + :baz true))]) + (def md-content - "--- -email: brent.hagany@gmail.com -uuid: 2078a34d-1b1a-4257-9eff-ffe215d90bcd -draft: true -author: Testy McTesterson ---- -# Hello there + "# Hello there This --- be ___markdown___.") +(def input-strings (map #(str "---\n" % "\n---\n" md-content) yamls)) + (def parsed-md-basic "

Hello there

\n

This --- be markdown.

") (def parsed-md-smarts "

Hello there

\n

This — be markdown.

") @@ -169,12 +201,27 @@ This --- be ___markdown___.") [data] (str "" (:content (:entry data)) "")) +(defn render-assortment + [data] + (let [{:keys [entry entries]} data] + (str "

assortment " (count entries) "

"))) + +(defn render-collection + [data] + (let [{:keys [entry entries]} data] + (str "

collection " (count entries) "

"))) + +(defn render-paginate + [data] + (let [{:keys [entry entries]} data] + (str "

paginate " (count entries) "

"))) + (defn render-static [data] "

static

") (deftesttask default-tests [] - (comp (add-txt-file :path "2017-01-01-test.md" :content md-content) + (comp (add-txt-file :path "2017-01-01-test.md" :content (nth input-strings 0)) (boot/with-pre-wrap fileset (pm/set-global-meta fileset {:base-url "http://example.com/" :site-title "Test Title" @@ -253,6 +300,30 @@ This --- be ___markdown___.") (file-exists? :path "public/atom.xml" :msg "`atom-feed` should write atom.xml")) + (add-txt-file :path "test2.md" :content (nth input-strings 3)) + (add-txt-file :path "test3.md" :content (nth input-strings 4)) + (add-txt-file :path "test4.md" :content (nth input-strings 5)) + (add-txt-file :path "test5.md" :content (nth input-strings 6)) + (p/markdown) + + (p/assortment :renderer 'io.perun-test/render-assortment) + (testing "assortment" + (content-check :path "public/index.html" + :content "assortment 6" + :msg "assortment should modify file contents")) + + (p/collection :renderer 'io.perun-test/render-collection) + (testing "collection" + (content-check :path "public/index.html" + :content "collection 7" + :msg "collection should modify file contents")) + + (p/paginate :renderer 'io.perun-test/render-paginate) + (testing "paginate" + (content-check :path "public/page-1.html" + :content "paginate 7" + :msg "`paginate` should write new files")) + (p/static :renderer 'io.perun-test/render-static) (testing "static" (content-check :path "public/index.html" @@ -279,7 +350,7 @@ This --- be ___markdown___.") :msg "`draft` should remove files")))) (deftesttask with-arguments-test [] - (comp (add-txt-file :path "test.md" :content md-content) + (comp (add-txt-file :path "test.md" :content (nth input-strings 0)) (boot/with-pre-wrap fileset (pm/set-global-meta fileset {:base-url "http://example.com/" :site-title "Test Title" @@ -389,6 +460,97 @@ This --- be ___markdown___.") (file-exists? :path "foo/test-atom.xml" :msg "`atom-feed` should write test-atom.xml")) + (add-txt-file :path "test2.md" :content (nth input-strings 3)) + (add-txt-file :path "test3.md" :content (nth input-strings 4)) + (add-txt-file :path "test4.md" :content (nth input-strings 5)) + (add-txt-file :path "test5.md" :content (nth input-strings 6)) + (p/markdown :meta {:assorting true} + :out-dir "assorting") + (sift :move {#"assorting/(.*)\.html" "assorting/$1.htm"}) + + (p/assortment :renderer 'io.perun-test/render-assortment + :out-dir "foo" + :grouper (fn [entries] + (reduce (fn [paths {:keys [baz tags] :as entry}] + (let [path (str baz "-" (first tags) ".html")] + (if (and (not (nil? baz)) (seq tags)) + (update-in paths [path :entries] conj entry) + paths))) + {} + entries)) + :filterer :assorting + :extensions [".htm"] + :sortby :order + :comparator #(compare %1 %2) + :meta {:assorted "yep"}) + (testing "assortment" + (comp + (content-check :path "foo/true-tag1.html" + :content "assortment 1" + :msg "assortment should modify file contents") + (content-check :path "foo/true-tag2.html" + :content "assortment 1" + :msg "assortment should modify file contents") + (content-check :path "foo/false-tag1.html" + :content "assortment 2" + :msg "assortment should modify file contents") + (value-check :path "foo/true-tag1.html" + :value-fn #(meta= %1 %2 :assorted "yep") + :msg "assortment should modify file metadata") + (value-check :path "foo/true-tag2.html" + :value-fn #(meta= %1 %2 :assorted "yep") + :msg "assortment should modify file metadata") + (value-check :path "foo/false-tag1.html" + :value-fn #(meta= %1 %2 :assorted "yep") + :msg "assortment should modify file metadata"))) + + (p/collection :renderer 'io.perun-test/render-collection + :out-dir "bar" + :filterer :baz + :extensions [".htm"] + :sortby :order + :comparator #(compare %1 %2) + :page "its-a-collection.html" + :meta {:collected "uh huh"}) + (testing "collection" + (comp + (content-check :path "bar/its-a-collection.html" + :content "collection 2" + :msg "collection should modify file contents") + (value-check :path "bar/its-a-collection.html" + :value-fn #(meta= %1 %2 :collected "uh huh") + :msg "collection should modify file metadata"))) + + (p/paginate :renderer 'io.perun-test/render-paginate + :out-dir "baz" + :prefix "decomplect-" + :page-size 2 + :filterer :assorting + :extensions [".htm"] + :sortby :order + :comparator #(compare %1 %2) + :meta {:paginated "mmhmm"}) + (testing "paginate" + (comp + (content-check :path "baz/decomplect-1.html" + :content "paginate 2" + :msg "`paginate` should write new files") + (content-check :path "baz/decomplect-2.html" + :content "paginate 2" + :msg "`paginate` should write new files") + (content-check :path "baz/decomplect-3.html" + :content "paginate 1" + :msg "`paginate` should write new files") + (value-check :path "baz/decomplect-1.html" + :value-fn #(meta= %1 %2 :paginated "mmhmm") + :msg "`paginate` should set metadata") + (value-check :path "baz/decomplect-2.html" + :value-fn #(meta= %1 %2 :paginated "mmhmm") + :msg "`paginate` should set metadata") + (value-check :path "baz/decomplect-3.html" + :value-fn #(meta= %1 %2 :paginated "mmhmm") + :msg "`paginate` should set metadata"))) + (p/static :renderer 'io.perun-test/render-static :out-dir "laphroiag" :page "neat.html" @@ -434,38 +596,38 @@ This --- be ___markdown___.") (comp (testing "Collection works without input files" ;; #77 (p/collection :renderer 'io.perun-test/render)) - (add-txt-file :path "test.md" :content md-content) + (add-txt-file :path "test.md" :content (nth input-strings 0)) (p/markdown) ;; render once - (add-txt-file :path "test.md" :content (str/replace md-content #"Hello" "Salutations")) + (add-txt-file :path "test.md" :content (str/replace (nth input-strings 0) #"Hello" "Salutations")) (p/markdown) (testing "detecting content changes" (content-check :path "public/test.html" :content "Salutations" :msg "content changes should result in re-rendering")) - (add-txt-file :path "test.md" :content (str/replace md-content #"draft: true" "draft: false")) + (add-txt-file :path "test.md" :content (nth input-strings 1)) (p/markdown) (testing "detecting metadata changes" (value-check :path "public/test.html" :value-fn #(meta= %1 %2 :draft false) :msg "metadata changes should result in re-rendering")) - (add-txt-file :path "test.md" :content (str/replace md-content #"draft: true" "draft: true\nfoo: bar")) + (add-txt-file :path "test.md" :content (nth input-strings 2)) (p/markdown) (testing "detecting metadata additions" (value-check :path "public/test.html" :value-fn #(meta= %1 %2 :foo "bar") :msg "metadata additions should result in re-rendering")) - (add-txt-file :path "test.md" :content md-content) + (add-txt-file :path "test.md" :content (nth input-strings 0)) (p/markdown) (testing "detecting metadata deletions" (value-check :path "public/test.html" :value-fn #(meta= %1 %2 :foo nil) :msg "metadata deletions should result in re-rendering")) - (add-txt-file :path "test2.md" :content md-content) + (add-txt-file :path "test2.md" :content (nth input-strings 3)) (p/markdown) (testing "detecting new files" (comp