1 ;;; esy-publish.el --- Simple Static Site Generator -*- lexical-binding:t -*-
3 ;; Copyright (C) 2023 Eshel Yaron
5 ;; Author: Eshel Yaron <me@eshelyaron.com>
6 ;; Maintainer: Eshel Yaron <me@eshelyaron.com>
7 ;; Keywords: languages extensions
8 ;; URL: http://git.eshelyaron.com/gitweb/?p=esy-publish.git
9 ;; Package-Version: 0.12.0
10 ;; Package-Requires: ((emacs "28.2"))
12 ;; This file is NOT part of GNU Emacs.
16 ;; Build a static websites from Org files
24 (require 'org-transclusion)
28 (require 'rainbow-delimiters)
30 (defvar esy-publish-did-setup-p nil)
32 (defvar esy-publish--buffers nil)
34 (defvar esy-publish--publishing nil)
36 (defvar esy-publish--local-server-process nil)
38 (defvar esy-publish-elisp-directory
39 (file-name-as-directory
42 (expand-file-name load-file-name)))))
44 (defvar esy-publish-root-directory esy-publish-elisp-directory)
46 (defvar esy-publish-drafts-directory
47 (file-name-as-directory (expand-file-name "drafts"
48 esy-publish-root-directory)))
50 (defvar esy-publish-source-directory
51 (file-name-as-directory (expand-file-name "source"
52 esy-publish-root-directory)))
54 (defvar esy-publish-notes-source-directory
55 (file-name-as-directory (expand-file-name "notes"
56 esy-publish-source-directory)))
58 (defvar esy-publish-posts-source-directory
59 (file-name-as-directory (expand-file-name "posts"
60 esy-publish-source-directory)))
62 (defvar esy-publish-remote-directory
63 "root@direct.eshelyaron.com:/var/www/html")
65 (defvar esy-publish-local-directory
66 (file-name-as-directory (expand-file-name "local"
67 esy-publish-root-directory)))
69 (defvar esy-publish-local-posts-directory
70 (file-name-as-directory (expand-file-name "posts"
71 esy-publish-local-directory)))
73 (defvar esy-publish-local-man-directory
74 (file-name-as-directory (expand-file-name "man"
75 esy-publish-local-directory)))
77 (defvar esy-publish-keywords '("emacs"
84 (defvar esy-publish-notes-completion-history nil)
86 (defconst esy-publish-post-metadata-line
88 "@@html:" "<div class=\"metadata\">" "@@"
89 "Created on [{{{date}}}], "
90 "last updated [{{{modification-time(%Y-%m-%d, t)}}}]"
91 "@@html:" "</div>" "@@"))
93 (defun esy-publish--title-to-file-base-name (title)
94 (downcase (string-join (string-split title (rx (+ (not alnum))) t)
96 (defun esy-publish--dom-to-string (&rest doms)
98 (mapc #'dom-print doms)
101 (defun esy-publish--file-url (file)
103 "https://eshelyaron.com/"
104 (let ((path (file-relative-name file esy-publish-local-directory)))
105 (if (string= (file-name-base file) "index")
106 (file-name-directory path)
110 (defun esy-publish-setup ()
111 (unless esy-publish-did-setup-p
112 (dolist (cell '(("posts" . esy-publish-insert-posts-dblock)
113 ("notes" . esy-publish-insert-notes-dblock)
114 ("links-to-note" . esy-publish-insert-links-to-note-dblock)))
115 (org-dynamic-block-define (car cell) (cdr cell)))
116 (org-link-set-parameters "note"
117 :follow #'esy-publish-follow-note-link
118 :export #'esy-publish-export-note-link
119 :store #'esy-publish-store-note-link
120 :complete #'esy-publish-complete-note-link
121 :insert-description #'esy-publish-describe-note-link
122 :face 'esy-publish-note-link)
123 (function-put 'esy/init-step 'doc-string-elt 2)
124 (setq esy-publish-did-setup-p t)))
127 (defun esy-publish-create-post (title subtitle description keywords)
128 (interactive (list (read-string "Post title: ")
129 (read-string "Post subtitle: ")
130 (read-string "Post description: ")
131 (completing-read-multiple "Post keywords: " esy-publish-keywords)))
133 (let* ((date (format-time-string "%F"))
134 (base (concat date "-" (esy-publish--title-to-file-base-name title)))
135 (file (expand-file-name (file-name-with-extension base "org")
136 esy-publish-drafts-directory)))
137 (if (file-exists-p file)
138 (error "Post already exists!")
141 "#+TITLE: " title "\n"
142 "#+SUBTITLE: " subtitle "\n"
143 "#+DESCRIPTION: " description "\n"
144 "#+KEYWORDS: " (string-join keywords ",") "\n"
146 esy-publish-post-metadata-line "\n")
147 (set-buffer-modified-p nil))))
149 (defun org-dblock-write:posts (params)
150 (let* ((dir (plist-get params :dir))
151 (limit (plist-get params :limit))
152 (all-posts (reverse (directory-files dir nil (rx bos digit (+ any) ".org" eos))))
153 (posts (if limit (take limit all-posts) all-posts)))
155 (let ((file (expand-file-name post dir)))
157 "[file:" (file-relative-name file) "]"
158 "[" (substring post 0 10) " ~ " (org-get-title file) "]"
160 (when (and limit (< limit (length all-posts)))
161 (insert "- [[file:" (file-relative-name dir) "][...older posts]]"))
162 (delete-blank-lines)))
164 (defun esy-publish-insert-posts-dblock (limit)
165 (interactive (list (when current-prefix-arg
166 (prefix-numeric-value current-prefix-arg))))
167 (org-create-dblock (list :name "posts"
168 :dir esy-publish-posts-source-directory
172 (defun org-dblock-write:notes (params)
173 (let* ((dir (plist-get params :dir))
174 (notes (delete "index.org"
175 (directory-files dir nil
176 (rx bos alnum (+ any)
179 (let* ((file (expand-file-name note dir))
180 (buffer (find-file-noselect file))
181 (titles (with-current-buffer buffer
182 (when esy-publish--publishing
183 (push (current-buffer) esy-publish--buffers))
184 (org-collect-keywords '("TITLE" "SUBTITLE"))))
185 (title (car (alist-get "TITLE" titles nil nil #'string=)))
186 (subtitle (car (alist-get "SUBTITLE" titles nil nil #'string=))))
187 (insert "- [[file:" (file-relative-name file) "][" title "]] :: " subtitle "\n")))
188 (delete-blank-lines)))
190 (defun esy-publish-insert-notes-dblock ()
192 (org-create-dblock (list :name "notes"
193 :dir esy-publish-notes-source-directory))
196 (defun org-dblock-write:links-to-note (params)
197 (let* ((dir (plist-get params :dir))
200 (mapcar #'xref-location-group
201 (mapcar #'xref-match-item-location
202 (xref-matches-in-directory (rx "[[note:" (literal (file-name-base (buffer-file-name))) "][")
208 (let* ((full (expand-file-name file dir))
209 (titles (with-current-buffer (find-file-noselect full)
210 (when esy-publish--publishing
211 (push (current-buffer) esy-publish--buffers))
212 (org-collect-keywords '("TITLE" "SUBTITLE"))))
213 (title (car (alist-get "TITLE" titles nil nil #'string=)))
214 (subtitle (car (alist-get "SUBTITLE" titles nil nil #'string=))))
215 (insert "- [[file:" (file-relative-name full) "][" title "]] :: " subtitle "\n")))
216 (delete-blank-lines)))
218 (defun esy-publish-insert-links-to-note-dblock ()
220 (org-create-dblock (list :name "links-to-note"
221 :dir esy-publish-notes-source-directory))
224 (defun esy-publish-follow-note-link (path arg)
225 (org-link-open-as-file
226 (expand-file-name (file-name-with-extension path "org")
227 esy-publish-notes-source-directory)
230 (defun esy-publish-export-note-link (path description backend &optional _info)
231 (when (eq backend 'html)
232 (esy-publish--dom-to-string
233 `(a ((href . ,(concat "/notes/" path ".html"))
234 (class . "note-link")
235 (title . ,(let* ((file (expand-file-name (file-name-with-extension path "org")
236 esy-publish-notes-source-directory))
237 (buffer (find-file-noselect file))
238 (titles (with-current-buffer buffer
239 (org-collect-keywords '("TITLE" "SUBTITLE"))))
240 (title (car (alist-get "TITLE" titles nil nil #'string=)))
241 (subtitle (car (alist-get "SUBTITLE" titles nil nil #'string=))))
242 (concat "Notes about " title " (" subtitle ")"))))
245 (defun esy-publish--note-titles ()
246 (mapcar (lambda (file)
248 (expand-file-name file esy-publish-notes-source-directory))
249 (file-name-base file)))
251 (directory-files esy-publish-notes-source-directory
252 nil (rx bos alnum (+ any)
255 (defun esy-publish-create-empty-note (title)
256 (interactive (list (read-string "Note subject: ")))
257 (let ((base (esy-publish--title-to-file-base-name title)))
260 "#+TITLE: " title "\n"
261 "#+SUBTITLE: " (read-string "Subtitle: ") "\n"
262 "#+DESCRIPTION: Eshel Yaron's notes about " title "\n"
263 "#+KEYWORDS: " (read-string "Keywords: ") "\n"
264 "#+DATE: " (format-time-string "%F") "\n"
266 "* References in published posts" "\n"
267 "#+BEGIN: links-to-note :dir \"" esy-publish-posts-source-directory "\"" "\n"
269 "* References in other notes" "\n"
270 "#+BEGIN: links-to-note :dir \"" esy-publish-notes-source-directory "\"" "\n"
272 (write-file (expand-file-name
273 (file-name-with-extension base "org")
274 esy-publish-notes-source-directory)))
277 (defun esy-publish-complete-note-link (&optional _arg)
278 (let* ((title-name-alist (esy-publish--note-titles))
279 (max-title-width (apply #'max
283 (completion-extra-properties
287 (let ((subtitle (with-current-buffer
290 (file-name-with-extension
296 esy-publish-notes-source-directory))
297 (when esy-publish--publishing
298 (push (current-buffer) esy-publish--buffers))
299 (car (alist-get "SUBTITLE"
300 (org-collect-keywords
302 nil nil #'string=)))))
303 (concat (make-string (1+ (- max-title-width
307 (title (completing-read "Note: "
309 nil 'confirm (when (use-region-p)
310 (buffer-substring-no-properties
311 (use-region-beginning)
313 'esy-publish-notes-completion-history))
314 (name (or (alist-get title
318 (esy-publish-create-empty-note title))))
319 (concat "note:" name)))
321 (defun esy-publish-describe-note-link (loc &optional _desc)
322 (org-get-title (expand-file-name (file-name-with-extension (substring loc 5) "org")
323 esy-publish-notes-source-directory)))
325 (defun esy-publish-store-note-link ()
326 (when (and (derived-mode-p 'org-mode)
328 (equal (file-name-as-directory (expand-file-name (file-name-directory (buffer-file-name))))
329 esy-publish-notes-source-directory))
330 (let* ((note (file-name-base (buffer-file-name)))
331 (link (concat "note:" note))
332 (description (org-get-title)))
333 (org-link-store-props
336 :description description))))
338 (defface esy-publish-note-link
339 '((t :underline (:style wave) :slant italic))
340 "Face applied to \"note:\" links.")
342 (defun esy-publish--post-to-feed-item (file)
343 (with-current-buffer (find-file-noselect
345 file esy-publish-local-posts-directory))
346 (push (current-buffer) esy-publish--buffers)
347 (let ((dom (libxml-parse-html-region (point-min) (point-max))))
349 (title nil ,(string-join (dom-strings (car (dom-by-tag dom 'title)))))
350 (author nil ,user-full-name)
351 (category nil ,(car (string-split (dom-attr (seq-find (lambda (m) (pcase m (`(meta ((name . "keywords") . ,_)) t))) (dom-by-tag dom 'meta)) 'content) " ")))
352 (link nil ,(concat "https://eshelyaron.com/posts/" file))
353 (guid ((isPermaLink . "true")) ,(concat "https://eshelyaron.com/posts/" file))
354 (pubDate nil ,(substring file 0 10))
355 (description nil ,(format "<![CDATA[%s]]>" (esy-publish--dom-to-string (car (dom-by-id dom "content")))))))))
357 (defun esy-publish--finalize-sitemap (plist)
358 (let ((locs (mapcar #'esy-publish--file-url
359 (directory-files-recursively esy-publish-local-directory
362 (insert "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n")
363 (dom-print `(urlset ((xlmns . "http://www.sitemaps.org/schemas/sitemap/0.9"))
364 ,@(mapcar (lambda (loc)
365 `(url nil (loc nil ,loc)))
368 (write-file (expand-file-name "sitemap.xml" (plist-get plist :publishing-directory))))))
370 (defun esy-publish--finalize-feed (plist)
371 (let ((posts (reverse (directory-files esy-publish-local-posts-directory
373 (rx bos digit (+ any) ".html" eos)))))
375 (insert "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n")
376 (dom-print `(rss ((version . "2.0"))
378 (title nil ,user-full-name)
379 (generator nil "GNU Emacs")
380 (link nil "https://eshelyaron.com")
381 (description nil "RSS Feed of eshelyaron.com")
382 (pubDate nil ,(format-time-string "%F"))
383 ,@(mapcar #'esy-publish--post-to-feed-item posts)))
385 (write-file (expand-file-name "rss.xml" (plist-get plist :publishing-directory))))))
387 (defun esy-publish--transclude-config (&rest _)
390 (expand-file-name "esy.org" esy-publish-source-directory))
391 (org-transclusion-add-all)
392 (push (current-buffer) esy-publish--buffers)))
394 (defvar esy-publish-example-modes '(("lisp" . emacs-lisp-mode)
395 ("prolog" . prolog-mode)))
397 (defun esy-publish-fontify-examples (file)
398 (interactive "fFile: ")
399 (let ((tmp (concat file ".tmp.html"))
400 (src (find-file-noselect file)))
401 (with-current-buffer src
402 (let ((dom (without-restriction
403 (xml-remove-comments (point-min) (point-max))
404 (set-buffer-modified-p nil)
405 (libxml-parse-html-region (point-min) (point-max)))))
406 (dolist-with-progress-reporter (example (dom-by-class dom "example[ ]"))
407 (concat "Processing example in " file)
408 (let ((example-class (dom-attr example 'class)))
409 (when (string-match (rx "example " (group-n 1 (+ (or alnum (any "-"))))) example-class)
411 (ms (match-string 1 example-class)))
412 (dolist (r-mm esy-publish-example-modes)
413 (if (and go (string-match (car r-mm) ms nil t))
414 (dolist (pre (dom-by-tag example 'pre))
417 (let ((prog-mode-hook '(rainbow-delimiters-mode))
418 (htmlize-css-name-prefix org-html-htmlize-font-prefix)
419 (buf (generate-new-buffer "*Example*")))
420 (with-current-buffer buf
421 (insert (dom-text pre))
423 (buffer-disable-undo (current-buffer))
425 (let* ((hb (htmlize-buffer buf))
427 (with-current-buffer hb
429 (libxml-parse-html-region
436 (dom-add-child-before (car (dom-by-tag dom 'head))
437 `(link ((rel . "canonical")
438 (href . ,(esy-publish--file-url file)))))
440 (let ((gc-cons-threshold most-positive-fixnum))
441 (with-delayed-message (1 (concat "Printing new DOM for "
446 (rename-file tmp file t)))
448 (defun esy-publish--sweep-texinfo (plist)
449 (make-directory esy-publish-local-man-directory t)
450 (let* ((in (expand-file-name "sweep/sweep.texi" esy-publish-root-directory))
451 (no-split (expand-file-name "sweep.html" esy-publish-local-directory))
452 (out (expand-file-name "sweep" esy-publish-local-man-directory))
454 "--css-ref" "../../style.css"
455 "-c" "TREE_TRANSFORMATIONS=regenerate_master_menu"
456 "-c" (concat "AFTER_BODY_OPEN="
457 (esy-publish--dom-to-string
458 '(div ((id . "preamble")
460 (nav ((id . "icon-links")
461 (class . "icon-links"))
462 (div ((class . "home-link"))
464 (img ((src . "/home.svg")
468 (div ((class . "other-links"))
469 (a ((href . "mailto:me@eshelyaron.com"))
470 (img ((src . "/mail.svg")
475 (a ((href . "https://emacs.ch/@eshel")
477 (img ((src . "/mastodon.svg")
480 (alt . "Mastodon"))))
482 (a ((href . "/rss.xml"))
483 (img ((src . "/rss.svg")
486 (alt . "RSS Feed")))))))
488 "-c" (concat "PRE_BODY_CLOSE="
489 (esy-publish--dom-to-string
490 '(div ((id . "postamble")
492 (footer ((id . "footer")
496 (time ((class . "copyright-year")) "2023")
499 (apply #'call-process "texi2any" nil nil nil
500 (append args (list out in)))
501 (apply #'call-process "texi2any" nil nil nil
502 (cons "--no-split" (append args (list no-split in))))
503 (dolist (file (cons no-split (directory-files out t (rx ".html" eos))))
504 (esy-publish-fontify-examples file))))
506 (defun esy-publish--prepare-indices (&rest _)
507 (dolist (dir (list esy-publish-notes-source-directory
508 esy-publish-posts-source-directory
509 esy-publish-source-directory))
510 (with-current-buffer (find-file-noselect (expand-file-name "index.org" dir))
511 (org-update-all-dblocks)
512 (push (current-buffer) esy-publish--buffers))))
514 (defun esy-publish--prepare-notes-links (&rest _)
515 (dolist (note (delete "index.org"
516 (directory-files esy-publish-notes-source-directory
518 (rx bos alnum (+ any)
520 (with-current-buffer (find-file-noselect note)
521 (org-update-all-dblocks)
522 (push (current-buffer) esy-publish--buffers))))
524 (defun esy-publish--prepare (&rest _)
525 (esy-publish--transclude-config)
526 (esy-publish--prepare-indices)
527 (esy-publish--prepare-notes-links))
529 (defun esy-publish--add-canonical-tags (_plist)
530 (dolist (file (directory-files-recursively esy-publish-local-directory
532 (with-current-buffer (find-file-noselect file)
533 (goto-char (point-min))
534 (when (search-forward "<!-- insert canonical tag here -->" nil t)
535 (replace-match (format "<link rel=\"canonical\" href=\"%s\" />"
536 (esy-publish--file-url file))
539 (push (current-buffer) esy-publish--buffers))))
541 (defun esy-publish--finalize (plist)
542 (esy-publish--sweep-texinfo plist)
543 (esy-publish--finalize-feed plist)
544 (esy-publish--add-canonical-tags plist)
545 (esy-publish--finalize-sitemap plist))
548 (defun esy-publish (&optional force)
551 (let* ((org-export-with-sub-superscripts '{})
552 (org-export-with-section-numbers nil)
553 (org-export-with-toc nil)
554 (org-export-with-smart-quotes t)
555 (org-export-time-stamp-file nil)
556 (org-html-htmlize-output-type 'css)
557 (org-html-metadata-timestamp-format "%Y-%m-%d")
558 (org-time-stamp-formats '("%Y-%m-%d" . "%Y-%m-%d %H:%M"))
559 (org-confirm-babel-evaluate nil)
560 (org-src-lang-modes nil)
561 (prog-mode-hook '(rainbow-delimiters-mode))
562 (make-backup-files nil)
563 (auto-save-default nil)
564 (esy-publish--buffers nil)
565 (esy-publish--publishing t)
566 (initial-buffers (buffer-list))
567 (org-publish-project-alist
568 (list '("all" :components ("assets" "org"))
570 :base-directory esy-publish-source-directory
571 :publishing-directory esy-publish-local-directory
572 :base-extension "svg\\|ico\\|css\\|png"
573 :publishing-function #'org-publish-attachment)
575 :completion-function #'esy-publish--finalize
576 :base-directory esy-publish-source-directory
577 :publishing-directory esy-publish-local-directory
578 :preparation-function #'esy-publish--prepare
579 :completion-function #'ignore
580 :base-extension "org"
584 :publishing-function #'org-html-publish-to-html
585 :html-doctype "html5"
588 :sitemap-title "Sitemap for eshelyaron.com"
591 :html-head-include-default-style nil
592 :html-head-include-scripts nil
594 :html-link-org-files-as-html t
595 :html-head (esy-publish--dom-to-string '(link ((rel . "stylesheet")
596 (href . "/style.css")
597 (type . "text/css"))))
598 :html-head-extra (concat (esy-publish--dom-to-string
599 '(link ((rel . "alternate")
601 (type . "application/rss+xml")
602 (title . "RSS feed of eshelyaron.com"))))
603 "\n<!-- insert canonical tag here -->")
604 :html-preamble-format
608 (esy-publish--dom-to-string
609 '(nav ((id . "icon-links")
610 (class . "icon-links"))
611 (div ((class . "home-link"))
613 (img ((src . "/home.svg")
617 (div ((class . "other-links"))
618 (a ((href . "mailto:me@eshelyaron.com"))
619 (img ((src . "/mail.svg")
624 (a ((href . "https://emacs.ch/@eshel")
626 (img ((src . "/mastodon.svg")
629 (alt . "Mastodon"))))
631 (a ((href . "/rss.xml"))
632 (img ((src . "/rss.svg")
635 (alt . "RSS Feed"))))))
638 :html-postamble-format
642 (esy-publish--dom-to-string
643 '(footer ((id . "footer")
647 (time ((class . "copyright-year")) "2023")
649 (org-publish "all" force)
650 (dolist (buffer (seq-uniq
651 (seq-difference esy-publish--buffers
653 (when (buffer-live-p buffer)
654 (with-current-buffer buffer
655 (set-buffer-modified-p nil))
656 (kill-buffer buffer))))
657 (setq esy-publish--buffers nil))
660 (defun esy-publish-to-remote ()
662 (compile (format "rsync -vrz %s %s"
663 esy-publish-local-directory
664 esy-publish-remote-directory)))
667 (defun esy-publish-local-server ()
669 (unless (process-live-p esy-publish--local-server-process)
670 (let ((default-directory esy-publish-local-directory))
671 (start-process "esy-publish-local-server" nil
672 "python3" "-m" "http.server"))))
674 (provide 'esy-publish)
675 ;;; esy-publish.el ends here