generate.lisp @ 578872d23f06
default tip
Links
author |
Steve Losh <steve@stevelosh.com> |
date |
Mon, 09 Sep 2024 10:31:31 -0400 |
parents |
2daa2d6a1d9e |
children |
(none) |
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload (list :alexandria :iterate :losh :local-time
:cl-who :3bmd :3bmd-ext-code-blocks
:plump :cl-slug)
:silent t))
(defpackage :stevelosh.com
(:use :cl :iterate :losh)
(:import-from :cl-who
:with-html-output :htm :str :fmt)
(:export :toplevel :build))
(in-package :stevelosh.com)
;;;; State and Configuration --------------------------------------------------
(defparameter *generate-timestamp* (file-write-date "generate.lisp"))
(defparameter *header-number* 0)
(setf 3bmd-code-blocks:*code-blocks* t)
;;;; Utilities ----------------------------------------------------------------
(defun human-date (timestamp)
(check-type timestamp local-time:timestamp)
(local-time:format-timestring
nil timestamp
:format `(:long-month " " :ordinal-day ", " :year)))
(defun rfc-822-date (timestamp)
(check-type timestamp local-time:timestamp)
(local-time:format-timestring
nil timestamp
:format `(:short-weekday ", " (:day 2) " " :short-month " " :year
" " (:hour 2) ":" (:min 2) ":" (:sec 2) " " :timezone)
:timezone local-time:+gmt-zone+))
(defmacro who (&body body)
`(with-html-output (*standard-output*) ,@body))
(defmacro whos (&body body)
`(with-output-to-string (s)
(with-html-output (s) ,@body)))
(defmacro delay (&body body)
`(let (result done)
(lambda ()
(if done
result
(setf done t result (progn ,@body))))))
(defun force (delay)
(funcall delay))
(defun cat (&rest strings)
(apply #'concatenate 'string strings))
(defun esc (string)
(cl-who:escape-string string))
;;;; Tables of Contents -------------------------------------------------------
(defun subheaderp (node)
(and (plump:element-p node)
(member (plump:tag-name node) '("h2" "h3" "h4" "h5" "h6") :test #'string=)))
(defun replace-node (node html)
(plump:replace-child node (elt (plump:children (plump:parse html)) 0)))
(defun replace-content (node html)
(plump:clear node)
(loop :for child :across (plump:children (plump:parse html))
:do (plump:append-child node child)))
(defun linkify-subheader (node)
(let* ((text (plump:text node))
(id (format nil "s~D-~A" (incf *header-number*) (slug:slugify text)))
(href (concatenate 'string "#" id)))
(plump:set-attribute node "id" id)
(replace-content node (whos (:a :href href (str text))))))
(defun linkify-subheaders (root)
(let ((*header-number* 0))
(plump:traverse root #'linkify-subheader :test #'subheaderp)))
(defun header-level (node)
(digit-char-p (char (plump:tag-name node) 1)))
(defun subheaders (root)
(_ root
(gathering
(plump:traverse _ #'gather :test #'subheaderp))
(mapcar (lambda (node) (cons (header-level node) node)) _)))
(defun split-if (pred list)
"Split list into two pieces, at the point where pred first becomes true.
The first element of the second list will be the point where pred becomes true.
"
(loop :for tail :on list
:for (next . more) = tail
:until (funcall pred next)
:collect next :into head
:finally (return (values head tail))))
(defun extract-toc (root)
"Extract a table of contents from `root` as a tree.
The result will be a tree of `(node &rest children)`, e.g.:
2 3 3 2 3 4 4 → ((h2 (h3) (h3))
(h2 (h3 (h4) (h4))))
This will add dummy headers when the level jumps unexpectedly, to keep the
proper TOC structure even when the source is borked.
"
(labels
((split (headers)
"Split `headers` into the first header, its children, and whatever else remains."
(destructuring-bind (first-header . remaining) headers
(multiple-value-bind (head tail)
(split-if (lambda (header)
(<= (car header) (car first-header)))
remaining)
(values first-header head tail))))
(section (level header children)
"Handle a single section (i.e. one header and its children)."
;; (node . …recur…)
(list* (cdr header) (sections (1+ level) children)))
(sections (level headers)
"Split `headers` into sibling sections, expecting to be at `level`."
(if (null headers)
nil
(let ((l (car (first headers))))
(if (< level l)
(sections level (cons (cons (1- l) nil) headers)) ; dummy
(multiple-value-bind (header children remaining) (split headers)
;; 2 3 3 4 3 2 3 3 2 3 4 4
;; [2 3 3 4 3] 2 3 3 2 3 4 4
;; section recur
(list*
(section level header children)
(sections level remaining))))))))
(sections 2 (subheaders root))))
(defun render-toc (toc)
"Render a TOC tree from `extract-toc` to HTML."
(whos
(:ol :class "table-of-contents"
(recursively ((sections toc))
(unless (null sections)
(destructuring-bind ((header &rest children) . remaining) sections
(htm (:li
(when header
(htm (:a :href (format nil "#~A" (plump:attribute header "id"))
(str (plump:text header)))))
(when children
(htm (:ol (recur children))))))
(recur remaining)))))))
(defun insert-toc (html)
(let* ((root (plump:parse html))
(div (plump:get-element-by-id root "toc")))
(when div
(linkify-subheaders root)
(replace-node div (render-toc (extract-toc root))))
(plump:serialize root nil)))
;;;; Content ------------------------------------------------------------------
(defclass* content ()
(input-path output-path date modified-timestamp body
url title gallery snip
(draft :initform nil)
(hidden :initform nil)
(mathjax :initform nil)))
(defun parse-markdown (path)
(insert-toc
(with-open-file (stream path :direction :input)
(read stream) ; discard metadata this time
(with-output-to-string (string)
(3bmd:parse-string-and-print-to-stream
(alexandria:read-stream-content-into-string stream)
string)))))
(defun read-content (path)
(with-open-file (s path :direction :input)
(let ((metadata (if (string= #\left_parenthesis (peek-char nil s))
(read s)
(return-from read-content nil))))
(callf (getf metadata :date) #'local-time:parse-rfc3339-timestring)
(apply
#'make-instance 'content
:input-path path
:output-path (ppcre:regex-replace "content/(.*)\\.markdown" path "build/\\1/index.html")
:url (ppcre:regex-replace "content/(.*)\\.markdown" path "/\\1/")
:modified-timestamp (file-write-date path)
:body (delay (parse-markdown path))
metadata))))
(defun walk (path)
(_ (sh (list "find" path "-name" "*.markdown") :result-type 'list)
(mapcar #'read-content _)
(remove nil _)
(sort _ #'local-time:timestamp> :key #'date)))
(defun gallery-photos (gallery)
(let* ((slug (gallery gallery))
(photo-path (cat "static/images/photography/photos/" slug))
(thumb-path (cat "static/images/photography/thumbnails/" slug))
(files (sh (list "ls" "-1" photo-path) :result-type 'list)))
(loop :for file :in files
:collect (cons (cat "/" photo-path "/" file)
(cat "/" thumb-path "/" file)))))
;;;; Base Templates -----------------------------------------------------------
(defmacro css (href &rest more)
`(who (:link :href ,href :rel "stylesheet" :type "text/css" ,@more)))
(defun t/header ()
(who
(:header
(:a :id "logo" :href "/" "Steve Losh")
(:nav
(:a :href "/blog/" "Blog")
" - " (:a :href "/projects/" "Projects")
" - " (:a :href "/photography/" "Photography")
" - " (:a :href "/links/" "Links")
" - " (:a :href "/rss.xml" "Feed")))))
(defun t/footer ()
(who
(:footer
(:nav
(:a :href "https://github.com/sjl/" "GitHub")
" ・ " (:a :href "https://mastodon.social/@sjl" "Mastodon")
" ・ " (:a :href "https://instagram.com/thirtytwobirds/" "Instagram")))))
(defun t/base (title page-id body)
(who
(:html :lang "en"
(:head
(:meta :charset "utf-8")
(:meta :name "pinterest" :content "nopin")
(css "/static/css/style.css")
(css "/static/css/print.css" :media "print")
(:title (str (esc (if title
(format nil "~A / Steve Losh" title)
"Steve Losh")))))
(:body
(t/header)
(:hr :class "main-separator")
(:main :id (format nil "page-~A" page-id)
(funcall body))
(:hr :class "main-separator")
(t/footer)))))
(defmacro with-base ((page-class &optional title) &body body)
`(t/base ,title ,page-class (lambda () ,@body)))
;;;; Page Templates -----------------------------------------------------------
(defun t/mathjax ()
(who "<script type='text/javascript' async
src='https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=TeX-AMS-MML_HTMLorMML'></script>"))
(defun t/index-item (item)
(who
(:li :class "item"
(:a :href (url item) (str (esc (title item))))
(:p :class "date" (str (human-date (date item))))
(:p :class "snippet" (str (esc (snip item)))))))
(defun t/index (items)
(who (:ol :class "index" (map nil #'t/index-item items))))
(defun t/blog/index (entries)
(with-base ("blog-index" "Blog")
(t/index entries)))
(defun t/blog/entry (entry)
(with-base ("blog-entry" (title entry))
(who
(:article
(when (mathjax entry) (t/mathjax))
(:h1 (:a :href (url entry) (str (esc (title entry)))))
(:p :class "date"
"Posted on " (str (human-date (date entry))) ".")
(str (force (body entry)))))))
(defun t/photography/index (galleries)
(with-base ("photography-index" "Photography")
(t/index galleries)))
(defun t/photography/gallery/photo (photo)
(destructuring-bind (photo . thumb) photo
(who (:li (:a :href photo (:img :src thumb))))))
(defun t/photography/gallery (gallery)
(with-base ("photography-gallery" (title gallery))
(who
(:article
(:h1 (:a :href (url gallery) (str (esc (title gallery)))))
(:p :class "date"
"Last updated on " (str (human-date (date gallery))) ".")
(str (force (body gallery)))
(:ul
(map nil #'t/photography/gallery/photo (gallery-photos gallery)))))))
(defun t/simple (content)
(with-base ("simple" (title content))
(who
(:article
(:h1 (:a :href (url content) (str (esc (title content)))))
(:p :class "date"
"Last updated on " (str (human-date (date content))) ".")
(str (force (body content)))))))
(defun t/home ()
(with-base ("home")
(who
(:p "Hello, I'm Steve.")
(:p "I'm a photographer, programmer, dancer, and bassist currently living in Ann Arbor, Michigan.")
(:p "If you want to get in touch with me, "
(:a :href "mailto:steve@stevelosh.com" "email")
" is best."))))
(defun t/404 ()
(with-base ("not-found")
(who
(:h1 "404 Not Found")
(:p "If you followed a link to get here, it's bad."))))
(defun t/rss (entries)
(who
(:rss :version "2.0"
(:channel
(:title "Steve Losh")
(:link "https://stevelosh.com/")
(:description "Steve Losh's blog.")
(:language "en-us")
(:copyright "Copyright 2020, Steve Losh")
(:|webMaster| "steve@stevelosh.com (Steve Losh)")
(:|lastBuildDate| (str (rfc-822-date (local-time:now))))
(:generator "Common Lisp")
(:docs "https://validator.w3.org/feed/docs/rss2.html")
(dolist (entry entries)
(let ((link (format nil "https://stevelosh.com~A" (url entry))))
(htm (:item
(:title (str (esc (title entry))))
(:link (str link))
(:guid :|isPermaLink| "true" (str link))
(:description (str (esc (snip entry))))
(:|pubDate| (str (rfc-822-date (date entry)))))))))))
(values))
;;;; Generation ---------------------------------------------------------------
(defun needs-render-p (path timestamps)
(or (null (probe-file path))
(let ((prev (file-write-date path)))
(or (> *generate-timestamp* prev)
(some (lambda (time) (> time prev))
(alexandria:ensure-list timestamps))))))
(defun render% (path mode timestamps thunk)
(when (needs-render-p path timestamps)
(progn
(format t "Rendering ~S~%" path)
(ensure-directories-exist path)
(with-open-file (*standard-output* path :direction :output :if-exists :supersede)
(setf (cl-who:html-mode) mode)
(ecase mode
(:html5 (write-line "<!DOCTYPE html>"))
(:xml (write-line "<?xml version='1.0'?>")))
(who (funcall thunk))))))
(defmacro render (path (&key times (mode :html5)) &body body)
`(render% ,path ,mode ,times (lambda () ,@body)))
(defun page/home ()
(render "build/index.html" ()
(t/home)))
(defun page/404 ()
(render "build/404.html" ()
(t/404)))
(defun page/pubkeys ()
(uiop:copy-file "static/pubkeys" "build/pubkeys"))
(defun page/blog/index (entries)
(render "build/blog/index.html"
(:times (mapcar #'modified-timestamp entries))
(t/blog/index (remove-if #'draft (remove-if #'hidden entries)))))
(defun page/photography/index (galleries)
(render "build/photography/index.html"
(:times (mapcar #'modified-timestamp galleries))
(t/photography/index galleries)))
(defun page/blog/entries (entries)
(dolist (entry entries)
(render (output-path entry) (:times (modified-timestamp entry))
(t/blog/entry entry))))
(defun page/photography/galleries (galleries)
(dolist (gallery galleries)
(render (output-path gallery) (:times (modified-timestamp gallery))
(t/photography/gallery gallery))))
(defun page/simple (content)
(render (output-path content) (:times (modified-timestamp content))
(t/simple content)))
(defun page/rss (entries)
(render "build/rss.xml"
(:times (mapcar #'modified-timestamp entries)
:mode :xml)
(t/rss (remove-if #'draft (remove-if #'hidden entries)))))
;;;; Toplevel -----------------------------------------------------------------
(defun toplevel ()
(let ((blog-entries (walk "content/blog"))
(photo-galleries (walk "content/photography"))
(links (read-content "content/links.markdown"))
(projects (read-content "content/projects.markdown"))
(resume (read-content "content/resume.markdown")))
(page/home)
(page/404)
(page/pubkeys)
(page/blog/index blog-entries)
(page/blog/entries blog-entries)
(page/photography/index photo-galleries)
(page/photography/galleries photo-galleries)
(page/simple links)
(page/simple projects)
(page/simple resume)
(page/rss blog-entries)))
(defun build ()
(sb-ext:save-lisp-and-die "generate" :executable t :toplevel 'toplevel))
#; Scratch --------------------------------------------------------------------