org-grimoire: a static site generator for GNU Emacs

Here's a small ssg emacs package i made. I made this post for MELPA, specifically, as an example tutorial.

Summary

When you call org-grimoire-build, it does five things in order:

  1. Scan the content directory and parse every .org file into a post plist
  2. Copy static assets
  3. Render each post to HTML using your theme templates ( pages are page.html, posts are post.html etc. Depending on content/dir where they are)
  4. Generate index pages, tag pages, and feeds from the collected posts
  5. Write a sitemap.xml

Once collect is done, every later step maps over that list of post plists.

Posts

Parsing is done with org-element, the Emacs built-in that returns an AST of an org file.

(defun org-grimoire--extract-keyword (ast keyword)
  "Return the value of KEYWORD from the Org AST."
  (org-element-map ast 'keyword
    (lambda (el)
      (when (string= (org-element-property :key el) keyword)
        (org-element-property :value el)))
    nil t))

org-element-map walks the AST and calls the lambda for each node of the given type. The trailing nil t means stop at the first match — since #+TITLE only appears once per file, there is no point continuing. This is used to pull out every frontmatter keyword: TITLE, DATE, TAGS, DRAFT, LISTED.

The result of parsing a file is a plist:

(defun org-grimoire--collect-file (filepath source-dir output-dir)
  (with-temp-buffer
    (insert-file-contents filepath)
    (let* ((ast    (org-element-parse-buffer))
           (title  (org-grimoire--extract-keyword ast "TITLE"))
           (date   (org-grimoire--extract-keyword ast "DATE"))
           (type   (org-grimoire--infer-type filepath source-dir))
           (draft  (org-grimoire--normalize-boolean
                    (org-grimoire--extract-keyword ast "DRAFT")))
           (listed (org-grimoire--normalize-boolean
                    (org-grimoire--extract-keyword ast "LISTED") t))
           (tags   (org-grimoire--parse-tags
                    (org-grimoire--extract-keyword ast "TAGS")))
           (slug   (org-grimoire--file-to-slug filepath))
           (output (expand-file-name
                    (concat (file-name-sans-extension
                             (file-relative-name filepath source-dir))
                            ".html")
                    output-dir))
           (assets (org-grimoire--collect-assets ast filepath)))
      (list :title  title  :date   date   :type   type
            :draft  draft  :listed listed :tags   tags
            :slug   slug   :source filepath
            :output output :assets assets))))

The :type is inferred from the directory — a file in content/post/ gets type post, a file in content/page/ gets type page. That type is what determines which template gets used for rendering. Add a content/note/ directory and write a note.html template; grimoire picks it up automatically without any special configuration.

The :assets field comes from the same AST walk that reads keywords — it also walks every file: link and collects the paths:

(defun org-grimoire--collect-assets (ast source-file)
  (let ((source-dir (file-name-directory source-file)))
    (delq nil
          (org-element-map ast 'link
            (lambda (el)
              (when (string= (org-element-property :type el) "file")
                (let* ((path     (org-element-property :path el))
                       (absolute (expand-file-name path source-dir)))
                  (when (file-exists-p absolute)
                    absolute))))))))

This is how images work. If your org file has [[file:images/shot.png]], that path gets collected here, and later during rendering it gets copied to the output directory preserving the relative path. Tools like org-download which drop images next to your org files work out of the box because of this.

Templates

Templates are plain HTML files with {{key}} placeholders and {{include filename.html}} for partials — that is the whole system.

(defun org-grimoire--render-template (template vars theme-dir)
  (let ((result (org-grimoire--process-includes template theme-dir)))
    (cl-loop for (key value) on vars by #'cddr do
      (setq result
            (replace-regexp-in-string
             (concat "{{" (substring (symbol-name key) 1) "}}")
             (or value "")
             result t t)))
    result))

Includes are processed first, then placeholders are replaced. The substring call strips the leading colon from keyword symbols so :title becomes the string "title" which matches ={{title}}.

Template resolution has a fallback chain. When looking for a template file, grimoire checks your defined theme directory first, then falls back to the built-in default theme. So if you only want to override post.html and leave everything else alone, you only need that one file in your theme directory.

(defun org-grimoire--resolve-theme-file (filename theme-dir)
  (let ((user-path    (when theme-dir
                        (expand-file-name filename theme-dir)))
        (default-path (expand-file-name
                       filename (org-grimoire--default-theme-dir))))
    (cond
     ((and user-path (file-exists-p user-path)) user-path)
     ((file-exists-p default-path) default-path))))

Rendering

The actual org-to-HTML conversion is just a wrapper around ox-html, the standard Emacs org exporter:

(defun org-grimoire--org-to-html (filepath)
  (with-temp-buffer
    (insert-file-contents filepath)
    (setq-local tab-width 8)
    (org-mode)
    (org-export-as 'html nil nil t
                   '(:with-toc nil
                     :with-title nil
                     :section-numbers nil))))

The fourth argument t is body-only, which tells the exporter to return just the content without the surrounding <html> boilerplate — grimoire handles the full page structure via the base template. TOC and title are disabled here and handled by the template instead.

This also means syntax highlighting works exactly as it does in your Emacs. The exporter produces spans with classes like org-keyword, org-string, org-comment, matching whatever face you have active. Your CSS then maps those classes to colors.

Rendering a post:

(defun org-grimoire--render-post (post)
  (let* ((theme-dir (org-grimoire--config-get :theme))
         (type      (or (plist-get post :type) "page"))
         (title     (or (plist-get post :title) ""))
         (template  (org-grimoire--load-template type theme-dir))
         (content   (org-grimoire--org-to-html (plist-get post :source)))
         (inner     (org-grimoire--render-template template
                      (list :title        title
                            :content      content
                            :date         (or (plist-get post :date) "")
                            :tags         (org-grimoire--tags-html
                                           (plist-get post :tags))
                            :reading-time (or (plist-get post :reading-time) "")
                            :slug         (plist-get post :slug))
                      theme-dir)))
    (org-grimoire--wrap-base inner title
                             (org-grimoire--post-site-url post))))

Load the type template, fill its placeholders with the post data, then wrap the result in base.html. The output is the full HTML document ready to write to disk.

Building

org-grimoire-build runs the whole pipeline. It has basic error handling — a post that fails to render should not stop the whole build.

(defun org-grimoire-build (name)
  (interactive
   (list (completing-read "Site: "
                          (hash-table-keys org-grimoire--sites) nil t)))
  (org-grimoire--log-reset)
  (unless (gethash name org-grimoire--sites)
    (user-error "No site configured with name: %s" name))
  (let* ((org-grimoire--current-site name)
         (source    (org-grimoire--config-get :source))
         (output    (org-grimoire--config-get :output))
         (static    (org-grimoire--config-get :static))
         (theme-dir (org-grimoire--config-get :theme)))
    (org-grimoire--validate-config
     (gethash name org-grimoire--sites))
    (condition-case err
        (let ((posts (org-grimoire--collect source output)))
          (org-grimoire--copy-static static
                                     (expand-file-name "static" output))
          (org-grimoire--copy-theme-static output theme-dir)
          (org-grimoire--render-all posts)
          (org-grimoire--generate-index posts output)
          (org-grimoire--generate-tags posts output)
          (org-grimoire--generate-feeds posts output)
          (org-grimoire--generate-sitemap posts output)
          (org-grimoire--log-summary))
      (error
       (org-grimoire--log :error (error-message-string err))
       (org-grimoire--log-summary)))))

org-grimoire--current-site is bound with let rather than setq so it unwinds cleanly even if the build errors out. The config is accessed throughout the build via org-grimoire--config-get which reads from that dynamic binding. All site state is kept in a hash table keyed by name, so multiple sites can coexist.

Individual post errors are caught in org-grimoire--render-all and logged as warnings — a broken image link in one post should not prevent the rest of the site from building.

Making a site

The process from nothing to a running local site:

Initialize

M-x org-grimoire-init

Enter a site name, a base directory, and a URL. Grimoire creates the directory structure and prints a config snippet to add to your init file( it prints it in the Messages buffer, not sure if there's a better solution):

(org-grimoire-setup "my-site"
  :base-dir    "~/my-site"
  :base-url    "https://example.com"
  :site-title  "My Site"
  :description "A personal site")

There are additional custom options you can pass:

Key Required Description
:base-dir yes Root directory of your site
:base-url yes Full URL including scheme (e.g. https://strah.net)
:site-title yes Global site name, used in feeds and navigation
:description no Site description, used in feeds and meta tags
:author no Author name, used in the Atom feed
:theme no Theme name under base-dir/themes/
:per-page no Posts per index page, defaults to 10
:reading-time no Set to t to enable reading time estimates

The directory it creates looks like this:

my-site/
├── content/
│   ├── post/
│   │   └── my-first-post.org
│   └── page/
│       └── about.org
└── static/

Write something

Open my-first-post.org. It already has the minimal frontmatter filled in. Write something, save it, remove the #+DRAFT: t line when ready.

Build

M-x org-grimoire-build

Select the site name. The build log goes to *Messages*. Output lands in public_html/.

Preview locally

If you have simple-httpd installed:

M-x org-grimoire-serve

Otherwise Python with python -m http.server works fine:

cd ~/my-site/public_html && python3 -m http.server 8080

2026-04-01_23-56-16_screenshot.png

Create more content

M-x org-grimoire-new prompts for a site, a content type from your existing directories, a title, and tags. It creates the file as a draft and opens it. The slug is derived from the title automatically.

Customizing the theme

Copy the themes/default/ directory from the package into my-site/themes/my-theme/ and point your config at it:

(org-grimoire-setup "my-site"
  :base-dir   "~/my-site"
  :base-url   "https://example.com"
  :site-title "My Site"
  :theme      "my-theme")

Now edit static/css/style.css and the HTML templates freely. The fallback chain means you can delete any template you do not want to override and grimoire will use the default version.

Installation

The package is available on MELPA:

(use-package org-grimoire
  :ensure t)

Or clone the repository directly:

git clone https://github.com/spiperac/org-grimoire

And then add to your init.el:

;; Load org-grimoire from local disk, replace path bellow
(add-to-list 'load-path "~/git/org-grimoire/")
(require 'org-grimoire)

The source is a single file. The default theme is in themes/default/. No other files are required at runtime.

This site is the example of org-grimoire website!