Atomizer — Make Atom feeds for your site (Emacs package)
Below you find the latest version of (1) the package's README and (2) its main source file.
For the git repository and issue tracker, see the project's page on sr.ht.
For more packages, see Software.
README.org
Overview
Atomizer makes Atom feeds for your site.
All it needs to output an XML compatible with the Atom Syndication Format is a pair of files containing the relevant metadata for the site and for its posts.
Let's see an example.
Example
Suppose we want the Atom feed string for the tag "greeting", using as input the files site.conf
and posts.tsv
.
We have these
site.conf
:
name = My Name uri = https://authors.example.com feedtitle = Title of My Page feedsubtitle = Subtitle of My Page base = https://this.example.com/page xmldir = feed xmlext = xml xmlall = all.xml
posts.tsv
(shown here as a table, since more readable):
CUSTOMID | CREATIONDATE | LASTUPDATE | EXPORTFILENAME | EXPORTTITLE | TAGS | KEYWORDS | DESCRIPTION |
---|---|---|---|---|---|---|---|
a-new-new-post | 2042-02-17 | 2042-02-17T06:44:00Z | a-new-new-post | A new new post | :testing: | test, stuff | A new new post |
some-new-post | 2042-02-18 | 2042-02-19T03:11:17Z | some-new-post | Some new post | Some new post | ||
hello-world-2 | 2042-02-16 | 2042-02-16T04:20:00Z | hello-world-2 | Hello World 2 | :hello:world: | hello, greeting | The second post |
hello-world | 2042-02-15 | 2042-02-15T06:22:00Z | hello-world | Hello World | :hello:world: | hello, greeting | The first post |
We run this
(atomizer-feed-string-for-tag "greeting" ;; Note that all the pairs passed below are the default, ;; and therefore could just as well have been omitted: ;; stopping after "greeting" would have sufficed here. ;; I make these explicit to show the available options. :site-conf "site.conf" :posts-tsv "posts.tsv" :f-keywords "KEYWORDS" :f-export-title "EXPORT_TITLE" :f-export-fname "EXPORT_FILE_NAME" :f-description "DESCRIPTION" :f-date "CREATION_DATE" :declaration atomizer-xml-declaration-default)
We get this
<?xml version="1.0" encoding="utf-8"?> <feed xmlns="http://www.w3.org/2005/Atom"> <title>Title of My Page</title> <subtitle>Subtitle of My Page</subtitle> <author> <name>My Name</name> <uri>https://authors.example.com</uri> </author> <id>https://this.example.com/page</id> <link rel="self" href="https://this.example.com/page/feed/greeting.xml"/> <link rel="alternate" type="text/html" href="https://this.example.com/page"/> <updated>2042-02-18T12:00:00Z</updated> <entry> <title>Hello World 2</title> <id>https://this.example.com/page/hello-world-2.html</id> <updated>2042-02-16T12:00:00Z</updated> <summary>The second post</summary> <link rel="alternate" type="text/html" href="https://this.example.com/page/hello-world-2.html"/> <category term="hello"/> <category term="greeting"/> <content type="html" xml:base="https://this.example.com/page"></content> </entry> <entry> <title>Hello World</title> <id>https://this.example.com/page/hello-world.html</id> <updated>2042-02-15T12:00:00Z</updated> <summary>The first post</summary> <link rel="alternate" type="text/html" href="https://this.example.com/page/hello-world.html"/> <category term="hello"/> <category term="greeting"/> <content type="html" xml:base="https://this.example.com/page"></content> </entry> </feed>
See Create feeds below for more details.
Installation
See my page Software for the most up-to-date instructions on how to download and install any of my Emacs packages.
Having downloaded and installed the package and its dependencies, adapt the configurations below to your init.el
file.
(use-package atomizer :demand t)
Alternatively, if you don't have use-package
:
(require 'atomizer)
Summary of callables
Here's an overview of this package's callables:
Function | Summary |
---|---|
atomizer-write-feed-for-tag | Make an Atom feed for a TAG and write it to disk. |
atomizer-write-feed-for-tags | Make an Atom feed for each tag in list TAGS and write them to disk. |
atomizer-write-all-feeds | Make an Atom feed for every available tag and write them to disk. |
atomizer-feed-string-for-tag | Return Atom feed string for TAG. |
atomizer-list-tags | Based on F-KEYWORDS and POSTS-TSV, list all tags. |
atomizer-see-readme | Open atomizer's README.org file. |
atomizer-see-news | See the News in atomizer's README.org file. |
They are described in more detail below.
Functions
Create feeds
There are three things you can do.
You can create an Atom feed file for:
- a given tag;
- each tag in a list of tags; or
- each tag from all your posts.
These are covered by the next three functions.
You'll need:
- A site.conf file having a few key–value lines with information about
the site. The keys specified by the functionatomizer--site-ht
must
be there. - A posts.tsv file having information about all your posts. The keys
can be customized by you. See defcustom variables starting with
'atomizer-posts-f-'.
Until this version, the Atom feeds created do not include the full
contents of your posts, "just" the metadata with the titles and links.
This is sufficient for the feed.
Some people, however, prefer to include the full contents. If this is a
feature you'd like to see, let me know. I haven't looked into it, and it
isn't a high priority — but if you know how to do this properly, then
suggestions for solutions are welcome.
atomizer-write-feed-for-tag (tag &rest params)
Make an Atom feed for a TAG and write it to disk.
See atomizer-feed-string-for-tag for info about TAG and PARAMS.
atomizer-write-all-feeds (&rest params)
Make an Atom feed for every available tag and write them to disk.
A feed file containing all posts will also be written.
See atomizer-feed-string-for-tag for info about PARAMS.
atomizer-feed-string-for-tag (tag &rest params)
Return Atom feed string for TAG.
If TAG is the symbol 'all, make a single feed using all entries
(that is, don't filter by tag). Otherwise, TAG must be a string
representing an existing tag. In this case it'll be filtered by the tag.
PARAMS are optional and passed as key–value pairs — like this:
KEY | VALUE | Default if VALUE is nil |
---|---|---|
:site-conf | SITE-CONF | atomizer-site-conf |
:posts-tsv | POSTS-TSV | atomizer-posts-tsv |
:f-keywords | F-KEYWORDS | atomizer-posts-f-keywords |
:f-export-title | F-EXPORT-TITLE | atomizer-posts-f-export-title |
:f-export-fname | F-EXPORT-FNAME | atomizer-posts-f-export-fname |
:f-description | F-DESCRIPTION | atomizer-posts-f-description |
:f-date | F-DATE | atomizer-posts-f-date |
:declaration | DECLARATION | atomizer-xml-declaration |
This is a side-effect–free function.
It's useful for verifying what would be written to disk.
;;;; In these tests/examples, we pass all optional parameters. ;;;; This is to prevent customized variables from masking defaults. ;;;;; Only the tag "greeting" (atomizer-feed-string-for-tag "greeting" :site-conf "site.conf" :posts-tsv "posts.tsv" :f-keywords "KEYWORDS" :f-export-title "EXPORT_TITLE" :f-export-fname "EXPORT_FILE_NAME" :f-description "DESCRIPTION" :f-date "CREATION_DATE" :declaration atomizer-xml-declaration-default) => "\ <?xml version=\"1.0\" encoding=\"utf-8\"?> <feed xmlns=\"http://www.w3.org/2005/Atom\"> <title>Title of My Page</title> <subtitle>Subtitle of My Page</subtitle> <author> <name>My Name</name> <uri>https://authors.example.com</uri> </author> <id>https://this.example.com/page</id> <link rel=\"self\" href=\"https://this.example.com/page/feed/greeting.xml\"/> <link rel=\"alternate\" type=\"text/html\" href=\"https://this.example.com/page\"/> <updated>2042-02-18T12:00:00Z</updated> <entry> <title>Hello World 2</title> <id>https://this.example.com/page/hello-world-2.html</id> <updated>2042-02-16T12:00:00Z</updated> <summary>The second post</summary> <link rel=\"alternate\" type=\"text/html\" href=\"https://this.example.com/page/hello-world-2.html\"/> <category term=\"hello\"/> <category term=\"greeting\"/> <content type=\"html\" xml:base=\"https://this.example.com/page\"></content> </entry> <entry> <title>Hello World</title> <id>https://this.example.com/page/hello-world.html</id> <updated>2042-02-15T12:00:00Z</updated> <summary>The first post</summary> <link rel=\"alternate\" type=\"text/html\" href=\"https://this.example.com/page/hello-world.html\"/> <category term=\"hello\"/> <category term=\"greeting\"/> <content type=\"html\" xml:base=\"https://this.example.com/page\"></content> </entry> </feed>" ;;;;; All tags (atomizer-feed-string-for-tag 'all :site-conf "site.conf" :posts-tsv "posts.tsv" :f-keywords "KEYWORDS" :f-export-title "EXPORT_TITLE" :f-export-fname "EXPORT_FILE_NAME" :f-description "DESCRIPTION" :f-date "CREATION_DATE" :declaration atomizer-xml-declaration-default) => "\ <?xml version=\"1.0\" encoding=\"utf-8\"?> <feed xmlns=\"http://www.w3.org/2005/Atom\"> <title>Title of My Page</title> <subtitle>Subtitle of My Page</subtitle> <author> <name>My Name</name> <uri>https://authors.example.com</uri> </author> <id>https://this.example.com/page</id> <link rel=\"self\" href=\"https://this.example.com/page/feed/all.xml\"/> <link rel=\"alternate\" type=\"text/html\" href=\"https://this.example.com/page\"/> <updated>2042-02-18T12:00:00Z</updated> <entry> <title>Some new post</title> <id>https://this.example.com/page/some-new-post.html</id> <updated>2042-02-18T12:00:00Z</updated> <summary>Some new post</summary> <link rel=\"alternate\" type=\"text/html\" href=\"https://this.example.com/page/some-new-post.html\"/> <content type=\"html\" xml:base=\"https://this.example.com/page\"></content> </entry> <entry> <title>A new new post</title> <id>https://this.example.com/page/a-new-new-post.html</id> <updated>2042-02-17T12:00:00Z</updated> <summary>A new new post</summary> <link rel=\"alternate\" type=\"text/html\" href=\"https://this.example.com/page/a-new-new-post.html\"/> <category term=\"test\"/> <category term=\"stuff\"/> <content type=\"html\" xml:base=\"https://this.example.com/page\"></content> </entry> <entry> <title>Hello World 2</title> <id>https://this.example.com/page/hello-world-2.html</id> <updated>2042-02-16T12:00:00Z</updated> <summary>The second post</summary> <link rel=\"alternate\" type=\"text/html\" href=\"https://this.example.com/page/hello-world-2.html\"/> <category term=\"hello\"/> <category term=\"greeting\"/> <content type=\"html\" xml:base=\"https://this.example.com/page\"></content> </entry> <entry> <title>Hello World</title> <id>https://this.example.com/page/hello-world.html</id> <updated>2042-02-15T12:00:00Z</updated> <summary>The first post</summary> <link rel=\"alternate\" type=\"text/html\" href=\"https://this.example.com/page/hello-world.html\"/> <category term=\"hello\"/> <category term=\"greeting\"/> <content type=\"html\" xml:base=\"https://this.example.com/page\"></content> </entry> </feed>"
List tags
Functions to list current tags.
Commands
See README
Commands to open atomizer's README.org. Optionally, find things in it.
atomizer-see-readme (&optional heading narrow)
Open atomizer's README.org file.
Search for the file in atomizer.el's directory.
If found, open it read-only.
If optional argument HEADING is passed, try to navigate to the
heading after opening it. HEADING should be a string.
If optional argument NARROW is non-nil, narrow to that heading.
This argument has no effect if HEADING is nil or not found.
atomizer-see-news ()
See the News in atomizer's README.org file.
Contributing
See my page Software for information about how to contribute to any of my Emacs packages.
News
0.2.0
Release
License
This project follows the REUSE Specification (FAQ), which in turn is built upon SPDX.
Therefore, license and copyright information can be found in:
- each file's comment header, or
- an adjacent file of the same name with the additional extension
.license
, or - the
.reuse/dep5
file
The full text of the licenses can be found in the LICENSES
subdirectory.
atomizer.el
Structure
;;; atomizer.el --- Make Atom feeds for your site -*- lexical-binding: t -*- ;;; Commentary: ;;;; For all the details, please do see the README ;;; Code: ;;;; Libraries ;;;; Package metadata ;;;; Customizable variables ;;;;; Group ;;;;; Input ;;;;; Field labels ;;;;; XML declaration ;;;;; Time and date ;;;; Functions ;;;;; Description macro ;;;;; Create feeds ;;;;; List tags ;;;;; Internal ;;;; Commands ;;;;; See README ;;;; Wrapping up ;;; atomizer.el ends here
Contents
;;; atomizer.el --- Make Atom feeds for your site -*- lexical-binding: t -*- ;; SPDX-FileCopyrightText: © flandrew <https://flandrew.srht.site/listful> ;;--------------------------------------------------------------------------- ;; Author: flandrew ;; Created: 2022-01-22 ;; Updated: 2025-03-31 ;; Keywords: hypermedia ;; Homepage: <https://flandrew.srht.site/listful/software.html> ;;-------------------------------------------------------------------------- ;; Package-Version: 0.2.1 ;; Package-Requires: ((emacs "25.1") (xht "2.0") (f "0.20")) ;;-------------------------------------------------------------------------- ;; SPDX-License-Identifier: GPL-3.0-or-later ;; This file 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 file 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 file. If not, see <https://www.gnu.org/licenses/>. ;;; Commentary: ;; ;; Atomizer makes Atom feeds for your site. All it needs to output an XML ;; compatible with the Atom Syndication Format is a pair of files containing ;; the relevant metadata for the site and for its posts. ;; ;; The standard: <https://en.wikipedia.org/wiki/Atom_(Web_standard)> ;; ;;;; For all the details, please do see the README ;; ;; Open it easily with: ;; (find-file-read-only "README.org") <--- C-x C-e here¹ ;; ;; or from any buffer: ;; M-x atomizer-see-readme ;; ;; or read it online: ;; <https://flandrew.srht.site/listful/sw-emacs-atomizer.html> ;; ;; ¹ or the key that ‘eval-last-sexp’ is bound to, if not C-x C-e. ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;; Code: ;;;; Libraries (require 'f) (require 'rx) (require 'xml) (require 'xht) ; <---also by the author of this package (require 'lisp-mnt) ; ‘lm-summary’, ‘lm-homepage’, ‘lm-version’, ‘lm-header’ ;;;; Package metadata (defvar atomizer--name "Atomizer") (defvar atomizer--dot-el (format "%s.el" (file-name-sans-extension (eval-and-compile (or load-file-name buffer-file-name))))) (defvar atomizer--readme-org (expand-file-name "README.org" (file-name-directory atomizer--dot-el))) (defvar atomizer--summary (lm-summary atomizer--dot-el)) (defvar atomizer--homepage (lm-homepage atomizer--dot-el)) (defvar atomizer--version (lm-with-file atomizer--dot-el (or (lm-header "package-version") (lm-version)))) ;;;; Customizable variables ;;;;; Group (defgroup atomizer nil (format "%s." atomizer--summary) :group 'hypermedia :link '(emacs-library-link :tag "Lisp file" "atomizer.el") :link `(file-link :tag "README.org" ,atomizer--readme-org) :link `(url-link :tag "Homepage" ,atomizer--homepage)) ;;;;; Input (defcustom atomizer-site-conf "site.conf" "Relative path of file with key–value lines of site metadata. This is the name to look for if one is not passed. If in doubt, use this as the filename for site metadata." :package-version '(atomizer "0.2.0") :type 'string) (defcustom atomizer-posts-tsv "posts.tsv" "Relative path of tsv file with posts metadata. This is the name to look for if one is not passed. If in doubt, use this as the filename for posts metadata." :package-version '(atomizer "0.2.0") :type 'string) ;;;;; Field labels (defcustom atomizer-posts-f-description "DESCRIPTION" "In your posts.tsv, the field label for the description of posts." :package-version '(atomizer "0.2.0") :type 'string) (defcustom atomizer-posts-f-date "CREATION_DATE" "In your posts.tsv, the date field label use in the feed. The label may be something else: PUBLISHED_DATE, DATE, etc. It must be in either YYYY-MM-DD or YYYY-MM-DDThh:mm:ssZ format." :package-version '(atomizer "0.2.0") :type 'string) (defcustom atomizer-posts-f-export-fname "EXPORT_FILE_NAME" "In your posts.tsv, the field label for the export file name of posts. The export file names themselves are to be sans extension or path, and most likely to match the unique identifier of the first column (but not necessarily, as you could generate unique identifiers for the first column in other ways)." :package-version '(atomizer "0.2.0") :type 'string) (defcustom atomizer-posts-f-export-title "EXPORT_TITLE" "In your posts.tsv, the field label for the title of posts." :package-version '(atomizer "0.2.0") :type 'string) (defcustom atomizer-posts-f-keywords "KEYWORDS" "From which field in your posts.tsv should keywords be extracted? Two common options would be \"KEYWORDS\" or \"TAGS\". You may want to use the `KEYWORDS' property because tags (here assuming to refer to Org tags) are limited: no spaces, no dashes, for example. Moreover, some tags are for your info only (noexport, etc). On the other hand, the `KEYWORDS' property can be populated from tags — but then manually edited as you desire, comma-separated." :package-version '(atomizer "0.2.0") :type 'string) ;;;;; XML declaration (defvar atomizer-xml-declaration-default "<?xml version=\"1.0\" encoding=\"utf-8\"?>" "Default XML declaration string to use in generated Atom feeds.") (defcustom atomizer-xml-declaration atomizer-xml-declaration-default "XML declaration string to use in generated Atom feeds. Its initial value is set from ‘atomizer-xml-declaration-default’. CAREFUL: an invalid declaration will invalidate your XML. Unless you know what you're doing, use the default." :package-version '(atomizer "0.2.0") :type 'string) ;;;;; Time and date ;; Simplified ISO 8601 (defvar atomizer--feed-iso-date-only-re (rx bos (= 4 num) "-" (= 2 num) "-" (= 2 num) eos) "Regular expression matching an ISO 8601 date only.") (defvar atomizer--feed-iso-date-time-re (rx bos (= 4 num) "-" (= 2 num) "-" (= 2 num) "T" (= 2 num) ":" (= 2 num) ":" (= 2 num) "Z" eos) "Regular expression matching an ISO 8601 date+time.") (defvar atomizer--feed-iso-time-re (rx bos "T" (= 2 num) ":" (= 2 num) ":" (= 2 num) "Z" eos) "Regular expression matching the time part of ISO 8601.") (defcustom atomizer-feed-iso-time-user "T12:00:00Z" "Time to add to a date-only ISO 8601 date. Defaults to the string T12:00:00Z and must match this exact format — that is, if you change it, then change only the numbers. This time will be added to the feed date whenever the date values given by f-date param (which defaults to ‘atomizer-posts-f-date’) is in YYYY-MM-DD format." :package-version '(atomizer "0.2.0") :type '(restricted-sexp :match-alternatives ((lambda (s) (and (stringp s) (s-matches? atomizer--feed-iso-time-re s)))))) ;;;; Functions ;;;;; Description macro (defmacro atomizer--describe (str) "Describe with string STR the contents under this heading. Think of it as a docstring for the headings of your elisp files. For the full docstring, look for ‘orgreadme-fy-describe’ in the package ‘orgreadme-fy’." (declare (indent 0)) (unless (stringp str) (user-error "‘atomizer--describe’ must receive a string"))) ;;;;; Create feeds (atomizer--describe "There are three things you can do. You can create an Atom feed file for: 1. a given tag; 2. each tag in a list of tags; or 3. each tag from all your posts. These are covered by the next three functions. You'll need: - A *site.conf* file having a few key–value lines with information about the site. The keys specified by the function ‘atomizer--site-ht’ must be there. - A *posts.tsv* file having information about all your posts. The keys can be customized by you. See defcustom variables starting with 'atomizer-posts-f-'. Until this version, the Atom feeds created do not include the full contents of your posts, \"just\" the metadata with the titles and links. This is sufficient for the feed. Some people, however, prefer to include the full contents. If this is a feature you'd like to see, let me know. I haven't looked into it, and it isn't a high priority — but if you know how to do this properly, then suggestions for solutions are welcome.") ;;;###autoload (defun atomizer-write-feed-for-tag (tag &rest params) "Make an Atom feed for a TAG and write it to disk. See ‘atomizer-feed-string-for-tag’ for info about TAG and PARAMS." (let ((tag-ht (apply #'atomizer--tag-ht tag params))) (h-let tag-ht (atomizer--write-file .xml .feed-file)))) ;;;###autoload (defun atomizer-write-feed-for-tags (tags &rest params) "Make an Atom feed for each tag in list TAGS and write them to disk. See ‘atomizer-feed-string-for-tag’ for info about PARAMS." (dolist (tag tags) (apply #'atomizer-write-feed-for-tag tag params))) ;;;###autoload (defun atomizer-write-all-feeds (&rest params) "Make an Atom feed for every available tag and write them to disk. A feed file containing all posts will also be written. See ‘atomizer-feed-string-for-tag’ for info about PARAMS." (h-let (h<-plist params) (let ((tags (cons 'all (atomizer-list-tags .f-keywords .posts-tsv)))) (dolist (tag tags) (apply #'atomizer-write-feed-for-tag tag params))))) ;;;###autoload (defun atomizer-feed-string-for-tag (tag &rest params) "Return Atom feed string for TAG. If TAG is the symbol \\='all, make a single feed using all entries \(that is, don't filter by tag). Otherwise, TAG must be a string representing an existing tag. In this case it'll be filtered by the tag. PARAMS are optional and passed as key–value pairs — like this: | KEY | VALUE | Default if VALUE is nil | |-----------------+----------------+---------------------------------| | :site-conf | SITE-CONF | ‘atomizer-site-conf’ | | :posts-tsv | POSTS-TSV | ‘atomizer-posts-tsv’ | | :f-keywords | F-KEYWORDS | ‘atomizer-posts-f-keywords’ | | :f-export-title | F-EXPORT-TITLE | ‘atomizer-posts-f-export-title’ | | :f-export-fname | F-EXPORT-FNAME | ‘atomizer-posts-f-export-fname’ | | :f-description | F-DESCRIPTION | ‘atomizer-posts-f-description’ | | :f-date | F-DATE | ‘atomizer-posts-f-date’ | | :declaration | DECLARATION | ‘atomizer-xml-declaration’ | This is a side-effect–free function. It's useful for verifying what would be written to disk." (let ((tag-ht (apply #'atomizer--tag-ht tag params))) (h-get tag-ht :xml))) ;;;;; List tags (atomizer--describe "Functions to list current tags.") ;;;###autoload (defun atomizer-list-tags (&optional f-keywords posts-tsv) "Based on F-KEYWORDS and POSTS-TSV, list all tags. If F-KEYWORDS is nil, use value of ‘atomizer-posts-f-keywords’. If POSTS-TSV is nil, use value of ‘atomizer-posts-tsv’." (setq f-keywords (or f-keywords atomizer-posts-f-keywords) posts-tsv (or posts-tsv atomizer-posts-tsv)) (--> (atomizer--posts-ht posts-tsv) (h--lmap (atomizer--tags-to-list (h-get* it key f-keywords)) it) -flatten -uniq (-sort #'string< it))) ;;;;; Internal (defun atomizer--tags-to-list (tags) "Given TAGS, return a list of tags. Input may be in any of these example forms: - \":tag_1:tag_2:\" ; string as in TAGS property - \"tag_1, tag 2\" ; string as in KEYWORDS property - \\='(\"tag_1\" \"tag 2\") ; list Output will be in this format: \\='(\"tag-1\" \"tag-2\")" (--map (s-replace-all '((" " . "-") ("_" . "-")) it) (pcase tags ((pred listp) tags) ((pred stringp) (->> tags (s-replace ", " ":") (s-chop-prefix ":") (s-chop-suffix ":") (s-split ":") (-remove-item "")))))) (defun atomizer--site-ht (&optional site-conf) "Make and return hash table based on site metadata. If SITE-CONF is non-nil, try to find that file; if not found, see if this variable holds key–value lines instead of a filename; if not, then signal error. If SITE-CONF is nil, look for file ‘atomizer-site-conf’. If not found, signal error. Note that SITE-CONF should consist of key–value lines using \" *= *\" as their separator. These exact keys (first column) MUST be there, in any order. The second column (show below) are examples of how their values would look like. name = Ms. Foo Bar uri = https://ms-foo-bar.example.com feedtitle = Foo Bar Blog feedsubtitle = Things Ms. Foo Bar loves base = https://ms-foo-bar.example.com/blog xmldir = feed xmlext = xml xmlall = all.xml" (let* ((conf (or site-conf atomizer-site-conf)) (contents (if (f-exists? conf) (f-read conf) conf))) (if (h-kvl? contents) (h<-kvl contents) (error "Invalid input: %s" conf)))) (defun atomizer--posts-ht (&optional posts-tsv) "Make and return hash table based on posts metadata. If POSTS-TSV is non-nil, try to find that file; if not found, see if this variable holds tsv-like data instead of a filename; if not, then signal error. If POSTS-TSV is nil, look for file ‘atomizer-posts-tsv’. If not found, signal error. Its fields (column labels on the first line) MUST match the values of these customizable variables: ‘atomizer-posts-f-description’ ‘atomizer-posts-f-date’ ‘atomizer-posts-f-export-fname’ ‘atomizer-posts-f-export-title’ ‘atomizer-posts-f-keywords’ If in doubt, just create your TSV using these values. The first column MUST have unique identifiers for your posts, most likely to match html slugs. The label \"CUSTOM_ID\" is suggested, assuming that the post came from an Org subtree, but this need not be the case: you can use whatever you like, and it will be used as keys regardless of its label. Note however that: - Both \"TAGS\" and \"KEYWORDS\" are perfectly acceptable values for ‘atomizer-posts-f-keywords’ — it's just that the latter offers more flexibility. - Dates must be in YYYY-MM-DD format (or at least start with it). - It seems that, depending on your workflow and choices, different values would be suitable for ‘atomizer-posts-f-date’ — such as \"CREATION_DATE\", \"DATE\", \"PUBLISHED_DATE\", \"LAST_UPDATE\". I'm not sure whether you should use this last one. I believe this could make the posts order in your feed change whenever you update a post, bumping the updated post to the top. The first of these is the suggested default." (let* ((tsv (or posts-tsv atomizer-posts-tsv)) (contents (if (f-exists? tsv) (f-read tsv) tsv))) (if (h-tsv? contents) (h<-tsv contents) (error "Invalid input: %s" tsv)))) (defun atomizer--feed-fname (tag site-ht) "The feed filename from TAG and SITE-HT." (h-let site-ht (pcase tag ('all (or (s-presence .xmlall) (format "%s.%s" tag .xmlext))) (_ (format "%s.%s" tag .xmlext))))) (defun atomizer--feed-iso-date-time-valid-p (date-time) "Whether feed DATE-TIME seems to be valid." (s-matches? atomizer--feed-iso-date-time-re date-time)) (defun atomizer--feed-iso-date-time (date) "Given an ISO 8601 DATE, concatenate the time in case it's absent." (unless (stringp date) (error "Not a string: %s" date)) (if (atomizer--feed-iso-date-time-valid-p date) date (setq date (format "%s%s" date atomizer-feed-iso-time-user)) (if (atomizer--feed-iso-date-time-valid-p date) date (error "Invalid date-time: %s" date)))) (defun atomizer--feed-ht-from-hts (tag site-ht posts-ht f-date) "Build feed hash table from TAG, SITE-HT, POSTS-HT, and F-DATE." (h-let site-ht (let* ((sorted-dates (->> posts-ht (h--lmap (cons key (h-get value f-date))) (--sort (string< (cdr other) (cdr it))))) (sorted-cids (-map #'car sorted-dates)) (last-date (-> sorted-dates cdar atomizer--feed-iso-date-time)) (feed-fname (atomizer--feed-fname tag site-ht)) (feed-file (f-expand feed-fname .xmldir)) (feed-url (format "%s/%s/%s" (s-chop-suffix "/" .base) (s-chop-suffix "/" .xmldir) feed-fname))) (h* :sorted-cids sorted-cids :last-date last-date :feed-file feed-file :feed-url feed-url :name .name :uri .uri :feedtitle .feedtitle :feedsubtitle .feedsubtitle :base .base)))) (defun atomizer--cid-ht-from-hts (cid site-ht posts-ht f-keywords f-export-title f-export-fname f-description f-date) "Build custom ID hash table from parameters." (let* ((export-title (h-get* posts-ht cid f-export-title)) (description (h-get* posts-ht cid f-description)) (tags (h-get* posts-ht cid f-keywords)) (taglist (atomizer--tags-to-list tags)) (categories (--map `(category ((term . ,(h-as-string it)))) taglist)) (content "") (base (h-let site-ht .base)) (link (->> (h-get* posts-ht cid f-export-fname) (format "%s/%s.html" base))) (date (->> (h-get* posts-ht cid f-date) (atomizer--feed-iso-date-time)))) (h* :export-title export-title :description description :categories categories :content content :base base :link link :date date))) (defun atomizer--nil-values-to-empty-str (hash-table) "Replace with empty string any nil VALUE in copy of HASH-TABLE." (h--hmap key (if value value "") hash-table)) (defun atomizer--entryless-feed-tree-from-feed-ht (feed-ht) "Build entryless feed tree from FEED-HT." (h-let (atomizer--nil-values-to-empty-str feed-ht) `((feed ((xmlns . "http://www.w3.org/2005/Atom")) (title () ,\.feedtitle) (subtitle () ,\.feedsubtitle) (author () (name () ,\.name) (uri () ,\.uri)) (id () ,\.base) (link ((rel . "self") (href . ,\.feed-url))) (link ((rel . "alternate") (type . "text/html") (href . ,\.base))) (updated () ,\.last-date))))) (defun atomizer--entry-tree-from-cid-ht (cid-ht) "Build entry tree from CID-HT." (h-let (atomizer--nil-values-to-empty-str cid-ht) `(entry () (title () ,\.export-title) (id () ,\.link) (updated () ,\.date) (summary () ,\.description) (link ((rel . "alternate") (type . "text/html") (href . ,\.link))) ,@\.categories (content ((type . "html") (xml:base . ,\.base)) ,\.content)))) (defun atomizer--include-cid-p (cid tag posts-ht f-keywords) "Whether to include CID." (pcase tag ('all t) (_ (->> (h-get* posts-ht cid f-keywords) (atomizer--tags-to-list) (member tag))))) (defun atomizer--entries-tree-for-tag-from-hts (tag site-ht posts-ht f-keywords f-export-title f-export-fname f-description f-date) "Create entries tree for TAG using SITE-HT and POSTS-HT." (let ((feed-ht (atomizer--feed-ht-from-hts tag site-ht posts-ht f-date)) empty-tree select-cids entries) (h-let feed-ht (setq empty-tree (atomizer--entryless-feed-tree-from-feed-ht feed-ht) select-cids (--filter (atomizer--include-cid-p it tag posts-ht f-keywords) .sorted-cids) entries (--map (atomizer--entry-tree-from-cid-ht (atomizer--cid-ht-from-hts it site-ht posts-ht f-keywords f-export-title f-export-fname f-description f-date)) select-cids)) (atomizer--feed-tree empty-tree entries)))) (defun atomizer--feed-tree (entryless-feed-tree entries) "Make feed tree from ENTRYLESS-FEED-TREE and ENTRIES. ENTRIES is a list of entry-trees. entryless-feed-tree: \\='((feed)) entries: \\='((entry1) (entry2)) feed-tree: \\='((feed (entry1) (entry2)))" (list (append (car entryless-feed-tree) entries))) (defun atomizer--feed-xml (tree declaration) "Build feed.xml from TREE and DECLARATION." (with-temp-buffer (insert declaration "\n") (xml-print tree) (buffer-string))) (defun atomizer--write-file (string filename) "Write file given STRING contents and FILENAME." (mkdir (f-parent filename) '-p) (f-write string 'utf-8 filename)) (defun atomizer--tag-ht (tag &rest params) "From TAG and PARAMS, build a hash table with all the variables. PARAMS are optional and passed as key–value pairs — like this: | KEY | VALUE | Default if VALUE is nil | |-----------------+----------------+---------------------------------| | :site-conf | SITE-CONF | ‘atomizer-site-conf’ | | :posts-tsv | POSTS-TSV | ‘atomizer-posts-tsv’ | | :f-keywords | F-KEYWORDS | ‘atomizer-posts-f-keywords’ | | :f-export-title | F-EXPORT-TITLE | ‘atomizer-posts-f-export-title’ | | :f-export-fname | F-EXPORT-FNAME | ‘atomizer-posts-f-export-fname’ | | :f-description | F-DESCRIPTION | ‘atomizer-posts-f-description’ | | :f-date | F-DATE | ‘atomizer-posts-f-date’ | | :declaration | DECLARATION | ‘atomizer-xml-declaration’ |" (h-let (h<-plist params) (let* ((site-conf (or .site-conf atomizer-site-conf)) (posts-tsv (or .posts-tsv atomizer-posts-tsv)) (f-keywords (or .f-keywords atomizer-posts-f-keywords)) (f-export-title (or .f-export-title atomizer-posts-f-export-title)) (f-export-fname (or .f-export-fname atomizer-posts-f-export-fname)) (f-description (or .f-description atomizer-posts-f-description)) (f-date (or .f-date atomizer-posts-f-date)) (declaration (or .declaration atomizer-xml-declaration)) (site-ht (atomizer--site-ht site-conf)) (posts-ht (atomizer--posts-ht posts-tsv)) (feed-ht (atomizer--feed-ht-from-hts tag site-ht posts-ht f-date)) (feed-file (h-get feed-ht :feed-file)) (tree (atomizer--entries-tree-for-tag-from-hts tag site-ht posts-ht f-keywords f-export-title f-export-fname f-description f-date)) (xml (atomizer--feed-xml tree declaration))) (h* :site-conf site-conf :posts-tsv posts-tsv :f-keywords f-keywords :f-export-title f-export-title :f-export-fname f-export-fname :f-description f-description :f-date f-date :declaration declaration :feed-file feed-file ; order changed: largest at the end :feed-ht feed-ht :site-ht site-ht :posts-ht posts-ht :tree tree :xml xml)))) ;;;; Commands ;;;;; See README (atomizer--describe "Commands to open atomizer's README.org. Optionally, find things in it.") ;;;###autoload (defun atomizer-see-readme (&optional heading narrow) "Open atomizer's README.org file. Search for the file in atomizer.el's directory. If found, open it read-only. If optional argument HEADING is passed, try to navigate to the heading after opening it. HEADING should be a string. If optional argument NARROW is non-nil, narrow to that heading. This argument has no effect if HEADING is nil or not found." (interactive) (let ((readme atomizer--readme-org)) (if (file-exists-p readme) (let ((pr (make-progress-reporter (format "Opening %s ... " (abbreviate-file-name readme))))) (find-file-read-only readme) (when heading (atomizer--goto-org-heading heading narrow)) (progress-reporter-done pr)) (message "Couldn't find %s's README.org" atomizer--name)))) ;;;###autoload (defun atomizer-see-news () "See the News in atomizer's README.org file." (interactive) (atomizer-see-readme "News" 'narrow) (atomizer--display-org-subtree)) (defun atomizer--display-org-subtree () "Selectively display org subtree." (let ((cmds '(outline-hide-subtree outline-show-children outline-next-heading outline-show-branches))) (and (fboundp (nth 0 cmds)) (fboundp (nth 1 cmds)) (fboundp (nth 2 cmds)) (fboundp (nth 3 cmds)) (mapc #'funcall cmds)))) (defun atomizer--goto-org-heading (heading &optional narrow) "Navigate to org HEADING and optionally NARROW to it." (let* ((hrx (format "^[*]+ %s" heading)) (pos (save-match-data (save-excursion (save-restriction (widen) (goto-char (point-max)) (re-search-backward hrx nil t 1)))))) (when pos (widen) (goto-char pos) (if (and narrow (fboundp 'org-narrow-to-subtree)) (org-narrow-to-subtree) (recenter-top-bottom 1)) (when (fboundp 'outline-show-subtree) (outline-show-subtree)) (when (fboundp 'org-flag-drawer) (save-excursion (forward-line 1) (org-flag-drawer t)))))) ;;;; Wrapping up (provide 'atomizer) ;; Local Variables: ;; coding: utf-8 ;; indent-tabs-mode: nil ;; sentence-end-double-space: nil ;; outline-regexp: ";;;;* " ;; End: ;;; atomizer.el ends here