diff --git a/project.clj b/project.clj index e29657d..57d65b7 100644 --- a/project.clj +++ b/project.clj @@ -4,6 +4,7 @@ :license {:name "Eclipse Public License" :url "http://www.eclipse.org/legal/epl-v10.html"} :dependencies [[org.clojure/clojure "1.8.0"] + [camel-snake-kebab "0.4.0"] [cheshire "5.7.0"] [clj-rss "0.2.3"] [clj-text-decoration "0.0.3"] diff --git a/src/cryogen_core/compiler.clj b/src/cryogen_core/compiler.clj index 053d1ba..6403cca 100644 --- a/src/cryogen_core/compiler.clj +++ b/src/cryogen_core/compiler.clj @@ -8,6 +8,7 @@ [selmer.util :refer [set-custom-resource-path!]] [text-decoration.core :refer :all] [cryogen-core.io :as cryogen-io] + [cryogen-core.klipse :as klipse] [cryogen-core.markup :as m] [cryogen-core.rss :as rss] [cryogen-core.sass :as sass] @@ -111,7 +112,8 @@ (merge (merge-meta-and-content file-name page-meta content) {:uri (page-uri file-name :page-root-uri config) - :page-index (:page-index page-meta)}))) + :page-index (:page-index page-meta) + :klipse (klipse/merge-configs (:klipse config) (:klipse page-meta))}))) (defn parse-post "Return a map with the given post's information." @@ -128,7 +130,8 @@ :formatted-archive-group formatted-group :parsed-archive-group (.parse archive-fmt formatted-group) :uri (page-uri file-name :post-root-uri config) - :tags (set (:tags page-meta))})))) + :tags (set (:tags page-meta)) + :klipse (klipse/merge-configs (:klipse config) (:klipse page-meta))})))) (defn read-posts "Returns a sequence of maps representing the data from markdown files of posts. @@ -474,11 +477,15 @@ (println (green "compiling assets...")) (let [{:keys [^String site-url blog-prefix rss-name recent-posts sass-dest keep-files ignored-files previews? author-root-uri theme] :as config} (read-config) - posts (add-prev-next (read-posts config)) + posts (map (fn [{:keys [klipse content] :as post}] + (assoc post :klipse (klipse/emit klipse content))) + (add-prev-next (read-posts config))) posts-by-tag (group-by-tags posts) posts (tag-posts posts config) latest-posts (->> posts (take recent-posts) vec) - pages (read-pages config) + pages (map (fn [{:keys [klipse content] :as page}] + (assoc page :klipse (klipse/emit klipse content))) + (read-pages config)) home-page (->> pages (filter #(boolean (:home? %))) (first)) diff --git a/src/cryogen_core/klipse.clj b/src/cryogen_core/klipse.clj new file mode 100644 index 0000000..3735c10 --- /dev/null +++ b/src/cryogen_core/klipse.clj @@ -0,0 +1,133 @@ +(ns cryogen-core.klipse + (:require [clojure.string :as str] + [camel-snake-kebab.core :refer [->snake_case_string ->camelCaseString]] + [cheshire.core :as json] + [net.cgrand.enlive-html :as enlive])) + +;;;;;;;;;;; +;; utils + +(defn map-keys + "Applies f to each key in m" + [f m] + (zipmap (map f (keys m)) (vals m))) + +(defn update-existing + "Like clojure.core/update, but returns m untouched if it doesn't contain k" + [m k f & args] + (if (contains? m k) (apply update m k f args) m)) + +(def map-or-nil? (some-fn map? nil?)) + +(defn deep-merge + "Like clojure.core/merge, but also merges nested maps under the same key." + [& ms] + (apply merge-with + (fn [v1 v2] + (if (and (map-or-nil? v1) (map-or-nil? v2)) + (deep-merge v1 v2) + v2)) + ms)) + +(defn filter-html-elems + "Recursively walks a sequence of enlive-style html elements depth first + and returns a flat sequence of the elements where (pred elem)" + [pred html-elems] + (reduce (fn [acc {:keys [content] :as elem}] + (into (if (pred elem) (conj acc elem) acc) + (filter-html-elems pred content))) + [] html-elems)) + +(defn code-block-classes + "Takes a string of html and returns a sequence of + all the classes on all code blocks." + [html] + (->> html + enlive/html-snippet + (filter-html-elems (comp #{:code} :tag)) + (keep (comp :class :attrs)) + (mapcat #(str/split % #" ")))) + +;;;;;;;;;;;; +;; klipse + +(def defaults + {:js-src + {:min "https://storage.googleapis.com/app.klipse.tech/plugin_prod/js/klipse_plugin.min.js" + :non-min "https://storage.googleapis.com/app.klipse.tech/plugin/js/klipse_plugin.js"} + + :css-base "https://storage.googleapis.com/app.klipse.tech/css/codemirror.css"}) + +;; This needs to be updated whenever a new clojure selector is introduced. +;; It should only be necessary for react wrappers and the like, so not very often. +;; When (if?) self hosted cljs becomes compatible with advanced builds +;; this can be removed and we can just always use minified js. +(def clojure-selectors + "A set of selectors that imply clojure evaluation." + #{"selector" "selector_reagent"}) + +(defn clojure-eval-classes + "Takes settings and returns a set of the html classes that imply clojure eval." + [normalized-settings] + (reduce (fn [classes selector] + (if-let [klass (get normalized-settings selector)] + (conj classes (->> klass rest (apply str))) ;; Strip the leading . + classes)) + #{} clojure-selectors)) + +(defn clojure-eval? + "Takes settings and html and returns whether there is any clojure eval." + [normalized-settings html] + (boolean (some (clojure-eval-classes normalized-settings) (code-block-classes html)))) + +(defn normalize-settings + "Transform the keys to the correct snake-case or camelCase strings." + [settings] + (-> (map-keys ->snake_case_string settings) + (update-existing "codemirror_options_in" (partial map-keys ->camelCaseString)) + (update-existing "codemirror_options_out" (partial map-keys ->camelCaseString)))) + +(defn merge-configs + "Merges the defaults, global config and post config, + transforms lisp-case keywords into snake_case/camelCase strings + Returns nil if there's no post-config. + A post-config with the value true counts as an empty map." + [global-config post-config] + (when post-config + (let [post-config (if (true? post-config) {} post-config)] + (deep-merge defaults + (update-existing global-config :settings normalize-settings) + (update-existing post-config :settings normalize-settings))))) + +(defn infer-clojure-eval + "Infers whether there's clojure eval and returns the config with the + appropriate value assoc'd to :js. + Returns the config untouched if :js is already specified." + [config html] + (if (:js config) + config + (assoc config :js + (if (clojure-eval? (:settings config) html) :non-min :min)))) + +(defn include-css [href] + (str "")) + +(defn include-js [src] + (str "")) + +(defn emit + "Takes the :klipse config from config.edn and the :klipse config from the + current post, and returns the html to include on the bottom of the page." + [config html] + (when-let [{:keys [settings js-src js css-base css-theme]} + (infer-clojure-eval config html)] + + (assert (#{:min :non-min} js) + (str ":js needs to be one of :min or :non-min but was: " js)) + + (str (include-css css-base) "\n" + (when css-theme (str (include-css css-theme) "\n")) + "\n" + (include-js (js js-src))))) diff --git a/test/cryogen_core/klipse_test.clj b/test/cryogen_core/klipse_test.clj new file mode 100644 index 0000000..33b15f6 --- /dev/null +++ b/test/cryogen_core/klipse_test.clj @@ -0,0 +1,75 @@ +(ns cryogen-core.klipse-test + (:require [cryogen-core.klipse :refer :all] + [clojure.test :refer [deftest testing is are]])) + +(deftest map-keys-test + (is (= {"a" 1 "b" 2} (map-keys name {:a 1 :b 2})))) + +(deftest update-existing-test + (is (= {:a 1 :b 2} (update-existing {:a 1 :b 1} :b inc))) + (is (= {:a 1} (update-existing {:a 1} :b (constantly 2))))) + +(deftest deep-merge-test + (is (= {:a {:b 1 :c 2}} (deep-merge {:a {:b 1}} {:a {:c 2}}))) + (is (= {:a {:b 1}} (deep-merge {:a {:b 1}} {:a nil}))) + (is (= {:a {:b 1 :c 3}} (deep-merge {:a {:b 1 :c 2}} {:a {:c 3}})))) + +;; For testing convenience. +(defn elt + "Returns an enlive style html element." + ([tag] (elt tag nil)) + ([tag attrs & content] + {:tag tag, :attrs attrs, :content content})) + +(deftest filter-html-elems-test + (is (= [(elt :div {:class "x"} :content [(elt :div {:class "x"} "foo")]) + (elt :div {:class "x"} "foo")]) + (filter-html-elems (comp #{"x"} :class :attrs) + [(elt :h1 {:class "y"} "things!") + (elt :div {:class "x"} (elt :div {:class "x"} "foo"))]))) + +(deftest code-block-classes-test + (is (= ["clojure" "ruby"] + (code-block-classes + "

stuff

+
(def x 42)
+
123
"))))
+
+(deftest clojure-eval-classes-test
+  (is (= #{"eval-cljs" "eval-reagent"}
+         (clojure-eval-classes {"selector" ".eval-cljs"
+                                "selector_reagent" ".eval-reagent"
+                                "selector_eval_ruby" ".eval-ruby"}))))
+
+(deftest clojure-eval?-test
+  (is (clojure-eval? {"selector" ".eval-cljs"}
+                     "

stuff

+
(def x 42)
+
123
"))
+
+  (is (not (clojure-eval? {"selector" ".eval-cljs"
+                           "selector_eval_ruby" ".eval-ruby"}
+                          "

stuff

+
123
"))))
+
+(deftest normalize-settings-test
+  (is (= {"selector_reagent" ".reagent"
+          "codemirror_options_in" {"lineNumbers" true}}
+         (normalize-settings
+          {:selector-reagent ".reagent"
+           :codemirror-options-in {:line-numbers true}}))))
+
+(deftest merge-configs-test
+  (testing "Things are merged correctly"
+    (is (= (merge defaults
+                  {:settings {"selector" ".clojure-eval"
+                              "codemirror_options_in" {"lineNumbers" true}}})
+           (merge-configs {:settings {:codemirror-options-in {:line-numbers true}}}
+                          {:settings {:selector ".clojure-eval"}}))))
+
+  (testing "If it's all set up in config.edn, in the post it can be just :klipse true"
+    (is (= (merge defaults {:settings {"selector_js" ".javascript"}})
+           (merge-configs {:settings {:selector-js ".javascript"}} true))))
+
+  (testing "Returns nil if there's nothing in the blog post"
+    (is (nil? (merge-configs {:settings {:selector ".clojure-eval"}} nil)))))