c499267711c3 lisp

Tables of contents and RSS
[view raw] [browse files]
author Steve Losh <steve@stevelosh.com>
date Wed, 08 Jan 2020 22:05:32 -0800
parents 2b4f7c840c51
children 53b4573ab42b
branches/tags lisp
files content/blog/2008/08/on-leading.markdown content/blog/2009/04/why-people-dont-like-metal.markdown content/blog/2009/11/my-sitesprint-project-lindyhub.markdown content/blog/2010/02/my-extravagant-zsh-prompt.markdown content/blog/2010/09/making-my-site-sing.markdown content/blog/2010/11/keep-calm-and-carry-on.markdown content/blog/2011/05/on-learning-and-teaching.markdown content/links.markdown content/projects.markdown content/resume.markdown generate.lisp static/css/style.css

Changes

--- a/content/blog/2008/08/on-leading.markdown	Wed Jan 08 22:05:24 2020 -0800
+++ b/content/blog/2008/08/on-leading.markdown	Wed Jan 08 22:05:32 2020 -0800
@@ -3,6 +3,7 @@
 :snip "Some of my thoughts on leading after five years of doing it."
 :date "2008-08-01T15:28:33Z"
 :draft nil
+:hidden t
 
 )
 
--- a/content/blog/2009/04/why-people-dont-like-metal.markdown	Wed Jan 08 22:05:24 2020 -0800
+++ b/content/blog/2009/04/why-people-dont-like-metal.markdown	Wed Jan 08 22:05:32 2020 -0800
@@ -3,6 +3,7 @@
 :snip "It’s probably not what you think."
 :date "2009-04-02T22:32:27Z"
 :draft nil
+:hidden t
 
 )
 
--- a/content/blog/2009/11/my-sitesprint-project-lindyhub.markdown	Wed Jan 08 22:05:24 2020 -0800
+++ b/content/blog/2009/11/my-sitesprint-project-lindyhub.markdown	Wed Jan 08 22:05:32 2020 -0800
@@ -3,6 +3,7 @@
 :snip "I want to make something awesome for dancers."
 :date "2009-11-16T19:15:07Z"
 :draft nil
+:hidden t
 
 )
 
--- a/content/blog/2010/02/my-extravagant-zsh-prompt.markdown	Wed Jan 08 22:05:24 2020 -0800
+++ b/content/blog/2010/02/my-extravagant-zsh-prompt.markdown	Wed Jan 08 22:05:32 2020 -0800
@@ -222,10 +222,10 @@
 out = (filled + empty).encode('utf-8')
 import sys
 
-color_green = '%{%}'
-color_yellow = '%{%}'
-color_red = '%{%}'
-color_reset = '%{%}'
+color_green = '%{&#x1b;[32m%}'
+color_yellow = '%{&#x1b;[1;33m%}'
+color_red = '%{&#x1b;[31m%}'
+color_reset = '%{&#x1b;[00m%}'
 color_out = (
     color_green if len(filled) > 6
     else color_yellow if len(filled) > 4
--- a/content/blog/2010/09/making-my-site-sing.markdown	Wed Jan 08 22:05:24 2020 -0800
+++ b/content/blog/2010/09/making-my-site-sing.markdown	Wed Jan 08 22:05:32 2020 -0800
@@ -3,6 +3,7 @@
 :snip "Designing with music."
 :date "2010-09-08T20:10:00Z"
 :draft nil
+:hidden t
 
 )
 
--- a/content/blog/2010/11/keep-calm-and-carry-on.markdown	Wed Jan 08 22:05:24 2020 -0800
+++ b/content/blog/2010/11/keep-calm-and-carry-on.markdown	Wed Jan 08 22:05:32 2020 -0800
@@ -3,6 +3,7 @@
 :snip "You don't always need to be sexy."
 :date "2010-11-05T16:30:00Z"
 :draft nil
+:hidden t
 
 )
 
--- a/content/blog/2011/05/on-learning-and-teaching.markdown	Wed Jan 08 22:05:24 2020 -0800
+++ b/content/blog/2011/05/on-learning-and-teaching.markdown	Wed Jan 08 22:05:32 2020 -0800
@@ -3,6 +3,7 @@
 :snip "Learning one thing isn't enough."
 :date "2011-05-22T17:00:00Z"
 :draft nil
+:hidden t
 
 )
 
--- a/content/links.markdown	Wed Jan 08 22:05:24 2020 -0800
+++ b/content/links.markdown	Wed Jan 08 22:05:32 2020 -0800
@@ -8,12 +8,7 @@
 every now and then when I'm bored.  I figured other people might find it
 interesting too.
 
-* [Blogs](#blogs)
-* [YouTube Channels](#youtube-channels)
-* [Subreddits](#subreddits)
-* [Tools](#tools)
-* [Game Development Beginner Resources](#game-development-beginner-resources)
-* [Common Lisp Utility Libraries](#common-lisp-utility-libraries)
+<div id="toc"/>
 
 Blogs
 -----
--- a/content/projects.markdown	Wed Jan 08 22:05:24 2020 -0800
+++ b/content/projects.markdown	Wed Jan 08 22:05:32 2020 -0800
@@ -3,14 +3,9 @@
  :draft nil)
 
 The following is a list of projects I've created.  They're grouped by
-maintenance status:
+maintenance status.
 
-* [Actively Maintained](#actively-maintained)
-* [Under Development](#under-development)
-* [Looking for Maintainers](#looking-for-maintainers)
-* [Transferred Maintainership](#transferred-maintainership)
-* [Finished](#finished)
-* [Deprecated/Abandoned](#deprecated-abandoned)
+<div id="toc"/>
 
 ## Actively Maintained
 
--- a/content/resume.markdown	Wed Jan 08 22:05:24 2020 -0800
+++ b/content/resume.markdown	Wed Jan 08 22:05:32 2020 -0800
@@ -20,6 +20,8 @@
 [github]: https://github.com/sjl/
 [RU]: https://www.ru.is/
 
+<div id="toc"/>
+
 ## Languages
 
 My current programming language of choice is for most of my projects is [Common
--- a/generate.lisp	Wed Jan 08 22:05:24 2020 -0800
+++ b/generate.lisp	Wed Jan 08 22:05:32 2020 -0800
@@ -1,19 +1,19 @@
 (eval-when (:compile-toplevel :load-toplevel :execute)
   (ql:quickload (list :alexandria :iterate :losh :local-time
-                      :cl-who :3bmd :3bmd-ext-code-blocks)
+                      :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)
+    :with-html-output :htm :str :fmt)
   (:export :toplevel))
 
 (in-package :stevelosh.com)
 
 ;;;; State and Configuration --------------------------------------------------
-(setf (cl-who:html-mode) :html5
-      3bmd-code-blocks:*code-blocks* t)
+(setf 3bmd-code-blocks:*code-blocks* t)
 
 
 ;;;; Utilities ----------------------------------------------------------------
@@ -23,9 +23,22 @@
     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 ()
@@ -36,23 +49,154 @@
 (defun force (delay)
   (funcall delay))
 
+
 (defun cat (&rest strings)
   (apply #'concatenate 'string strings))
 
 
+(defun esc (string)
+  (cl-who:escape-string string))
+
+
+;;;; Tables of Contents -------------------------------------------------------
+(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)))
+
+(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.:
+
+      h2               ((h2 (h3)
+        h3                  (h3))
+        h3              (h2 (h3 (h4)
+      h2                        (h4))
+        h3        →         (h3 (h4
+          h4                      (h5)
+          h4                      (h5))))
+        h3              (h2 (nil (h4)
+          h4                     (h4))
+            h5              (h3))
+            h5
+      h2
+          h4
+          h4
+        h3
+
+  Note the addition of 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)."
+         (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))
+               (multiple-value-bind (header children remaining) (split headers)
+                 (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 draft gallery snip
+   url title gallery snip
+   (draft :initform nil)
+   (hidden :initform nil)
    (mathjax :initform nil)))
 
+
 (defun parse-markdown (path)
-  (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))))
+  (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)
@@ -87,7 +231,7 @@
 
 ;;;; Base Templates -----------------------------------------------------------
 (defmacro css (href &rest more)
-  `(who (:link :href ,href :rel "stylesheet" :type "text/css" :charset "utf-8" ,@more)))
+  `(who (:link :href ,href :rel "stylesheet" :type "text/css" ,@more)))
 
 (defun t/header ()
   (who
@@ -117,9 +261,9 @@
       (:meta :charset "utf-8")
       (css "/static/css/style.css")
       (css "/static/css/print.css" :media "print")
-      (:title (str (if title
-                     (format nil "~A / Steve Losh" title)
-                     "Steve Losh"))))
+      (:title (str (esc (if title
+                          (format nil "~A / Steve Losh" title)
+                          "Steve Losh")))))
      (:body
       (t/header)
       (:hr :class "main-separator")
@@ -134,16 +278,16 @@
 
 ;;;; Page Templates -----------------------------------------------------------
 (defun t/mathjax ()
-  (who (str "<script type=\"text/javascript\" async
-                     src=\"https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js?config=TeX-AMS-MML_HTMLorMML\"></script>")))
+  (who (str "<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 (title item)))
+     (:a :href (url item) (str (esc (title item))))
      (:p :class "date" (str (human-date (date item))))
-     (:p :class "snippet" (str (snip item))))))
+     (:p :class "snippet" (str (esc (snip item)))))))
 
 (defun t/index (items)
   (who (:ol :class "index" (map nil #'t/index-item items))))
@@ -158,7 +302,7 @@
     (who
       (:article
        (when (mathjax entry) (t/mathjax))
-       (:h1 (:a :href (url entry) (str (title entry))))
+       (:h1 (:a :href (url entry) (str (esc (title entry)))))
        (:p :class "date"
         "Posted on " (str (human-date (date entry))) ".")
        (str (force (body entry)))))))
@@ -177,7 +321,7 @@
   (with-base ("photography-gallery" (title gallery))
     (who
       (:article
-       (:h1 (:a :href (url gallery) (str (title gallery))))
+       (:h1 (:a :href (url gallery) (str (esc (title gallery)))))
        (:p :class "date"
         "Last updated on " (str (human-date (date gallery))) ".")
        (str (force (body gallery)))
@@ -189,7 +333,7 @@
   (with-base ("simple" (title content))
     (who
       (:article
-       (:h1 (:a :href (url content) (str (title content))))
+       (:h1 (:a :href (url content) (str (esc (title content)))))
        (:p :class "date"
         "Last updated on " (str (human-date (date content))) ".")
        (str (force (body content)))))))
@@ -212,6 +356,29 @@
       (: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 ---------------------------------------------------------------
 (defparameter *generate-timestamp* (file-write-date "generate.lisp"))
@@ -223,7 +390,7 @@
             (some (lambda (time) (> time prev))
                   (alexandria:ensure-list times))))))
 
-(defun render% (path times thunk)
+(defun render% (path mode times thunk)
   (if (needs-render-p path times)
     (progn
       (format t "Rendering ~S~%" path)
@@ -231,43 +398,54 @@
       (with-open-file (*standard-output* path
                                          :direction :output
                                          :if-exists :supersede)
-        (with-html-output (*standard-output* nil :prologue t)
-          (funcall thunk))))
+        (setf (cl-who:html-mode) mode)
+        (ecase mode
+          (:html5 (write-line "<!DOCTYPE html>"))
+          (:xml (write-line "<?xml version='1.0'?>")))
+        (who (funcall thunk))))
     #+no (format t "Skipping ~S, file is up to date.~%" path)))
 
-(defmacro render (path &optional times &body body)
-  `(render% ,path ,times (lambda () ,@body)))
+(defmacro render (path (&key times (mode :html5)) &body body)
+  `(render% ,path ,mode ,times (lambda () ,@body)))
 
 (defun page/home ()
-  (render "build/index.html" nil
+  (render "build/index.html" ()
     (t/home)))
 
 (defun page/404 ()
-  (render "build/404.html" nil
+  (render "build/404.html" ()
     (t/404)))
 
 (defun page/blog/index (entries)
-  (render "build/blog/index.html" (mapcar #'modified-timestamp entries)
-    (t/blog/index entries)))
+  (render "build/blog/index.html"
+      (:times (mapcar #'modified-timestamp entries))
+    (t/blog/index (remove-if #'hidden entries))))
 
 (defun page/photography/index (galleries)
-  (render "build/photography/index.html" (mapcar #'modified-timestamp 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) (modified-timestamp entry)
+    (render (output-path entry) (:times (modified-timestamp entry))
       (t/blog/entry entry))))
 
 (defun page/photography/galleries (galleries)
   (dolist (gallery galleries)
-    (render (output-path gallery) (modified-timestamp gallery)
+    (render (output-path gallery) (:times (modified-timestamp gallery))
       (t/photography/gallery gallery))))
 
 (defun page/simple (content)
-  (render (output-path content) (modified-timestamp 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 entries)))
+
 
 ;;;; Toplevel -----------------------------------------------------------------
 (defun toplevel ()
@@ -284,9 +462,13 @@
     (page/photography/galleries photo-galleries)
     (page/simple links)
     (page/simple projects)
-    (page/simple resume)))
+    (page/simple resume)
+    (page/rss blog-entries)))
 
 
 #; Scratch --------------------------------------------------------------------
+
 (defparameter *blog-entries*
   (walk "content/blog"))
+
+(plump:parse *x*)
--- a/static/css/style.css	Wed Jan 08 22:05:24 2020 -0800
+++ b/static/css/style.css	Wed Jan 08 22:05:32 2020 -0800
@@ -5,8 +5,8 @@
     text-rendering: optimizeLegibility;
     margin: 10px auto 200px;
     width: 700px;
-    font-family: Palatino, "Palatino Linotype", serif;
-    font-size: 17px;
+    font-family: serif;
+    font-size: 18px;
     line-height: 24px;
     background: #fcfcfc;
     color: black;
@@ -29,7 +29,7 @@
 }
 header > nav {
     float: right;
-    font-size: 17px;
+    font-size: 18px;
     line-height: 30px;
 }
 
@@ -61,7 +61,7 @@
 
 /* Headings ---------------------------------------------------------------- */
 h1, h2, h3, h4, h5, h6 {
-    font-family: HoeflerText-Regular, 'Hoefler Text', 'Goudy Old Style','Palatino', 'Palatino Linotype', serif;
+    /* font-family: HoeflerText-Regular, 'Hoefler Text', 'Goudy Old Style','Palatino', 'Palatino Linotype', serif; */
     font-weight: normal;
 }
 h1 a, h2 a, h3 a, h4 a {
@@ -136,6 +136,17 @@
 a#logo { color: #000000; }
 a#logo:hover { color: #e50053; }
 
+ol.table-of-contents {
+    list-style-type: none;
+    margin: 0px 0px 24px 0px;
+    padding: 0px;
+}
+ol.table-of-contents ol {
+    list-style-type: none;
+    margin: 0px 0px 0px 36px;
+    padding: 0px;
+}
+
 /* Index Pages ------------------------------------------------------------- */
 ol.index {
     list-style-type: none;
@@ -145,6 +156,7 @@
     margin-bottom: 24px;
 }
 ol.index p.snippet {
+    margin-top: 2px;
     font-style: italic;
 }
 ol.index p.date {