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:

  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.

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-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.

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.

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.

;; Note that results vary depending on the F-KEYWORDS field chosen.
;; See posts.tsv to compare.

(atomizer-list-tags "KEYWORDS" "posts.tsv")
=> '("greeting" "hello" "stuff" "test")

(atomizer-list-tags "TAGS" "posts.tsv")
=> '("hello" "testing" "world")

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