This is an Eleventy project that builds https://hans.gerwitz.com/
The repository is a mix of site-building code, which may be authored with AI, and content, which is human-crafted.
Most page content is in markdown parsed by markdown-it, layout templates and more complex pages use Nunjucks.
Source files are in /src and site is built to /_site for deployment.
To set up, from the root directory: npm run build
To start a test server: npm run start
npm run build renders the site once, compiles the Typst expression to PDF, then creates the Pagefind search index using the same canonical content records used by related-content generation. A successful full Eleventy build writes those records to the ignored, private .cache/content-records.json artifact, outside the deployed _site directory. npm run search:index can rebuild the index from that artifact without rendering the site again; after source changes, run a full build first. Failed or incremental filesystem builds invalidate the artifact rather than leaving stale or partial records available.
Native .typ templates in src/_editions/typst/ use Nunjucks and the shared
src/_layouts/typst.njk layout. The typst filter converts Markdown using the
site's existing parser; the initial document uses the About page. Images are
linked rather than embedded, and relative links resolve to the original web page.
npm run build compiles every generated .typ file under _site/editions/typst/
to a neighboring .pdf, retaining the source. The first document is published at
/editions/typst/index.pdf, with source at /editions/typst/index.typ.
npm run typst:compile recompiles the already-generated sources separately;
npm start renders sources but does not refresh PDFs automatically.
The native compiler and bundled fonts are installed through npm. No separate Typst CLI, Dockerfile changes, or Coolify settings are needed. Compilation errors fail the build with source diagnostics and remove the affected stale PDF.
Set sharing.image in a page’s front matter to use a custom Open Graph and Twitter preview image. For a local PNG, JPEG, or GIF published from src/ (including a file next to its source page or a root file from src/_meta/), the build reads its width, height, and MIME type automatically. The rendered image URL is absolute. Externally hosted images and formats not copied by the site need explicit sharing.width, sharing.height, and sharing.type values.
sharing:
image: /assets/example/social-preview.png
alt: Description of the preview image.Pages and articles can use the sidenote column for a wide image on desktop screens. Use a semantic figure with the wide class; images without that class and images at narrower viewport widths remain in the content column.
<figure class="wide">
<img src="/media/example.jpg" alt="Description of the image">
<figcaption>Optional caption.</figcaption>
</figure>Stage your source edits, then run npm run content:prepare, or let the pre-commit
hook run it automatically. This is the single preparation step for the content
catalog, creation dates, required map SVGs, embeddings, and related-content data.
The command uses an isolated snapshot of the Git index, not unrelated unstaged
work, and stages only its generated artifacts. Preparation is best-effort in the
pre-commit hook: failures warn but do not block commits or pushes. The explicit
command still reports errors; caught publication errors roll back generated
changes. Dirty, deleted, or untracked generated files must be staged or restored
before replacement.
Local npm install / npm ci installs the versioned hooks when no existing hooks
would be replaced. For an already-installed checkout, run npm run hooks:install.
Custom hooks are preserved; chain .githooks/pre-commit into them if needed. Docker dependency installation does
not install hooks or run preparation.
Pre-commit attempts to prepare and stage artifacts for the same commit; no
follow-up commit is created. Ordinary staged commits and git add -p are
supported. For git commit --only or pathspec commits, preparation warns and
skips rather than risking index corruption. There is no pre-push preparation
gate. npm run content:check is an optional offline diagnostic, not a publishing
requirement.
The preparation step loads the optional local .env. New or changed embedding
inputs require OPENAI_API_KEY and are sent to OpenAI; cached vectors are reused,
and dates-only changes do not request embeddings. Existing generate:maps and
related commands are compatibility aliases for the single preparation step.
Normal builds consume whatever committed artifacts are available without Git or
API calls. Content added directly to the repository is still published and
indexed; uncatalogued content gets an RSS date estimate, and missing embeddings
simply mean no new related-content suggestions until local preparation runs.
For local development, requests under /media/* can be redirected to the production media bucket by setting MEDIA_ORIGIN before starting Eleventy. For example:
MEDIA_ORIGIN=https://your-media-host.example npm run start
This keeps content URLs site-relative, such as /media/example.jpg, while loading media from the bucket during local development. The /media/ prefix is removed when redirecting, so /media/example.jpg redirects to https://your-media-host.example/example.jpg.
/feeds/everything.rss includes exactly the sources listed at /site/content/,
including published gemposts with their Gemini destinations. It includes every
entry, newest first, with stable URL-based GUIDs and existing summaries.
An item's RSS pubDate uses, in order:
- Explicit
updatedfront matter, when present. - An authored
dateor the date in its filename. - The catalog's preserved creation date.
- The current build timestamp, as an estimate for uncatalogued content.
Git modification times and filesystem timestamps are not used. updated
overrides the publication date even when it is earlier. This feed does not
change the site's existing content-date displays. An estimated date can advance
on subsequent builds until a local preparation records a creation date; invalid
updated values fail the build. Some readers update an existing stable-GUID item
rather than marking it unread again when its date changes.
Creation dates and shared metadata live in committed
generated/content-metadata.json. Existing sources are backfilled from full Git
history; genuinely new staged sources use a timestamp captured when preparation
starts. Committed dates are preserved, including across staged renames. A failed
commit attempt does not permanently fix a new source's timestamp. This is an
initial-preparation timestamp for new files, not an exact future Git author date.
Eleventy reads the catalog without executing Git. Docker excludes .git and
requires no source-availability or cloning setting in Coolify. Missing or invalid
catalogs warn and fall back to estimated dates instead of failing deployment.
The catalog is not served
publicly; only its relevant values appear in generated pages and feeds.
Related-content artifacts are committed so normal and deployment builds do not
make API requests. npm run content:prepare refreshes them alongside the shared
catalog and maps.
Related-content commands always extract fresh records from the current sources, rather than trusting the previous build artifact. Search and related-content generation share content selection, text extraction, and metadata; search also uses high-confidence related titles and topics as neighbor vocabulary. Published gemposts join both corpora with canonical gemini://hans.gerwitz.com/gemlog/…gmi destinations, while drafts remain excluded and no web counterparts are generated. Search results and related links to these posts use the gemini-link class with a prefixed Gemini symbol; following them requires a Gemini protocol handler. Prepare after staging gemposts, then rebuild the site to publish their relationships.
Use npm run related:check to list eligible content without requesting embeddings.
The shared catalog stores source facts and current record metadata; the embedding
cache stores only model/hash-addressed vectors, and the graph cache stores its
incremental relationship state. Preparation stages generated/content-metadata.json,
generated/related-content-cache.json, generated/related-graph-cache.json,
src/_data/related.json, and required src/_generated/maps/ artifacts.
This project is configured for Dockerfile-based deployment in Coolify.
- Build source:
Dockerfile - Runtime base image:
ghcr.io/gerwitz/calmserve - Runtime servers: Nginx, Gemini, and Spartan
- Generated site path:
/_sitecopied to/usr/share/nginx/html - Generated Gemini path:
/_site/editions/geminicopied to/srv/calmserve
Media requests under /media/* are proxied by Nginx to an S3-compatible origin, using the MEDIA_ORIGIN_HOST environment variable.
Set this in Coolify application environment variables, for example:
MEDIA_ORIGIN_HOST=your-bucket.s3.fr-par.scw.cloud
With this configuration, browser URLs remain same-origin (for example /media/example.jpg) while content is fetched from the object storage backend.
The same media origin is used by the Gemini and Spartan service. Expose container
port 1965 for Gemini and map the public Spartan port 300 to container port
3000.
In the Coolify application's persistent storage settings, add a volume mounted
at /var/lib/calmserve/certificates before deploying. The Dockerfile cannot
configure Coolify's persistent storage; a Dockerfile VOLUME declaration alone
does not ensure the same volume is reused across deployments. Keep this mount
attached to the application so cert.pem and key.pem survive container
replacement. Gemini clients trust the self-signed certificate across visits, so
losing these files causes certificate warnings. If the previous certificate and
key are available, restore both to the volume to retain existing client trust;
otherwise clients must accept the new certificate once.
The calmserve image is maintained in the
gerwitz/calmserve repository. It must be
publicly readable in GitHub Container Registry, or Coolify must be configured
with registry credentials. The site Docker build accepts CALMSERVE_IMAGE as a
build argument when a specific image tag or digest should be used instead of
latest.
Maintain endpoints in pings.json. One JSON array supports gemini://, http://, and https:// URLs;
strings make GET requests. An empty array disables notifications. The file is
copied privately to /etc/calmserve/pings.json, not into the public site.
HTTP webhooks can also specify a method, headers, and body. For example:
[
"gemini://example.org/submit?gemini%3A%2F%2Fhans.gerwitz.com%2Fgemlog%2F",
"https://webhook.example.org/update?key=${WEBHOOK_API_KEY}",
{
"url": "https://webhook.example.org/events",
"method": "POST",
"headers": { "Authorization": "Bearer ${WEBHOOK_API_KEY}" },
"body": { "feed": "https://hans.gerwitz.com/feeds/everything.rss" }
}
]Set secret variables in the container's runtime environment. ${VARIABLE}
placeholders in URL paths or queries are percent-encoded as individual values;
header/body placeholders are replaced verbatim, without shell evaluation or
recursive expansion. Variables are not supported in URL hosts or schemes. A
missing/empty variable skips only that endpoint. JSON bodies receive
application/json unless a content type is supplied. Never commit actual keys.
The IndieKit notification uses INDIEKIT_ACCESS_TOKEN, which must be set as a
secret Coolify environment variable to IndieKit's server access token.
No Coolify hook or enabling flag is needed. Calmserve's startup script detects
executable /usr/local/bin/calmserve-notify, waits for local HTTP, Gemini, and
Spartan readiness, then invokes it once in the background. The site supplies
scripts/notify.mjs at that path; it no longer has a separate startup wrapper.
Calmserve retains process supervision and terminates the notifier on shutdown.
Publish the updated Calmserve base image before building this site image; an
outdated base fails the Docker build with an upgrade instruction.
Output appears in the container's normal logs. Image builds, local npm builds, and recurring Docker health checks do not send notifications. Remove any previous Coolify post-deployment ping command to avoid invoking the client twice.
The client compares the public
https://hans.gerwitz.com/.well-known/calmserve/updated.svg byte-for-byte with its
local copy. The badge includes the content-tree fingerprint, so notifications
wait for the correct web-content version, not merely an unchanged Gemlog.
Optional PING_READY_URL/PING_READY_FILE overrides are supported; the previous
GEMINI_PING_READY_URL/GEMINI_PING_READY_FILE names remain aliases.
Gemini requests keep the existing self-signed-certificate policy and accept 2x responses. HTTP uses normal HTTPS certificate verification and accepts 2xx. Requests time out after five seconds. Gemini and HTTP GET requests retry network errors or temporary statuses up to twice; HTTP GET redirects stay on the same origin, and Gemini redirects remain Gemini-only, with at most three redirects. POST requests are neither automatically retried nor redirected, avoiding accidental duplicate webhook actions or secret forwarding. Endpoint failures never interrupt serving. Logs and dry runs omit URL paths, queries, and resolved secrets; missing-variable errors name the variable, never its value.
This is a container-readiness trigger, not a deployment-platform completion hook. Restarts also attempt notification. A development or preview container with the same content as production can notify as well: the public-version check confirms content availability, not deployment identity. An empty endpoint list is the off switch. Identical content can be notified again on a restart.
Validate configuration locally without making requests:
npm run notify -- --dry-runJavascript is 100% optional. CSS is also optional, but without it everything will be ugly.
Navigation and URL structure match as closely as is reasonable.
"Static page" URLs are "pretty" and end with /.
"Dated content" source files (e.g. posts and notes) are named {iso-date}-{slug}.md. Organization into folders such as years is arbitrary and only for ease management.
If they do, a 301 redirect is put in place at the old URL.
Quotes are rendered by markdown-it-attribution which renders HTML as <figure> elements, à la ALA.
/weeks contains journal entries that will be included in the weekly archive URLs.
/writing contains posts that include a title and publication date. They are published to dated URLs. Often they are PESOS (manually).
/gemposts contains titled capsule posts, published only in Gemini. Author them in src/gemposts/YYYY-MM-DD-slug.md with a title, an optional explicit date (otherwise inferred from the filename), and a Markdown body. They join writing in collections.gemlog; the existing web collections.posts is unchanged.
The capsule publishes both writing and gemposts at /gemlog/YYYY-MM-DD-slug.gmi. /gemlog/ lists the newest 50, and /gemlog/archive/ is the complete archive. The build generates editions/gemini/redirects.json to redirect former /posts/ and Gemini writing URLs. Deploy a rebuilt calmserve image with redirect-map support before deploying this URL change.
/notes contains short notes that are identified primarily by publication time. They are published to dated URLs. They are generally tweet-length, but there is no formal limits on their content. They are usually posted via IndieKit. They are POSSE to Micro.blog and Twitter (via an RSS feed).
Notes are also exported as twtxt: /twtxt.txt contains the current calendar year's notes, and /twtxt-YYYY.txt contains each previous year with notes. Entries are oldest first, render inline Markdown links as label <url>, retain other Markdown, and encode internal line breaks as U+2028. The feeds form an archive-extension prev chain, using the main feed URL for hashes. Dates preserve authored timezone offsets; otherwise they use Amsterdam time, with noon assumed when no time is known. The current year follows Amsterdam time. Drafts and notes with excludeFromFeed are omitted.
/media contains attachments to dated content (posts and notes), often uploaded via IndieKit.
Almost everything else is a page meant to be permanent and fit within some sort of topic hierarchy.
Inspired by https://jkc.codes/blog/creating-drafts-in-eleventy/ the dynamic data files in /writing and /notes cause a true draft value to exclude pages from collections (defined in index.js).