mw.lisp @ 90717aee8905

Add static file copying
author Steve Losh <steve@stevelosh.com>
date Tue, 12 Mar 2024 10:24:34 -0400
parents 1af33b2f2616
children d9ae1a68cda2
(eval-when (:compile-toplevel :load-toplevel :execute)
  (ql:quickload (list :alexandria :iterate :losh :local-time :str
                      :cl-who :3bmd :plump :cl-slug :safe-read)
                :silent t))

(defpackage :config
  (:use)
  (:import-from :cl :t :nil)
  (:export
    :title :toc
    :extra-titles :extra-slugs
    :link-color
    :t :nil))

(defpackage :mw
  (:use :cl :iterate :losh)
  (:import-from :cl-who
    :with-html-output :htm :str :fmt)
  (:export :toplevel :build))

(in-package :mw)

;;;; Config/Metadata ----------------------------------------------------------
(defparameter *config* nil)
(defparameter *current-path* nil)

(defclass* config ()
  (title (link-color :initform nil)))

(defun read-dammit (stream &optional (packages '(config)))
  ;; Have to loop because safe-read is bonkers and just stops after every
  ;; newline, jesus.
  (loop (multiple-value-bind (result error)
            (safe-read:safe-read stream packages)
          (cond (result (return result))
                ((eql error :incomplete-input) (progn))
                (t (error "Error reading from ~A: ~A~%" *current-path* error))))))

(defun read-config ()
  (with-open-file (f "index.markdown" :direction :input)
    (let ((config (read-dammit f)))
      (make-instance 'config
        :title (getf config 'config:title)
        :link-color (getf config 'config:link-color)))))

(defun read-metadata (stream)
  (read-dammit stream))

(eval-dammit
  ;; Have to do this at toplevel because otherwise cl-who *inlines the default
  ;; prolog into a write-string call*, jesus.
  (setf (cl-who:html-mode) :html5))


;;;; Static -------------------------------------------------------------------
(defparameter *style.css*
  (alexandria:read-file-into-string "style.css"))

(defun write-stylesheet ()
  (with-open-file (s "build/style.css" :direction :output :if-exists :supersede)
    (write-string *style.css* s)
    (format s "~2%:root {~%")
    (when (link-color *config*)
      (format s "  --link-color: ~A;~%" (link-color *config*)))
    (write-line "}" s)))

(defun has-static-p ()
  (uiop:directory-exists-p "static"))

(defun copy-static-directory ()
  (sh '("rsync" "-a" "static/" "build/static/")))


;;;; Utils --------------------------------------------------------------------
(defmacro delay (&body body)
  `(let (result done)
     (lambda ()
       (if done
         result
         (setf done t result (progn ,@body))))))

(defun force (delay)
  (funcall delay))

(defmacro who (&body body)
  `(with-html-output (*standard-output* nil :indent t) ,@body))

(defmacro whos (&body body)
  `(with-output-to-string (s)
     (with-html-output (s) ,@body)))

(defmacro esc (string)
  `(str (cl-who:escape-string ,string)))

(defun human-date (timestamp)
  (check-type timestamp local-time:timestamp)
  (local-time:format-timestring
    nil timestamp
    :format `(:long-month " " :ordinal-day ", " :year)))

(defun err (&rest format-args)
  (apply #'format *error-output* format-args))


;;;; TOCs ---------------------------------------------------------------------
(defparameter *header-number* 0)

(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))
  root)

(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."
  (plump:first-child
    (plump:parse
      (whos
        (:details :open t :class "table-of-contents"
         (:summary "Table of Contents")
         (:ol (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 (root)
  (when-let ((toc (extract-toc root)))
    (plump:prepend-child root (render-toc toc)))
  root)


;;;; Content ------------------------------------------------------------------
(defclass* page ()
  (input-path output-path
   body title url sort-key slug
   extra-titles extra-slugs))

(defmethod print-object ((o page) s)
  (print-unreadable-object (o s :type t)
    (format s "~A" (title o))))


;;;; Linking ------------------------------------------------------------------
(defparameter *title-table* nil)
(defparameter *slug-table* nil)

(defun build-title-table (pages)
  (iterate
    (for page :in pages)
    (for keys = (cons (title page) (extra-titles page)))
    (dolist (key keys)
      (when-let ((dupe (gethash key result)))
        (err "Duplicate link title ~S:~%  ~A~%  ~A~%"
             key page dupe))
      (collect-hash (key page) :into result :test 'equal))
    (returning result)))

(defun build-slug-table (pages)
  (iterate
    (for page :in pages)
    (for keys = (cons (slug page) (extra-slugs page)))
    (dolist (key keys)
      (when-let ((dupe (gethash key result)))
        (err "Duplicate link slug ~S:~%  ~A~%  ~A~%"
             key page dupe))
      (collect-hash (key page) :into result :test 'equal))
    (returning result)))


(defun link-to-fix-p (node)
  (and (plump:element-p node)
       (string= "a" (plump:tag-name node))
       (let ((href (plump:attribute node "href")))
         (and (not (str:starts-with-p "http://" href))
              (not (str:starts-with-p "https://" href))
              (not (str:starts-with-p "#" href))
              (not (str:ends-with-p ".html" href))))))

(defun fixup-link (node)
  (let* ((text (str:trim (plump:text node)))
         (href (plump:attribute node "href"))
         (page (if (str:emptyp href)
                 (gethash text *title-table*)
                 (gethash href *slug-table*))))
    (if (null page)
      (progn (err "Can't resolve link in ~A: ~A~%"
                  *current-path*
                  (plump:serialize node nil))
             (plump:set-attribute node "href" "TODO")
             (plump:set-attribute node "class" "broken"))
      (plump:set-attribute node "href" (url page)))
    node))

(defun fixup-links (root)
  (plump:traverse root #'fixup-link :test #'link-to-fix-p)
  root)


;;;; Parsing ------------------------------------------------------------------
(defun parse-markdown (path &key (insert-toc t))
  (let ((*current-path* path))
    (with-open-file (stream path :direction :input)
      (read-metadata stream) ; discard metadata this time
      (_ stream
        alexandria:read-stream-content-into-string
        (with-output-to-string (s)
          (3bmd:parse-string-and-print-to-stream _ s))
        plump:parse
        linkify-subheaders
        (if insert-toc (insert-toc _) _)
        fixup-links
        (plump:serialize _ nil)))))

(defun read-page (path)
  (with-open-file (f path :direction :input)
    (let* ((*current-path* path)
           (metadata (read-metadata f))
           (slug (pathname-name path)))
      (make-instance 'page
        :input-path path
        :output-path (format nil "build/~A.html" slug)
        :slug slug
        :url (format nil "~A.html" slug)
        :title (getf metadata 'config:title)
        :extra-titles (ensure-list (getf metadata 'config:extra-titles))
        :extra-slugs (ensure-list (getf metadata 'config:extra-slugs))
        :sort-key (string-downcase (getf metadata 'config:title))
        :body (delay
                (parse-markdown path :insert-toc (getf metadata 'config:toc t)))))))

(defun walk (path)
  (_ (sh (list "find" path "-name" "*.markdown") :result-type 'list)
    (remove-if (curry #'str:ends-with-p "index.markdown") _)
    (mapcar #'read-page _)
    (sort _ #'string< :key #'sort-key)))


;;;; HTML ---------------------------------------------------------------------
(defun a (url text)
  (with-html-output (*standard-output* nil :indent nil)
    (:a :href url (esc text))))

(defun render% (thunk &key page-title)
  (let ((wiki-title (title *config*))
        (now (local-time:now)))
    (with-html-output (*standard-output* nil :prologue t :indent t)
      (:html :lang "en"
       (:head
        (:meta :charset "utf-8")
        (:meta :name "pinterest" :content "nopin")
        (:title (esc (if page-title
                       (format nil "~A / ~A" page-title wiki-title)
                       wiki-title)))
        (:link :href "style.css" :rel "stylesheet" :type "text/css")
        (:link :rel "icon" :href "data:;base64,iVBORw0KGgo="))
       (:body
        (:header
         (:nav (a "page-index.html" "Index"))
         (a "index.html" wiki-title)
         (when page-title
           (with-html-output (*standard-output*)
             (str " / ")
             (a "" page-title))))
        (:main
         (when page-title
           (htm (:h1 (esc page-title))))
         (str (format nil "~2%<!-- begin page content -->~2%"))
         (funcall thunk)
         (str (format nil "~2%<!-- end page content -->~2%")))
        (with-html-output (*standard-output* nil :indent nil)
          (:footer
           (str "Generated ")
           (:time :datetime (local-time:to-rfc3339-timestring now)
            (esc (human-date now)))
           (str "."))))))))

(defmacro render ((path title) &body body)
  `(with-open-file (*standard-output* ,path
                                      :direction :output
                                      :if-exists :supersede)
     (render% (lambda ()
                (with-html-output (*standard-output* nil :indent t) ,@body))
              :page-title ,title)))

(defun write-home ()
  (let ((body (parse-markdown "index.markdown" :insert-toc nil)))
    (render ("build/index.html" nil)
      (:h1 (esc (title *config*)))
      (str body))))

(defun write-index (pages)
  (render ("build/page-index.html" "Page Index")
    (dolist (page pages)
      (htm (a (url page) (title page))
           (:br)))))

(defun write-page (page)
  (render ((output-path page) (title page))
    (str (force (body page)))))


;;;; Toplevel -----------------------------------------------------------------
(defun toplevel ()
  (let ((*config* (read-config)))
    (ensure-directories-exist "build/" :verbose t)
    (when (has-static-p)
      (ensure-directories-exist "build/static/" :verbose t)
      (copy-static-directory))
    (write-stylesheet)
    (let* ((pages (walk "."))
           (*title-table* (build-title-table pages))
           (*slug-table* (build-slug-table pages)))
      (write-home)
      (write-index pages)
      (map nil #'write-page pages))))

(defun build ()
  (sb-ext:save-lisp-and-die "mw"
    :executable t
    :compression nil
    :toplevel 'toplevel))


#; Scratch --------------------------------------------------------------------