"All science is static in the sense that it describes the unchanging aspects of things." Franck Knight.
An opinionated Static-Site Generator associating Clojure, Hiccup, Markdown and TailwindCSS with the following stated goals:
- Use configuration by convention whenever it's possible
- Use Clojure's Hiccup format and pure clojure function for composition and templating
- Use Markdown and provides utility to process markdown effectively (a la Markdoc)
- Good developer experience (at least on MacOS for the moment): Reload the browser's tab(s) displaying the rendered HTML file(s) whenever a change is made to either clojure code or markdown files (
so no dev Server logic in order to keep the code simplechanged my mind on this...see server.clj ns) using AppleScript interacting with Safari. - Tailwind CSS friendly, as it's a very efficient way of doing styling.
- Target static page hosting with a CI/CD mechanism like Cloudflare Pages that will build and host your HTML files (example in section Cloudflare Hosting)
Table of Contents
- Staticly
- Rationale
- Usage
- Engine implementation
- Hosting
- Developer Experience with Tailwind CSS and Hiccup
- Native binary
- Metadata and front-matter
I wanted a versatile blog engine in Clojure that allows me build website and blog in an easy and simple way with the following features:
- Hiccup component based as I found it's a very easy way to build reusable components, utility to convert Tailwind CSS and UI components into hiccup
- Markdown processing with a way to override how each element are rendered (thanks to nextjournal/markdown)
There are three manners of render something with staticly:
- pure Clojure, just output some Hiccup from a
render
function - Single page rendering from a Markdown file with a template
- Multiple page rendering from a Markdown file with several templates (the page for the markdown, pages aggregation for several markdown (all or specific to a tag)). These three rendering ways are described in the next sections.
You write a render
function in your ns, with no argument, that just output a Hiccup data structure, you're free to use any data structure and way of structuring (or not) the content. Then you add the def-render-builder
macro invocation at the end of the namespace that will:
- Export the HTML, from the hiccup returned by the
render
function, in theresources/public
directory with the namespace last name as the filename with the.html
suffix, - Reload the browser's tab(s) that have the namespace first name in the url,
- E.g. the
mywebsite.index
namespace will export the rendered HTML in theresources/public/index.html
file and reload the tab withmywebsite
in its URL,
- E.g. the
- Define a
build!
function in that namespace that will do the export and reload steps describe above.
(ns mywebsite.index)
(require 'defsquare.staticly)
;;YOU write
(defn head []
...)
(defn body []
...)
(defn footer []
...)
(defn render []
[:html {:lang "en"}
(head)
(body)
(footer)])
;;WE export the html, reload the browser and define a `build!` function in that ns
(staticly/def-render-builder)
The idea is to have a markdown file (with a front-matter in Yaml or EDN) associated with the template defined in a Clojure function.
You write a page-template
function in your ns with one argument that outputs hiccup:
- The only argument is a map with the keys representing the markdown:
{:keys [metadata html hiccup raw] :as markdown}
the markdown is pre-processed with hiccup, standard html and metadata from the front matter.
The template is a 1-arg page-template
function with map as an argument representing the markdown: metadata, html, hiccup and raw content, this function should output hiccup.
Staticly provices a macro def-page-builder
you should put at the end of your namespace that will:
- define a
build!
function in the ns to invoke whenever a change is made - Start a watcher thread detecting change in markdown files and invoking the
build!
function then reload the browser tabs
We rely on the great nextjournal/markdown library to be able to customize the emitted HTML from the markdown (see the page-transform
function in the example below). Staticly offers shortcut functions for transforming markdown to hiccup (->hiccup
, normalize
and into-markup
). Here is the list of the available keys to bind your markdown->hiccup renderer with.
- The markdown files must sit in the directory with the same name as your ns last name:
website.pages
means the markdowns are in thepages
directory at the root of your repo - The exported HTML files are named from the markdown file name:
my-specific-content.md
means the HTML is named asmy-specific-content.html
(ns mywebsite.pages
(:require [defsquare.markdown :as md]
[defsquare.staticly :as staticly]
[mywebsite.common :refer [footer page-header menu-entries]]))
(defn page-transform [metadata raw]
(let [metadata (md/parse-metadata-str raw)
data (-> raw md/drop-metadata-str md/parse)]
(md/->hiccup {:heading (fn [_ctx node]
(case (:heading-level node)
1 [:h1 {:class "font-title ml-2 mb-8 underline decoration-solid decoration-accent underline-offset-8"
:id (md/normalize (get-in node [:content 0 :text]))}
(get-in node [:content 0 :text])]
2 [:h2 {:class "font-title text-xl ml-2 mb-8decoration-solid decoration-accent underline-offset-8"
:id (md/normalize (get-in node [:content 0 :text]))}
(get-in node [:content 0 :text])]
[(keyword (str "h" (:heading-level node))) {} (get-in node [:content 0 :text])]))
:bullet-list (partial md/into-markup [:ul])
:list-item (partial md/into-markup [:li {:class "font-body text-gray-700" :style "list-style-type: square"}])
:paragraph (partial md/into-markup [:section {:class "font-body sm:ml-1 ml-2 mt-8 text-gray-700"}])
:plain (partial md/into-markup [:p {:class "font-body text-gray-700"}])}
data)))
(defn page-template [{:keys [metadata html hiccup raw] :as markdown}]
[:html {:lang "en"}
(head (:head metadata))
[:body {:class "antialiased"}
(page-header "img/logo/logo-mywebsite.svg"
(menu-entries ["Services" "Products" "Blog" "Company"] "Services")
[:div
[:span {:class "text-accent"} (:baseline1 metadata)]
[:span {:class "text-gray-100"}
(str " " (:baseline2 metadata))]
[:br {:class "xl:hidden"}]
])
[:div {:class "page container mx-auto"}
[:div {:class "flex flex-wrap mb-16"}
[:div {:class "w-full bg-white leading-normal relative max-w-3xl mx-auto flex-none"}
(page-transform metadata raw)
]]]
(footer)])
(staticly/def-page-builder)
Three "template" functions to implement :
post-template
: template that outputs HTML for a single markdown filehome-template
: template that outputs HTML given all the markdownstag-template
: template that outputs HTML for a given tag (holding a set of markdown files)
Staticly provides a macro def-blog-builder
that:
The engine can be considered as a pipeline from a seq of from
dirs that export to
a single dir.
The from
dirs contains either assets (js, css, img files) that are copied or markdown files that are rendered.
The engine has currently 2 options of input/output for templates:
Input | Output | Key in templates param | Usage |
---|---|---|---|
one file | one output file | :1-1 |
one markdown file gives one HTML file (About page, a landing page) |
multiple files | one output file | :n-1 |
multiple markdown files gives one HTML file (the home of a blog that lists all the posts, the posts for an author) |
| one file | multiple output files | :1-n
|
| multiple files | multiple output files | :n-n
|
Assets (file types in (def copied-filetypes #{"jpg" "png" "svg" "css" "html" "js"}
) are copied with the relative path from the root from
dir to the to
destination dir.
Templates takes an input files in the file types (def rendered-filetypes #{"md" "clj" "cljc" "cljs" "yaml" "json" "edn"})
and return hiccup in a map with keys a string as the path to the file they will be rendered into.
For every input files, each one are given as a map to the template with the following keys:
path
a path object of the filefile
, file objectraw
a string with the raw content of the filetype
a string among the rendered-filetypesmd
,clj
,cljc
,cljs
,yaml
,json
oredn
plus the following keys for:
md
files::hiccup
(for the html or custom transformation see section ) and:metadata
with the EDN content of the YAML front-matteryaml
,json
oredn
: conversion and evaluation under the:data
keysclj
,cljc
orcljs
: evaluation of the file (so the file's symbols are then available in its namespace)
A template then return a map of string for path as key and hiccup as value.
The build!
function is configured with a map with the from
, to
and templates
.
Define a project on Clouflare Pages
Write a build
namespace that will invoke all the included namespace building your content.
E.g.:
(ns mywebsite.build)
(defn -main [& args]
(index/build!)
(blog/build!)
(pages/build!)
(shutdown-agents))
Write a shell script invoking the previous build ns that will be invoked by the Cloudflare's CI (note we just install Clojure on the default Cloudflare image):
#!/bin/bash
curl -O https://download.clojure.org/install/linux-install-1.11.1.1165.sh
chmod +x linux-install-1.11.1.1165.sh
sudo ./linux-install-1.11.1.1165.sh
clojure -m mywebsite.build
if [ $? -eq 0 ]; then
echo "My website successfully built!"
else
echo "Failure during website build, check what's gone wrong!"
exit $?
fi
# don't forget to build the CSS
npx tailwindcss -i ./src/mywebsite/styles.css -o ./resources/public/css/mywebsite.css
Tailwind UI (and Taildwind Templates or various ones - like Cruip - you can find on the web) provides components with their HTML code you can copy easily in your clipboard. To get the Hiccup corresponding to the HTML code you just have to execute the following Clojure code:
(require '[defsquare.hiccup :refer :all])
;;copy the HTML code in your system clipboard
(html->hiccup (paste)) ;;=> [:div {:class "flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8"} [:div ... ]]]
;or you can just convert your clipboard by doing this
(-> (paste)
html->hiccup
str
copy)
;;here is the fully qualified version to avoid any require
(->
(defsquare.clipboard/paste);;paste the system clipboard into a string
(defsquare.hiccup/html->hiccup);;transform that string into Hiccup data structure
(clojure.pprint/pprint);;align and wrap the result
(with-out-str);;into a string
(defsquare.clipboard/copy);;copy back the string with hiccup into the system clipboard
)
Run the script build-plantuml-watcher.sh
to build the plantuml-watcher
native executable, be sure to use the Liberica NIK GraalVM version that include the proper AWT native lib for plantuml PNG rendering feature (use sdkman to easily switch between Oracle's GraalVM and Liberica Nik version).
TODO
TODO