diff --git a/build.el b/build.el
index 7c6414c..d20d311 100644
--- a/build.el
+++ b/build.el
@@ -13,15 +13,76 @@
"")
+(defun blog-extract-title (file)
+ "Extract #+TITLE: value from FILE."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ (if (re-search-forward "^#\\+TITLE:[ \t]*\\(.+\\)$" nil t)
+ (string-trim (match-string 1))
+ (file-name-base file))))
+
+(defun blog-extract-excerpt (file &optional max-chars)
+ "Extract first body paragraph from FILE, up to MAX-CHARS characters."
+ (let ((max (or max-chars 250)))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ ;; Skip metadata lines and headings
+ (while (and (not (eobp))
+ (looking-at "^\\(#\\+\\|\\*\\|[ \t]*$\\)"))
+ (forward-line 1))
+ (let ((start (point)))
+ ;; Collect lines until blank line or heading
+ (while (and (not (eobp))
+ (not (looking-at "^\\(\\*\\|[ \t]*$\\)")))
+ (forward-line 1))
+ (let ((text (string-trim (buffer-substring-no-properties start (point)))))
+ ;; Collapse whitespace
+ (setq text (replace-regexp-in-string "[ \t\n]+" " " text))
+ (if (> (length text) max)
+ (concat (substring text 0 max) "...")
+ text))))))
+
+(defun blog-generate-listings (_project)
+ "Scan content/blog/ and generate recent-posts.org and all-posts.org."
+ (let* ((blog-dir "./content/blog")
+ (gen-dir "./content/generated")
+ (pattern (concat blog-dir "/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.org"))
+ (files (sort (file-expand-wildcards pattern t)
+ (lambda (a b) (string> (file-name-base a)
+ (file-name-base b)))))
+ (make-entry
+ (lambda (file)
+ (let* ((base (file-name-base file))
+ (date (substring base 0 10))
+ (title (blog-extract-title file))
+ (excerpt (blog-extract-excerpt file))
+ (link (concat "file:../blog/" base ".org")))
+ (concat
+ "*[[" link "][" title "]]* — " date "\n\n"
+ excerpt "\n\n"
+ "[[" link "][Read more →]]\n\n"
+ "-----\n\n")))))
+ (make-directory gen-dir t)
+ (with-temp-file (concat gen-dir "/recent-posts.org")
+ (dolist (file (seq-take files 3))
+ (insert (funcall make-entry file))))
+ (with-temp-file (concat gen-dir "/all-posts.org")
+ (dolist (file files)
+ (insert (funcall make-entry file))))))
(setq org-publish-project-alist
`(("site-pages"
:base-directory "./content"
:base-extension "org"
:publishing-directory "./public"
:recursive t
+ :exclude "generated/"
:publishing-function org-html-publish-to-html
+ :preparation-function blog-generate-listings
:html-head-include-default-style nil
:html-head-include-scripts nil
:html-head ""
diff --git a/content/blog.org b/content/blog.org
new file mode 100644
index 0000000..8e700ca
--- /dev/null
+++ b/content/blog.org
@@ -0,0 +1,6 @@
+#+TITLE: Blog
+#+DESCRIPTION: All blog posts
+
+* All Posts
+
+#+INCLUDE: generated/all-posts.org
diff --git a/content/blog/post001.org b/content/blog/2025-05-08-nix-selfhosting-in-the-age-of-llms.org
similarity index 100%
rename from content/blog/post001.org
rename to content/blog/2025-05-08-nix-selfhosting-in-the-age-of-llms.org
diff --git a/content/index.org b/content/index.org
index a802664..81bad44 100644
--- a/content/index.org
+++ b/content/index.org
@@ -3,7 +3,7 @@
* Recent Blog Posts
-- [[file:blog/post001.org][Nix Selfhosting in the Age of LLMs]]
+#+INCLUDE: generated/recent-posts.org
* Contact me
diff --git a/flake.nix b/flake.nix
index 244d8be..22b04bb 100644
--- a/flake.nix
+++ b/flake.nix
@@ -95,9 +95,37 @@
'';
};
+ new-post = pkgs.writeShellApplication {
+ name = "new-post";
+ runtimeInputs = [ pkgs.coreutils pkgs.gnused ];
+ text = ''
+ if [ $# -eq 0 ]; then
+ echo "Usage: new-post \"Post Title\""
+ exit 1
+ fi
+ title="$*"
+ slug=$(echo "$title" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//;s/-$//')
+ date=$(date +%Y-%m-%d)
+ filename="content/blog/''${date}-''${slug}.org"
+ mkdir -p content/blog
+ cat > "$filename" <