Files
spine/docs/specs/2026-06-20-initial-scaffold-design.md
T

5.0 KiB

Spine — initial scaffold

A minimal working Emacs web server that serves templated HTML. This is the first concrete slice of spine-spec.md, resolving the open "glue language + web framework" decision: Emacs owns the HTTP socket and serves the UI directly.

Architecture decision

Emacs end-to-end. No external web process, no emacsclient IPC for reads. The app is a single Emacs instance running simple-httpd. This keeps the build simple (one language, two packages) and is adequate for Spine's complexity level.

Packages

Package Version Source Purpose
simple-httpd MELPA latest MELPA HTTP server, defservlet routing
mustache MELPA latest MELPA Mustache template rendering
ht MELPA latest MELPA Hash table for template context

Project structure

spine/
├── scripts/
│   └── run                 # start the server as a daemon
├── spine.el               # entry point: bootstrap, server start, handlers
├── templates/
│   └── hello.mustache     # smoke-test template
├── spine-spec.md          # design brief (existing)
├── spine-mockup-a-agenda.html
├── spine-mockup-b-journal.html
└── README.md
  • spine.el is the sole Elisp file for now. Split later when a seam earns it.
  • Templates live in templates/ as .mustache files.
  • Mockups stay as reference artifacts; they are not served.

Package bootstrap

spine.el uses package.el (built-in). On first run it installs missing packages from MELPA; subsequent starts skip the install step.

(require 'package)
(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t)
(package-initialize)
(dolist (pkg '(simple-httpd mustache ht))
  (unless (package-installed-p pkg)
    (package-refresh-contents)
    (package-install pkg)))

package-refresh-contents runs only when a package is missing — not on every start.

Server startup

(defvar spine-port 8080
  "Port for the Spine HTTP server.")

(defvar spine-host nil
  "Host for the Spine HTTP server.
nil means localhost only. Set to a string like \"0.0.0.0\" to listen on
all interfaces.")

(defvar spine-template-dir
  (expand-file-name "templates/" (file-name-directory load-file-name))
  "Directory containing .mustache templates.")

(setq httpd-host spine-host)
(setq httpd-port spine-port)
(httpd-start)
(message "Spine listening on http://%s:%d"
         (or spine-host "localhost") spine-port)
  • Port defaults to 8080, overridable via spine-port.
  • Host defaults to nil (localhost-only), overridable via spine-host.
  • spine-template-dir resolves relative to spine.el's directory so the app works regardless of default-directory.

Template rendering

A single helper wraps Mustache rendering:

(defun spine-render (name context)
  "Render templates/NAME.mustache with CONTEXT (an ht hash table).
Returns the rendered string."
  (mustache-render
   (with-temp-buffer
     (insert-file-contents
      (expand-file-name name spine-template-dir))
     (buffer-string))
   context))
  • Reads the template file from disk on every request. No caching — correct and simple; add caching if profiling shows a need.
  • context is an ht hash table whose keys map to {{key}} placeholders.

Hello World handler

(defservlet hello text/html ()
  (insert (spine-render "hello.mustache"
            (ht ("title"   "Spine")
                ("message" "Hello from Emacs.")))))

templates/hello.mustache:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{title}}</title>
</head>
<body>
  <h1>{{message}}</h1>
</body>
</html>
  • Accessible at http://localhost:8080/hello.
  • The root / is unbound and will show simple-httpd's default directory listing — acceptable for a smoke test.

Invocation

Production use — Emacs daemon with a named socket, no GUI frame:

scripts/run [HOST:]PORT
  • scripts/run 9090http://localhost:9090/hello
  • scripts/run 0.0.0.0:8080http://0.0.0.0:8080/hello (all interfaces)
  • scripts/runhttp://localhost:8080/hello

Stop: emacsclient --socket-name=spine --eval '(kill-emacs)'

The socket name spine is separate from the default Emacs daemon socket, so your personal daemon is unaffected.

Development use — interactive Emacs in the terminal:

emacs --quick --load spine.el

--quick (aliases: -Q) skips init files for a clean, repeatable start.

Acceptance

  • emacs --quick --load spine.el starts without errors.
  • Browsing http://localhost:8080/hello shows "Hello from Emacs."
  • Templates load relative to spine.el's directory, not default-directory.
  • Second invocation skips package install (packages already present).

Out of scope

  • Book data, Org file integration, any handler beyond /hello.
  • Static file serving (CSS, JS, icons).
  • Tests — the scaffold is small enough to smoke-test manually.