1af33b2f2616

First version

Apparently I forgot to commit the actual program when I originally
made this repo, incredible.
[view raw] [browse files]
author Steve Losh <steve@stevelosh.com>
date Thu, 07 Mar 2024 14:42:49 -0500
parents bcc1fdf7aa10
children 90717aee8905
branches/tags (none)
files README.markdown mw.lisp static/style.css

Changes

--- a/README.markdown	Tue Mar 05 11:32:52 2024 -0500
+++ b/README.markdown	Thu Mar 07 14:42:49 2024 -0500
@@ -7,13 +7,34 @@
 * Make Wiki
 * Markdown Wiki
 
+License: GPLv3.
+
 ## Design
 
 Wiki pages are vanilla Markdown files, because writing in any other format is
 awful and version control is good.  Metadata will be a single Lisp list at the
 beginning of the file, read with `safe-read`.
 
-Linking can be special-cased because it's so common.
+No directories.  Just one big list of Markdown files that get transformed into
+`.html` files one-by-one.  Links should be relative so you can serve them from
+a subdirectory and it'll still work.
+
+The home page is special-cased, its metadata includes metadata for the entire
+wiki (title, link color, etc).
+
+Linking is special-cased because it's so common.  In addition to vanilla
+Markdown links, there's an extra layer of postprocessing:
+
+* `[Some Page]()`: looks up the destination by page title.
+* `[Some Page](some-slug)`: looks up the destination by page slug.
+
+Pages can have `extra-titles` and `extra-slugs`, so you can alias really common
+pages to avoid typing.
 
 The output will be static, vanilla HTML files that can be served with anything.
 No Javascript required.
+
+## TODO
+
+* Static files (e.g. images) in a folder should get copied over.
+* Maybe an actual CLI?
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mw.lisp	Thu Mar 07 14:42:49 2024 -0500
@@ -0,0 +1,402 @@
+(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 *static/style.css*
+  (alexandria:read-file-into-string "static/style.css"))
+
+(defun write-stylesheet ()
+  (with-open-file (s "build/static/style.css" :direction :output :if-exists :supersede)
+    (write-string *static/style.css* s)
+    (format s "~2%:root {~%")
+    (when (link-color *config*)
+      (format s "  --link-color: ~A;~%" (link-color *config*)))
+    (write-line "}" s)))
+
+
+;;;; 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 "static/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)
+    (ensure-directories-exist "build/static/" :verbose t)
+    (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 --------------------------------------------------------------------
--- a/static/style.css	Tue Mar 05 11:32:52 2024 -0500
+++ b/static/style.css	Thu Mar 07 14:42:49 2024 -0500
@@ -28,6 +28,7 @@
 footer {
     margin-top: 10px;
     border-top: 1px solid #ccc;
+    text-align: center;
 }
 
 body {
@@ -60,3 +61,8 @@
 h1 a:hover, h2 a:hover, h3 a:hover, h4 a:hover, h5 a:hover, h6 a:hover {
     color: var(--link-color);
 }
+
+a.broken {
+    color: red;
+    font-style: italic;
+}