summaryrefslogtreecommitdiff
path: root/lisp/garden-publish.el
diff options
context:
space:
mode:
Diffstat (limited to 'lisp/garden-publish.el')
-rw-r--r--lisp/garden-publish.el450
1 files changed, 450 insertions, 0 deletions
diff --git a/lisp/garden-publish.el b/lisp/garden-publish.el
new file mode 100644
index 0000000..cafbcda
--- /dev/null
+++ b/lisp/garden-publish.el
@@ -0,0 +1,450 @@
+;;; garden-publish.el --- publish the garden to the zola site -*- lexical-binding: t; -*-
+
+(require 'garden-core)
+(require 'subr-x)
+
+(declare-function denote-publish-to-md "denote-publish")
+(declare-function org-publish "ox-publish")
+
+(defvar denote-directory)
+(defvar org-export-with-broken-links)
+(defvar org-export-use-babel)
+(defvar org-publish-project-alist)
+
+(defcustom garden-publish-site-directory (expand-file-name "~/cashmere.rs/") "Root directory of the Zola site checkout." :type 'directory :group 'garden)
+(defcustom garden-publish-wiki-subdir "content/wiki" "Wiki output directory relative to the site root." :type 'string :group 'garden)
+
+(defun garden-publish--clean-generated (dir)
+ "Delete all generated Markdown files in DIR except _index.md."
+ (dolist (old (directory-files dir t "\\.md\\'"))
+ (unless (equal (file-name-nondirectory old) "_index.md") (delete-file old))))
+
+(defun garden-publish--ensure-index (dir title)
+ "Create an _index.md with TITLE in DIR unless one already exists."
+ (let ((idx (expand-file-name "_index.md" dir)))
+ (unless (file-exists-p idx)
+ (with-temp-file idx (insert (format "+++\ntitle = %S\nsort_by = \"date\"\n+++\n" title))))))
+
+(defun garden-publish--filename-slug (file)
+ "Return the slug part of the denote FILE name, or nil."
+ (let ((base (file-name-nondirectory file)))
+ (when (string-match "\\`[0-9]\\{8\\}T[0-9]\\{6\\}--\\([a-z0-9-]+\\)\\(?:__[a-z0-9_-]+\\)?\\.org\\'" base)
+ (match-string 1 base))))
+
+(defun garden-publish--slug-map ()
+ "Return a hash table mapping note ids to unique URL slugs."
+ (let ((map (make-hash-table :test 'equal))
+ (seen (make-hash-table :test 'equal)))
+ (dolist (file (sort (garden--note-files) #'string<))
+ (let* ((id (garden--file-id file))
+ (base (garden-publish--filename-slug file))
+ (kw (car (garden--file-keywords file))))
+ (when (and id base)
+ (let ((slug base))
+ (when (gethash slug seen)
+ (setq slug (if (and kw (not (gethash (concat base "-" kw) seen)))
+ (concat base "-" kw)
+ (let ((n 2))
+ (while (gethash (format "%s-%d" base n) seen) (setq n (1+ n)))
+ (format "%s-%d" base n)))))
+ (puthash slug t seen)
+ (puthash id slug map)))))
+ map))
+
+(defcustom garden-publish-exclude-keywords '("draft" "private" "noexport") "File keywords that exclude a note from publishing." :type '(repeat string) :group 'garden)
+
+(defun garden-publish--exclude-ids ()
+ "Return a hash table of note ids that must not be published."
+ (let ((ex (make-hash-table :test 'equal)))
+ (dolist (file (garden--note-files))
+ (when (seq-intersection garden-publish-exclude-keywords (garden--file-keywords file))
+ (puthash (garden--file-id file) t ex)))
+ ex))
+
+(defun garden-publish--rewrite-denote-links (map)
+ "Rewrite exported denote links in the current buffer using MAP.
+MAP maps note ids to wiki slugs; links to unknown ids are reduced
+to their description."
+ (goto-char (point-min))
+ (while (re-search-forward
+ "<a href=\"denote:\\([0-9]\\{8\\}T[0-9]\\{6\\}\\)\\(?:::[^\"]*\\)?\\(?:\\.html\\)?\"[^>]*>\\(\\(?:.\\|\n\\)*?\\)</a>"
+ nil t)
+ (let* ((tid (match-string 1))
+ (desc (match-string 2))
+ (tslug (gethash tid map)))
+ (replace-match (if tslug (format "<a href=\"/wiki/%s/\">%s</a>" tslug desc) desc) t t)))
+ (goto-char (point-min))
+ (while (re-search-forward
+ "\\[\\([^]]*\\)\\](denote:\\([0-9]\\{8\\}T[0-9]\\{6\\}\\)[^)]*)"
+ nil t)
+ (let* ((desc (match-string 1))
+ (tslug (gethash (match-string 2) map)))
+ (replace-match (if tslug (format "[%s](/wiki/%s/)" desc tslug) desc) t t))))
+
+(defun garden-publish--fix-wiki-frontmatter ()
+ "Move top-level tags/category in YAML front matter into a Zola taxonomies block.
+This is done in the current buffer."
+ (save-excursion
+ (goto-char (point-min))
+ (when (looking-at "^---$")
+ (let ((start (point)))
+ (forward-line)
+ (when (re-search-forward "^---$" nil t)
+ (let ((end (point)))
+ (save-restriction
+ (narrow-to-region start end)
+ (goto-char (point-min))
+ (when (re-search-forward "^\\(tags:\\|category:\\)" nil t)
+ (beginning-of-line)
+ (insert "taxonomies:\n")
+ (goto-char (point-min))
+ (while (re-search-forward "^tags:" nil t)
+ (replace-match " tags:"))
+ (goto-char (point-min))
+ (while (re-search-forward "^category:" nil t)
+ (replace-match " categories:"))))))))))
+
+(defun garden-publish--fix-wiki (dir map exclude)
+ "Post-process exported wiki files in DIR.
+Rewrite denote links using the id-to-slug table MAP, delete files
+whose id is in the EXCLUDE table, strip PGP blocks and TODO
+keywords, fix front matter taxonomies, and rename each file to its slug."
+ (dolist (md (directory-files dir t "\\.md\\'"))
+ (let* ((base (file-name-nondirectory md))
+ (id (and (string-match "\\`\\([0-9]\\{8\\}T[0-9]\\{6\\}\\)" base) (match-string 1 base)))
+ (slug (and id (gethash id map))))
+ (cond
+ ((and id (gethash id exclude)) (delete-file md))
+ ((null slug) nil)
+ (t
+ (with-temp-buffer
+ (insert-file-contents md)
+ (garden-publish--fix-wiki-frontmatter)
+ (goto-char (point-min))
+ (while (re-search-forward "\\(?:-----\\|&ndash;&mdash;\\)?BEGIN PGP\\(?:.\\|\n\\)*?END PGP[^&\n-]*\\(?:-----\\|&ndash;&mdash;\\)?" nil t)
+ (replace-match ""))
+ (goto-char (point-min))
+ (while (re-search-forward "^\\(#+\\)[ \t]+\\(?:TODO\\|DONE\\|NEXT\\|WAIT[A-Z]*\\|CANCELL?ED\\|HOLD\\|STRT\\)[ \t]+" nil t)
+ (replace-match "\\1 "))
+ (garden-publish--rewrite-denote-links map)
+ (write-region (point-min) (point-max) md nil 'silent))
+ (unless (equal base (concat slug ".md"))
+ (rename-file md (expand-file-name (concat slug ".md") dir) t)))))))
+
+;;;###autoload
+(defun garden-publish-wiki ()
+ "Export all garden notes to Markdown in the site wiki directory."
+ (interactive)
+ (require 'denote)
+ (require 'ox-gfm)
+ (require 'denote-publish)
+ (let* ((out (expand-file-name garden-publish-wiki-subdir garden-publish-site-directory))
+ (denote-directory (expand-file-name garden-directory))
+ (org-export-with-broken-links t)
+ (org-export-use-babel nil)
+ (make-backup-files nil)
+ (org-publish-project-alist
+ `(("garden-wiki"
+ :base-directory ,(expand-file-name garden-directory)
+ :publishing-directory ,out
+ :publishing-function denote-publish-to-md
+ :recursive nil
+ :exclude-tags ("noexport" "draft")
+ :with-toc nil))))
+ (make-directory out t)
+ (garden-publish--clean-generated out)
+ (garden-publish--ensure-index out "Wiki")
+ (garden-build)
+ (org-publish "garden-wiki" t)
+ (garden-publish--fix-wiki out (garden-publish--slug-map) (garden-publish--exclude-ids))
+ (message "garden: published wiki to %s" out)))
+
+(defcustom garden-publish-blog-source (expand-file-name "~/garden/posts/")
+ "Directory holding the Org source files for blog posts."
+ :type 'directory :group 'garden)
+
+(defcustom garden-publish-pages-source (expand-file-name "~/garden/pages/")
+ "Directory holding the Org source files for standalone pages."
+ :type 'directory :group 'garden)
+
+(defcustom garden-publish-images-source (expand-file-name "~/garden/posts/images/")
+ "Directory holding images referenced by blog posts."
+ :type 'directory :group 'garden)
+
+(defcustom garden-publish-blog-subdir "content/blog" "Blog output directory relative to the site root." :type 'string :group 'garden)
+
+(declare-function org-export-as "ox")
+
+(defun garden-publish--parse-post (file)
+ "Parse the Org post FILE into a (PROPERTIES . BODY) pair."
+ (let ((props nil) (body-start 0) (i 0)
+ (lines (with-temp-buffer (insert-file-contents file) (split-string (buffer-string) "\n"))))
+ (catch 'done
+ (dolist (line lines)
+ (cond
+ ((string-match "\\`#\\+\\([A-Za-z_]+\\):[ \t]*\\(.*\\)\\'" line)
+ (push (cons (downcase (match-string 1 line)) (string-trim (match-string 2 line))) props))
+ ((string-empty-p (string-trim line)) nil)
+ (t (setq body-start i) (throw 'done nil)))
+ (setq i (1+ i))))
+ (cons props (string-join (nthcdr body-start lines) "\n"))))
+
+(defun garden-publish--strip-toc (body)
+ "Return BODY with any Table of Contents section removed."
+ (let ((out '()) (skip nil))
+ (dolist (line (split-string body "\n"))
+ (cond
+ ((string-match-p "\\`\\*+ +Table of Contents[ \t]*\\'" line) (setq skip t))
+ ((and skip (string-match-p "\\`\\*+ " line)) (setq skip nil) (push line out))
+ (skip nil)
+ (t (push line out))))
+ (string-join (nreverse out) "\n")))
+
+(defun garden-publish--clean-slug (s)
+ "Normalize string S into a lowercase URL slug."
+ (let ((x (replace-regexp-in-string "[^a-z0-9]+" "-" (downcase (string-trim s)))))
+ (replace-regexp-in-string "\\(?:\\`-+\\|-+\\'\\)" "" x)))
+
+(defun garden-publish--org-to-gfm (body)
+ "Export the Org string BODY to GitHub-flavored Markdown."
+ (require 'ox-gfm)
+ (with-temp-buffer
+ (insert body)
+ (org-mode)
+ (let ((org-export-with-broken-links t)
+ (org-export-use-babel nil))
+ (org-export-as 'gfm nil nil t '(:with-toc nil :with-section-numbers nil)))))
+
+(defun garden-publish--blog-one (file out-dir)
+ "Convert the blog post FILE to Markdown in OUT-DIR unless drafted."
+ (let* ((parsed (garden-publish--parse-post file))
+ (props (car parsed))
+ (body (cdr parsed)))
+ (unless (equal (cdr (assoc "status" props)) "draft")
+ (let* ((title (or (cdr (assoc "title" props)) (file-name-base file)))
+ (slug (garden-publish--clean-slug (or (cdr (assoc "slug" props)) (file-name-base file))))
+ (date (let ((d (cdr (assoc "date" props)))) (and d (substring d 0 (min 10 (length d))))))
+ (tags (let ((tg (cdr (assoc "tags" props)))) (and tg (split-string tg "[ ,]+" t))))
+ (desc (cdr (assoc "description" props)))
+ (md (garden-publish--org-to-gfm
+ (garden-publish--strip-toc
+ (replace-regexp-in-string "/content/images/" "/images/" body))))
+ (fm (concat "+++\n"
+ (format "title = %S\n" title)
+ (or (when date (format "date = \"%s\"\n" date)) "")
+ (or (when (and desc (not (string-empty-p desc)))
+ (format "description = %S\n" desc))
+ "")
+ (or (when tags
+ (concat "[taxonomies]\n"
+ (format "tags = [%s]\n"
+ (mapconcat (lambda (x) (format "%S" x)) tags ", "))))
+ "")
+ "+++\n\n")))
+ (with-temp-file (expand-file-name (concat slug ".md") out-dir)
+ (insert fm md))))))
+
+;;;###autoload
+(defun garden-publish-blog ()
+ "Export all blog posts and images to the site blog directory."
+ (interactive)
+ (let ((out (expand-file-name garden-publish-blog-subdir garden-publish-site-directory))
+ (postsrc garden-publish-blog-source)
+ (imgsrc garden-publish-images-source)
+ (imgdst (expand-file-name "static/images" garden-publish-site-directory)))
+ (make-directory out t)
+ (garden-publish--clean-generated out)
+ (garden-publish--ensure-index out "Blog")
+ (if (file-directory-p postsrc)
+ (dolist (f (directory-files postsrc t "\\.org\\'"))
+ (garden-publish--blog-one f out))
+ (message "garden: blog source missing, skipping posts"))
+ (when (file-directory-p imgsrc)
+ (make-directory imgdst t)
+ (copy-directory imgsrc imgdst t t t))
+ (message "garden: published blog to %s" out)))
+
+(defcustom garden-publish-pages-skip '("wiki-archive") "Page slugs that are never published." :type '(repeat string) :group 'garden)
+
+(defcustom garden-publish-pages-manifest (expand-file-name ".garden/pages.eld" garden-directory) "File recording the page files written by the last publish run." :type 'file :group 'garden)
+
+(defun garden-publish--load-pages-manifest ()
+ "Read the pages manifest file and return its entries, or nil."
+ (when (file-exists-p garden-publish-pages-manifest)
+ (with-temp-buffer
+ (insert-file-contents garden-publish-pages-manifest)
+ (ignore-errors (read (current-buffer))))))
+
+(defun garden-publish--save-pages-manifest (entries)
+ "Write ENTRIES to the pages manifest file."
+ (make-directory (file-name-directory garden-publish-pages-manifest) t)
+ (with-temp-file garden-publish-pages-manifest (prin1 entries (current-buffer))))
+
+(defun garden-publish--page-one (file out-dir)
+ "Convert the page FILE to Markdown in OUT-DIR and return its name."
+ (let* ((parsed (garden-publish--parse-post file))
+ (props (car parsed))
+ (body (cdr parsed))
+ (slug (garden-publish--clean-slug (or (cdr (assoc "slug" props)) (file-name-base file)))))
+ (unless (or (equal (cdr (assoc "status" props)) "draft")
+ (member slug garden-publish-pages-skip))
+ (let* ((title (or (cdr (assoc "title" props)) (file-name-base file)))
+ (desc (cdr (assoc "description" props)))
+ (md (garden-publish--org-to-gfm
+ (garden-publish--strip-toc
+ (replace-regexp-in-string "/content/images/" "/images/" body))))
+ (fm (concat "+++\n"
+ (format "title = %S\n" title)
+ (or (when (and desc (not (string-empty-p desc)))
+ (format "description = %S\n" desc))
+ "")
+ "+++\n\n")))
+ (with-temp-file (expand-file-name (concat slug ".md") out-dir)
+ (insert fm md))
+ (concat slug ".md")))))
+
+;;;###autoload
+(defun garden-publish-pages ()
+ "Export all standalone pages and remove stale ones from the site."
+ (interactive)
+ (let ((out (expand-file-name "content" garden-publish-site-directory))
+ (src garden-publish-pages-source)
+ (written '()))
+ (make-directory out t)
+ (if (file-directory-p src)
+ (dolist (f (directory-files src t "\\.org\\'"))
+ (let ((name (garden-publish--page-one f out)))
+ (when name (push name written))))
+ (message "garden: pages source missing, skipping pages"))
+ (let ((stale (seq-difference (garden-publish--load-pages-manifest) written)))
+ (dolist (name stale)
+ (let ((md (expand-file-name name out)))
+ (when (file-exists-p md) (delete-file md))))
+ (garden-publish--save-pages-manifest written)
+ (if stale
+ (message "garden: published pages to %s (removed %d stale)" out (length stale))
+ (message "garden: published pages to %s" out)))))
+
+(defcustom garden-publish-deploy-command "just publish"
+ "Shell command that deploys the generated site."
+ :type 'string :group 'garden)
+
+(defun garden-publish--post-files ()
+ "Return the Org files of all blog posts, or nil if the source is missing."
+ (when (file-directory-p garden-publish-blog-source)
+ (directory-files garden-publish-blog-source t "\\.org\\'")))
+
+(defun garden-publish--page-files ()
+ "Return the Org files of all standalone pages, or nil if the source is missing."
+ (when (file-directory-p garden-publish-pages-source)
+ (directory-files garden-publish-pages-source t "\\.org\\'")))
+
+(defun garden-publish--pick-file (prompt files)
+ "Pick one of FILES by base name with PROMPT."
+ (if (null files)
+ (user-error "No source files available")
+ (let ((table (mapcar (lambda (f) (cons (file-name-nondirectory f) f)) files)))
+ (cdr (assoc (completing-read prompt table nil t) table)))))
+
+;;;###autoload
+(defun garden-publish-post (file)
+ "Export the single blog post FILE to the site blog directory."
+ (interactive (list (garden-publish--pick-file "Publish post: " (garden-publish--post-files))))
+ (let ((out (expand-file-name garden-publish-blog-subdir garden-publish-site-directory))
+ (props (car (garden-publish--parse-post file))))
+ (if (equal (cdr (assoc "status" props)) "draft")
+ (message "garden: %s is a draft, not published" (file-name-base file))
+ (make-directory out t)
+ (garden-publish--ensure-index out "Blog")
+ (garden-publish--blog-one file out)
+ (message "garden: published post %s → %s" (file-name-base file) out))))
+
+;;;###autoload
+(defun garden-publish-page (file)
+ "Export the single standalone page FILE to the site content directory."
+ (interactive (list (garden-publish--pick-file "Publish page: " (garden-publish--page-files))))
+ (let* ((out (expand-file-name "content" garden-publish-site-directory))
+ (name (progn (make-directory out t) (garden-publish--page-one file out))))
+ (if (null name)
+ (message "garden: %s is a draft or skipped, not published" (file-name-base file))
+ (let ((manifest (garden-publish--load-pages-manifest)))
+ (unless (member name manifest)
+ (garden-publish--save-pages-manifest (cons name manifest))))
+ (message "garden: published page %s" name))))
+
+;;;###autoload
+(defun garden-publish-note-as-page (&optional file slug)
+ "Export the garden note FILE as the standalone page SLUG on the site.
+Interactively, offers the current buffer's note when visiting one,
+and prompts for the slug. The note file itself stays untouched;
+tag it noexport if it should not also appear under /wiki/."
+ (interactive)
+ (let* ((current (buffer-file-name))
+ (file (or file
+ (if (and current
+ (string-prefix-p (expand-file-name garden-directory)
+ (expand-file-name current))
+ (y-or-n-p (format "Publish %s as a page? "
+ (file-name-nondirectory current))))
+ current
+ (garden-publish--pick-file "Publish note as page: " (garden--note-files)))))
+ (parsed (garden-publish--parse-post file))
+ (props (car parsed))
+ (title (or (cdr (assoc "title" props)) (file-name-base file)))
+ (slug (garden-publish--clean-slug
+ (or slug
+ (read-string "Page slug: " (or (garden-publish--filename-slug file)
+ (garden-publish--clean-slug title))))))
+ (md (garden-publish--org-to-gfm
+ (garden-publish--strip-toc
+ (replace-regexp-in-string "/content/images/" "/images/" (cdr parsed)))))
+ (out (expand-file-name "content" garden-publish-site-directory))
+ (map (garden-publish--slug-map)))
+ (make-directory out t)
+ (with-temp-file (expand-file-name (concat slug ".md") out)
+ (insert (format "+++\ntitle = %S\n+++\n\n" title) md)
+ (garden-publish--rewrite-denote-links map))
+ (message "garden: published note as page /%s" slug)))
+
+;;;###autoload
+(defun garden-publish-one ()
+ "Publish a single post, page or note, then offer to deploy."
+ (interactive)
+ (let* ((candidates
+ (append
+ (mapcar (lambda (f) (cons (concat "post: " (file-name-base f)) (cons #'garden-publish-post f)))
+ (garden-publish--post-files))
+ (mapcar (lambda (f) (cons (concat "page: " (file-name-base f)) (cons #'garden-publish-page f)))
+ (garden-publish--page-files))
+ (mapcar (lambda (f) (cons (concat "note: " (file-name-nondirectory f)) (cons #'garden-publish-note-as-page f)))
+ (garden--note-files))))
+ (choice (cdr (assoc (completing-read "Publish one: " candidates nil t) candidates))))
+ (funcall (car choice) (cdr choice))
+ (when (y-or-n-p (format "Deploy now (%s)? " garden-publish-deploy-command))
+ (garden-publish-deploy))))
+
+;;;###autoload
+(defun garden-publish ()
+ "Publish the wiki, the blog and the pages to the site directory."
+ (interactive)
+ (garden-publish-wiki)
+ (garden-publish-blog)
+ (garden-publish-pages)
+ (message "garden: published wiki + blog + pages → %s" garden-publish-site-directory))
+
+;;;###autoload
+(defun garden-publish-deploy ()
+ "Run the deploy command asynchronously in the site directory."
+ (interactive)
+ (let ((default-directory (expand-file-name garden-publish-site-directory)))
+ (async-shell-command garden-publish-deploy-command "*garden-deploy*")))
+
+;;;###autoload
+(defun garden-publish-all ()
+ "Publish the whole site and then deploy it."
+ (interactive)
+ (garden-publish)
+ (garden-publish-deploy))
+
+(provide 'garden-publish)
+;;; garden-publish.el ends here