1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
|
;;; hugoista.el --- Manage Hugo posts like a barista -*- lexical-binding: t -*-
;; Copyright (C) 2025 Free Software Foundation, Inc.
;; Author: Alexander Adolf <alexander.adolf@condition-alpha.com>, Thanos Apollo <public@thanosapollo.org>
;; Maintainer: Alexander Adolf <alexander.adolf@condition-alpha.com>
;; Version: 0.2.1
;; Package-Requires: ((emacs "24.3") seq)
;; Homepage: https://codeberg.org/c-alpha/hugoista
;; This file is not part of GNU Emacs
;; This program is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;;; Commentary:
;; This package is intended to help in curating and authoring a set of
;; blog post files for a Hugo-generated website.
;; Hugo (https://gohugo.io) is an open-source static site generator.
;; It uses text input files (for example Markdown, Org, AsciiDoc,
;; Pandoc, or reStructuredText) to generate static web pages. Unlike
;; dynamic web content, static pages do not change their content based
;; on the HTTP request. Hugo processes a set of such input files in
;; one file-system tree into a corresponding set of HTML files in
;; another file system tree. These HTML files can then be uploaded to
;; a web server as a website's content.
;; The hugoista package displays the set of those text input files in
;; an input file-system tree, which will be processed into blog posts
;; by Hugo. It shows them in a list view, grouped by their Hugo
;; publication status (draft, scheduled, published, and expired) along
;; with further Hugo metadata associated with each blog post. Within
;; each status group, entries can be sorted by date, publication date,
;; expiration date, or title. The text input file associated with
;; each entry can be visited, and a new text input file for a new blog
;; post can be created using single-key shortcuts.
;; To show such a list of text input files, call the function
;; `hugoista'. It accepts an optional argument, which is the root
;; directory of a Hugo input file-system tree (that is, the directory
;; where the `hugo.toml' file for the website is). Each hugoista
;; buffer references it own website input directory. It is thus
;; possible to have several hugoista buffers open at the same time,
;; each for a different website input directory. As a convenience for
;; when a single website is to be managed only, the optional argument
;; can be omitted, in which case the directory indicated by
;; `hugoista-site-dir' will be used as the default website input
;; directory.
;; Two operations are provided to act on individual entries.
;; `hugoista-visit-post' (bound to RET by default), and
;; `hugoista-new-post' (bound to N and + by default). Further
;; operations (for example renaming, or deleting text input files) can
;; conveniently be performed by other built-in facilities, such as for
;; example `dired'.
;;; Code:
(require 'cl-lib)
(require 'seq)
(require 'tabulated-list)
;;;; Customisation Variables
(defgroup hugoista nil
"Managing Hugo posts."
:group 'external)
(defcustom hugoista-hugo-command "hugo"
"Command to use the Hugo command line interface."
:type 'string)
(defcustom hugoista-site-dir "~/"
"Default site directory to be used by `hugoista'."
:type 'directory
:local t)
(defcustom hugoista-posts-dir "posts"
"Hugo content directory containing the blog posts."
:type 'string
:local t)
(defcustom hugoista-initial-sort t
"Sort order for newly created `hugoista' buffers."
:type '(choice (const :tag "descending" t)
(const :tag "ascending" nil)))
;;;; Internal Stuff
(cl-defstruct post-desc
"Structure containing information about an individual post."
path slug title date expiry-date publish-date draft permalink kind section)
(defun hugoista--in-quotes-p (pos line)
"Return t if POS in LINE is within quotes, nil otherwise."
(let ((quote-count 0)
(i 0))
(while (< i pos)
(when (= (aref line i) ?\")
(setq quote-count (1+ quote-count)))
(setq i (1+ i)))
(= (mod quote-count 2) 1)))
(defun hugoista--next-comma (line start)
"Find next legitimate comma from START in LINE."
(let ((pos start))
(while (and (setq pos (string-match "," line pos))
(hugoista--in-quotes-p pos line))
(setq pos (1+ pos)))
pos))
(defun hugoista--parse-csv-to-list (line)
"Parse LINE as CSV.
Handles quoted fields with embedded commas and escaped quotes."
(let ((fields nil)
(start 0)
(end (length line)))
(while (< start end)
(let* ((next-comma (hugoista--next-comma line start))
(field-end (or next-comma end))
(field (substring line start field-end))
(cleaned-field
(if (string-match "\\`\"\\(.*\\)\"\\'" field)
(replace-regexp-in-string "\"\"" "\""
(match-string 1 field))
field)))
(push cleaned-field fields)
(setq start (if next-comma (1+ field-end) end))))
(nreverse fields)))
(defun hugoista--list-content (&optional restrict)
"Obtain a list of site content from Hugo.
The set of content returned can optionally be constrained via RESTRICT,
which is a cons of the form (KIND . SECTION). Here, KIND is a Hugo page
kind, and Section is a Hugo section. Both are strings. When RESTRICT
is supplied, only content with matching KIND and SECTION values is
returned. Setting either of the two to nil, or an empty string acts as
a wildcard matching all values of the respective attribute.
This function calls `hugo list' to obtain a list of site content, and
parses the Hugo output into a grouped list containing `post-desc'
structures, which is returned. It is a list of the form:
\((drafts (POST ...)) (future (POST ...))
(published (POST ...)) (expired (POST ...)))
The car of each element is a symbol representing one of the possible
publication states of a Hugo content element. The cdr is a list of
`post-desc' structs, or nil when there are no matching elements with the
respective publication state."
(let ((selector-list '(drafts future published expired))
(filter-kind (or (car restrict) ""))
(filter-section (or (cdr restrict) ""))
result)
(dolist (sel selector-list)
(let (bol eol members)
(with-temp-buffer
(call-process hugoista-hugo-command nil t nil
;; command line args
"list" (symbol-name sel))
(goto-char (point-min))
;; skip header line
(forward-line 1)
;; parse all subsequent lines
(setq bol (pos-bol)
eol (pos-eol))
(while (not (= bol eol))
(seq-let (path slug title date expiry-date publish-date draft
permalink kind section)
(hugoista--parse-csv-to-list (buffer-substring-no-properties bol eol))
;; Drafts with a future release date appear in both,
;; `hugo list drafts', and `hugo list future'. To avoid
;; double appearance in the hugoista buffer, show such
;; entries under drafts only.
(if (or (not (eq sel 'future))
(not (string= draft "true")))
;; filter entries based on criteria in RESTRICT;
;; empty value matches all
(when (or (and (string-empty-p filter-kind)
(string-empty-p filter-section))
(and (string= filter-kind kind)
(string= filter-section section))
(and (not (string-empty-p filter-kind))
(string-empty-p filter-section)
(string= filter-kind kind))
(and (string-empty-p filter-kind)
(not (string-empty-p filter-section))
(string= filter-section section)))
(setq members (push (make-post-desc :path path
:slug slug
:title title
:date date
:expiry-date expiry-date
:publish-date publish-date
:draft draft
:permalink permalink
:kind kind
:section section)
members)))))
(forward-line 1)
(setq bol (pos-bol)
eol (pos-eol)))
(setq members (nreverse members)
result (push (list sel members) result)))))
(nreverse result)))
(defun hugoista--list-posts ()
"Obtain a list of post pages from Hugo."
(hugoista--list-content (cons "page" hugoista-posts-dir)))
(defun hugoista--tabulated-list-groups (content-groups)
"Produce a structure suitable to set to the variable `tabulated-list-groups'.
CONTENT-GROUPS is a grouped list in the format produced by
`hugoista--list-content'."
(mapcar (lambda (group)
(cons (upcase (symbol-name (car group)))
(mapcar (lambda (member)
(list member
(vector (substring-no-properties
(post-desc-date member) 0 10)
(if (string= (post-desc-date member)
(post-desc-publish-date member))
""
(substring-no-properties
(post-desc-publish-date member) 0 10))
(if (string= "0001-01-01T00:00:00Z"
(post-desc-expiry-date member))
""
(substring-no-properties
(post-desc-expiry-date member) 0 10))
(post-desc-title member))))
(cadr group))))
content-groups))
(defun hugoista--content-dir ()
"Query Hugo for the top-level content directory of the site."
(with-temp-buffer
(call-process hugoista-hugo-command nil t nil
;; command line args
"config")
(goto-char (point-min))
(when (re-search-forward "^contentdir[ \t]*=[ \t]*'\\([^']+\\)'$" nil t)
(match-string-no-properties 1))))
(defun hugoista--hugo-site-dir-p (dir)
"Test whether DIR is a Hugo site directory."
(let* ((cfgrx (rx bos
(or "hugo" "config")
"."
(or "toml" (seq "y" (opt "a") "ml"))
eos))
(cfgsubdir (expand-file-name "config" dir))
(rootcfgs (length (directory-files dir nil cfgrx t)))
(subdircfgs (if (file-directory-p cfgsubdir)
(length (directory-files-recursively cfgsubdir cfgrx
nil nil t))
0)))
(> (+ rootcfgs subdircfgs) 0)))
;;;; Interactive Functions
(defun hugoista-reload (&optional _ignore-auto _noconfirm)
"Refresh the contents of a `hugoista-mode' buffer."
(interactive)
(setq tabulated-list-groups
(hugoista--tabulated-list-groups
(hugoista--list-posts)))
(tabulated-list-print t t))
(defun hugoista-visit-post ()
"Visit the file containing the Hugo post in the current row."
(interactive)
(find-file (expand-file-name (post-desc-path (tabulated-list-get-id))
hugoista-site-dir)))
(defun hugoista-new-post ()
"Create a new Hugo post file, querying for the file name."
(interactive)
(let* ((content-dir (expand-file-name (hugoista--content-dir)
hugoista-site-dir))
(posts-dir (file-name-as-directory (expand-file-name hugoista-posts-dir
content-dir)))
(new-file (read-file-name "New post file " posts-dir))
(new-post (file-relative-name new-file content-dir)))
(with-temp-buffer
(call-process hugoista-hugo-command nil t nil
;; command line args
"new" "content" new-post))
(hugoista-reload)))
;;;; Tabulated List Derived Major Mode, and Entry Point Listing Function
(defun hugoista--date-less-p (e1 e2)
"Sorting predicate for the date column in `hugoista-mode'.
E1 and E2 are `post-desc' structs."
(let ((t1 (date-to-time (post-desc-date (car e1))))
(t2 (date-to-time (post-desc-date (car e2)))))
(time-less-p t1 t2)))
(defun hugoista--publish-date-less-p (e1 e2)
"Sorting predicate for the publication date column in `hugoista-mode'.
E1 and E2 are `post-desc' structs."
(let ((t1 (date-to-time (post-desc-publish-date (car e1))))
(t2 (date-to-time (post-desc-publish-date (car e2)))))
(time-less-p t1 t2)))
(defun hugoista--expiry-date-less-p (e1 e2)
"Sorting predicate for the expiration date column in `hugoista-mode'.
E1 and E2 are `post-desc' structs."
(let ((t1 (date-to-time (post-desc-expiry-date (car e1))))
(t2 (date-to-time (post-desc-expiry-date (car e2)))))
(time-less-p t1 t2)))
(defvar-keymap hugoista-mode-map
:doc "Keymap for buffers created by `hugoista'."
:parent tabulated-list-mode-map
"RET" #'hugoista-visit-post
"N" #'hugoista-new-post
"+" #'hugoista-new-post)
(define-derived-mode hugoista-mode tabulated-list-mode "Hugoista"
"Major mode for buffers made by \\[hugoista]."
:interactive nil
(setq tabulated-list-format `[("Posted" 12 ,#'hugoista--date-less-p)
("Releases" 12 ,#'hugoista--publish-date-less-p)
("Expires" 12 ,#'hugoista--expiry-date-less-p)
("Title" 0 t)])
(setq tabulated-list-padding 3)
(setq tabulated-list-sort-key '("Posted" . hugoista-initial-sort))
(tabulated-list-init-header))
;;;###autoload
(defun hugoista (&optional dir)
"Manage posts on a Hugo-powered site like a barista.
Presents all posts of the Hugo site rooted at DIR (defaulting to
`hugoista-site-dir') in a tabular overview, categorised by post status.
Posts can be acted upon using the following keys:
\\<hugoista-mode-map>
- \\[hugoista-visit-post] visits the file containing the post
- \\[hugoista-new-post] creates a new post, querying for a file name
\\<tabulated-list-mode-map>
The list buffer uses major mode `hugoista-mode', which is derived from
`tabulated-list-mode'. Thus, all the usual key bindings for tabulated
lists are available in `hugoista-mode-map', such as for instance
\\[revert-buffer] for updating the table contents after one or more of the files have
been modified."
(interactive)
(let ((buffer-dir (file-name-as-directory (or dir hugoista-site-dir))))
(if (not (hugoista--hugo-site-dir-p buffer-dir))
(message "No Hugo site found at \"%s\"" buffer-dir)
(if (not (executable-find hugoista-hugo-command))
(message "Hugo executable \"%s\" not found" hugoista-hugo-command)
(let ((buf (get-buffer-create
(format "*%s*" (abbreviate-file-name buffer-dir)))))
(set-buffer buf)
(hugoista-mode)
(setq hugoista-site-dir buffer-dir
default-directory buffer-dir)
(setq-local revert-buffer-function #'hugoista-reload)
(revert-buffer)
(switch-to-buffer buf))))))
(provide 'hugoista)
;;; hugoista.el ends here
|