X-Git-Url: https://git.korewanetadesu.com/?p=pelican-mode.git;a=blobdiff_plain;f=pelican-mode.el;h=893fc2901245d7ef45b36e8ab619f917849ea330;hp=ddc21ea5fca13b0a4cc2a0ae601c38a84a2737a7;hb=0eeee74fd9a459dd8c04c264c183d80f2d2b98b2;hpb=c1a3b1044b0fc327ecfc2c5104a283201d7a0b7a diff --git a/pelican-mode.el b/pelican-mode.el index ddc21ea..893fc29 100644 --- a/pelican-mode.el +++ b/pelican-mode.el @@ -1,174 +1,286 @@ -;;; pelican-mode.el --- Minor mode for editing pages and posts in Pelican sites +;;; pelican-mode.el --- Minor mode for editing Pelican sites -*- lexical-binding: t -*- ;; -;; Author: Joe Wreschnig -;; This code is released into the public domain. +;; Copyright 2013-2017 Joe Wreschnig +;; +;; Author: Joe Wreschnig +;; Package-Version: 20170807 +;; Package-Requires: ((emacs "25")) +;; URL: https://git.korewanetadesu.com/pelican-mode.git +;; Keywords: convenience, editing +;; +;; 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 . + + ;;; Commentary: ;; -;; Probably, this doesn't handle a lot of error cases. I also never -;; tested it on networked drives and the lookup for pelicanconf.py -;; might slow it down considerably. +;; pelican-mode is an Emacs minor mode for editing articles and pages +;; in Pelican sites. Pelican is a static site generator which can +;; process a variety of text file formats. For more information, see +;; URL https://blog.getpelican.com/. +;; +;; It's intended to be used alongside a major mode for the Pelican +;; document. Currently supported formats are Markdown, +;; reStructuredText, AsciiDoc, and Org. It also assumes you've set up +;; Pelican with ``pelican-quickstart'' or something like it. In +;; particular it expects: +;; +;; * The existence of ``pelicanconf.py'' and ``Makefile'' in some +;; ancestor directory. +;; * The first component of the path (e.g. ``content'') after that +;; ancestor is irrelevant. +;; * If the next component is ``pages'', that indicates a page +;; rather than an article. +;; +;; To enable by default on all text files in a Pelican site: +;; +;; (require 'pelican-mode) +;; (pelican-global-mode) +;; +;; Or, register `pelican-mode' or `pelican-mode-enable-if-site' +;; as hook functions for more direct control. + + ;;; Code: -(defun pelican-timestamp-now () - "Generate a Pelican-compatible timestamp." - (format-time-string "%Y-%m-%d %H:%M")) - -(defun pelican-is-markdown () - "Check if the buffer is likely using Markdown." - (eq major-mode 'markdown-mode)) - -(defun pelican-field (name value) - "Helper to format a field NAME and VALUE." - (if value (format "%s: %s\n" name value) "")) - -(defun pelican-markdown-header (title date status category tags slug) - "Generate a Pelican Markdown header. - -All parameters but TITLE may be nil to omit them. DATE may be a -string or 't to use the current date and time." - (let ((title (format "Title: %s\n" title)) - (status (pelican-field "Status" status)) - (category (pelican-field "Category" category)) - (tags (pelican-field "Tags" tags)) - (slug (pelican-field "Slug" slug)) - (date (if date (format "Date: %s\n" - (if (stringp date) date - (pelican-timestamp-now))) - ""))) - (concat title date status tags category slug "\n"))) - -(defun pelican-rst-header (title date status category tags slug) - "Generate a Pelican reStructuredText header. - -All parameters but TITLE may be nil to omit them. DATE may be a -string or 't to use the current date and time." - (let ((title (format "%s\n%s\n\n" title - (make-string (string-width title) ?#))) - (status (pelican-field ":status" status)) - (category (pelican-field ":category" category)) - (tags (pelican-field ":tags" tags)) - (slug (pelican-field ":slug" slug)) - (date (if date (format ":date: %s\n" - (if (stringp date) date - (pelican-timestamp-now))) - ""))) - (concat title date status tags category slug "\n"))) - -(defun pelican-insert-draft-post-header (title tags) - "Insert a Pelican header for a draft post." - (interactive "sPost title: \nsTags: ") - (let ((slug (pelican-default-slug)) - (header (if (pelican-is-markdown) - 'pelican-markdown-header 'pelican-rst-header))) - (save-excursion - (goto-char 0) - (insert (funcall header title 't "draft" nil tags slug))))) - -(defun pelican-insert-page-header (title hidden) - "Insert a Pelican header for a page." - (interactive - (list (read-string "Page title: ") - (y-or-n-p "Hidden? "))) - (let ((slug (pelican-default-slug)) - (hidden (if hidden "hidden" nil)) - (header (if (pelican-is-markdown) - 'pelican-markdown-header 'pelican-rst-header))) +(require 'seq) +(require 'subr-x) + +;; Customizations + +(defgroup pelican-mode nil + "Support for Pelican articles and pages. + +For more information about Pelican see URL https://blog.getpelican.com/." + :group 'convenience) + +(defcustom pelican-mode-keymap-prefix (kbd "C-c P") + "Pelican mode keymap prefix." + :group 'pelican-mode + :type 'string) + +(defcustom pelican-mode-default-page-fields + '(:slug slug) + "Fields to include when creating a new page. + +See the documentation for `pelican-mode-set-field' for more information +about metadata fields and special values." + :group 'pelican-mode + :type '(plist)) + +(defcustom pelican-mode-default-article-fields + '(:date now :status "draft" :slug slug) + "Fields to include when creating a new article. + +See the documentation for `pelican-mode-set-field' for more information +about metadata fields and special values." + :group 'pelican-mode + :type '(plist)) + +(defcustom pelican-mode-formats + '((adoc-mode . pelican-mode-set-field-adoc-mode) + (markdown-mode . pelican-mode-set-field-markdown-mode) + (org-mode . pelican-mode-set-field-org-mode) + (rst-mode . pelican-mode-set-field-rst-mode)) + "Functions to handle setting metadata, based on major mode. + +This association list maps modes to functions that take two +arguments, field and value strings." + :group 'pelican-mode + :type '(alist :key-type function :value-type function)) + + + +;; Mode Definition + +(defvar pelican-mode-command-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "d") #'pelican-mode-update-date) + (define-key map (kbd "f") #'pelican-mode-set-field) + (define-key map (kbd "h") #'pelican-make-html) + (define-key map (kbd "n") #'pelican-mode-insert-header) + (define-key map (kbd "p") #'pelican-mode-publish) + (define-key map (kbd "u") #'pelican-make-rsync-upload) + map) + "Keymap for Pelican commands after `pelican-mode-keymap-prefix'.") +(fset 'pelican-mode-command-map pelican-mode-command-map) + +(defvar pelican-mode-map + (let ((map (make-sparse-keymap))) + (define-key map pelican-mode-keymap-prefix + 'pelican-mode-command-map) + map) + "Keymap for Pelican mode.") + +;;;###autoload +(define-minor-mode pelican-mode + "Toggle Pelican mode. +With a prefix argument ARG, enable Pelican mode if ARG is +positive, and disable it otherwise. If called from Lisp, enable +the mode if ARG is omitted or nil. + +Pelican is a static site generator which can process a variety of +text file formats. For more information, see URL +https://blog.getpelican.com/. + +Rather than manually enabling this mode, you may wish to use +`pelican-global-mode' or `pelican-mode-enable-if-site'. + +When Pelican mode is enabled, additional commands are available +for editing articles or pages: + +\\{pelican-mode-map}" + :keymap pelican-mode-map + :lighter " Pelican") + +;;;###autoload +(define-minor-mode pelican-global-mode + "Toggle Pelican global mode. +With a prefix argument ARG, enable Pelican global mode if ARG is +positive, and disable it otherwise. If called from Lisp, enable +the mode if ARG is omitted or nil. + +Pelican is a static site generator which can process a variety of +text file formats. For more information, see URL +https://blog.getpelican.com/. + +When Pelican global mode is enabled, text files which seem to +be part of a Pelican site will have `pelican-mode' automatically +enabled. + +If you disable this, you may still enable `pelican-mode' manually +or add `pelican-mode-enable-if-site' to more specific mode +hooks." + :global t + :group 'pelican-mode + (if pelican-global-mode + (add-hook 'text-mode-hook #'pelican-mode-enable-if-site) + (remove-hook 'text-mode-hook #'pelican-mode-enable-if-site))) + +;;;###autoload +(defun pelican-mode-enable-if-site () + "Enable `pelican-mode' if this buffer is part of a Pelican site. + +Pelican sites are detected by looking for a file named `pelicanconf.py' +in an ancestor directory." + (when (pelican-mode-find-root) + (pelican-mode))) + + + +;; User Commands + +(defun pelican-mode-set-field (field value) + "Set FIELD to VALUE. + +FIELD may be a string or a symbol; if it is a symbol, the +symbol name is used (removing a leading ':' if present). + +When called from Lisp, VALUE may be any value; except for the +following special values, the unquoted printed representation of +it is used: + +- `now' means the current time. + +- `slug' means the file's path relative to the document root sans + extension; see `pelican-mode-default-slug'. + +- nil or an empty string removes the field. + +The buffer must be in a format listed in `pelican-mode-formats' +for this function to work correctly." + (interactive "sField: \nsValue: ") + (setq value (pcase value + ('now (format-time-string "%Y-%m-%d %H:%M")) + ('slug (pelican-mode-default-slug)) + ('"" nil) + (_ value))) + (when (symbolp field) + (setq field (string-remove-prefix ":" (symbol-name field)))) + (let ((set-field + (assoc-default nil pelican-mode-formats #'derived-mode-p))) + (unless set-field + (error "Unsupported major mode %S" major-mode)) (save-excursion (goto-char 0) - (insert (funcall header title nil hidden nil nil slug))))) + (funcall set-field field value)))) -(defun pelican-insert-header () - "Insert a Pelican header for a page or post." - (interactive) - (call-interactively (if (pelican-is-page) - 'pelican-insert-page-header - 'pelican-insert-draft-post-header))) +(defun pelican-mode-remove-field (field) + "Remove FIELD." + (interactive "sField: ") + (pelican-mode-set-field field nil)) -(defun pelican-update-date () - "Update a Pelican date header." +(defun pelican-mode-set-title (title) + "Set the title to TITLE." + (interactive "sTitle: ") + (pelican-mode-set-field :title title)) + +(defun pelican-mode-update-date (&optional original) + "Update the document's modification date. + +If ORIGINAL is non-nil, the publication date is updated rather +than the modification date." + (interactive "P") + (pelican-mode-set-field (if original :date :modified) 'now)) + +(defun pelican-mode-publish () + "Remove draft or hidden status from a Pelican article." (interactive) + (pelican-mode-remove-field :status) + (pelican-mode-update-date :date)) + +(defun pelican-mode-insert-article-header (title tags) + "Insert a Pelican header for an article with a TITLE and TAGS." + (interactive "sArticle title: \nsTags: ") (save-excursion (goto-char 0) - (let* ((field (if (pelican-is-markdown) "Date" ":date")) - (re (format "^%s: [-0-9 :]+\n" field)) - (date (pelican-timestamp-now))) - (if (re-search-forward re nil t) - (replace-match (format "%s: %s\n" field date)) - (message "This doesn't look like a Pelican page."))))) - -(defun pelican-publish-draft () - "Remove draft status from a Pelican post." - (interactive) + (insert "\n") + (apply #'pelican-mode-set-fields + `(:title ,title + ,@pelican-mode-default-article-fields + :tags ,tags)))) + +(defun pelican-mode-insert-page-header (title &optional hidden) + "Insert a Pelican header for a page with a TITLE. + +If HIDDEN is non-nil, the page is marked hidden; otherwise it +has no status." + (interactive "sPage title: \nP") (save-excursion (goto-char 0) - (let* ((field (if (pelican-is-markdown) "Status" ":status")) - (re (format "^%s: draft\n" field))) - (if (re-search-forward re nil t) - (progn - (replace-match (format "")) - (pelican-update-date)) - (message "This doesn't look like a Pelican draft."))))) - -(defun pelican-is-page () - "Guess the current buffer is a Pelican page (vs. a post or neither)." - (let ((pelican-base (pelican-find-root))) - (if pelican-base - (let* ((relative (file-relative-name buffer-file-name pelican-base)) - (components (split-string relative "/"))) - (string= "pages" (car (cdr components))))))) - -(defun pelican-default-slug () - "Generate a Pelican post/page slug for the current buffer." - (let ((pelican-base (pelican-find-root)) - (file-name (file-name-sans-extension buffer-file-name))) - (if pelican-base - (let* ((relative (file-relative-name file-name pelican-base)) - (components (cdr (split-string relative "/"))) - (components (if (string= "pages" (car components)) - (cdr components) components))) - (mapconcat 'identity components "/")) - (format "%s/%s" - (file-name-nondirectory - (directory-file-name - (file-name-directory file-name))) - (file-name-base file-name))))) - -(defun pelican-find-in-parents (file-name) - "Find FILE-NAME in the default directory or one of its parents, or nil." - (let* ((parent (expand-file-name default-directory))) - (while (and (not (file-readable-p (concat parent file-name))) - (not (string= parent (directory-file-name parent)))) - (setq parent (file-name-directory (directory-file-name parent)))) - (let ((found (concat parent file-name))) - (if (file-readable-p found) found nil)))) - -(defun pelican-find-root () - "Return the root of the buffer's Pelican site, or nil." - (let ((conf (pelican-find-in-parents "pelicanconf.py"))) - (if conf (file-name-directory conf)))) - -(defun pelican-is-in-site () - "Check if this buffer is under a Pelican site." - (not (not (pelican-find-root)))) + (insert "\n") + (apply #'pelican-mode-set-fields + (append + (list :title title :status (when hidden "hidden")) + pelican-mode-default-page-fields)))) -(defun pelican-enable-if-site () - "Enable `pelican-mode' if this buffer is under a Pelican site." - (if (pelican-is-in-site) - (pelican-mode 1))) +(defun pelican-mode-insert-header () + "Insert a Pelican header for a page or article." + (interactive) + (call-interactively + (if (pelican-mode-page-p) + #'pelican-mode-insert-page-header + #'pelican-mode-insert-article-header))) (defun pelican-make (target) "Execute TARGET in a Makefile at the root of the site." (interactive "sMake Pelican target: ") - (let ((default-directory (pelican-find-root))) - (if default-directory - (let ((output (get-buffer-create "*Pelican Output*"))) - (display-buffer output) - (pop-to-buffer output) - (compilation-mode) - (start-process "Pelican Makefile" output "make" target)) - (message "This doesn't look like a Pelican site.")))) + (if-let (default-directory (pelican-mode-find-root)) + (compilation-start (format "make %s" target) + nil (lambda (_) "*pelican*")) + (user-error "No Pelican site root could be found"))) (defun pelican-make-html () "Generate HTML via a Makefile at the root of the site." @@ -180,34 +292,105 @@ string or 't to use the current date and time." (interactive) (pelican-make "rsync_upload")) -(defconst pelican-keymap (make-sparse-keymap) - "The default keymap used in Pelican mode.") -(define-key pelican-keymap [?\C-x ?p ?n] - 'pelican-insert-header) -(define-key pelican-keymap [?\C-x ?p ?p] - 'pelican-publish-draft) -(define-key pelican-keymap [?\C-x ?p ?t] - 'pelican-update-date) -(define-key pelican-keymap [?\C-x ?p ?h] - 'pelican-make-html) -(define-key pelican-keymap [?\C-x ?p ?u] - 'pelican-make-rsync-upload) + -(define-minor-mode pelican-mode - "Toggle Pelican mode. +(defun pelican-mode-set-fields (&rest fields) + "Insert a Pelican header for an article with metadata FIELDS." + (mapc (apply-partially #'apply #'pelican-mode-set-field) + (seq-partition fields 2))) -Interactively with no argument, this command toggles the mode. -to show buffer size and position in mode-line. -" - :init-value nil - :lighter " Pelican" - :keymap pelican-keymap - :group 'pelican - ) +(defun pelican-mode-set-field-rst-mode (field value) + "Set reStructuredText metadata FIELD to VALUE." + (setq field (downcase field)) + (if (equal field "title") + (let ((header (format "%s\n%s\n\n" + value (make-string (string-width value) ?#)))) + (if (looking-at ".*\n#+\n+") + (replace-match header) + (insert header))) + (let ((text (when value (format ":%s: %s\n" field value)))) + (when (looking-at "^.*\n#") + (forward-line 3)) + (if (re-search-forward (format "^:%s:.*\n" (regexp-quote field)) nil t) + (replace-match (or text "")) + (when text + (if (re-search-forward "^$" nil t) + (replace-match text) + (insert text))))))) -(add-hook 'markdown-mode-hook 'pelican-enable-if-site) -(add-hook 'rst-mode-hook 'pelican-enable-if-site) +(defun pelican-mode-set-field-markdown-mode (field value) + "Set Markdown metadata FIELD to VALUE." + (setq field (capitalize field)) + (let ((text (when value (format "%s: %s\n" field value)))) + (if (re-search-forward (format "^%s:.*\n" (regexp-quote field)) nil t) + (replace-match text) + (when value + (if (re-search-forward "^$" nil t) + (replace-match text) + (insert text)))))) -(provide 'pelican-mode) +(defun pelican-mode-set-field-adoc-mode (field value) + "Set AsciiDoc metadata FIELD to VALUE." + (setq field (downcase field)) + (if (equal field "title") + (let ((header (format "= %s\n\n" value))) + (if (looking-at "= .*\n\n+") + (replace-match header) + (insert header))) + (let ((text (when value (format ":%s: %s\n" field value)))) + (when (looking-at "^=") + (forward-line 2)) + (if (re-search-forward (format "^:%s:.*\n" (regexp-quote field)) nil t) + (replace-match (or text "")) + (when text + (if (re-search-forward "^$" nil t) + (replace-match text) + (insert text))))))) +(defun pelican-mode-set-field-org-mode (field value) + "Set Org global metadata FIELD to VALUE." + ;; None of org-mode's functions I can find for setting properties + ;; operate on the global list, only a single property drawer. + (setq field (upcase field)) + (setq field + (format (if (member field '("TITLE" "DATE" "CATEGORY" "AUTHOR")) + "#+%s:" + "#+PROPERTY: %s") + field)) + (let ((text (when value (format "%s %s\n" field value)))) + (if (re-search-forward (format "^%s .*\n" (regexp-quote field)) nil t) + (replace-match (or text "")) + (when text + (if (re-search-forward "^$" nil t) + (replace-match text) + (insert text)))))) + +(defun pelican-mode-page-p () + "Return non-nil the current buffer is a Pelican page." + (string-match-p + "^[^/]+/pages/" + (file-relative-name + (abbreviate-file-name (or (buffer-file-name) (buffer-name))) + (pelican-mode-find-root)))) + +(defun pelican-mode-default-slug () + "Generate a Pelican slug for the current buffer." + (file-name-sans-extension + (replace-regexp-in-string + "^[^/]+/\\(?:pages/\\)?" "" + (file-relative-name + (abbreviate-file-name (or (buffer-file-name) (buffer-name))) + (pelican-mode-find-root))))) + +(defun pelican-mode-find-root () + "Return the root of the buffer's Pelican site, or nil." + (locate-dominating-file default-directory "pelicanconf.py")) + +(provide 'pelican-mode) ;;; pelican-mode.el ends here + + + +;; Local Variables: +;; sentence-end-double-space: t +;; End: