Skip to content
gerwitzPublic

About

The twelfth version of my website, built with Eleventy

Topics

Resources

Stars

9 stars

Watchers

1 watching

Forks

Repository files navigation

hans.gerwitz.com

production timestamp badge

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.

Building

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.

Typst expression

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.

Social preview images

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.

Wide images

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>

Author-time preparation

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.

Everything RSS and publication dates

/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:

  1. Explicit updated front matter, when present.
  2. An authored date or the date in its filename.
  3. The catalog's preserved creation date.
  4. 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

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.

Deployment (Coolify)

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: /_site copied to /usr/share/nginx/html
  • Generated Gemini path: /_site/editions/gemini copied 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.

Gemini and HTTP notifications when the container becomes ready

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-run

Standards

Javascript 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.

Content

/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.

Implementation notes

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).

About

The twelfth version of my website, built with Eleventy

Topics

Resources

Stars

9 stars

Watchers

1 watching

Forks

Used by

Contributors

Languages