publish.el — how this site gets built and deployed, as a literate program

so, in my first post I said I'd upload the code "soon™".

it's been a while. but it's HERE now — and not just as a boring blob you squint at. I figured I'd do something genuinely fun: write it as a literate program. which means you are reading the source code RIGHT NOW. the actual publish.el that runs this site is tangled directly out of this org file. if you do M-x org-babel-tangle on this document, you get the real, working script. isn't that insane?! the blog post IS the program.

(if you've somehow stumbled here without knowing what org-mode is: it's a markup/notes/calendar/spreadsheet/everything system built into emacs. I wrote a little about it on the homepage.)

okay!! let's GO.

1. The big picture

the script does three things:

  1. Builds the site — takes org/ full of org files, spits out public/ full of HTML
  2. Generates a feed — produces public/feed.xml as a proper Atom 1.0 feed
  3. Deploys to Neocities — diffs what's live against what's local, then uploads only what changed

the Atom feed stuff lives in emacs-ox-atom (my own package!!). the Neocities API stuff lives in emacs-neocities (also mine!!). this file wires them together with ox-publish, which is the built-in org-mode publishing engine. three separate things, one file to rule them all.

the directory layout it expects looks like this:

project/
├── publish.el        ; this file, tangled from publish.org
├── org/
│   ├── index.org     ; homepage
│   ├── style.css     ; stylesheet
│   └── posts/        ; one .org file per post
└── public/           ; generated output (auto-created)

2. Preamble

every emacs lisp file that's a proper package starts with a header. the -*- lexical-binding: t; -*- bit is NOT decorative — it switches on lexical scoping, which makes closures work like you'd expect and avoids a whole class of subtle, maddening bugs. (if you've ever been burned by dynamic binding in elisp you know exactly what I mean. if you haven't: trust me, you want this on.)

;;; publish.el --- Build and deploy blog -*- lexical-binding: t; -*-

;; Homepage: https://coopi.neocities.org

;;; Commentary:

;; Personal static blog generator.  Turns Org files into a styled
;; static site with a post index and Atom feed, and optionally
;; deploys to Neocities.
;;
;; DIRECTORY LAYOUT
;;
;; project/
;; ├── publish.el                 ; this file
;; ├── org/
;; │   ├── index.org              ; homepage
;; │   ├── style.css              ; site stylesheet (default)
;; │   └── posts/                 ; blog posts (*.org)
;; └── public/                    ; generated output (auto-created)
;;
;; Any other org/*.org file becomes a top-level page.  Static assets
;; are copied verbatim.
;;
;; WRITING POSTS
;;
;; #+TITLE: My post
;; #+DATE: <YYYY-MM-DD>
;; #+DESCRIPTION: optional summary for the Atom feed
;; #+DRAFT: t        ; skip from export, index, and feed
;;
;; USAGE
;;
;; M-x publish-build              incremental export
;; M-x publish-build-force        full rebuild
;; M-x publish-diff               show what would change on Neocities
;; M-x publish-deploy             diff, confirm, then upload to Neocities
;; M-x publish-build-and-deploy
;;
;; CONFIGURATION
;;
;; M-x customize-group RET publish RET

;;; Code:

3. Requires

nothing exotic here. cl-lib for common lisp goodies (seq-take, cl-return-from, etc.). neocities and ox-atom are my packages. the rest ship with emacs.

(require 'cl-lib)
(require 'neocities)
(require 'ox-atom)
(require 'ox-publish)
(require 'rx)
(require 'seq)

4. Configuration

I use defgroup / defcustom here. defcustom is the modern standard for user-facing variables in elisp — it registers the variable with the type system and integrates with help. I don't actually use the customize UI, I just set things in my config. but using defcustom is the right thing to do and I am trying to have good habits.

4.1. The group

;;; Configuration

(defgroup publish nil
  "Personal static blog generator."
  :group 'tools)

4.2. Site identity

these three are the things most likely to change if someone else picks up this script and uses it for their own site. (idk why anyone would want my shitty code tho)

(defcustom publish-site-url "https://coopi.neocities.org"
  "Base URL of the site, without trailing slash."
  :type 'string
  :group 'publish)

(defcustom publish-site-title "coopi's corner of the web!"
  "Title used in the Atom feed and HTML head."
  :type 'string
  :group 'publish)

(defcustom publish-site-author "coopi"
  "Author name used in Atom feed entries."
  :type 'string
  :group 'publish)

4.3. Stylesheet

this one is a bit fancy. it accepts three different shapes:

  • nil — no stylesheet at all
  • a string like "style.css" — a local file, resolved relative to the org/ directory, copied verbatim to public/
  • a cons cell (url . sri-hash) — an external stylesheet with Subresource Integrity, so the browser can verify it wasn't tampered with

I'm currently using a local style.css. the SRI option is there for external stylesheets — and note that if you use the cons cell form, the SRI hash is mandatory, not optional. this is intentional!! if you're loading something from a CDN you should be verifying it. im not going to make it easy for you not to.

(defcustom publish-stylesheet "style.css"
  "The stylesheet used on every page.

This can be one of the following options: nil, which means no stylesheet
link is emitted; a string, which should be the path to a local CSS file
relative to the project root (the file must reside under the `org/'
directory so it can be copied to `public/'); or a cons cell (URL
. SRI-HASH), which specifies an external stylesheet loaded with
Subresource Integrity."
  :type '(choice (const :tag "None" nil)
                 (string :tag "Local file (relative to project root)")
                 (cons :tag "External URL with SRI"
                       (string :tag "URL")
                       (string :tag "SRI hash")))
  :group 'publish)

4.4. Scripts

same idea as the stylesheet but a list, because you might want multiple scripts. each entry is either a local path or a (url . sri-hash) cons.

(defcustom publish-scripts nil
  "List of scripts to include on every page.

Each element is either a string (a path to a local JS file relative to
the `org/' directory) or a cons cell of the form (URL . SRI-HASH) for an
external script loaded with Subresource Integrity.  Scripts are emitted
in list order, just before the closing head content."
  :type '(repeat (choice (string :tag "Local file (relative to org/ directory)")
                         (cons :tag "External URL with SRI"
                               (string :tag "URL")
                               (string :tag "SRI hash"))))
  :group 'publish)

I discover scripts from a org/scripts/ directory and add them to the list automatically. since all my scripts live there, I just evaluate this once whenever I add a new one:

(dolist (f (directory-files (expand-file-name "scripts/" publish--org-dir) nil "\\.js$"))
  (add-to-list 'publish-scripts (concat "scripts/" f) t))

(this doesn't go into publish.el. it's a little snippet I evaluate interactively when needed, not something that runs automatically.)

5. Hooks

standard emacs hook variables. nothing is registered here by default — these are just the slots where you'd hang your own code if you wanted to, say, minify CSS after a build, or ping a webhook after a deploy, or play a victory sound effect. (I have considered the victory sound effect.)

;;; Hooks

(defvar publish-before-build-hook nil
  "Hook run before exporting Org files.")

(defvar publish-after-build-hook nil
  "Hook run after exporting Org files.")

(defvar publish-before-deploy-hook nil
  "Hook run before uploading files to the host.")

(defvar publish-after-deploy-hook nil
  "Hook run after uploading files to the host.")

6. Paths

three path variables that everything else derives from. the key one is publish--root: it figures out where this file lives, regardless of what the working directory is when it gets loaded. no more "it worked on my machine" nonsense.

the double-dash convention (publish--root vs publish-root) is emacs lisp's way of marking something as private to the package. the language doesn't enforce it — it's purely a naming convention — but everyone follows it, and it's a useful signal that you're not supposed to touch these from outside.

;;; Paths

(defvar publish--root
  (file-name-directory (or load-file-name buffer-file-name default-directory))
  "Project root directory.")

(defvar publish--org-dir (expand-file-name "org/" publish--root)
  "Directory containing Org source files.")

(defvar publish--pub-dir (expand-file-name "public/" publish--root)
  "Directory where exported HTML is written.")

7. Draft support

posts with #+DRAFT: t at the top should be invisible everywhere: not exported, not in the index, not in the feed. org-collect-keywords reads the file's keyword metadata without doing a full parse, which is fast enough to call on every single file without slowing everything to a crawl.

;;; Draft support

(defun publish--draft-p (filename)
  "Return non-nil if FILENAME contains #+DRAFT: t."
  (let ((keywords (org-collect-keywords '("DRAFT") nil filename)))
    (assoc "DRAFT" keywords)))

(defun publish--publish-unless-draft (plist filename pub-dir)
  "Like `org-html-publish-to-html', but skip files with #+DRAFT: t."
  (if (publish--draft-p filename)
      (message " SKIP (draft) %s" (file-name-nondirectory filename))
    (org-html-publish-to-html plist filename pub-dir)))

8. HTML export settings

these setq calls configure the global org-html exporter. let me call out the interesting ones:

  • org-html-html5-fancy enables semantic HTML5 elements (<article>, <nav>, etc.) instead of plain <div> soup. more meaningful markup for basically free!!
  • org-html-head-include-scripts and org-html-head-include-default-style are both nil because I'm supplying my own CSS and do NOT want org's default stylesheet leaking through. (it's not bad per se — it's just extremely minimal, basically bare bones, and I have opinions about how things should look.)
  • org-html-htmlize-output-type 'css means syntax-highlighted source blocks get CSS classes instead of inline style attributes, so my stylesheet controls the colours rather than having them hardcoded everywhere
  • org-html-divs is the spicy one — it remaps the preamble/content/postamble wrappers to <nav>, <article>, and <footer> respectively. more semantic, better for accessibility, and WAY nicer to target with CSS selectors
;;; HTML export settings

(setq org-html-doctype "html5"
      org-html-html5-fancy t
      org-html-head-include-scripts nil
      org-html-head-include-default-style nil
      org-html-htmlize-output-type 'css
      org-html-validation-link nil
      org-export-with-toc nil
      org-export-with-section-numbers nil
      org-html-self-link-headlines t
      org-html-divs '((preamble  "nav"     "preamble")
                      (content   "article" "content")
                      (postamble "footer"  "postamble")))

8.1. Head fragment helpers

publish--stylesheet-link and publish--script-tags just turn the customization variables into HTML strings. boring but necessary — someone has to do the string formatting.

publish--html-head assembles the full <head> fragment that gets injected into every exported page. it also sticks a <link rel​=​"me"> pointing at the site root in there, which is how IndieAuth rel=me verification works. (tiny detail, but I love it.)

(defun publish--stylesheet-link ()
  "Return the <link> tag for `publish-stylesheet', or an empty string."
  (cond
   ((null publish-stylesheet) "")
   ((stringp publish-stylesheet)
    (format "<link rel=\"stylesheet\" href=\"/%s\">" publish-stylesheet))
   ((consp publish-stylesheet)
    (format "<link rel=\"stylesheet\" href=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\">"
            (car publish-stylesheet) (cdr publish-stylesheet)))))

(defun publish--script-tags ()
  "Return <script> tags for every entry in `publish-scripts'."
  (mapconcat
   (lambda (entry)
     (cond
      ((stringp entry)
       (format "<script src=\"/%s\"></script>" entry))
      ((consp entry)
       (format "<script src=\"%s\" integrity=\"%s\" crossorigin=\"anonymous\"></script>"
               (car entry) (cdr entry)))))
   publish-scripts "\n"))

(defun publish--html-head ()
  "Return the <head> fragment for every page."
  (let ((scripts (publish--script-tags)))
    (format "%s%s
<link rel=\"me\" href=\"%s\">
<meta name=\"generator\" content=\"Org-mode\">"
            (publish--stylesheet-link)
            (if (string-empty-p scripts) "" (concat "\n" scripts))
            publish-site-url)))

9. Sitemap (the post index)

ox-publish's built-in sitemap feature generates a posts/index.org automatically from your posts directory. (it's not a sitemap in the XML sense — it's literally just an org file with a list of links, which then gets exported to HTML like everything else. I love a good reuse of existing infrastructure.)

I customize two things:

  1. publish--sitemap-entry — controls how each post appears in the list. I format them as YYYY-MM-DD — [[link][Title]]
  2. publish--sitemap-format — controls the overall structure of the index file. I filter out draft entries here — and here's the fun part: they arrive as the literal string "nil"​. yes, the string "nil"​, not the value nil. that's what org-publish gives you when publish--sitemap-entry returns nil. I love elisp. I hate elisp.

there's also publish--ensure-sitemap-placeholder which creates an empty posts/index.org if it doesn't exist yet, because org-publish will error out on a fresh clone where that file is missing. the kind of tiny papercut that takes an hour to debug and two lines to fix.

;;; Sitemap (auto-generated post index)

(defun publish--ensure-sitemap-placeholder (_plist)
  "Create an empty posts/index.org if it does not exist.
Prevents `org-publish' from erroring on the first build."
  (let ((f (expand-file-name "posts/index.org" publish--org-dir)))
    (unless (file-exists-p f)
      (make-empty-file f)
      (message "Created placeholder %s" f))))

(defun publish--sitemap-entry (entry _style project)
  "Format ENTRY as \"YYYY-MM-DD — Title\".  Skip directories and drafts."
  (unless (directory-name-p entry)
    (let ((abs (expand-file-name entry (plist-get (cdr project) :base-directory))))
      (unless (publish--draft-p abs)
        (format "%s — [[file:%s][%s]]"
                (format-time-string "%Y-%m-%d"
                                    (org-publish-find-date entry project))
                entry
                (org-publish-find-title entry project))))))

(defun publish--sitemap-format (title list)
  "Build sitemap Org string from TITLE and LIST, filtering out drafts.
Drafts arrive as the literal string \"nil\" from org-publish."
  (let ((entries (seq-remove (lambda (e) (equal (car e) "nil")) (cdr list))))
    (concat "#+TITLE: " title "\n#+OPTIONS: title:t\n\n"
            (if entries
                (org-list-to-org (cons (car list) entries))
              "(No posts yet.)\n"))))

10. Atom feed

this is where emacs-ox-atom comes in!! org-atom-write-feed takes a list of entry plists and serializes them into a valid Atom 1.0 XML file. org-atom-entries-from-files does the scanning — it reads each post's #+TITLE, #+DATE, #+DESCRIPTION, etc. and builds the entry data from those.

the function is registered as a :completion-function on the posts project, so it runs automatically after all posts have been exported to HTML. that ordering matters: org-atom-entries-from-files reads the HTML output to populate the <content> elements, so the HTML has to exist first. get the ordering wrong and you get an empty feed. ask me how I know.

;;; Atom feed

(defun publish--generate-atom-feed (&rest _)
  "Write Atom 1.0 feed to public/feed.xml via ox-atom.
Called as a :completion-function after the posts project is exported."
  (let* ((posts-dir (expand-file-name "posts/" publish--org-dir))
         (html-dir  (expand-file-name "posts/" publish--pub-dir))
         (files (seq-remove
                 (lambda (f) (string= (file-name-nondirectory f) "index.org"))
                 (directory-files posts-dir t "\\.org$"))))
    (org-atom-write-feed
     :file    (expand-file-name "feed.xml" publish--pub-dir)
     :title   publish-site-title
     :id      (concat publish-site-url "/")
     :link    (concat publish-site-url "/")
     :self    (concat publish-site-url "/feed.xml")
     :authors (list :name publish-site-author)
     :entries (org-atom-entries-from-files
               files
               :base-url      (concat publish-site-url "/posts/")
               :html-dir      html-dir
               :exclude-drafts t))))

11. Project definition

okay, HERE we go!! org-publish-project-alist is the central registry that tells ox-publish what to build and how. I define four projects:

pages
top-level .org files (the homepage, any standalone pages)
(no term)
posts — the posts/ subdirectory, with the sitemap and Atom feed hooked in
static
non-org assets (CSS, images, JS, …) copied verbatim
site
a meta-project that builds all three with a single command

the :base-extension for the static project is an enormous rx expression — that's every single file type Neocities allows on the free tier. unfortunately, im too broke to be a supporter. so that list is exactly what I've got to work with, and I copy all of it verbatim.

the preamble and postamble are little HTML snippets — the nav bar and footer — injected into every page.

;;; Publish project definition

(defun publish--setup-projects ()
  "Configure `org-publish-project-alist' for this site."
  (let ((head     (publish--html-head))
        (preamble (concat
                   "<a href=\"/\">Home</a>\n"
                   "<a href=\"/posts/\">Posts</a>\n"
                   "<a href=\"/feed.xml\">Feed</a>"))
        (postamble "Made with Org-mode &middot; %C"))
    (setq org-publish-project-alist
          `(("pages"
             :base-directory       ,publish--org-dir
             :base-extension       "org"
             :recursive            nil
             :publishing-directory ,publish--pub-dir
             :publishing-function  org-html-publish-to-html
             :html-head            ,head
             :html-preamble        ,preamble
             :html-postamble       ,postamble)

            ("posts"
             :base-directory       ,(expand-file-name "posts/" publish--org-dir)
             :base-extension       "org"
             :recursive            nil
             :publishing-directory ,(expand-file-name "posts/" publish--pub-dir)
             :publishing-function  publish--publish-unless-draft
             :html-head            ,head
             :html-preamble        ,preamble
             :html-postamble       ,postamble
             :preparation-function publish--ensure-sitemap-placeholder
             :auto-sitemap         t
             :sitemap-filename     "index.org"
             :sitemap-title        "Posts"
             :sitemap-sort-files   anti-chronologically
             :sitemap-format-entry publish--sitemap-entry
             :sitemap-function     publish--sitemap-format
             :completion-function  publish--generate-atom-feed)

            ("static"
             :base-directory       ,publish--org-dir
             :base-extension       ,(rx (or "apng" "asc" "atom" "avif" "bin"
                                            "cjs" "css" "csv" "dae" "eot"
                                            "epub" "geojson" "gif" "glb"
                                            "glsl" "gltf" "gpg" "htm" "html"
                                            "ico" "jpeg" "jpg" "js" "json"
                                            "jxl" "key" "kml" "knowl" "less"
                                            "manifest" "map" "markdown" "md"
                                            "mf" "mid" "midi" "mjs" "mtl"
                                            "obj" "opml" "osdx" "otf" "pdf"
                                            "pgp" "pls" "png" "py" "rdf"
                                            "resolveHandle" "rss" "sass"
                                            "scss" "sf2" "svg" "text" "toml"
                                            "ts" "tsv" "ttf" "txt" "webapp"
                                            "webmanifest" "webp" "woff"
                                            "woff2" "xcf" "xml" "yaml" "yml"))
             :recursive            t
             :publishing-directory ,publish--pub-dir
             :publishing-function  org-publish-attachment)

            ("site" :components ("pages" "posts" "static"))))))

12. Deployment

now for the REALLY fun part: actually getting the site onto Neocities!!

Neocities has a REST API. my emacs-neocities package wraps it. the deployment workflow is:

  1. list all files in public/
  2. fetch the live file listing from Neocities (including SHA-1 hashes)
  3. compare: find what's new, what's changed, what's been deleted
  4. show a diff, ask for confirmation
  5. upload changed files in batches of 20

the batching matters — Neocities's API has limits on how much you can shove into one request, so I chunk it up. 20 at a time feels good.

12.1. Collecting local files

publish--collect-files returns an alist of (remote-path . local-path) pairs. the remote path is the relative path under public/ with a leading slash, to match the format Neocities uses in its API responses.

;;; Deployment

(defun publish--collect-files ()
  "Return an alist of (REMOTE-PATH . LOCAL-PATH) for `publish--pub-dir'."
  (let ((base (file-name-as-directory publish--pub-dir)))
    (mapcar (lambda (f)
              (cons (file-relative-name f base) f))
            (directory-files-recursively publish--pub-dir ".*"))))

12.2. Diffing against Neocities

publish--sha1-file computes the SHA-1 of a local file. publish--remote-index fetches the Neocities file listing and dumps it into a hash table keyed by remote path. then publish--compute-diff walks the local files, compares hashes, and returns a plist with three keys: :new, :modified, :deleted.

this means deploys are fast even if you have a lot of files — you only upload what actually changed. no more "upload everything and hope" vibes.

;;; Diff support

(defun publish--sha1-file (path)
  "Return the lowercase hex SHA-1 digest of the file at PATH."
  (with-temp-buffer
    (insert-file-contents-literally path)
    (secure-hash 'sha1 (current-buffer))))

(defun publish--remote-index ()
  "Fetch the Neocities file listing and return a hash table.
Keys are remote path strings (e.g. \"/index.html\").
Values are SHA-1 hex strings.  Directories are excluded."
  (let ((table (make-hash-table :test #'equal))
        (files (neocities-response-files (neocities-list))))
    (seq-doseq (entry files)
      (unless (eq (alist-get 'is_directory entry) t)
        (let ((path (alist-get 'path  entry))
              (sha1 (alist-get 'sha1_hash entry)))
          (when (and path sha1)
            (puthash path sha1 table)))))
    table))

(defun publish--compute-diff ()
  "Compare local public/ files against the live Neocities site.
Returns a plist with three keys:
  :new      — list of remote paths that do not exist on the site yet
  :modified — list of remote paths whose SHA-1 differs
  :deleted  — list of remote paths present on the site but not locally"
  (unless (file-directory-p publish--pub-dir)
    (user-error "public/ does not exist — run publish-build first"))
  (message "Fetching remote file listing from Neocities...")
  (let* ((remote (publish--remote-index))
         (local  (publish--collect-files))
         (seen   (make-hash-table :test #'equal))
         new modified deleted)
    ;; Classify each local file.
    (dolist (pair local)
      (let* ((rel         (car pair))
             (abs         (cdr pair))
             (remote-path (concat "/" rel))
             (remote-sha1 (gethash remote-path remote)))
        (puthash remote-path t seen)
        (cond
         ((null remote-sha1)
          (push remote-path new))
         ((not (string= remote-sha1 (publish--sha1-file abs)))
          (push remote-path modified)))))
    ;; Any remote path not seen locally is a candidate for deletion.
    (maphash (lambda (path _sha1)
               (unless (gethash path seen)
                 (push path deleted)))
             remote)
    (list :new      (sort new      #'string<)
          :modified (sort modified #'string<)
          :deleted  (sort deleted  #'string<))))

12.3. Displaying the diff

publish--format-diff turns the plist into a human-readable string. publish--show-diff-buffer pops it up in a *publish-diff* buffer using special-mode — that's a built-in minor mode that makes the buffer read-only and binds q to quit. perfect for "show something, user reads it, user presses q".

(defun publish--format-diff (diff)
  "Return a human-readable string summarising DIFF (as from `publish--compute-diff')."
  (let ((new      (plist-get diff :new))
        (modified (plist-get diff :modified))
        (deleted  (plist-get diff :deleted))
        (lines nil))
    (if (and (null new) (null modified) (null deleted))
        "No changes — site is already up to date."
      (when new
        (push (format "New (%d):\n%s" (length new)
                      (mapconcat (lambda (p) (concat "  + " p)) new "\n"))
              lines))
      (when modified
        (push (format "Modified (%d):\n%s" (length modified)
                      (mapconcat (lambda (p) (concat "  ~ " p)) modified "\n"))
              lines))
      (when deleted
        (push (format "Deleted (%d):\n%s" (length deleted)
                      (mapconcat (lambda (p) (concat "  - " p)) deleted "\n"))
              lines))
      (mapconcat #'identity (nreverse lines) "\n\n"))))

(defun publish--show-diff-buffer (diff)
  "Pop up a *publish-diff* buffer displaying DIFF."
  (let ((buf (get-buffer-create "*publish-diff*")))
    (with-current-buffer buf
      (let ((inhibit-read-only t))
        (erase-buffer)
        (insert "publish diff — local public/ vs. live Neocities site\n")
        (insert (make-string 54 ?─) "\n\n")
        (insert (publish--format-diff diff))
        (insert "\n"))
      (special-mode)
      (goto-char (point-min)))
    (display-buffer buf)))

13. Entry points

these are the interactive commands — the things you actually call from M-x.

publish-build runs the full ox-publish pipeline. publish-build-force is the same but nukes the timestamp cache first, forcing everything to re-export even if nothing changed. you want this when you've tweaked a stylesheet or nav template and need every page to pick up the new <head>.

publish-diff fetches the remote listing and shows you what would change. it does NOT upload anything. purely informational. I use this constantly.

publish-deploy is the main event. it runs the diff, shows it, asks for confirmation, then uploads in batches. with a prefix argument (C-u M-x publish-deploy) it skips the diff and goes straight to uploading — for when you just want it done.

publish-build-and-deploy chains them. one command, whole pipeline.

;;; Entry points

(defun publish-build (&optional force)
  "Export Org files to public/.
With prefix argument FORCE, rebuild everything."
  (interactive "P")
  (run-hooks 'publish-before-build-hook)
  (publish--setup-projects)
  (org-publish-all force)
  (message "Build complete.")
  (run-hooks 'publish-after-build-hook))

(defun publish-build-force ()
  "Export all Org files, ignoring the timestamp cache."
  (interactive)
  (publish-build t))

(defun publish-diff ()
  "Show what would change if you deployed public/ to Neocities now.
Opens a *publish-diff* buffer with new, modified, and deleted files.
Does not upload anything."
  (interactive)
  (unless neocities-api-key
    (neocities-fetch-api-key))
  (let ((diff (publish--compute-diff)))
    (publish--show-diff-buffer diff)
    diff))

(defun publish-deploy (&optional skip-diff)
  "Upload public/ to Neocities, showing a diff and asking for confirmation first.
With prefix argument SKIP-DIFF, bypass the diff check and upload immediately
\(equivalent to the old behaviour).
Prompts for credentials if `neocities-api-key' is nil."
  (interactive "P")
  (run-hooks 'publish-before-deploy-hook)
  (unless neocities-api-key
    (neocities-fetch-api-key))
  (unless skip-diff
    (let* ((diff     (publish--compute-diff))
           (new      (plist-get diff :new))
           (modified (plist-get diff :modified))
           (deleted  (plist-get diff :deleted)))
      (if (and (null new) (null modified) (null deleted))
          (progn
            (message "publish-deploy: site is already up to date, nothing to upload.")
            (run-hooks 'publish-after-deploy-hook)
            (cl-return-from publish-deploy))
        (publish--show-diff-buffer diff)
        (unless (yes-or-no-p
                 (format "Deploy %d new, %d modified, %d deleted file(s) to Neocities? "
                         (length new) (length modified) (length deleted)))
          (message "Deploy cancelled.")
          (run-hooks 'publish-after-deploy-hook)
          (cl-return-from publish-deploy)))))
  (let* ((files      (publish--collect-files))
         (total      (length files))
         (batch-size 20)
         (uploaded   0))
    (unless files
      (user-error "public/ is empty — run publish-build first"))
    (message "Deploying %d files to Neocities..." total)
    (while files
      (let* ((batch    (seq-take files batch-size))
             (response (neocities-upload batch)))
        (setq files    (seq-drop files batch-size)
              uploaded (+ uploaded (length batch)))
        (message " batch %d/%d → %s"
                 uploaded total
                 (neocities-response-result response)))))
  (message "Deploy complete (%d files)." total)
  (run-hooks 'publish-after-deploy-hook))

(defun publish-build-and-deploy (&optional force)
  "Build the site, then deploy to Neocities.
With prefix argument FORCE, rebuild everything."
  (interactive "P")
  (publish-build force)
  (publish-deploy))

15. That's it!!

THAT'S THE WHOLE THING!! ~500 lines!! a static site generator, an Atom feed, and a deployment pipeline, all wired together in elisp, all tangled out of this very post!!

if you want to use this yourself (u shouldnt, it's extremely opinionated, but okay):

  1. grab emacs-ox-atom and emacs-neocities and put them on your load-path
  2. tangle this file with M-x org-babel-tangle (or just download publish.el directly if you can't be bothered)
  3. set up the directory layout from the top of this post
  4. meow at the nearest cat (this is IMPORTANT)
  5. M-x load-file RET publish.el RET
  6. M-x publish-build
  7. M-x publish-deploy

and yes!! this post itself is being built and deployed using the very script it describes. the blog post IS the program, the program builds the blog post, it's turtles all the way down.