commit 8d23feb581cf0a59780e0df3dabc140fd5a59e77
parent 632728767d80e8ec81c958bb61aa12885b8aa60f
Author: Decay <decay@todayiwilllaunchmyinfantsonintoorbit.com>
Date:   Sat, 17 Feb 2024 21:05:37 -0800
Prerender content
As a performance and resource improvement, prerender post HTML and store
it in the database instead of rendering at load time. Also includes some
basic initial functionality to support draft post/pages
Diffstat:
9 files changed, 179 insertions(+), 89 deletions(-)
diff --git a/src/content.lisp b/src/content.lisp
@@ -8,42 +8,56 @@
   (defvar *page-template* (compile-template* "page.dtl"))
   (defvar *rss-template* (compile-template* "rss.dtl")))
 
+(defun augment-with-rendered-content (entry-obj)
+  (if (not (getf entry-obj :rendered-content))
+      (let ((content (getf entry-obj :content))
+            (augmented-entry (copy-list entry-obj)))
+        (setf (getf augmented-entry :rendered-content) (miniblog.format:markdown content))
+        augmented-entry)
+      entry-obj))
+
+(defun augment-posts-with-rendered-content (posts)
+  (loop for post in posts
+        collect (augment-with-rendered-content post)))
+
 (defun render-posts (posts pages &key stream title root-uri header links
-                                      stylesheet year month archive-date-list
-                                      enable-rss twitter-card collapse-posts-p)
-  (render-template* *posts-template* stream
-                    :posts posts
-                    :pages pages
-                    :title title
-                    :root-uri root-uri
-                    :header header
-                    :links links
-                    :stylesheet stylesheet
-                    :year year
-                    :month month
-                    :archive-date-list archive-date-list
-                    :enable-rss enable-rss
-                    :twitter-card twitter-card
-                    :collapse-posts-p collapse-posts-p))
+                                   stylesheet year month archive-date-list
+                                   enable-rss twitter-card collapse-posts-p)
+  (let ((posts (augment-posts-with-rendered-content posts)))
+    (render-template* *posts-template* stream
+                      :posts posts
+                      :pages pages
+                      :title title
+                      :root-uri root-uri
+                      :header header
+                      :links links
+                      :stylesheet stylesheet
+                      :year year
+                      :month month
+                      :archive-date-list archive-date-list
+                      :enable-rss enable-rss
+                      :twitter-card twitter-card
+                      :collapse-posts-p collapse-posts-p)))
 
 (defun render-page (page path pages &key stream title root-uri header links
-                                                stylesheet year month
-                                                archive-date-list enable-rss
-                                                twitter-card)
-  (render-template* *page-template* stream
-                    :page page
-                    :path path
-                    :pages pages
-                    :title title
-                    :root-uri root-uri
-                    :header header
-                    :links links
-                    :stylesheet stylesheet
-                    :year year
-                    :month month
-                    :archive-date-list archive-date-list
-                    :enable-rss enable-rss
-                    :twitter-card twitter-card))
+                                      stylesheet year month
+                                      archive-date-list enable-rss
+                                      twitter-card)
+  (let ((page (augment-with-rendered-content page)))
+    (render-template* *page-template* stream
+                      :page page
+                      :path path
+                      :pages pages
+                      :title title
+                      :root-uri root-uri
+                      :header header
+                      :links links
+                      :stylesheet stylesheet
+                      :year year
+                      :month month
+                      :archive-date-list archive-date-list
+                      :enable-rss enable-rss
+                      :twitter-card twitter-card)))
 
 (defun render-rss (posts &key stream title link description image-url language
                               copyright managing-editor webmaster category)
diff --git a/src/data.lisp b/src/data.lisp
@@ -28,7 +28,9 @@
           :last-updated-at (mito.dao.mixin:object-updated-at entry)
           :title (entry-title entry)
           :content (entry-content entry)
+          :rendered-content (entry-rendered-content entry)
           :created-by (entry-username entry)
+          :draftp (entry-draftp entry)
           :last-updated-by (entry-last-updated-by entry)
           :link (format-post-link entry))))
 
@@ -42,11 +44,13 @@
           :parent (page-parent entry)
           :title (page-title entry)
           :content (page-content entry)
+          :rendered-content (page-rendered-content entry)
+          :draftp (page-draftp entry)
           :created-by (page-username entry)
           :last-updated-by (page-last-updated-by entry))))
 
-(defun add-entry (title content &key (username "nobody"))
-  (miniblog.db:add-entry title content :username username :transform #'xform))
+(defun add-entry (title content &key rendered-content (username "nobody") draftp)
+  (miniblog.db:add-entry title content :rendered-content rendered-content :draftp draftp :username username :transform #'xform))
 
 (defun get-entry (id)
   "Get entry by id, or nil if the requested id isn't found"
@@ -61,22 +65,26 @@
        (progn ,@body)
        (format t "Post ID ~d not found~%" ,id))))
 
-(defun get-entries (&key year month max-entries)
+(defun get-entries (&key year month max-entries include-drafts-p)
   "Get entries from the database, optionally limited to a date
    range or count"
-  (miniblog.db:get-entries :year year :month month :max-entries max-entries :transform #'xform))
+  (miniblog.db:get-entries :year year :month month :include-drafts-p include-drafts-p :max-entries max-entries :transform #'xform))
 
-(defun update-entry (id title content &key (username "nobody"))
+(defun update-entry (id title content &key rendered-content (username "nobody") draftp)
   "Update entry by id. Returns the updated entry or nil if the id doesn't exist."
-  (miniblog.db:update-entry id title content :username username :transform #'xform))
+  (miniblog.db:update-entry id title content :rendered-content rendered-content :draftp draftp :username username :transform #'xform))
+
+(defun update-entry-rendered-content (id rendered-content &key (username "nobody"))
+  "Updated entry RENDERED-CONTENT by ID. Returns the updated page or NIL if ID doesn't exist."
+  (miniblog.db:update-entry-rendered-content id rendered-content :username username))
 
 (defun delete-entry (id)
   "Delete the specified entry from the database. No-op if the id is invalid."
   (miniblog.db:delete-entry id))
 
-(defun add-page (name title content &key (parent 0) (username "nobody"))
+(defun add-page (name title content &key (parent 0) rendered-content (username "nobody") draftp)
   "Add a new page to the database"
-  (miniblog.db:add-page name title content :parent parent :username username :transform #'xform))
+  (miniblog.db:add-page name title content :parent parent :rendered-content rendered-content :draftp draftp :username username :transform #'xform))
 
 (defun get-page (id)
   "Get page by id, or NIL if the requested id isn't found"
@@ -91,13 +99,17 @@
        (progn ,@body)
        (format t "Page ID ~d not found~%" ,id))))
 
-(defun get-pages (&optional (root-id 0))
+(defun get-pages (&key (root-id 0) include-drafts-p)
   "Get all pages in a tree, or optionally a subtree starting from a given id. Returns the subtree as the first return value and the (unconditionally) full hashtable as the second."
-  (miniblog.db:get-pages :root-id root-id :transform #'xform))
+  (miniblog.db:get-pages :root-id root-id :include-drafts-p include-drafts-p :transform #'xform))
 
-(defun update-page (id name title content &key (username "nobody"))
+(defun update-page (id name title content &key rendered-content (username "nobody") draftp)
   "Update page by id. Returns the updated page or nil if the id doesn't exist."
-  (miniblog.db:update-page id name title content :username username :transform #'xform))
+  (miniblog.db:update-page id name title content :rendered-content rendered-content :username username :transform #'xform :draftp draftp))
+
+(defun update-page-rendered-content (id rendered-content &key (username "nobody"))
+  "Updated page RENDERED-CONTENT by ID. Returns the updated page or NIL if ID doesn't exist."
+  (miniblog.db:update-page-rendered-content id rendered-content :username username))
 
 (defun move-page (id parent)
   "Move a page under a new parent page (or to the root if parent is 0 or NIL)"
diff --git a/src/db.lisp b/src/db.lisp
@@ -8,7 +8,11 @@
    (title :col-type (:varchar 200)
           :accessor entry-title)
    (content :col-type (:varchar 16384)
-            :accessor entry-content))
+            :accessor entry-content)
+   (rendered-content :col-type (or (:varchar 32768) :null)
+                     :accessor entry-rendered-content)
+   (draftp :col-type :boolean
+           :accessor entry-draftp))
   (:metaclass mito:dao-table-class))
 
 (defclass blog-pages ()
@@ -23,7 +27,11 @@
    (title :col-type (:varchar 200)
           :accessor page-title)
    (content :col-type (:varchar 16384)
-            :accessor page-content))
+            :accessor page-content)
+   (rendered-content :col-type (or (:varchar 32768) :null)
+                     :accessor page-rendered-content)
+   (draftp :col-type :boolean
+           :accessor page-draftp))
   (:metaclass mito:dao-table-class)
   (:unique-keys (parent name)))
 
@@ -39,13 +47,20 @@
   (apply #'connect-toplevel params)
   (init-tables))
 
-(defun add-entry (title content &key (username "nobody") (transform #'identity))
+(defun as-sql-bool (bool)
+  (if bool
+      t
+      'nil))
+
+(defun add-entry (title content &key rendered-content (username "nobody") (transform #'identity) draftp)
   "Add a new blog entry to the database"
   (funcall transform (create-dao 'blog-entries
                                  :title title
                                  :content content
+                                 :rendered-content rendered-content
                                  :username username
-                                 :last-updated-by username)))
+                                 :last-updated-by username
+                                 :draftp (as-sql-bool draftp))))
 
 (defun get-raw-entry (id)
   (find-dao 'blog-entries :id id))
@@ -56,7 +71,7 @@
     (if entry
       (funcall transform entry))))
 
-(defun get-entries (&key year month max-entries (transform #'identity))
+(defun get-entries (&key year month max-entries (transform #'identity) include-drafts-p)
   "Get entries from the database, optionally limited to a date
    range or count"
   (labels
@@ -83,9 +98,13 @@
          (format nil "~d-~2,'0d-~2,'0d" end-year end-month 1)))
 
      (where-clause (year month)
-       (if (or year month)
-         (where (:and (:>= :created_at (start-range year month))
-                      (:< :created_at (end-range year month)))))))
+       (let ((date-clauses (when (or year month)
+                             `((:>= :created_at ,(start-range year month))
+                               (:< :created_at ,(end-range year month)))))
+             (drafts-clause (when (not include-drafts-p)
+                              '((:= :draftp "false")))))
+         (if (or date-clauses drafts-clause)
+             (where `(:and ,@date-clauses ,@drafts-clause))))))
 
     (mapcar transform
             (select-dao 'blog-entries
@@ -94,30 +113,44 @@
                         (if max-entries
                           (limit max-entries))))))
 
-(defun update-entry (id title content &key (username "nobody") (transform #'identity))
+(defun update-entry (id title content &key rendered-content (username "nobody") (transform #'identity) draftp)
   "Update entry by id. Returns the updated entry or nil if the id doesn't exist."
   (let ((entry (get-raw-entry id)))
     (if entry
       (funcall transform
                (progn (setf (entry-title entry) title)
                       (setf (entry-content entry) content)
+                      (setf (entry-rendered-content entry) rendered-content)
                       (setf (entry-last-updated-by entry) username)
+                      (setf (entry-draftp entry) (as-sql-bool draftp))
                       (save-dao entry)
                       entry)))))
 
+(defun update-entry-rendered-content (id rendered-content &key (username "nobody") (transform #'identity))
+  "Updated just the rendered content for an entry by ID. Returns the updated entry or NIL if ID doesn't exist."
+    (let ((entry (get-raw-entry id)))
+      (if entry
+          (funcall transform
+                   (progn (setf (entry-rendered-content entry) rendered-content)
+                          (setf (entry-last-updated-by entry) username)
+                          (save-dao entry)
+                          entry)))))
+
 (defun delete-entry (id)
   "Delete the specified entry from the database. No-op if the id is invalid."
   (let ((entry (get-raw-entry id)))
     (if entry
       (delete-dao entry))))
 
-(defun add-page (name title content &key (parent 0) (username "nobody") (transform #'identity))
+(defun add-page (name title content &key (parent 0) rendered-content (username "nobody") (transform #'identity) draftp)
   "Add a new page to the database"
   (funcall transform (create-dao 'blog-pages
                                  :name name
                                  :parent parent
                                  :title title
                                  :content content
+                                 :rendered-content rendered-content
+                                 :draftp (as-sql-bool draftp)
                                  :username username
                                  :last-updated-by username)))
 
@@ -145,12 +178,17 @@
       (mapcar #'build-tree dao-list)
       entry-table)))
 
-(defun get-pages (&key (root-id 0) (transform #'identity))
+(defun get-pages (&key (root-id 0) (transform #'identity) include-drafts-p)
   "Get all pages in a tree, or optionally a subtree starting from a given id. Returns the subtree as the first return value and the (unconditionally) full hashtable as the second."
-  (let* ((entry-table (build-tree-table (select-dao 'blog-pages) transform)))
+  (let* ((entry-table (build-tree-table
+                       (if include-drafts-p
+                           (select-dao 'blog-pages)
+                           (select-dao 'blog-pages
+                             (where (:= :draftp "false"))))
+                       transform)))
     (values (gethash root-id entry-table) entry-table)))
 
-(defun update-page (id name title content &key (username "nobody") (transform #'identity))
+(defun update-page (id name title content &key rendered-content (username "nobody") (transform #'identity) draftp)
   "Update page by id. Returns the updated page or nil if the id doesn't exist."
   (let ((entry (get-raw-page id)))
     (if entry
@@ -158,10 +196,22 @@
                (progn (setf (page-name entry) name)
                       (setf (page-title entry) title)
                       (setf (page-content entry) content)
+                      (setf (page-rendered-content entry) rendered-content)
                       (setf (page-last-updated-by entry) username)
+                      (setf (page-draftp entry) (as-sql-bool draftp))
                       (save-dao entry)
                       entry)))))
 
+(defun update-page-rendered-content (id rendered-content &key (username "nobody") (transform #'identity))
+  "Updated just the rendered content for a page by ID. Returns the updated page or NIL if ID doesn't exist."
+    (let ((entry (get-raw-page id)))
+      (if entry
+          (funcall transform
+                   (progn (setf (page-rendered-content entry) rendered-content)
+                          (setf (page-last-updated-by entry) username)
+                          (save-dao entry)
+                          entry)))))
+
 (defun move-page-dao (entry parent)
   (setf (page-parent entry) parent)
   (save-dao entry)
diff --git a/src/format.lisp b/src/format.lisp
@@ -10,15 +10,21 @@
   "Long format datetime, like:
    Monday, February 3rd 2020 at 2:46 PM PST")
 
-(defun markdown (content &optional collapse-link)
-  "Translate CONTENT with 3bmd. If EXPAND is true then render the full post, otherwise cut at the first ---."
-  (let* ((md-content (with-output-to-string (os)
-                       (3bmd:parse-string-and-print-to-stream content os)))
-         (rule-pos (search "<hr/>" md-content)))
-    (if (and rule-pos collapse-link)
+(defun truncate-and-emit-read-more (rendered-content collapse-link)
+  "Cut RENDERED-CONTENT (HTML as emitted by 3BMD) at the first <hr/> and replace with a Read more... link."
+  (let ((rule-pos (search "<hr/>" rendered-content)))
+    (if rule-pos
         (concatenate 'string
-                     (subseq md-content 0 rule-pos)
+                     (subseq rendered-content 0 rule-pos)
                      (format nil "<a href=\"~a\">Read more...</a>~%" collapse-link))
+        rendered-content)))
+
+(defun markdown (content &optional collapse-link)
+  "Translate CONTENT with 3bmd. If COLLAPSE-LINK is NIL then unconditionally render the full post, otherwise cut at the first ---."
+  (let ((md-content (with-output-to-string (os)
+                       (3bmd:parse-string-and-print-to-stream content os))))
+    (if collapse-link
+        (truncate-and-emit-read-more md-content collapse-link)
         md-content)))
 
 (defun rfc-822-format (datetime &optional (tz *default-timezone*))
@@ -104,6 +110,9 @@
 (def-filter :markdown (content &optional collapse-link)
   (markdown content (when collapse-link (djula::resolve-variable-phrase (djula::parse-variable-phrase collapse-link)))))
 
+(def-filter :read-more (content collapse-link)
+  (truncate-and-emit-read-more content (djula::resolve-variable-phrase (djula::parse-variable-phrase collapse-link))))
+
 (def-filter :strip-html (content)
   (format nil "~{~A~}"
           (mapcar
diff --git a/src/miniblog.lisp b/src/miniblog.lisp
@@ -245,6 +245,7 @@
            (new-entry (miniblog.data:add-entry
                         (or title "Untitled")
                         content
+                        :rendered-content (miniblog.format:markdown content)
                         :username (get-username)))
            (year-month (miniblog.content:year-month-of-entry new-entry))
            (year (car year-month))
@@ -270,6 +271,7 @@
                                 name
                                 (or title "Untitled")
                                 content
+                                :rendered-content (miniblog.format:markdown content)
                                 :parent parent-id
                                 :username (get-username))))
               (if (or regen (= (length uri-components) 1))
@@ -329,6 +331,7 @@
              (content (nth 1 post)))
         (miniblog.data:update-entry
           id title content
+          :rendered-content (miniblog.format:markdown content)
           :username (get-username))
         (let* ((created-at (getf entry :created-at))
                (year (timestamp-year created-at))
@@ -347,6 +350,7 @@
              (content (nth 1 page))
              (new-entry (miniblog.data:update-page
                           id (getf entry :name) title content
+                          :rendered-content (miniblog.format:markdown content)
                           :username (get-username))))
         (if (not regen)
             (let* ((pages-tuple (multiple-value-list (miniblog.data:get-pages)))
@@ -452,7 +456,7 @@
                    (format t "~v{~a~:*~}~a (ID ~d) - \"~a\" ~a~%" depth '("  ") name id title created-by))
                (loop for entry in children
                      do (traverse entry (1+ depth))))))
-    (let ((pages (miniblog.data:get-pages start)))
+    (let ((pages (miniblog.data:get-pages :root-id start)))
       (traverse pages 0))))
 
 (defun init-tz ()
diff --git a/src/packages.lisp b/src/packages.lisp
@@ -20,34 +20,35 @@
 
 (defpackage :miniblog.db
   (:use :cl :mito :sxql)
-  (:export blog-entries blog-pages
+  (:export #:blog-entries #:blog-pages
            #:entry-username #:entry-last-updated-by #:entry-title #:entry-content
+           #:entry-rendered-content #:entry-draftp
            #:page-username #:page-last-updated-by #:page-name #:page-parent
-           #:page-title #:page-content
+           #:page-title #:page-content #:page-rendered-content #:page-draftp
            #:init #:init-tables #:add-entry #:get-entry
-           #:get-entries #:update-entry #:delete-entry
+           #:get-entries #:update-entry #:update-entry-rendered-content #:delete-entry
            #:add-page #:get-page
-           #:get-pages #:update-page #:delete-page
+           #:get-pages #:update-page #:update-page-rendered-content #:delete-page
            #:move-page))
 
 (defpackage :miniblog.data
   (:use :cl)
   (:import-from :miniblog.db :blog-entries :blog-pages
                 :entry-username :entry-last-updated-by :entry-title
-                :entry-content
+                :entry-content :entry-rendered-content :entry-draftp
                 :page-username :page-last-updated-by :page-name :page-parent
-                :page-title :page-content)
+                :page-title :page-content :page-rendered-content :page-draftp)
   (:export #:add-entry #:get-entry #:with-entry-id
-           #:get-entries #:update-entry #:delete-entry
+           #:get-entries #:update-entry #:update-entry-rendered-content #:delete-entry
            #:add-page #:get-page #:with-page-id
-           #:get-pages #:update-page #:delete-page
+           #:get-pages #:update-page #:update-page-rendered-content #:delete-page
            #:move-page))
 
 (defpackage :miniblog.content
   (:use :cl :local-time :str)
   (:import-from :djula :add-template-directory :compile-template*
                 :render-template*)
-  (:export *posts-template* *page-template* *rss-template*
+  (:export #:*posts-template* #:*page-template* #:*rss-template*
            #:render-posts #:render-page #:render-rss
            #:make-generator #:make-page-generator
            #:make-rss-generator #:get-archive-date-list
@@ -60,9 +61,9 @@
   (:use :cl :command-line-arguments :local-time)
   (:import-from :alexandria :if-let :when-let*)
   (:export #:initialize #:entry-point
-           *blog-title* *blog-header* *blog-links* *blog-stylesheet*
-           *blog-timezone* *db-config* *public-html* *root-uri*
-           *generator* *page-generator* *rss-generator*
-           *blog-description* *blog-link* *blog-image-url* *blog-language*
-           *blog-category* *blog-copyright* *blog-managing-editor*
-           *blog-webmaster* *twitter-card*))
+           #:*blog-title* #:*blog-header* #:*blog-links* #:*blog-stylesheet*
+           #:*blog-timezone* #:*db-config* #:*public-html* #:*root-uri*
+           #:*generator* #:*page-generator* #:*rss-generator*
+           #:*blog-description* #:*blog-link* #:*blog-image-url* #:*blog-language*
+           #:*blog-category* #:*blog-copyright* #:*blog-managing-editor*
+           #:*blog-webmaster* #:*twitter-card*))
diff --git a/templates/page.dtl b/templates/page.dtl
@@ -7,13 +7,13 @@
       <meta name="twitter:image" content="{{ twitter-card.image }}">
     {% endif %}
     <meta name="twitter:title" content="{{ page.title }}">
-    <meta name="twitter:description" content="{{ page.content|markdown|strip-html|truncatechars:200 }}">
+    <meta name="twitter:description" content="{{ page.rendered-content|strip-html|truncatechars:200 }}">
   {% endif %}
 {% endblock %}
 {% block main %}
-  {% if page.content %}
+  {% if page.rendered-content %}
     <h2>{{ page.title }}</h2>
-    <article>{{ page.content|safe|markdown }}</article>
+    <article>{{ page.rendered-content|safe }}</article>
     <p role="note">
       <small>
         Posted by {{ page.created-by }} on {{ page.created-at-long }}
diff --git a/templates/posts.dtl b/templates/posts.dtl
@@ -8,7 +8,7 @@
     {% endif %}
     {% if posts %}
       <meta name="twitter:title" content="{{ posts.0.title }}">
-      <meta name="twitter:description" content="{{ posts.0.content|markdown|strip-html|truncatechars:200 }}">
+      <meta name="twitter:description" content="{{ posts.0.rendered-content|strip-html|truncatechars:200 }}">
     {% else %}
       <meta name="twitter:title" content="{{ title }}">
     {% endif %}
@@ -27,11 +27,11 @@
       {% if collapse-posts-p %}
         <h2><a href="{{ post.link }}">{{ post.title }}</a></h2>
         <article>
-          {{ post.content|safe|markdown:post.link }}
+          {{ post.rendered-content|safe|read-more:post.link }}
         </article>
       {% else %}
         <h2>{{ post.title }}</h2>
-        <article>{{ post.content|safe|markdown }}</article>
+        <article>{{ post.rendered-content|safe }}</article>
       {% endif %}
       <p role="note">
         <small>
diff --git a/templates/rss.dtl b/templates/rss.dtl
@@ -43,10 +43,10 @@
         <item>
           <title>{{ post.title }}</title>
           <link>{{ link }}#{{ post.id }}</link>
-          <description>{{ post.content|markdown|strip-html|truncatechars:200 }}</description>
+          <description>{{ post.rendered-content|strip-html|truncatechars:200 }}</description>
           <pubDate>{{ post.created-at-rfc-822 }}</pubDate>
           <content:encoded><![CDATA[
-            {{ post.content|markdown|safe }}
+            {{ post.rendered-content|safe }}
           ]]></content:encoded>
         </item>
       {% endfor %}