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:
- Builds the site — takes
org/full of org files, spits outpublic/full of HTML - Generates a feed — produces
public/feed.xmlas a proper Atom 1.0 feed - 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 theorg/directory, copied verbatim topublic/ - 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-fancyenables semantic HTML5 elements (<article>,<nav>, etc.) instead of plain<div>soup. more meaningful markup for basically free!!org-html-head-include-scriptsandorg-html-head-include-default-styleare bothnilbecause 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 'cssmeans syntax-highlighted source blocks get CSS classes instead of inlinestyleattributes, so my stylesheet controls the colours rather than having them hardcoded everywhereorg-html-divsis 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:
publish--sitemap-entry— controls how each post appears in the list. I format them asYYYY-MM-DD — [[link][Title]]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 valuenil. that's what org-publish gives you whenpublish--sitemap-entryreturns 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
.orgfiles (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 · %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:
- list all files in
public/ - fetch the live file listing from Neocities (including SHA-1 hashes)
- compare: find what's new, what's changed, what's been deleted
- show a diff, ask for confirmation
- 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):
- grab emacs-ox-atom and emacs-neocities and put them on your
load-path - tangle this file with
M-x org-babel-tangle(or just downloadpublish.eldirectly if you can't be bothered) - set up the directory layout from the top of this post
- meow at the nearest cat (this is IMPORTANT)
M-x load-file RET publish.el RETM-x publish-buildM-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.