seam

Personal wiki toolkit for Emacs
Log | Files | Refs | LICENSE

seam-export.el (17352B)


      1 ;;; seam-export.el --- Seam HTML exporter  -*- lexical-binding: t -*-
      2 
      3 ;; Copyright (C) 2025 Spencer Williams
      4 
      5 ;; Author: Spencer Williams <spnw@plexwave.org>
      6 
      7 ;; SPDX-License-Identifier: GPL-3.0-or-later
      8 
      9 ;; This file is not part of GNU Emacs.
     10 
     11 ;; This program is free software: you can redistribute it and/or modify
     12 ;; it under the terms of the GNU General Public License as published by
     13 ;; the Free Software Foundation, either version 3 of the License, or
     14 ;; (at your option) any later version.
     15 
     16 ;; This program is distributed in the hope that it will be useful,
     17 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
     18 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     19 ;; GNU General Public License for more details.
     20 
     21 ;; You should have received a copy of the GNU General Public License
     22 ;; along with this program.  If not, see <http://www.gnu.org/licenses/>.
     23 
     24 ;;; Commentary:
     25 
     26 ;; Frontend for Seam's HTML exporter.
     27 
     28 ;;; Code:
     29 
     30 (require 'cl-lib)
     31 (require 'mustache)
     32 (require 'ox-ascii)
     33 (require 'ox-org)
     34 (require 'time-date)
     35 (require 'seam-html)
     36 
     37 (defvar seam-export--types nil)
     38 (defvar seam-export--template nil)
     39 (defvar seam-export--template-values nil)
     40 (defvar seam-export--root-path nil)
     41 (defvar seam-export--include-drafts nil)
     42 (defvar seam-export--no-extension nil)
     43 (defvar seam-export--time-format nil)
     44 (defvar seam-export--time-format-dt nil)
     45 (defvar seam-export--time-zone nil)
     46 (defvar seam-export--ignore-same-day-modifications nil)
     47 (defvar seam-export--internal-link-class nil)
     48 (defvar seam-export--backend-options nil)
     49 
     50 (defgroup seam-export nil
     51   "Options for Seam exporter."
     52   :tag "Seam Export"
     53   :group 'seam)
     54 
     55 (defcustom seam-export-alist nil
     56   "Association list used by Seam to determine how to export notes.
     57 
     58 The car of each element is an HTML directory to which Seam will export a
     59 subset of notes.  The cdr is a plist containing any number of these
     60 properties:
     61 
     62   `:types'
     63 
     64     List of note types to export to this directory.  Required.
     65 
     66   `:template-file'
     67 
     68     The HTML template file to be used by the exporter.  If this
     69     is missing, falls back to :template-string,
     70     `seam-export-template-file', or `seam-export-template-string'
     71     in that order.
     72 
     73   `:template-string'
     74 
     75     The HTML template string to be used by the exporter.  If this
     76     is missing, falls back to :template-file,
     77     `seam-export-template-file', or `seam-export-template-string'
     78     in that order.
     79 
     80   `:template-values'
     81 
     82     An alist of template variables and their values.  Values
     83     specified here will take precedence over those in
     84     `seam-export-template-values'.  Defaults to nil.
     85 
     86   `:root-path'
     87 
     88     The root path used for rendering internal links.  Defaults to \"\",
     89     which means all paths are relative.
     90 
     91   `:include-drafts'
     92 
     93     Whether to export draft notes as well.  Defaults to nil.
     94 
     95   `:no-extension'
     96 
     97     Whether to drop the \".html\" file extension in links.  Defaults to
     98     nil.
     99 
    100   `:time-format'
    101 
    102     Human-readable format for template time strings.  Defaults to
    103     the value of `seam-export-time-format'.
    104 
    105   `:time-format-dt'
    106 
    107     Machine-readable format for template time strings.  Defaults
    108     to the value of `seam-export-time-format-dt'.
    109 
    110   `:time-zone'
    111 
    112     Time zone used for template time strings.  Defaults to the
    113     value of `seam-export-time-zone'.
    114 
    115   `:ignore-same-day-modifications'
    116 
    117     Whether the `modified?' template variable should be false if
    118     creation and modification date are on the same day.  Defaults
    119     to the value of `seam-export-ignore-same-day-modifications'.
    120 
    121   `:internal-link-class'
    122 
    123     CSS class name for internal links.  Defaults to the value of
    124     `seam-export-internal-link-class'.
    125 
    126   `:backend-options'
    127 
    128     A plist of extra options passed to the Org HTML backend.  This can be
    129     used to override any of the defaults set in
    130     `seam-export-backend-options'."
    131   :group 'seam-export
    132   :type '(alist :key-type string :value-type plist))
    133 
    134 (defcustom seam-export-template-file nil
    135   "The HTML template file to be used by the exporter.  The template format
    136 is documented at `seam-export-default-template-string'.
    137 
    138 See `seam-export-alist' for more information about specifying templates."
    139   :group 'seam-export
    140   :type '(choice file (const nil)))
    141 
    142 (defvar seam-export-default-template-string
    143   "<!doctype html>
    144 <html lang=\"en\">
    145 <head>
    146 <meta charset=\"utf-8\" />
    147 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
    148 <title>{{title}}</title>
    149 </head>
    150 <body>
    151 <main>
    152 <header>
    153 <h1>{{{raw-title}}}</h1>
    154 <p class=\"modified\">Last modified: <time datetime=\"{{modified-dt}}\">{{modified}}</time></p>
    155 </header>
    156 {{{contents}}}
    157 <section class=\"backlinks\">
    158 <h1>Backlinks</h1>
    159 {{{backlinks}}}
    160 </section>
    161 </main>
    162 </body>
    163 </html>"
    164   "The default HTML template string if no other template is specified.
    165 
    166 It should be plain HTML5.  Several variables are defined which
    167 can be interpolated using Mustache bracket syntax.  {{variable}}
    168 will HTML-escape the interpolated text, while {{{variable}}} will
    169 interpolate it as-is.
    170 
    171   `contents'
    172 
    173    The full HTML contents of the note, sans the title header.
    174 
    175   `title'
    176 
    177    The note's title, in a plain text format suitable for a
    178    <title> tag.
    179 
    180   `raw-title'
    181 
    182    The note's title, in an HTML format suitable for an <h1> tag.
    183 
    184   `slug'
    185 
    186    The note's slug (that is, its filename without any extension).
    187 
    188   `backlinks'
    189 
    190    A list (<ul>) of notes that link to the given note.
    191 
    192   `created'
    193 
    194    The human-readable date that the note was created.  See
    195    `seam-export-time-format'.
    196 
    197   `created-dt'
    198 
    199    The machine-readable date that the note was created.  See
    200    `seam-export-time-format-dt'.
    201 
    202   `modified'
    203 
    204    The human-readable date that the note was last modified.  See
    205    `seam-export-time-format'.
    206 
    207   `modified-dt'
    208 
    209    The machine-readable date that the note was last modified.
    210    See `seam-export-time-format-dt'.
    211 
    212   `modified?'
    213 
    214    When used as a block, this will render only when the creation
    215    and modification dates are not the same.")
    216 
    217 (defcustom seam-export-template-string seam-export-default-template-string
    218   "The HTML template string to be used by the exporter.  The template
    219 format is documented at `seam-export-default-template-string'. 
    220 
    221 See `seam-export-alist' for more information about specifying templates."
    222   :group 'seam-export
    223   :type '(choice string (const nil)))
    224 
    225 (defcustom seam-export-template-values nil
    226   "An alist of (VAR . VALUE) pairs, where VAR is a string naming a
    227 template variable, and VALUE is the value to be used when
    228 interpolating that variable.  See the mustache.el docs for more
    229 information."
    230   :group 'seam-export
    231   :type '(alist :key-type string :value-type sexp))
    232 
    233 (defcustom seam-export-time-format "%e %B %Y"
    234   "Human-readable format for template time strings.  Passed to
    235 `format-time-string'."
    236   :group 'seam-export
    237   :type 'string)
    238 
    239 (defcustom seam-export-time-format-dt "%Y-%m-%d"
    240   "Machine-readable format for template time strings.  Meant to be used in
    241 the datetime attribute of <time>.  Passed to `format-time-string'."
    242   :group 'seam-export
    243   :type 'string)
    244 
    245 (defcustom seam-export-time-zone t
    246   "Time zone used for template time strings.  Passed to
    247 `format-time-string'."
    248   :group 'seam-export
    249   :type 'sexp)
    250 
    251 (defcustom seam-export-ignore-same-day-modifications t
    252   "When non-nil, the `modified?' template variable will evaluate to
    253 false if creation and modification date are on the same day.")
    254 
    255 (defcustom seam-export-internal-link-class nil
    256   "CSS class name to use for internal links (i.e., links to other Seam
    257 notes)."
    258   :group 'seam-export
    259   :type 'string)
    260 
    261 (defvar seam-export-backend-options
    262   (list
    263    :html-container "article"
    264    :html-doctype "html5"
    265    :html-html5-fancy t
    266    :html-text-markup-alist
    267    '((bold . "<strong>%s</strong>")
    268      (code . "<code>%s</code>")
    269      (italic . "<em>%s</em>")
    270      (strike-through . "<s>%s</s>")
    271      (underline . "<span class=\"underline\">%s</span>")
    272      (verbatim . "<code>%s</code>"))
    273    :html-toplevel-hlevel 1
    274    :html-use-infojs nil
    275    :section-numbers nil
    276    :time-stamp-file nil
    277    :with-smart-quotes t
    278    :with-toc nil))
    279 
    280 (cl-defmacro seam-export--export-to-string ((&key backend) &rest body)
    281   (declare (indent 1))
    282   (let ((buf (gensym)))
    283     `(let ((,buf (generate-new-buffer " *seam-export*")))
    284        (unwind-protect
    285            (progn (with-temp-buffer
    286                     ,@body
    287                     ;; This let prevents Org from popping up a window.
    288                     (let ((org-export-show-temporary-export-buffer nil))
    289                       (org-export-to-buffer ,backend ,buf nil nil nil t seam-export--backend-options nil)))
    290                   (with-current-buffer ,buf
    291                     (buffer-string)))
    292          (kill-buffer ,buf)))))
    293 
    294 (defun seam-export--convert-string (backend s)
    295   "Export Org string using the given Org exporter backend."
    296   (seam-export--export-to-string (:backend backend)
    297     (insert s)))
    298 
    299 (defun seam-export--org-to-html (s)
    300   "Convert single-line Org string to HTML via Org exporter."
    301   (string-remove-prefix
    302    "<p>\n"
    303    (string-remove-suffix
    304     "</p>\n"
    305     (seam-export--convert-string 'seam s))))
    306 
    307 (defun seam-export--org-to-text (s)
    308   "Convert single-line Org string to plain text via Org exporter."
    309   (string-chop-newline
    310    (let ((org-ascii-charset 'utf-8))
    311      (seam-export--convert-string 'ascii s))))
    312 
    313 (defun seam-export--get-props (file props)
    314   (with-temp-buffer
    315     (insert-file-contents file)
    316     (when (re-search-forward "^\\* " nil t)
    317       (org-mode)
    318       (cl-loop for prop in props
    319                collect (org-element-property prop (org-element-at-point))))))
    320 
    321 (defun seam-export--generate-backlinks (file)
    322   (seam-export--export-to-string (:backend 'seam)
    323     (let ((files (cl-sort
    324                   (let ((seam--subset seam-export--types))
    325                     (cl-loop for x in (seam-get-links-to-file file)
    326                              collect (cons (seam-get-title-from-file x) x)))
    327                   #'string<
    328                   :key #'car)))
    329       (when files
    330         (cl-loop for (title . file) in files
    331                  do (insert (format "- [[seam:%s][%s]]\n" (file-name-base file) title)))))))
    332 
    333 ;;; This was copied from time-date.el, with the addition of a ZONE
    334 ;;; argument.
    335 (defun seam-export--time-to-days (time &optional zone)
    336   "The absolute pseudo-Gregorian date for TIME, a time value.
    337 The absolute date is the number of days elapsed since the imaginary
    338 Gregorian date Sunday, December 31, 1 BC."
    339   (let* ((tim (decode-time time zone))
    340 	       (year (decoded-time-year tim)))
    341     (+ (time-date--day-in-year tim)     ;	Days this year
    342        (* 365 (1- year))                ;	+ Days in prior years
    343        (/ (1- year) 4)                  ;	+ Julian leap years
    344        (- (/ (1- year) 100))            ;	- century years
    345        (/ (1- year) 400))))
    346 
    347 (defun seam-export--note-to-html (note-file html-directory)
    348   (seam-ensure-directory-exists html-directory)
    349   (cl-destructuring-bind (created-prop modified-prop)
    350       (seam-export--get-props note-file '(:SEAM_CREATED :SEAM_MODIFIED))
    351     (let* ((html-file (file-name-concat html-directory
    352                                         (concat (seam-get-slug-from-file-name note-file) ".html")))
    353            (modified
    354             (or (ignore-errors (parse-iso8601-time-string modified-prop))
    355                 (file-attribute-modification-time
    356                  (file-attributes note-file))))
    357            (created
    358             (or (ignore-errors (parse-iso8601-time-string created-prop))
    359                 modified)))
    360       (with-temp-buffer
    361         (insert
    362          (mustache-render
    363           seam-export--template
    364           (append
    365            seam-export--template-values
    366            seam-export-template-values
    367            `(("title" .
    368               ,(seam-export--org-to-text
    369                 (seam-get-title-from-file note-file)))
    370              ("raw-title" .
    371               ,(seam-export--org-to-html
    372                 (seam-get-title-from-file note-file)))
    373              ("slug" .
    374               ,(seam-get-slug-from-file-name note-file))
    375              ("created" .
    376               ,(format-time-string
    377                 seam-export--time-format
    378                 created
    379                 seam-export--time-zone))
    380              ("created-dt" .
    381               ,(format-time-string
    382                 seam-export--time-format-dt
    383                 created
    384                 seam-export--time-zone))
    385              ("modified" .
    386               ,(format-time-string
    387                 seam-export--time-format
    388                 modified
    389                 seam-export--time-zone))
    390              ("modified-dt" .
    391               ,(format-time-string
    392                 seam-export--time-format-dt
    393                 modified
    394                 seam-export--time-zone))
    395              ("modified?" .
    396               ,(lambda (template context)
    397                  (unless (cond
    398                           (seam-export--ignore-same-day-modifications
    399                            (= (seam-export--time-to-days
    400                                created
    401                                seam-export--time-zone)
    402                               (seam-export--time-to-days
    403                                modified
    404                                seam-export--time-zone)))
    405                           (t
    406                            (equal created modified)))
    407                    (mustache-render template context))))
    408              ("contents" .
    409               ,(seam-export--export-to-string (:backend 'seam)
    410                  (insert-file-contents note-file)
    411                  (re-search-forward "^\\* ")
    412                  (org-mode)            ;Needed for `org-set-property'.
    413                  (org-set-property "seam-title-p" "t")))
    414              ("backlinks" .
    415               ,(seam-export--generate-backlinks note-file)))
    416            nil)))
    417         (write-file html-file)))))
    418 
    419 (defun seam-export--file-string (file)
    420   (with-temp-buffer
    421     (insert-file-contents file)
    422     (buffer-string)))
    423 
    424 (defun seam-export-note (file)
    425   (let ((type (seam-get-note-type file))
    426         (draft-p (seam-draft-p file)))
    427     (cl-loop for (dir . plist) in seam-export-alist
    428              do
    429              (let ((types (plist-get plist :types))
    430                    (template-file (plist-get plist :template-file))
    431                    (template-string (plist-get plist :template-string))
    432                    (template-values (plist-get plist :template-values)))
    433                (unless types
    434                  (error "You must specify :types for export"))
    435                (let ((template
    436                       (cond
    437                        (template-file (seam-export--file-string template-file))
    438                        (template-string template-string)
    439                        (seam-export-template-file (seam-export--file-string
    440                                                    seam-export-template-file))
    441                        (seam-export-template-string seam-export-template-string)
    442                        (t (error "You must specify a template for export (see `seam-export-alist')")))))
    443                  (when (and (member type types)
    444                             (or (not (seam-draft-p file))
    445                                 (plist-get plist :include-drafts)))
    446                    (let ((seam-export--types types)
    447                          (seam-export--root-path (cl-getf plist :root-path ""))
    448                          (seam-export--include-drafts (plist-get plist :include-drafts))
    449                          (seam-export--no-extension (plist-get plist :no-extension))
    450                          (seam-export--template template)
    451                          (seam-export--template-values template-values)
    452                          (seam-export--time-format
    453                           (cl-getf plist
    454                                    :time-format
    455                                    seam-export-time-format))
    456                          (seam-export--time-format-dt
    457                           (cl-getf plist
    458                                    :time-format-dt
    459                                    seam-export-time-format-dt))
    460                          (seam-export--time-zone
    461                           (cl-getf plist
    462                                    :time-zone
    463                                    seam-export-time-zone))
    464                          (seam-export--ignore-same-day-modifications
    465                           (cl-getf plist
    466                                    :ignore-same-day-modifications
    467                                    seam-export-ignore-same-day-modifications))
    468                          (seam-export--internal-link-class
    469                           (cl-getf plist
    470                                    :internal-link-class
    471                                    seam-export-internal-link-class))
    472                          (seam-export--backend-options
    473                           (org-combine-plists
    474                            seam-export-backend-options
    475                            (plist-get plist :backend-options))))
    476                      (seam-export--note-to-html file dir))))))))
    477 
    478 (defun seam-export-all-notes ()
    479   "Export all note files as HTML."
    480   (interactive)
    481   (unless seam-export-alist
    482     (error "Nothing to export.  Please configure `seam-export-alist'."))
    483   (dolist (dir (seam-note-subdirectories))
    484     (dolist (file (directory-files dir t seam-note-file-regexp))
    485       (seam-delete-html-files-for-note file)
    486       (seam-export-note file))))
    487 
    488 (provide 'seam-export)
    489 
    490 ;;; seam-export.el ends here