cmc/cleberg.net

My personal web garden & blog.

clone: git clone https://gitbay.org/cmc/cleberg.net.git

70abefc0a63967ff5b2d57050538b16d701696c6

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2023-12-03T05:26:58Z

feat: add initial RSS support and centralize project settings
 elisp/ox-rss.el  | 434 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 elisp/publish.el |  58 ++++++++
 2 files changed, 492 insertions(+)

diff --git a/elisp/ox-rss.el b/elisp/ox-rss.el
new file mode 100644
index 0000000..df7aeb6
--- /dev/null
+++ b/elisp/ox-rss.el
@@ -0,0 +1,434 @@
+;;; ox-rss.el --- RSS 2.0 Back-End for Org Export Engine
+
+;; Copyright (C) 2013-2021 Free Software Foundation, Inc.
+
+;; Author: Bastien Guerry <bzg@gnu.org>
+;; Maintainer: Benedict Wang <foss@bhw.name>
+;; Version: 0.0.1
+;; Package-Requires: ((emacs "26.1") (org "9.3"))
+;; Keywords: org, wp, blog, feed, rss
+;; Homepage: https://github.com/benedicthw/ox-rss.git
+
+;; 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 GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This library implements an RSS 2.0 back-end for Org exporter, based
+;; on the `html' back-end.
+;;
+;; It requires Emacs 24.1 at least.
+;;
+;; It provides two commands for export, depending on the desired output:
+;; `org-rss-export-as-rss' (temporary buffer) and `org-rss-export-to-rss'
+;; (as a ".xml" file).
+;;
+;; This backend understands three new option keywords:
+;;
+;; #+RSS_EXTENSION: xml
+;; #+RSS_IMAGE_URL: https://myblog.org/mypicture.jpg
+;; #+RSS_FEED_URL: https://myblog.org/feeds/blog.xml
+;;
+;; It uses #+HTML_LINK_HOME: to set the base url of the feed.
+;;
+;; Exporting an Org file to RSS modifies each top-level entry by adding a
+;; PUBDATE property.  If `org-rss-use-entry-url-as-guid', it will also add
+;; an ID property, later used as the guid for the feed's item.
+;;
+;; The top-level headline is used as the title of each RSS item unless
+;; an RSS_TITLE property is set on the headline.
+;;
+;; You typically want to use it within a publishing project like this:
+;;
+;; (add-to-list
+;;  'org-publish-project-alist
+;;  '("homepage_rss"
+;;    :base-directory "~/myhomepage/"
+;;    :base-extension "org"
+;;    :rss-image-url "http://lumiere.ens.fr/~guerry/images/faces/15.png"
+;;    :html-link-home "http://lumiere.ens.fr/~guerry/"
+;;    :html-link-use-abs-url t
+;;    :rss-extension "xml"
+;;    :publishing-directory "/home/guerry/public_html/"
+;;    :publishing-function (org-rss-publish-to-rss)
+;;    :section-numbers nil
+;;    :exclude ".*"            ;; To exclude all files...
+;;    :include ("index.org")   ;; ... except index.org.
+;;    :table-of-contents nil))
+;;
+;; ... then rsync /home/guerry/public_html/ with your server.
+;;
+;; By default, the permalink for a blog entry points to the headline.
+;; You can specify a different one by using the :RSS_PERMALINK:
+;; property within an entry.
+
+;;; Code:
+
+(require 'ox-html)
+(declare-function url-encode-url "url-util" (url))
+
+;;; Variables and options
+
+(defgroup org-export-rss nil
+  "Options specific to RSS export back-end."
+  :tag "Org RSS"
+  :group 'org-export
+  :version "24.4"
+  :package-version '(Org . "8.0"))
+
+(defcustom org-rss-image-url "https://orgmode.org/img/org-mode-unicorn-logo.png"
+  "The URL of the image for the RSS feed."
+  :group 'org-export-rss
+  :type 'string)
+
+(defcustom org-rss-extension "xml"
+  "File extension for the RSS 2.0 feed."
+  :group 'org-export-rss
+  :type 'string)
+
+(defcustom org-rss-categories 'from-tags
+  "Where to extract items category information from.
+The default is to extract categories from the tags of the
+headlines.  When set to another value, extract the category
+from the :CATEGORY: property of the entry."
+  :group 'org-export-rss
+  :type '(choice
+	  (const :tag "From tags" from-tags)
+	  (const :tag "From the category property" from-category)))
+
+(defcustom org-rss-use-entry-url-as-guid t
+  "Use the URL for the <guid> metatag?
+When nil, Org will create ids using `org-icalendar-create-uid'."
+  :group 'org-export-rss
+  :type 'boolean)
+
+;;; Define backend
+
+(org-export-define-derived-backend 'rss 'html
+  :menu-entry
+  '(?r "Export to RSS"
+       ((?R "As RSS buffer"
+	    (lambda (a s v b) (org-rss-export-as-rss a s v)))
+	(?r "As RSS file" (lambda (a s v b) (org-rss-export-to-rss a s v)))
+	(?o "As RSS file and open"
+	    (lambda (a s v b)
+	      (if a (org-rss-export-to-rss t s v)
+		(org-open-file (org-rss-export-to-rss nil s v)))))))
+  :options-alist
+  '((:description "DESCRIPTION" nil nil newline)
+    (:keywords "KEYWORDS" nil nil space)
+    (:with-toc nil nil nil) ;; Never include HTML's toc
+    (:rss-extension "RSS_EXTENSION" nil org-rss-extension)
+    (:rss-image-url "RSS_IMAGE_URL" nil org-rss-image-url)
+    (:rss-feed-url "RSS_FEED_URL" nil nil t)
+    (:rss-categories nil nil org-rss-categories))
+  :filters-alist '((:filter-final-output . org-rss-final-function))
+  :translate-alist '((headline . org-rss-headline)
+		     (comment . (lambda (&rest args) ""))
+		     (comment-block . (lambda (&rest args) ""))
+		     (timestamp . (lambda (&rest args) ""))
+		     (plain-text . org-rss-plain-text)
+		     (section . org-rss-section)
+		     (template . org-rss-template)))
+
+;;; Export functions
+
+;;;###autoload
+(defun org-rss-export-as-rss (&optional async subtreep visible-only)
+  "Export current buffer to an RSS buffer.
+
+If narrowing is active in the current buffer, only export its
+narrowed part.
+
+If a region is active, export that region.
+
+A non-nil optional argument ASYNC means the process should happen
+asynchronously.  The resulting buffer should be accessible
+through the `org-export-stack' interface.
+
+When optional argument SUBTREEP is non-nil, export the sub-tree
+at point, extracting information from the headline properties
+first.
+
+When optional argument VISIBLE-ONLY is non-nil, don't export
+contents of hidden elements.
+
+Export is done in a buffer named \"*Org RSS Export*\", which will
+be displayed when `org-export-show-temporary-export-buffer' is
+non-nil."
+  (interactive)
+  (let ((file (buffer-file-name (buffer-base-buffer))))
+    (org-icalendar-create-uid file 'warn-user)
+    (org-rss-add-pubdate-property))
+  (org-export-to-buffer 'rss "*Org RSS Export*"
+    async subtreep visible-only nil nil (lambda () (text-mode))))
+
+;;;###autoload
+(defun org-rss-export-to-rss (&optional async subtreep visible-only)
+  "Export current buffer to an RSS file.
+
+If narrowing is active in the current buffer, only export its
+narrowed part.
+
+If a region is active, export that region.
+
+A non-nil optional argument ASYNC means the process should happen
+asynchronously.  The resulting file should be accessible through
+the `org-export-stack' interface.
+
+When optional argument SUBTREEP is non-nil, export the sub-tree
+at point, extracting information from the headline properties
+first.
+
+When optional argument VISIBLE-ONLY is non-nil, don't export
+contents of hidden elements.
+
+Return output file's name."
+  (interactive)
+  (let ((file (buffer-file-name (buffer-base-buffer))))
+    (org-icalendar-create-uid file 'warn-user)
+    (org-rss-add-pubdate-property))
+  (let ((outfile (org-export-output-file-name
+		  (concat "." org-rss-extension) subtreep)))
+    (org-export-to-file 'rss outfile async subtreep visible-only)))
+
+;;;###autoload
+(defun org-rss-publish-to-rss (plist filename pub-dir)
+  "Publish an org file to RSS.
+
+FILENAME is the filename of the Org file to be published.  PLIST
+is the property list for the given project.  PUB-DIR is the
+publishing directory.
+
+Return output file name."
+  (let ((bf (get-file-buffer filename)))
+    (if bf
+	  (with-current-buffer bf
+	    (org-icalendar-create-uid filename 'warn-user)
+	    (org-rss-add-pubdate-property)
+	    (write-file filename))
+      (find-file filename)
+      (org-icalendar-create-uid filename 'warn-user)
+      (org-rss-add-pubdate-property)
+      (write-file filename) (kill-buffer)))
+  (org-publish-org-to
+   'rss filename (concat "." org-rss-extension) plist pub-dir))
+
+;;; Main transcoding functions
+
+(defun org-rss-headline (headline contents info)
+  "Transcode HEADLINE element into RSS format.
+CONTENTS is the headline contents.  INFO is a plist used as a
+communication channel."
+  (if (> (org-export-get-relative-level headline info) 1)
+      (org-export-data-with-backend headline 'html info)
+    (unless (org-element-property :footnote-section-p headline)
+      (let* ((email (org-export-data (plist-get info :email) info))
+	     (author (and (plist-get info :with-author)
+			  (let ((auth (plist-get info :author)))
+			    (and auth (org-export-data auth info)))))
+	     (htmlext (plist-get info :html-extension))
+	     (hl-number (org-export-get-headline-number headline info))
+	     (hl-home (file-name-as-directory (plist-get info :html-link-home)))
+	     (hl-pdir (plist-get info :publishing-directory))
+	     (hl-perm (org-element-property :RSS_PERMALINK headline))
+	     (anchor
+              (or (org-element-property :CUSTOM_ID headline)
+                  (org-export-get-reference headline info)))
+	     (category (org-rss-plain-text
+			(or (org-element-property :CATEGORY headline) "") info))
+	     (pubdate0 (org-element-property :PUBDATE headline))
+	     (pubdate (let ((system-time-locale "C"))
+			(if (and pubdate0 (not (string-empty-p pubdate0)))
+			    (format-time-string
+			     "%a, %d %b %Y %T %z"
+			     (org-time-string-to-time pubdate0)))))
+	     (title (org-rss-plain-text
+		     (or (org-element-property :RSS_TITLE headline)
+			 (replace-regexp-in-string
+			  org-link-bracket-re
+			  (lambda (m) (or (match-string 3 m)
+					  (match-string 1 m)))
+			  (org-element-property :raw-value headline))) info))
+	     (publink
+	      (or (and hl-perm (concat (or hl-home hl-pdir) hl-perm))
+		  (concat
+		   (or hl-home hl-pdir)
+		   (file-name-nondirectory
+		    (file-name-sans-extension
+		     (plist-get info :input-file))) "." htmlext "#" anchor)))
+	     (guid (if org-rss-use-entry-url-as-guid
+		       publink
+		     (org-rss-plain-text
+		      (or (org-element-property :ID headline)
+			  (org-element-property :CUSTOM_ID headline)
+			  publink)
+		      info))))
+	(if (not pubdate) "" ;; Skip entries with no PUBDATE prop
+	  (format
+	   (concat
+	    "<item>\n"
+	    "<title>%s</title>\n"
+	    "<link>%s</link>\n"
+	    "<author>%s (%s)</author>\n"
+	    "<guid isPermaLink=\"false\">%s</guid>\n"
+	    "<pubDate>%s</pubDate>\n"
+	    (org-rss-build-categories headline info) "\n"
+	    "<description><![CDATA[%s]]></description>\n"
+	    "</item>\n")
+	   title publink email author guid pubdate contents))))))
+
+(defun org-rss-build-categories (headline info)
+  "Build categories for the RSS item from INFO.
+Fallback to the HEADLINE :CATEGORY property."
+  (if (eq (plist-get info :rss-categories) 'from-tags)
+      (mapconcat
+       (lambda (c) (format "<category><![CDATA[%s]]></category>" c))
+       (org-element-property :tags headline)
+       "\n")
+    (let ((c (org-element-property :CATEGORY headline)))
+      (format "<category><![CDATA[%s]]></category>" c))))
+
+(defun org-rss-template (contents info)
+  "Return complete document string after RSS conversion.
+CONTENTS is the transcoded contents string.  INFO is a plist used
+as a communication channel."
+  (let ((style (plist-get info :rss-stylesheet)))
+	  (concat
+	   (format "<?xml version=\"1.0\" encoding=\"%s\"?>"
+			       (symbol-name org-html-coding-system))
+	   (if style
+		     (format "\n<?xml-stylesheet type=\"text/xsl\" href=\"%s\"?>"
+				         style))
+	   "\n<rss version=\"2.0\"
+	xmlns:content=\"http://purl.org/rss/1.0/modules/content/\"
+	xmlns:wfw=\"http://wellformedweb.org/CommentAPI/\"
+	xmlns:dc=\"http://purl.org/dc/elements/1.1/\"
+	xmlns:atom=\"http://www.w3.org/2005/Atom\"
+	xmlns:sy=\"http://purl.org/rss/1.0/modules/syndication/\"
+	xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\"
+	xmlns:georss=\"http://www.georss.org/georss\"
+        xmlns:geo=\"http://www.w3.org/2003/01/geo/wgs84_pos#\"
+        xmlns:media=\"http://search.yahoo.com/mrss/\">"
+	   "<channel>"
+	   (org-rss-build-channel-info info) "\n"
+	   contents
+	   "</channel>\n"
+	   "</rss>")))
+
+(defun org-rss-build-channel-info (info)
+  "Given plist INFO build the RSS channel information."
+  (let* ((system-time-locale "C")
+	 (title (org-export-data (plist-get info :title) info))
+	 (email (org-export-data (plist-get info :email) info))
+	 (author (and (plist-get info :with-author)
+		      (let ((auth (plist-get info :author)))
+			(and auth (org-export-data auth info)))))
+	 (date (format-time-string "%a, %d %b %Y %T %z")) ;; RFC 882
+	 (description (org-export-data (plist-get info :description) info))
+	 (lang (plist-get info :language))
+	 (keywords (plist-get info :keywords))
+	 (rssext (plist-get info :rss-extension))
+	 (blogurl (or (plist-get info :html-link-home)
+		      (plist-get info :publishing-directory)))
+	 (image (url-encode-url (plist-get info :rss-image-url)))
+	 (ifile (plist-get info :input-file))
+	 (publink
+	  (or (plist-get info :rss-feed-url)
+	      (concat (file-name-as-directory blogurl)
+		      (file-name-nondirectory
+		       (file-name-sans-extension ifile))
+		      "." rssext))))
+    (format
+     "\n<title>%s</title>
+<atom:link href=\"%s\" rel=\"self\" type=\"application/rss+xml\" />
+<link>%s</link>
+<description><![CDATA[%s]]></description>
+<language>%s</language>
+<pubDate>%s</pubDate>
+<lastBuildDate>%s</lastBuildDate>
+<generator>%s</generator>
+<webMaster>%s (%s)</webMaster>
+<image>
+<url>%s</url>
+<title>%s</title>
+<link>%s</link>
+</image>
+"
+     title publink blogurl description lang date date
+     (concat (format "Emacs %d.%d"
+		     emacs-major-version
+		     emacs-minor-version)
+	     " Org-mode " (org-version))
+     email author image title blogurl)))
+
+(defun org-rss-section (section contents info)
+  "Transcode SECTION element into RSS format.
+CONTENTS is the section contents.  INFO is a plist used as
+a communication channel."
+  contents)
+
+(defun org-rss-timestamp (timestamp contents info)
+  "Transcode a TIMESTAMP object from Org to RSS.
+CONTENTS is nil.  INFO is a plist holding contextual
+information."
+  (org-html-encode-plain-text
+   (org-timestamp-translate timestamp)))
+
+(defun org-rss-plain-text (contents info)
+  "Convert plain text CONTENTS into RSS encoded text.
+INFO is a plist used as a communication channel"
+  (let (output)
+    (setq output (org-html-encode-plain-text contents)
+	  output (org-export-activate-smart-quotes
+		  output :html info))))
+
+;;; Filters
+
+(defun org-rss-final-function (contents backend info)
+  "Prettify the RSS output.
+CONTENTS is the headline contents as a transcoded string.  BACKEND
+is a symbol representation of the backend used.  INFO is a plist
+used as a communication channel."
+  (with-temp-buffer
+    (xml-mode)
+    (insert contents)
+    (indent-region (point-min) (point-max))
+    (buffer-substring-no-properties (point-min) (point-max))))
+
+;;; Miscellaneous
+
+(defun org-rss-add-pubdate-property ()
+  "Set the PUBDATE property for top-level headlines."
+  (let (msg)
+    (org-map-entries
+     (lambda ()
+       (let* ((entry (org-element-at-point))
+	      (level (org-element-property :level entry)))
+	 (when (= level 1)
+	   (unless (org-entry-get (point) "PUBDATE")
+	     (setq msg t)
+	     (org-set-property
+	      "PUBDATE" (format-time-string
+			 (cdr org-time-stamp-formats)))))))
+     nil nil 'comment 'archive)
+    (when msg
+      (message "Property PUBDATE added to top-level entries in %s"
+	       (buffer-file-name))
+      (sit-for 2))))
+
+(provide 'ox-rss)
+
+;;; ox-rss.el ends here
diff --git a/elisp/publish.el b/elisp/publish.el
new file mode 100644
index 0000000..c8dbdf1
--- /dev/null
+++ b/elisp/publish.el
@@ -0,0 +1,58 @@
+;; Publish org files to HTML
+
+;; Functions
+(defun org-rss-publish-to-rss (plist filename pub-dir)
+  "Publish RSS with PLIST, only when FILENAME is 'rss.org'.
+PUB-DIR is when the output will be placed."
+  (if (equal "rss.org" (file-name-nondirectory filename))
+      (org-rss-export-to-rss plist filename pub-dir)))
+
+;; Project publishing settings
+(setq org-publish-project-alist
+      '(("cleberg.net"
+	:base-directory "~/Source/cleberg.net/"
+	:publishing-function org-html-publish-to-html
+	:publishing-directory "~/Source/cleberg.net/public/"
+	:auto-sitemap t
+	:recursive t
+	:section-numbers nil
+	:with-author nil
+	:html-validation-link nil
+	:with-tags t
+        :html-head-include-default-style nil
+        :html-head-include-scripts nil
+	:html-head "<link rel=stylesheet href=/static/styles.css.min>"
+	:html-preamble "<nav><a href=/>Home</a><a href=/blog/>Blog</a><a href=/services/>Services</a><a href=/wiki/>Wiki</a></nav>")
+
+	("rss"
+	 :base-directory "~/Source/cleberg.net/blog/"
+	 :base-extension "org"
+	 :html-link-home "https://cleberg.net/"
+	 :html-link-use-abs-url t
+	 :rss-extension "xml"
+	 :publishing-directory "~/Source/cleberg.net/public/blog/"
+	 :publishing-function org-rss-publish-to-rss
+	 :section-numbers nil
+	 :exclude "index.org"
+	 :author "cmc"
+	 :email ""
+	 ;; test
+	 :html-link-org-files-as-html t
+	 :auto-sitemap t
+         :sitemap-filename "rss.org"
+         :sitemap-title "cleberg.net"
+         :sitemap-style list
+         :sitemap-sort-files anti-chronologically
+         ;;:sitemap-function 'rw/format-rss-feed
+         ;;:sitemap-format-entry 'rw/format-rss-feed-entry
+	 ;; end test
+	 )
+
+	("static"
+	:base-directory "~/Source/cleberg.net/static/"
+	:base-extension "css\\|el"
+	:publishing-directory "~/Source/cleberg.net/public/static/"
+        :publishing-function org-publish-attachment)
+	("website" :components ("cleberg.net" "rss" "static"))))
+
+(provide 'publish)