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