From eb381c4f472db6ab2e6c0b53b21fb6c2e8945e4a Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 14 Jun 2020 20:33:28 -0400 Subject: [PATCH] Feat: Table of contents. (#18) * refactor extract-metadata-helper. * Fix: strip "." from anchors for jump-to-heading. * Clean: cljfmt fix. --- clojure/src/firn/file.clj | 132 +++++++++++++++++++----------- clojure/src/firn/layout.clj | 28 +++++-- clojure/src/firn/markup.clj | 113 +++++++++++++++++++++---- clojure/src/firn/org.clj | 43 ++++++++-- clojure/src/firn/util.clj | 36 ++++++-- clojure/test/firn/file_test.clj | 13 --- clojure/test/firn/layout_test.clj | 4 +- clojure/test/firn/markup_test.clj | 58 ++++++++++--- clojure/test/firn/org_test.clj | 13 +++ clojure/test/firn/util_test.clj | 11 +++ docs/layout.org | 45 +++++++++- 11 files changed, 377 insertions(+), 119 deletions(-) diff --git a/clojure/src/firn/file.clj b/clojure/src/firn/file.clj index eb58952e..d9dec307 100644 --- a/clojure/src/firn/file.clj +++ b/clojure/src/firn/file.clj @@ -73,19 +73,6 @@ [f m] (merge f m)) -(defn get-keywords - "Returns a list of org-keywords from a file. All files must have keywords." - [f] - (let [expected-keywords (get-in f [:as-edn :children 0 :children])] - (if (= "keyword" (:type (first expected-keywords))) - expected-keywords - (u/print-err! :error "The org file <<" (f :name) ">> does not have 'front-matter' Please set at least the #+TITLE keyword for your file.")))) - -(defn get-keyword - "Fetches a(n org) #+keyword from a file, if it exists." - [f keywrd] - (->> f get-keywords (u/find-first #(= keywrd (:key %))) :value)) - (defn keywords->map "Converts an org-file's keywords into a map. [{:type keyword, :key TITLE, :value Firn, :post_blank 0} @@ -93,7 +80,7 @@  Becomes  {:title Firn, :date-created <2020-03-01--09-53>, :status active, :firn-layout project}" [f] - (let [kw (get-keywords f) + (let [kw (org/get-keywords f) lower-case-it #(when % (s/lower-case %)) dash-it #(when % (s/replace % #"_" "-")) key->keyword (fn [k] (-> k :key lower-case-it dash-it keyword))] @@ -103,7 +90,7 @@ "Returns true if a file meets the conditions of being 'private' Assumes the files has been read into memory and parsed to edn." [config f] - (let [is-private? (get-keyword f "FIRN_PRIVATE") + (let [is-private? (org/get-keyword f "FIRN_PRIVATE") file-path (-> f :path (s/split #"/")) in-priv-folder? (some (set file-path) (config :ignored-dirs))] (or @@ -140,8 +127,8 @@ items to keep track of headline values that precede a logbook. This is easier and more performant than searching an entire edn-tree of headings to see if they have a logbook to associate with. ┬──┬◡ノ(° -°ノ)" - [tree-seq] - (loop [tree-items tree-seq + [tree-data] + (loop [tree-items tree-data output [] last-headline nil] (if (empty? tree-items) @@ -157,6 +144,59 @@ output)] (recur remaining-items new-output headline-val))))) +(defn extract-metadata-helper + "There are lots of things we want to extract when iterating over the AST. + Rather than filter/loop/map over it several times, it all happens here. + Collects: + - Logbooks + - Links + - Headings for TOC. + - eventually... a plugin for custom file collection?" + [tree-data file-metadata] + (loop [tree-data tree-data + out-logs [] + out-links [] + out-toc [] + last-headline nil] ; the most recent headline we've encountered. + (if (empty? tree-data) + ;; All done! return the collected stuff. + {:logbook out-logs + :toc out-toc + :links out-links} + ;; Do the work. + (let [x (first tree-data) + xs (rest tree-data)] + (case (:type x) + "headline" ; if headline, collect data, push into toc, and set as "last-headline" + (let [toc-item {:level (x :level) + :text (org/get-headline-helper x) + :anchor (org/make-headline-anchor x)} + new-toc (conj out-toc toc-item)] + (recur xs out-logs out-links new-toc x)) + + "clock" ; if clock, merge headline-data into it, and push/recurse new-logs. + (let [headline-meta {:from-headline (-> last-headline :children first :raw)} + log-augmented (merge headline-meta file-metadata x) + new-logs (conj out-logs log-augmented)] + (recur xs new-logs out-links out-toc last-headline)) + + "link" ; if link, also merge file metadata and push into new-links and recurse. + (let [link-item (merge x file-metadata) + new-links (conj out-links link-item)] + (recur xs out-logs new-links out-toc last-headline)) + + ;; default case, recur. + (recur xs out-logs out-links out-toc last-headline)))))) + +(defn htmlify + "Renders files according to their `layout` keyword." + [config f] + (let [layout (keyword (org/get-keyword f "FIRN_LAYOUT")) + as-html (when-not (is-private? config f) + (layout/apply-layout config f layout))] + ;; as-html + (change f {:as-html as-html}))) + (defn extract-metadata "Iterates over a tree, and returns metadata for site-wide usage such as links (for graphing between documents, tbd) and logbook entries." @@ -164,45 +204,38 @@ (let [org-tree (file :as-edn) tree-data (tree-seq map? :children org-tree) file-metadata {:from-file (file :name) :from-file-path (file :path-web)} - links (filter #(= "link" (:type %)) tree-data) - logbook (extract-metadata-logbook-helper tree-data) - logbook-aug (map #(merge % file-metadata) logbook) - logbook-sorted (sort-logbook logbook-aug file) - links-aug (map #(merge % file-metadata) links) - date-updated (get-keyword file "DATE_UPDATED") - date-created (get-keyword file "DATE_CREATED")] - - {:links links-aug + date-updated (org/get-keyword file "DATE_UPDATED") + date-created (org/get-keyword file "DATE_CREATED") + metadata (extract-metadata-helper tree-data file-metadata) + logbook-sorted (sort-logbook (metadata :logbook) file)] + + {:links (metadata :links) :logbook logbook-sorted :logbook-total (sum-logbook logbook-sorted) - :keywords (get-keywords file) - :title (get-keyword file "TITLE") - :firn-under (get-keyword file "FIRN_UNDER") + :keywords (org/get-keywords file) + :title (org/get-keyword file "TITLE") + :firn-under (org/get-keyword file "FIRN_UNDER") + :toc (metadata :toc) :date-updated (when date-updated (u/strip-org-date date-updated)) :date-created (when date-created (u/strip-org-date date-created)) :date-updated-ts (when date-updated (u/org-date->ts date-updated)) :date-created-ts (when date-created (u/org-date->ts date-created))})) -(defn htmlify - "Renders files according to their `layout` keyword." - [config f] - (let [layout (keyword (get-keyword f "FIRN_LAYOUT")) - as-html (when-not (is-private? config f) - (layout/apply-layout config f layout))] - ;; as-html - (change f {:as-html as-html}))) - (defn process-one "Munge the 'file' datastructure; slowly filling it up, using let-shadowing. Essentially, converts `org-mode file string` -> json, edn, logbook, keywords" [config f] + (let [new-file (make config f) ; make an empty "file" map. as-json (->> f slurp org/parse!) ; slurp the contents of a file and parse it to json. as-edn (-> as-json (json/parse-string true)) ; convert the json to edn. new-file (change new-file {:as-json as-json :as-edn as-edn}) ; shadow the new-file to add the json and edn. file-metadata (extract-metadata new-file) ; collect the file-metadata from the edn tree. - new-file (change new-file {:meta file-metadata}) ; shadow hte file and add the metadata - final-file (htmlify config new-file)] ; parses the edn tree -> html. + new-file (change new-file {:meta file-metadata}) ; shadow the file and add the metadata + ;; TODO PERF: htmlify happening as well in `process-all`. + ;; this is due to the dev server. There should be a conditional + ;; that checks if we are running in server. + final-file (htmlify config new-file)] ; parses the edn tree -> html. final-file)) @@ -225,6 +258,7 @@ :site-map @site-map :site-links @site-links :site-logs @site-logs) + ;; FIXME: I think we are rendering html twice here, should prob only happen here? with-html (into {} (for [[k pf] output] [k (htmlify config-with-data pf)])) final (assoc config-with-data :processed-files with-html)] final) @@ -242,9 +276,11 @@ (dissoc (processed-file :meta) :logbook :links :keywords) {:path (str "/" (processed-file :path-web))})] - + ;; add to sitemap when file is not private. + + (when-not is-private (swap! site-map conj new-site-map-item) (swap! site-links concat (-> processed-file :meta :links)) @@ -265,13 +301,13 @@ :description (str (f :as-html))))] (io/make-parents feed-file) (->> processed-files - (filter (fn [[_ f]] (-> f :meta :date-created))) - (map make-rss) - (sort-by :pubDate) - (reverse) - (u/prepend-vec first-entry) ; first entry must be about the site - (apply rss/channel-xml) - (spit feed-file))) + (filter (fn [[_ f]] (-> f :meta :date-created))) + (map make-rss) + (sort-by :pubDate) + (reverse) + (u/prepend-vec first-entry) ; first entry must be about the site + (apply rss/channel-xml) + (spit feed-file))) config) (defn reload-requested-file diff --git a/clojure/src/firn/layout.clj b/clojure/src/firn/layout.clj index a3c9ccff..35b93f64 100644 --- a/clojure/src/firn/layout.clj +++ b/clojure/src/firn/layout.clj @@ -6,7 +6,8 @@ (:require [firn.markup :as markup] [firn.org :as org] - [hiccup.core :as h])) + [hiccup.core :as h] + [sci.core :as sci])) (defn- internal-default-layout "The default template if no `layout` key is specified. @@ -51,10 +52,7 @@ (= action :file) (markup/to-html (file :as-edn)) - ;; render a headline title. - - (and is-headline? (= opts :title)) (let [hl (org/get-headline org-tree action)] (-> hl :children first markup/to-html)) @@ -77,11 +75,22 @@ (= action :logbook-polyline) (org/poly-line (-> file :meta :logbook) opts) - :else - (str "DEBUG: Incorrect use of `render` function in template: -
action: => " action " << is this a valid value? -
opts: => " opts " << is this a valid value? " - "
"))))) + ;; render a table of contents + (= action :toc) + (let [toc (-> file :meta :toc) ; get the toc for hte file. + firn_toc (sci/eval-string (org/get-keyword file "FIRN_TOC")) ; read in keyword for overrides + opts (or firn_toc opts {})] ; apply most pertinent options. + (when (seq toc) + (markup/make-toc toc opts))) + + :else ; error message to indicate incorrect use of render. + (str "
" + "
Render Error.
" + "
Incorrect use of `render` function in template: +
action: => " action " << is this a valid value? +
opts: => " opts " << is this a valid value? " + "
" + "
"))))) (defn prepare "Prepare functions and data to be available in layout functions. @@ -111,3 +120,4 @@ [config file layout] (let [selected-layout (get-layout config file layout)] (h/html (selected-layout (prepare config file))))) + diff --git a/clojure/src/firn/markup.clj b/clojure/src/firn/markup.clj index a04dc91d..e182f0b4 100644 --- a/clojure/src/firn/markup.clj +++ b/clojure/src/firn/markup.clj @@ -6,7 +6,99 @@ (declare to-html) -;; Renderers +;; Feature: Table of Contents -------------------------------------------------- + +(defn make-toc-helper-reduce + "(ಥ﹏ಥ) Yeah. So. See the docstring for make-toc. + Basically, this is a bit of a nightmare. This turns a flat list into a tree + So that we can property create nested table of contents." + [{:keys [out prev min-level] :as acc} curr] + (cond + ;; top level / root headings. + (or (empty? out) (= min-level (curr :level))) + (let [with-meta (assoc curr :next-sibling [:out (count out)]) + with-meta (assoc with-meta :next-child [:out (count out) :children])] + (-> acc + (update :out conj with-meta) + (assoc :prev with-meta))) + + ;; if the new items level >= prev item, go to the last item in out + ;; iterate through children, and try and find `prev`, when you do, collect "path to prev" + ;; if/when you do update the child list. + (> (curr :level) (prev :level)) + (let [parent-path (count (get-in acc (prev :next-child))) + with-meta (assoc curr :next-sibling (prev :next-child)) + with-meta (assoc with-meta :next-child (conj (prev :next-child) parent-path :children))] + (-> acc + (update-in (prev :next-child) conj with-meta) + (assoc :prev with-meta))) + + (= (curr :level) (prev :level)) + (let [parent-path (count (get-in acc (prev :next-sibling))) + with-meta (assoc curr :next-sibling (prev :next-sibling)) ;; if more, add children, if equal, conj onto children. + with-meta (assoc with-meta :next-child (conj (prev :next-sibling) parent-path :children))] ;; if more, add children, if equal, conj onto children. + (-> acc + (update-in (prev :next-sibling) conj with-meta) + (assoc :prev with-meta))) + + (< (curr :level) (prev :level)) + (let [difference (- (prev :level) (curr :level)) ; if we are on level 5, and the next is level 3... + diff-to-take (* difference 2) ; we need to take (5 - 3 ) * 2 = 4 items off the last :next-sibling + ;; HACK: we can use the prev-elements :next-sibling path and chop N + ;; elements off the ending based on our heading's leve; which gives us + ;; the path to conj onto. + path (vec (drop-last diff-to-take (prev :next-sibling))) + parent-path (count (get-in acc path)) + with-meta (assoc curr :next-sibling path) ;; if more, add children, if equal, conj onto children. + with-meta (assoc with-meta :next-child (conj path parent-path :children))] + (-> acc + (update-in path conj with-meta) + (assoc :prev with-meta))) + + :else + (do (println "Something has gone wrong. ") acc))) + +(defn toc->html + [toc kind] + (->> toc + (map (fn [x] + (if (empty? (x :children)) + [:li + [:a {:href (x :anchor)} (x :text)]] + [:li + [:a {:href (x :anchor)} (x :text)] + [kind (toc->html (x :children) kind)]]))))) + +(defn make-toc + "toc: a flattened list of headlines with a :level value of 1-> N: + [{:level 1, :text 'Process', :anchor '#process'} {:level 2, :text 'Relevance', :anchor '#relevance'}] + + We conditonally thread the heading, passing in configured values, such as + where to start the table of contents (at a specific headline?) + or unto what depth we want the headings to render." + ([toc] + (make-toc toc {})) + ([toc {:keys [headline depth list-type exclusive?] + :or {depth nil list-type :ol} + :as opts}] + (let [s-h (u/find-first #(= (% :text) headline) toc) ; if user specified a heading to start at, go find it. + toc (cond->> toc ; apply some filtering to the toc, if params are passed in. + depth (filter #(<= (% :level) depth)) ; if depth; keep everything under that depth. + headline (drop-while #(not= s-h %)) ; drop everything up till the selected heading we want. + headline (u/take-while-after-first ; remove everything that's not a child of the selected heading. + #(> (% :level) (s-h :level))) + exclusive? (drop 1)) ; don't include selected heading; just it's children.) + + min-level (if (seq toc) (:level (apply min-key :level toc)) 1) ; get the min level for calibrating the reduce. + toc-cleaned (->> toc + (map #(assoc % :children [])) ; create a "children" key on every item.) + (reduce make-toc-helper-reduce {:out [] :prev nil :min-level min-level}) + :out)] + + (if (empty? toc-cleaned) nil + (into [list-type] (toc->html toc-cleaned list-type)))))) + +;; General Renderers ----------------------------------------------------------- (defn date->html [v] @@ -33,26 +125,13 @@ [:span.firn-img-caption desc]] [:img {:src path}])) -(defn clean-anchor - "converts `::*My Heading` => #my-heading - NOTE: This could be a future problem area; ex: forwars slashes have to be - replaces, otherwise they break the html rendering, thus - 'my heading / example -> my-heading--example - Future chars to watch out for: `>` `<` `&` `!`" - [anchor] - (str "#" (-> anchor - (s/replace #"::\*" "") - (s/replace #"\/" "") - (s/replace #" " "-") - (s/lower-case)))) - (defn internal-link-handler "Takes an org link and converts it into an html path." [org-link] (let [regex #"(file:)(.*)\.(org)(\:\:\*.+)?" res (re-matches regex org-link) anchor-link (last res) - anchor-link (when anchor-link (-> res last clean-anchor))] + anchor-link (when anchor-link (-> res last u/clean-anchor))] (if anchor-link (str "./" (nth res 2) anchor-link) (str "./" (nth res 2))))) @@ -109,10 +188,10 @@ keywrd (v :keyword) priority (v :priority) value (v :value) - parent {:type "headline" :level level :children [v]} + parent {:type "headline" :level level :children [v]} ; reconstruct the parent so we can pull out the content. heading-priority (u/str->keywrd "span.firn-headline-priority.firn-headline-priority__" priority) heading-keyword (u/str->keywrd "span.firn-headline-keyword.firn-headline-keyword__" keywrd) - heading-anchor (-> parent org/get-headline-helper clean-anchor) + heading-anchor (org/make-headline-anchor parent) heading-id+class #(u/str->keywrd "h" % heading-anchor ".firn-headline.firn-headline-" %) h-level (case level 1 (heading-id+class 1) diff --git a/clojure/src/firn/org.clj b/clojure/src/firn/org.clj index c186d605..3587502e 100644 --- a/clojure/src/firn/org.clj +++ b/clojure/src/firn/org.clj @@ -4,7 +4,8 @@ Which are created by the rust binary." (:require [clojure.java.shell :as sh] [clojure.string :as s] - [firn.util :as u]) + [firn.util :as u] + [firn.org :as org]) (:import iceshelf.clojure.rust.ClojureRust) (:import (java.time LocalDate))) @@ -46,9 +47,10 @@ (for [child title-children] (s/trim (case (:type child) - "text" (get-trimmed-val child :value) - "link" (get-trimmed-val child :desc) - "code" (get-trimmed-val child :value) + "text" (get-trimmed-val child :value) + "link" (get-trimmed-val child :desc) + "code" (get-trimmed-val child :value) + "verbatim" (get-trimmed-val child :value) ""))))))) (defn get-headline @@ -65,6 +67,12 @@ (let [headline (get-headline tree name)] (update headline :children (fn [d] (filter #(not= (:type %) "title") d))))) +(defn make-headline-anchor + "Takes a headline data structure and returns the id 'anchored' for slugifying + TODO: test me." + [node] + (-> node get-headline-helper u/clean-anchor)) + (defn parsed-org-date->unix-time "Converts the parsed org date (ex: [2020-04-27 Mon 15:39] -> 1588003740000) and turns it into a unix timestamp." @@ -78,8 +86,25 @@ (u/print-err! :warning (str "Failed to parse the logbook for file:" "<<" name ">>" "\nThe logbook may be incorrectly formatted.\nError value:" e)) "???")))) -;; -- stats -- +;; NOTE: These should be in File.clj, but they are not due to a circular dependency. +;; -- + +(defn get-keywords + "Returns a list of org-keywords from a file. All files must have keywords." + [f] + (let [expected-keywords (get-in f [:as-edn :children 0 :children])] + (if (= "keyword" (:type (first expected-keywords))) + expected-keywords + (u/print-err! :error "The org file <<" (f :name) ">> does not have 'front-matter' Please set at least the #+TITLE keyword for your file.")))) +(defn get-keyword + "Fetches a(n org) #+keyword from a file, if it exists." + [f keywrd] + (->> f get-keywords (u/find-first #(= keywrd (:key %))) :value)) + +;; -- + +;; -- stats -- (defn- find-day-to-update [calendar-year log-entry] @@ -90,7 +115,7 @@ (defn- update-logbook-day "Updates a day in a calander from build-year with logbook data." [{:keys [duration] :as log-entry}] - (fn [{:keys [log-count logs-raw log-sum day] :as cal-day}] + (fn [{:keys [log-count logs-raw log-sum] :as cal-day}] (let [log-count (inc log-count) logs-raw (conj logs-raw log-entry) log-sum (u/timestr->add-time log-sum duration)] @@ -123,17 +148,17 @@ ;; Rendered charts: +;; TODO This should be in markup, as it's spitting out html (defn poly-line "Takes a logbook, formats it so that it can be plotted along a polyline." ([logbook] (poly-line logbook {})) ([logbook {:keys [width height stroke stroke-width] - :or {width 365 height 100 stroke "#0074d9" stroke-width 1} - :as opts}] + :or {width 365 height 100 stroke "#0074d9" stroke-width 1}}] [:div (for [[year year-of-logs] (logbook-year-stats logbook) - :let [max-log (apply max-key :hour-sum year-of-logs) ;; Don't need this yet. + :let [; max-log (apply max-key :hour-sum year-of-logs) ;; Don't need this yet. ;; This should be measured against the height and whatever the max-log is. g-multiplier (/ height 8) ;; 8 - max hours we expect someone to log in a day fmt-points #(str %1 "," (* g-multiplier (%2 :hour-sum))) diff --git a/clojure/src/firn/util.clj b/clojure/src/firn/util.clj index bb032907..37fa0f33 100644 --- a/clojure/src/firn/util.clj +++ b/clojure/src/firn/util.clj @@ -155,6 +155,12 @@ [f coll] (first (filter f coll))) +(defn take-while-after-first + [pred lst] + (let [head (first lst) + tail (take-while pred (rest lst))] + (concat [head] tail))) + ;; For interception thread macros and enabling printing the passed in value. (def spy #(do (println "DEBUG:" %) %)) @@ -166,10 +172,10 @@ "<2020-05-14 19:11> -> 2020-05-14 19:11" [org-date] (-> org-date - (s/replace #"\]" "") - (s/replace #"\[" "") - (s/replace #"<" "") - (s/replace #">" ""))) + (s/replace #"\]" "") + (s/replace #"\[" "") + (s/replace #"<" "") + (s/replace #">" ""))) (defn org-date->java-date "Converts <2020-02-25 05:51> -> java..." @@ -183,9 +189,9 @@ (defn org-date->ts [org-date] (-> org-date - strip-org-date - (org-date->java-date) - java-date->unix-ts)) + strip-org-date + (org-date->java-date) + java-date->unix-ts)) (defn native-image? "Check if we are in the native-image or REPL." @@ -198,10 +204,26 @@ [& args] (keyword (apply str args))) +(defn clean-anchor + "converts `::*My Heading` => #my-heading + NOTE: This could be a future problem area; ex: forwards slashes have to be + replaced, otherwise they break the html rendering, thus + 'my heading / example -> my-heading--example + Future chars to watch out for: `>` `<` `&` `!`" + [anchor] + (str "#" (-> anchor + (s/replace #"::\*" "") + (s/replace #"\/" "") + (s/replace #"\." "") + (s/replace #" " "-") + (s/lower-case)))) + + ;; Time ------------------------------------------------------------------------ ;; NOTE: timestr->hours-min + timevec->time-str could use better input testing? ;; At the very least, `Integer.` is an opportunity for errors when parsing. + (defn parse-int [number-string] (try (Integer/parseInt number-string) (catch Exception e nil))) diff --git a/clojure/test/firn/file_test.clj b/clojure/test/firn/file_test.clj index d48e347e..c6747743 100644 --- a/clojure/test/firn/file_test.clj +++ b/clojure/test/firn/file_test.clj @@ -45,19 +45,6 @@ (t/is (= (new-file :path) (.getPath ^java.io.File test-file))) (t/is (= (new-file :path-web) "file1"))))) -(t/deftest get-keywords - (t/testing "A file with keywords returns a vector where each item is a map with a key of :type 'keyword'" - (let [file-1 (stub/gtf :tf-1 :processed) - res (sut/get-keywords file-1)] - (doseq [keywrd res] - (t/is (= "keyword" (:type keywrd))))))) - -(t/deftest get-keyword - (t/testing "It returns a keyword" - (let [file-1 (stub/gtf :tf-1 :processed) - res (sut/get-keyword file-1 "FIRN_LAYOUT")] - (t/is (= "default" res))))) - (t/deftest keywords->map (t/testing "A list of keywords gets converted into a map. " (let [file-1 (stub/gtf :tf-1 :processed) diff --git a/clojure/test/firn/layout_test.clj b/clojure/test/firn/layout_test.clj index 4d02ef46..ee45a2d4 100644 --- a/clojure/test/firn/layout_test.clj +++ b/clojure/test/firn/layout_test.clj @@ -2,7 +2,7 @@ (:require [firn.layout :as sut] [firn.stubs :as stub] [clojure.test :as t] - [firn.file :as file] + [firn.org :as org] [firn.build :as build])) (t/deftest prepare @@ -15,6 +15,6 @@ (t/testing "The tf-layout file returns a sci function.") (let [test-file (stub/gtf :tf-layout :processed) sample-config (build/setup (stub/sample-config)) - layout (keyword (file/get-keyword test-file "FIRN_LAYOUT")) + layout (keyword (org/get-keyword test-file "FIRN_LAYOUT")) res (sut/get-layout sample-config test-file layout)] (t/is (= sci.impl.vars.SciVar (type res))))) diff --git a/clojure/test/firn/markup_test.clj b/clojure/test/firn/markup_test.clj index 1b3c9851..8433f8b8 100644 --- a/clojure/test/firn/markup_test.clj +++ b/clojure/test/firn/markup_test.clj @@ -50,13 +50,51 @@ (t/is (= res1 "./foo")) (t/is (= res2 "./foo#my-headline-link"))))) -(t/deftest clean-anchor - (t/testing "Expected results." - (let [res1 (sut/clean-anchor "foo bar") - res2 (sut/clean-anchor "foo bar baz") - res3 (sut/clean-anchor "foo / bar") - res4 (sut/clean-anchor "foo bar")] - (t/is (= res1 "#foo-bar")) - (t/is (= res2 "#foo-bar-baz")) - (t/is (= res3 "#foo--bar")) - (t/is (= res4 "#foo--------bar"))))) +(t/deftest make-toc + (let [ex1 [{:level 1, :text "Process" :anchor "#process"} + {:level 2, :text "Relevance" :anchor "#relevance"}] + ex1-res [:ol [:li + [:a {:href "#process"} "Process"] + [:ol [[:li [:a {:href "#relevance"} "Relevance"]]]]]] + + ex2 [{:level 1, :text "Process" :anchor "#process"} + {:level 2, :text "Relevance" :anchor "#relevance"} + {:level 3, :text "Level3" :anchor "#level3"} + {:level 2, :text "Level2-again" :anchor "#level2-again"} + {:level 4, :text "Foo" :anchor "#foo"}] + + ex2-res [:ol + [:li + [:a {:href "#process"} "Process"] + [:ol + '([:li + [:a {:href "#relevance"} "Relevance"] + [:ol ([:li [:a {:href "#level3"} "Level3"]])]] + [:li + [:a {:href "#level2-again"} "Level2-again"] + [:ol ([:li [:a {:href "#foo"} "Foo"]])]])]]]] + + (t/testing "expected results, no options." + (let [res (sut/make-toc ex1) + res2 (sut/make-toc ex2)] + (t/is (= res ex1-res)) + (t/is (= res2 ex2-res)))) + (t/testing "Depth limits work." + (let [res-d1 (sut/make-toc ex2 {:depth 1}) + res-d2 (sut/make-toc ex2 {:depth 2}) + expected-d1 [:ol [:li [:a {:href "#process"} "Process"]]] + expected-d2 [:ol + [:li [:a {:href "#process"} "Process"] + [:ol + '([:li [:a {:href "#relevance"} "Relevance"]] + [:li [:a {:href "#level2-again"} "Level2-again"]])]]]] + (t/is (= res-d1 expected-d1)) + (t/is (= res-d2 expected-d2)))) + + (t/testing "headline select works" + (let [res-1 (sut/make-toc ex2 {:headline "Relevance"}) + expected [:ol + [:li + [:a {:href "#relevance"} "Relevance"] + [:ol '([:li [:a {:href "#level3"} "Level3"]])]]]] + (t/is (= res-1 expected)))))) diff --git a/clojure/test/firn/org_test.clj b/clojure/test/firn/org_test.clj index 10d995c2..0ffdfa6b 100644 --- a/clojure/test/firn/org_test.clj +++ b/clojure/test/firn/org_test.clj @@ -71,3 +71,16 @@ (t/is (= (second-of-2020 :log-count) 1)) (t/is (= (second-of-2020 :hour-sum) 0.18)) (t/is (= (-> second-of-2020 :logs-raw count) 1)))) + +(t/deftest get-keywords + (t/testing "A file with keywords returns a vector where each item is a map with a key of :type 'keyword'" + (let [file-1 (stub/gtf :tf-1 :processed) + res (sut/get-keywords file-1)] + (doseq [keywrd res] + (t/is (= "keyword" (:type keywrd))))))) + +(t/deftest get-keyword + (t/testing "It returns a keyword" + (let [file-1 (stub/gtf :tf-1 :processed) + res (sut/get-keyword file-1 "FIRN_LAYOUT")] + (t/is (= "default" res))))) diff --git a/clojure/test/firn/util_test.clj b/clojure/test/firn/util_test.clj index f635cbee..a091aefe 100644 --- a/clojure/test/firn/util_test.clj +++ b/clojure/test/firn/util_test.clj @@ -83,6 +83,17 @@ (let [res (sut/prepend-vec 1 [2 3])] (t/is (= res [1 2 3]))))) +(t/deftest clean-anchor + (t/testing "Expected results." + (let [res1 (sut/clean-anchor "foo bar") + res2 (sut/clean-anchor "foo bar baz") + res3 (sut/clean-anchor "foo / bar") + res4 (sut/clean-anchor "foo bar")] + (t/is (= res1 "#foo-bar")) + (t/is (= res2 "#foo-bar-baz")) + (t/is (= res3 "#foo--bar")) + (t/is (= res4 "#foo--------bar"))))) + ;; -- Time / Date Tests -------------------------------------------------------- (t/deftest timestr->hours-min diff --git a/docs/layout.org b/docs/layout.org index 89e56009..07110b8e 100644 --- a/docs/layout.org +++ b/docs/layout.org @@ -1,6 +1,6 @@ #+TITLE: Layout #+DATE_CREATED: <2020-03-24 Tue> -#+DATE_UPDATED: <2020-05-25 21:05> +#+DATE_UPDATED: <2020-06-14 09:29> #+FILE_UNDER: docs #+FIRN_LAYOUT: docs @@ -136,9 +136,9 @@ file-specific meta-data. Example meta-data: | logbook | A list of logbooks entries per file. | list | | partials | a list of invokable partials in your =partials= folder | list | | render | Enables rendering parts or entirety of an org file. | function | -| site-links | A list of all links across all documents | list | -| site-logs | A list of ALL logbook entries. | list | -| site-map | A list of all files on the wiki | list | +| site-links | A list of all links across all documents | vector | +| site-logs | A list of ALL logbook entries. | vector | +| site-map | A list of all files on the wiki | vector | | title | The org mode file. | string | | meta | A map of metadata about the file (logbook, links, etc) | map | | title | The #+TITLE value of the file. | string | @@ -146,6 +146,7 @@ file-specific meta-data. Example meta-data: | date-updated | The #+DATE_UPDATED value of the file | string | | date-created | The #+DATE_CREATED value of the file | string | | logbook-total | The sum of all the logbook entries | string | +| toc | Table of contents | vector | This may seem like a lot of information to make available to a layout template. And that's because it is. But thanks to destructuring in Clojure, you can make your templates only ask for what they need: @@ -239,6 +240,42 @@ Graphs all logbook entries for the current file in a polyline, generating a char [:div (render :logbook-polyline {:width 365})]]))) #+END_SRC +* Table of Contents +When a file is processed, Firn collects *all* of it's headlines, whether you're +choosing to render the entire file, or just one headline. + +There are several ways you can create table of contents in your files, from simple to more complex use cases. + +1. Render a table of contents for an entire file. + +#+BEGIN_SRC clojure +(defn default + [{:keys [render partials]}] + (let [{:keys [head]} partials] + + (head + [:body + [:div (render :toc)] + [:div (render :file)]]))) +#+END_SRC + +2. Render a table of contents for everything within a specific headline. + +#+BEGIN_SRC clojure +(defn default + [{:keys [render partials]}] + (let [{:keys [head]} partials] + (head + [:body + ;; only renders a table of contents for a single headline's children. + ;; `:eclusive?` means we don't render "Notes"; just headlines that fall under it. + [:div (render :toc {:headline "Notes" + :depth 4 + :exclusive? true})] + [:div (render "Notes")]]))) +#+END_SRC + + * Styling Layouts You can write css as you normally would by placing css files in the =_firn/static/css=