summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-22 06:57:00 -0500
committerCraig Jennings <c@cjennings.net>2026-04-22 06:57:00 -0500
commit96c73033bbd759054d394172dfad6971816c3dc3 (patch)
tree2909a9fa340f75997e761f478f1be882fcd4a768
parenta80477572b267d2fbf1684d2ef4b34bdb560cfc3 (diff)
downloaddotemacs-96c73033bbd759054d394172dfad6971816c3dc3.tar.gz
dotemacs-96c73033bbd759054d394172dfad6971816c3dc3.zip
feat(hugo): draft picker, preview toggle, publish command
Put the full Hugo workflow inside Emacs. All of it lives in modules/hugo-config.el. New functions: - cj/hugo-open-draft reads all .org files under content-org/log, finds those with #+hugo_draft: true, and offers a completing-read picker. - cj/hugo-preview toggles a local hugo server subprocess and opens the preview URL in the browser. A second press stops the server. - cj/hugo-publish opens magit-status on the website repo. The server-side post-receive hook on cjennings.net already rebuilds and deploys on push, so committing and pushing is the deploy. Two pure helpers support the picker: cj/hugo--post-metadata parses the front matter region of a post, and cj/hugo--collect-drafts walks a directory and filters to drafts. Seven ERT tests cover both helpers across normal, boundary, and error cases. Keybinding note: C-; h d and C-; h D have swapped roles. Lowercase d now opens the draft picker. Uppercase D toggles the draft flag in the current buffer. The previous lowercase-d binding was toggle.
-rw-r--r--modules/hugo-config.el81
-rw-r--r--tests/test-hugo-config--collect-drafts.el81
-rw-r--r--tests/test-hugo-config--post-metadata.el64
3 files changed, 224 insertions, 2 deletions
diff --git a/modules/hugo-config.el b/modules/hugo-config.el
index 33245fd4..ed71b4ff 100644
--- a/modules/hugo-config.el
+++ b/modules/hugo-config.el
@@ -34,6 +34,36 @@
;; ----------------------------- Hugo Blog Functions ---------------------------
+(defun cj/hugo--post-metadata (file)
+ "Return minimal front-matter metadata for Hugo post FILE, or nil if not one.
+A file counts as a Hugo post only if it contains `#+hugo_draft: true' or
+`#+hugo_draft: false' in its front matter region.
+Returns a plist (:title TITLE :draft BOOL). TITLE falls back to the file
+basename when `#+title:' is absent. Reads only the first 2048 bytes."
+ (with-temp-buffer
+ (insert-file-contents file nil 0 2048)
+ (let (title draft is-hugo)
+ (goto-char (point-min))
+ (when (re-search-forward "^#\\+title: *\\(.+\\)$" nil t)
+ (setq title (match-string 1)))
+ (goto-char (point-min))
+ (when (re-search-forward "^#\\+hugo_draft: *\\(true\\|false\\)" nil t)
+ (setq draft (string= (match-string 1) "true")
+ is-hugo t))
+ (when is-hugo
+ (list :title (or title (file-name-base file)) :draft draft)))))
+
+(defun cj/hugo--collect-drafts (dir)
+ "Return alist of (TITLE . FILEPATH) for draft Hugo posts under DIR.
+Walks non-recursively through DIR for .org files and keeps only those
+whose `cj/hugo--post-metadata' returns a :draft-t plist."
+ (let (drafts)
+ (dolist (f (directory-files dir t "\\.org\\'"))
+ (let ((meta (cj/hugo--post-metadata f)))
+ (when (and meta (plist-get meta :draft))
+ (push (cons (plist-get meta :title) f) drafts))))
+ drafts))
+
(defun cj/hugo--post-file-path (title)
"Return the file path for a Hugo post with TITLE.
Generates a slug from TITLE using `org-hugo-slug' and returns
@@ -114,13 +144,57 @@ Switches #+hugo_draft between true and false."
(if (string= current "true") "false" "true")))
(user-error "No #+hugo_draft keyword found in this file"))))
+(defun cj/hugo-open-draft ()
+ "Pick a draft post via completing-read and open it."
+ (interactive)
+ (let ((drafts (cj/hugo--collect-drafts cj/hugo-content-org-dir)))
+ (if (null drafts)
+ (message "No drafts found in %s" cj/hugo-content-org-dir)
+ (let ((choice (completing-read "Open draft: "
+ (mapcar #'car drafts) nil t)))
+ (find-file (cdr (assoc choice drafts)))))))
+
+;; ---------------------------- Preview and Publish ----------------------------
+
+(defvar cj/hugo--preview-process nil
+ "Handle to the running hugo preview server, or nil.")
+
+(defun cj/hugo-preview ()
+ "Toggle the `hugo server' preview.
+Start the server and open the browser if stopped; stop it if running."
+ (interactive)
+ (if (process-live-p cj/hugo--preview-process)
+ (progn
+ (kill-process cj/hugo--preview-process)
+ (setq cj/hugo--preview-process nil)
+ (message "hugo server stopped"))
+ (let ((default-directory website-dir))
+ (setq cj/hugo--preview-process
+ (start-process "hugo-server" "*hugo-server*"
+ "hugo" "server" "-D"
+ "--noHTTPCache" "--disableFastRender"))
+ (run-at-time "1 sec" nil #'browse-url "http://localhost:1313/")
+ (message "hugo server starting — C-; h p again to stop"))))
+
+(declare-function magit-status-setup-buffer "magit-status")
+
+(defun cj/hugo-publish ()
+ "Open magit-status on the website repo so a push triggers server-side deploy.
+The cjennings.net bare repo's post-receive hook rebuilds Hugo and writes
+to /var/www/cjennings/, so a successful push is the deploy."
+ (interactive)
+ (magit-status-setup-buffer website-dir))
+
;; -------------------------------- Keybindings --------------------------------
(global-set-key (kbd "C-; h n") #'cj/hugo-new-post)
(global-set-key (kbd "C-; h e") #'cj/hugo-export-post)
(global-set-key (kbd "C-; h o") #'cj/hugo-open-blog-dir)
(global-set-key (kbd "C-; h O") #'cj/hugo-open-blog-dir-external)
-(global-set-key (kbd "C-; h d") #'cj/hugo-toggle-draft)
+(global-set-key (kbd "C-; h d") #'cj/hugo-open-draft)
+(global-set-key (kbd "C-; h D") #'cj/hugo-toggle-draft)
+(global-set-key (kbd "C-; h p") #'cj/hugo-preview)
+(global-set-key (kbd "C-; h P") #'cj/hugo-publish)
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
@@ -129,7 +203,10 @@ Switches #+hugo_draft between true and false."
"C-; h e" "export post"
"C-; h o" "open in dirvish"
"C-; h O" "open in file manager"
- "C-; h d" "toggle draft"))
+ "C-; h d" "open draft"
+ "C-; h D" "toggle draft"
+ "C-; h p" "preview (toggle)"
+ "C-; h P" "publish (magit push)"))
(provide 'hugo-config)
;;; hugo-config.el ends here
diff --git a/tests/test-hugo-config--collect-drafts.el b/tests/test-hugo-config--collect-drafts.el
new file mode 100644
index 00000000..8076b3d3
--- /dev/null
+++ b/tests/test-hugo-config--collect-drafts.el
@@ -0,0 +1,81 @@
+;;; test-hugo-config--collect-drafts.el --- Tests for draft collector -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/hugo--collect-drafts. Walks a directory of Org files
+;; and returns an alist of (TITLE . FILEPATH) for posts where :draft is t.
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'hugo-config)
+
+(defun test-hugo-config--collect-drafts--mkdir ()
+ "Return a fresh temp directory for fixture files."
+ (make-temp-file "hugo-test-drafts-" t))
+
+(defun test-hugo-config--collect-drafts--write (dir name contents)
+ "Write CONTENTS into NAME inside DIR. Return the absolute path."
+ (let ((file (expand-file-name name dir)))
+ (with-temp-file file (insert contents))
+ file))
+
+;;; Normal Cases
+
+(ert-deftest test-hugo-config--collect-drafts-normal-mixed-dir ()
+ "Normal: directory with a draft and a published post returns only the draft."
+ (let ((dir (test-hugo-config--collect-drafts--mkdir)))
+ (unwind-protect
+ (progn
+ (test-hugo-config--collect-drafts--write
+ dir "draft.org"
+ "#+title: Unfinished Thought
+#+hugo_draft: true
+
+Body.")
+ (test-hugo-config--collect-drafts--write
+ dir "published.org"
+ "#+title: Shipped
+#+hugo_draft: false
+
+Body.")
+ (let ((result (cj/hugo--collect-drafts dir)))
+ (should (= 1 (length result)))
+ (should (string= "Unfinished Thought" (car (car result))))))
+ (delete-directory dir t))))
+
+;;; Boundary Cases
+
+(ert-deftest test-hugo-config--collect-drafts-boundary-empty-dir ()
+ "Boundary: empty directory returns nil."
+ (let ((dir (test-hugo-config--collect-drafts--mkdir)))
+ (unwind-protect
+ (should (null (cj/hugo--collect-drafts dir)))
+ (delete-directory dir t))))
+
+(ert-deftest test-hugo-config--collect-drafts-boundary-all-published ()
+ "Boundary: directory with only published posts returns nil."
+ (let ((dir (test-hugo-config--collect-drafts--mkdir)))
+ (unwind-protect
+ (progn
+ (test-hugo-config--collect-drafts--write
+ dir "one.org" "#+title: One\n#+hugo_draft: false\n")
+ (test-hugo-config--collect-drafts--write
+ dir "two.org" "#+title: Two\n#+hugo_draft: false\n")
+ (should (null (cj/hugo--collect-drafts dir))))
+ (delete-directory dir t))))
+
+;;; Error Cases
+
+(ert-deftest test-hugo-config--collect-drafts-error-non-hugo-org ()
+ "Error: Org files without #+hugo_draft: are not posts; returns nil."
+ (let ((dir (test-hugo-config--collect-drafts--mkdir)))
+ (unwind-protect
+ (progn
+ (test-hugo-config--collect-drafts--write
+ dir "random.org" "#+title: Not a post\n\nJust an org file.")
+ (should (null (cj/hugo--collect-drafts dir))))
+ (delete-directory dir t))))
+
+(provide 'test-hugo-config--collect-drafts)
+;;; test-hugo-config--collect-drafts.el ends here
diff --git a/tests/test-hugo-config--post-metadata.el b/tests/test-hugo-config--post-metadata.el
new file mode 100644
index 00000000..d0719f6f
--- /dev/null
+++ b/tests/test-hugo-config--post-metadata.el
@@ -0,0 +1,64 @@
+;;; test-hugo-config--post-metadata.el --- Tests for hugo post metadata parser -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/hugo--post-metadata. Reads the Hugo front matter region of
+;; an Org file and returns (:title TITLE :draft BOOL) or nil for non-Hugo posts.
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'hugo-config)
+
+(defun test-hugo-config--post-metadata--write-fixture (contents)
+ "Write CONTENTS to a unique temp .org file and return its path."
+ (let ((file (make-temp-file "hugo-test-" nil ".org")))
+ (with-temp-file file (insert contents))
+ file))
+
+;;; Normal Cases
+
+(ert-deftest test-hugo-config--post-metadata-normal-draft-with-title ()
+ "Normal: draft post with title returns plist with title and :draft t."
+ (let ((file (test-hugo-config--post-metadata--write-fixture
+ "#+title: My First Post
+#+date: 2026-01-01
+#+hugo_draft: true
+
+Body text.")))
+ (unwind-protect
+ (let ((meta (cj/hugo--post-metadata file)))
+ (should (equal (plist-get meta :title) "My First Post"))
+ (should (eq (plist-get meta :draft) t)))
+ (delete-file file))))
+
+;;; Boundary Cases
+
+(ert-deftest test-hugo-config--post-metadata-boundary-published-no-title ()
+ "Boundary: published post without #+title: falls back to file basename."
+ (let ((file (test-hugo-config--post-metadata--write-fixture
+ "#+hugo_draft: false
+
+Body only.")))
+ (unwind-protect
+ (let ((meta (cj/hugo--post-metadata file)))
+ (should (stringp (plist-get meta :title)))
+ (should (string= (plist-get meta :title)
+ (file-name-base file)))
+ (should (eq (plist-get meta :draft) nil)))
+ (delete-file file))))
+
+;;; Error Cases
+
+(ert-deftest test-hugo-config--post-metadata-error-missing-hugo-draft ()
+ "Error: Org file without #+hugo_draft: keyword is not a Hugo post; returns nil."
+ (let ((file (test-hugo-config--post-metadata--write-fixture
+ "#+title: Just an Org file
+
+No Hugo front matter here.")))
+ (unwind-protect
+ (should (null (cj/hugo--post-metadata file)))
+ (delete-file file))))
+
+(provide 'test-hugo-config--post-metadata)
+;;; test-hugo-config--post-metadata.el ends here