Add GoatCounter analytics, Miniflux, and update CLAUDE.md

- Add self-hosted GoatCounter via systemd binary service (stats.monotrope.au)
- Add Miniflux RSS reader via Docker Compose (reader.monotrope.au)
- Extend Ansible playbook with goatcounter and miniflux tags; all provisioning is idempotent
- Add Caddy reverse proxy blocks for both new services
- Inject GoatCounter script in baseof.html (production builds only)
- Add goatcounter and miniflux Makefile targets
- Rewrite CLAUDE.md to reflect actual project state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Louis Simoneau
2026-04-09 15:09:53 +10:00
parent b090231557
commit 5a734d404b
20 changed files with 963 additions and 183 deletions

137
CLAUDE.md
View File

@@ -6,97 +6,98 @@ Personal blog and server infrastructure for monotrope.au.
``` ```
monotrope/ monotrope/
site/ # Hugo site (content, templates, config) site/ # Hugo site (content, templates, CSS)
infra/ # Server setup scripts and config files infra/
deploy.sh # Build + rsync to production ansible/playbook.yml # Single playbook for all server provisioning
Makefile # Common tasks miniflux/ # Docker Compose for Miniflux RSS reader
Caddyfile # Copied to /etc/caddy/Caddyfile by Ansible
deploy.sh # Build + rsync to production
Makefile # Common tasks
.env # Local secrets (not committed)
``` ```
## Tech Stack ## Tech Stack
- **Static site generator:** Hugo - **Static site generator:** Hugo (no theme — templates built from scratch)
- **Web server:** Caddy (automatic HTTPS via Let's Encrypt) - **Web server:** Caddy (automatic HTTPS via Let's Encrypt)
- **Hosting:** DigitalOcean droplet (Sydney region, Ubuntu 24.04 LTS) - **Hosting:** DigitalOcean droplet (Sydney region, Ubuntu 24.04 LTS)
- **Deployment:** `hugo build` then `rsync` to server - **Deployment:** `hugo --minify` then `rsync` to `/var/www/monotrope`
- **Future services:** Docker Compose for anything beyond the blog - **Provisioning:** Ansible (`infra/ansible/playbook.yml`)
## Setup Tasks ## Services
### 1. Initialise the repo | Service | URL | How it runs | Port |
|-------------|-----------------------------|-----------------|------|
| Blog | https://monotrope.au | Static files | — |
| Miniflux | https://reader.monotrope.au | Docker Compose | 8080 |
| GoatCounter | https://stats.monotrope.au | systemd binary | 8081 |
- Create the directory structure above ## Ansible Playbook
- `git init` with a sensible `.gitignore` (Hugo output dir `public/`, `.env`, etc.)
- Initialise a new Hugo site inside `site/`
- Use a minimal theme or no theme — we'll build templates from scratch later
- Hugo config should set `baseURL = "https://monotrope.au"`
### 2. Create a minimal Hugo site **All server changes must go through Ansible.** Everything must be idempotent — no ad-hoc SSH changes.
- A single layout (`layouts/_default/baseof.html`, `single.html`, `list.html`) The playbook is at `infra/ansible/playbook.yml`. Tags let individual services be re-provisioned without touching the rest.
- Minimal, clean CSS — no framework. Readable prose typography, dark mode support via `prefers-color-scheme`
- A sample first post in `site/content/posts/` so we can test the build
- RSS feed enabled (Hugo does this by default)
- No JavaScript unless strictly necessary
### 3. Server provisioning script | Tag | What it covers |
|---------------|------------------------------------------------------|
| `miniflux` | Miniflux Docker Compose + Caddyfile update |
| `goatcounter` | GoatCounter binary, systemd service + Caddyfile |
| (no tag) | Full provisioning (system, Caddy, Docker, UFW, users)|
Create `infra/setup.sh` — a bash script intended to be run once on a fresh Ubuntu 24.04 droplet via SSH. It should: ### Secrets
- Update packages Pulled from environment variables, loaded from `.env` via Makefile:
- Install Caddy via the official apt repo
- Create a `www` user with no login shell to own the site files
- Create `/var/www/monotrope` owned by `www`
- Install a Caddyfile at `/etc/caddy/Caddyfile` that:
- Serves `monotrope.au` from `/var/www/monotrope`
- Enables gzip/zstd compression
- Sets sensible cache headers for static assets
- Handles `www.monotrope.au` redirect to apex
- Enable and start the Caddy service
- Set up UFW: allow SSH, HTTP, HTTPS, deny everything else
- Install Docker and Docker Compose (for future use, not needed yet)
- Create a deploy user with SSH key auth and permission to write to `/var/www/monotrope`
Also create `infra/Caddyfile` as a standalone config file that the setup script copies into place. ```
MONOTROPE_HOST
MINIFLUX_DB_PASSWORD
MINIFLUX_ADMIN_USER
MINIFLUX_ADMIN_PASSWORD
GOATCOUNTER_ADMIN_EMAIL
GOATCOUNTER_ADMIN_PASSWORD
```
### 4. Deploy script ### GoatCounter
Create `deploy.sh` at the repo root: Runs as a systemd service (not Docker) using the pre-built binary from GitHub releases.
Version is pinned via `goatcounter_version` var in the playbook.
Initial site/user creation is gated on a `/var/lib/goatcounter/.admin_created` marker file
so re-running the playbook never attempts to recreate the user.
- Run `hugo --minify` in `site/` ## Makefile Targets
- `rsync -avz --delete site/public/ deploy@<DROPLET_IP>:/var/www/monotrope/`
- Print the URL on success
The droplet IP should come from an environment variable `MONOTROPE_HOST` or a `.env` file (not committed). ```
make build # hugo --minify
### 5. Makefile make serve # hugo server --buildDrafts (local dev)
make deploy # build + rsync to production
Targets: make ssh # SSH as deploy user
- `make build` — build the site locally make setup # Full Ansible provisioning (fresh droplet)
- `make serve``hugo server` for local dev with live reload make miniflux # Ansible --tags miniflux
- `make deploy` — run `deploy.sh` make goatcounter # Ansible --tags goatcounter
- `make ssh` — SSH into the droplet as the deploy user ```
- `make setup` — run the provisioning script on a fresh droplet
## Conventions
- All shell scripts should use `set -euo pipefail`
- Prefer clarity over cleverness in all scripts
- No unnecessary dependencies — this should stay simple
- Australian English in all content and comments
- Markdown content lives in `site/content/`; one subdirectory per content type (e.g. `posts/`, `pages/`)
## DNS ## DNS
The user will configure DNS separately via their registrar. The server expects:
- `monotrope.au` → droplet IP (A record) - `monotrope.au` → droplet IP (A record)
- `www.monotrope.au` → droplet IP (A record) - `www.monotrope.au` → droplet IP (A record, redirects to apex via Caddy)
- `reader.monotrope.au` → droplet IP (A record)
- `stats.monotrope.au` → droplet IP (A record)
Caddy will handle certificate provisioning automatically once DNS is pointed. ## Site Layout
## What This Is Not Content lives in `site/content/`:
- `posts/` — writing
- `reviews/` — book reviews
- `about.md` — about page (uses `layouts/page/single.html`)
- No CI/CD pipeline — deploy is manual via `make deploy` Templates are in `site/layouts/`. No JavaScript unless strictly necessary.
- No containerisation of the blog itself — it's static files The GoatCounter analytics script is injected in `baseof.html` and only loads
- No database in production builds (`hugo.IsProduction`).
- No analytics (for now)
- No comments system (for now) ## Conventions
- All shell scripts use `set -euo pipefail`
- All server changes go through Ansible — no one-off SSH commands
- Ansible tasks must be idempotent
- Australian English in content and comments
- No CI/CD — deploys are manual via `make deploy`

View File

@@ -1,4 +1,4 @@
.PHONY: build serve deploy ssh setup .PHONY: build serve deploy ssh setup miniflux goatcounter
# Load .env if it exists # Load .env if it exists
-include .env -include .env
@@ -24,3 +24,11 @@ ssh:
setup: setup:
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1) @test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml
miniflux:
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml --tags miniflux
goatcounter:
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml --tags goatcounter

View File

@@ -22,3 +22,17 @@ monotrope.au {
www.monotrope.au { www.monotrope.au {
redir https://monotrope.au{uri} permanent redir https://monotrope.au{uri} permanent
} }
# Miniflux RSS reader
reader.monotrope.au {
reverse_proxy localhost:8080
encode zstd gzip
}
# GoatCounter analytics
stats.monotrope.au {
reverse_proxy localhost:8081
encode zstd gzip
}

View File

@@ -7,6 +7,12 @@
site_dir: /var/www/monotrope site_dir: /var/www/monotrope
deploy_user: deploy deploy_user: deploy
deploy_pubkey: "{{ lookup('file', lookup('env', 'HOME') + '/.ssh/id_ed25519.pub') }}" deploy_pubkey: "{{ lookup('file', lookup('env', 'HOME') + '/.ssh/id_ed25519.pub') }}"
miniflux_db_password: "{{ lookup('env', 'MINIFLUX_DB_PASSWORD') }}"
miniflux_admin_user: "{{ lookup('env', 'MINIFLUX_ADMIN_USER') | default('admin') }}"
miniflux_admin_password: "{{ lookup('env', 'MINIFLUX_ADMIN_PASSWORD') }}"
goatcounter_version: "2.7.0"
goatcounter_admin_email: "{{ lookup('env', 'GOATCOUNTER_ADMIN_EMAIL') }}"
goatcounter_admin_password: "{{ lookup('env', 'GOATCOUNTER_ADMIN_PASSWORD') }}"
tasks: tasks:
@@ -58,6 +64,9 @@
group: caddy group: caddy
mode: '0640' mode: '0640'
notify: Restart Caddy notify: Restart Caddy
tags:
- miniflux
- goatcounter
- name: Enable and start Caddy - name: Enable and start Caddy
systemd: systemd:
@@ -149,6 +158,142 @@
enabled: true enabled: true
state: started state: started
# ── Miniflux ────────────────────────────────────────────────────────────
- name: Create Miniflux directory
file:
path: /opt/miniflux
state: directory
owner: root
group: root
mode: '0750'
tags: miniflux
- name: Copy Miniflux docker-compose.yml
copy:
src: ../miniflux/docker-compose.yml
dest: /opt/miniflux/docker-compose.yml
owner: root
group: root
mode: '0640'
tags: miniflux
- name: Write Miniflux .env
copy:
dest: /opt/miniflux/.env
owner: root
group: root
mode: '0600'
content: |
MINIFLUX_DB_PASSWORD={{ miniflux_db_password }}
MINIFLUX_ADMIN_USER={{ miniflux_admin_user }}
MINIFLUX_ADMIN_PASSWORD={{ miniflux_admin_password }}
no_log: true
tags: miniflux
- name: Pull and start Miniflux
command: docker compose up -d --pull always
args:
chdir: /opt/miniflux
tags: miniflux
# ── GoatCounter ─────────────────────────────────────────────────────────
- name: Create goatcounter system user
user:
name: goatcounter
system: true
create_home: false
shell: /usr/sbin/nologin
state: present
tags: goatcounter
- name: Create GoatCounter data directory
file:
path: /var/lib/goatcounter
state: directory
owner: goatcounter
group: goatcounter
mode: '0750'
tags: goatcounter
- name: Download GoatCounter binary
get_url:
url: "https://github.com/arp242/goatcounter/releases/download/v{{ goatcounter_version }}/goatcounter-v{{ goatcounter_version }}-linux-amd64.gz"
dest: /tmp/goatcounter.gz
mode: '0644'
tags: goatcounter
- name: Decompress GoatCounter binary
shell: gunzip -f /tmp/goatcounter.gz && mv /tmp/goatcounter /usr/local/bin/goatcounter && chmod 0755 /usr/local/bin/goatcounter
args:
creates: /usr/local/bin/goatcounter
tags: goatcounter
- name: Check if GoatCounter admin user has been created
stat:
path: /var/lib/goatcounter/.admin_created
register: goatcounter_admin_marker
tags: goatcounter
- name: Create GoatCounter admin user
command: >
goatcounter db create site
-db sqlite+/var/lib/goatcounter/goatcounter.sqlite3
-createdb
-vhost stats.monotrope.au
-user.email {{ goatcounter_admin_email }}
-user.password {{ goatcounter_admin_password }}
become_user: goatcounter
when: not goatcounter_admin_marker.stat.exists
no_log: true
tags: goatcounter
- name: Mark GoatCounter admin user as created
file:
path: /var/lib/goatcounter/.admin_created
state: touch
owner: goatcounter
group: goatcounter
mode: '0600'
when: not goatcounter_admin_marker.stat.exists
tags: goatcounter
- name: Install GoatCounter systemd service
copy:
dest: /etc/systemd/system/goatcounter.service
owner: root
group: root
mode: '0644'
content: |
[Unit]
Description=GoatCounter analytics
After=network.target
[Service]
User=goatcounter
Group=goatcounter
ExecStart=/usr/local/bin/goatcounter serve \
-listen localhost:8081 \
-db sqlite+/var/lib/goatcounter/goatcounter.sqlite3 \
-tls none \
-domain stats.monotrope.au
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
notify: Restart GoatCounter
tags: goatcounter
- name: Enable and start GoatCounter
systemd:
name: goatcounter
enabled: true
state: started
daemon_reload: true
tags: goatcounter
# ── Deploy user ────────────────────────────────────────────────────────── # ── Deploy user ──────────────────────────────────────────────────────────
- name: Create deploy user - name: Create deploy user
@@ -186,3 +331,8 @@
systemd: systemd:
name: caddy name: caddy
state: restarted state: restarted
- name: Restart GoatCounter
systemd:
name: goatcounter
state: restarted

View File

@@ -0,0 +1,38 @@
services:
miniflux:
image: miniflux/miniflux:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:8080:8080"
environment:
DATABASE_URL: "postgres://miniflux:${MINIFLUX_DB_PASSWORD}@db/miniflux?sslmode=disable"
RUN_MIGRATIONS: "1"
CREATE_ADMIN: "1"
ADMIN_USERNAME: "${MINIFLUX_ADMIN_USER}"
ADMIN_PASSWORD: "${MINIFLUX_ADMIN_PASSWORD}"
BASE_URL: "https://reader.monotrope.au"
env_file:
- .env
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- miniflux_db:/var/lib/postgresql/data
environment:
POSTGRES_DB: miniflux
POSTGRES_USER: miniflux
POSTGRES_PASSWORD: "${MINIFLUX_DB_PASSWORD}"
env_file:
- .env
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
timeout: 5s
retries: 5
volumes:
miniflux_db:

10
site/content/about.md Normal file
View File

@@ -0,0 +1,10 @@
---
title: About
type: page
---
Monotrope is a play on the idea of [monotropism](https://en.wikipedia.org/wiki/Monotropism). I created this site as an experiment in writing regularly, and also in trying to own things instead of putting them on corporate platforms. Monotropism relates to the autistic experience, but I also just like the idea of deep singular focus and flow states. Modern work, especially with the use of AI, is increasingly fragmented and alienating.
I read across all genres, and post my reviews here because I want to own them.
I live on Djadjawurrung and Taungurong land.

View File

@@ -4,4 +4,4 @@ date: 2026-04-08
draft: false draft: false
--- ---
This is the first post on Monotrope. More to come. This is the first post on Monotrope. My intent is to write here as frequently as possible. In part this is to practice the craft of writing directly, and in part it is to ensure I own the stuff I'm writing.

View File

@@ -0,0 +1,16 @@
---
title: "Thoughts on AI, circa April 2026"
date: 2026-04-08
draft: true
---
Part of the reason I wanted to make this site is to practice writing more, and in part that's because the writing I do at work (project plans, technical outlines, etc.) are increasingly written with the help of AI.
This opens up a can of worms, of which some I will attempt to dissect here. A disclaimer: I don't think my views on this topic are currently ideologically consistent. Broadly I think this is ok, and I think with nascent and rapidly changing areas it's valuable to be able to hold opposing views in your mind for awhile. As the title of the post indicates, these thoughts are also current as of mid-April 2026, and are very likely to change dramatically.
Firstly, I think AI is, in many contexts, useful. I have spent a bit of time on Bluesky and a lot of anti-AI sentiment there revolves around "AI is dumb, it makes stuff up, no one actually gets value from it." As someone who works in software it is incredibly obvious to me that AI _for coding_ is increidbly useful if you know what you're doing. But I think AI can also be genuinely useful for writing, researching, and ideating. Some of the "AI is not useful" critique will be rooted in a view of AI that is a year or more out of date. "Asking ChatGPT for something instead of Googling for a simple answer" _might have been_ dumb when dealing with early versions of the models that couldn't use tools or search the web, but an AI that can run multiple web searches and consolidate the results is a real value-add, especially when you're not wading through pages of ads and fluff in the search results.
However, AI in the workplace has not been all sunshine and rainbows. The teams are writing code more quickly, routine work like version upgrades is _much_ quicker, and my personal Claude/Obsidian setup has been good for my own context and productivity. But holy shit the documents. You get out of a meeting where someone takes an action to write a plan and BAM! 7 minutes later they've published a detailed, polished plan that you have to read.
But the issue

View File

@@ -0,0 +1,3 @@
---
title: Reading
---

View File

@@ -0,0 +1,26 @@
---
title: "The Compound"
book_author: "Aisling Rawle"
date: 2026-03-01
date_read: "March 2026"
rating: 4
tags: ["dystopia", "satire"]
---
I liked this book _a lot_. I'm giving it 4 stars because I'm not sure I can fully decipher what it was trying to say, or if it was trying to say anything.
The pitch is "Love Island meets Lord of the Flies", and that probably about sums it up. Its setting is a realtiy TV show in a very ambiguous future dystopia (there are references to "the wars", but really we get no detail of the world outside the show).
In a sense maybe it's a bit of a mess, it's a satire but it's not obvious exactly what it's satirising. There's an ambient theme of consumerism and late-stage capitalism, but it's never really a plot point, it's just there. It doesn't really make you think so much as make you vaguely uneasy. As an example, the contestants on the show get rewards for completing tasks, and when they get the reward they go to the camera and thank the brand that provided the reward. Nothing else happens about this, and the narrator (whose name is Lilly) doesn't really reflect on it much either. But it creates this sense of the absurd that contrasts with the darkness of what's going on between the characters.
The narrator is an interesting point of view as well, because she's deliberately written as a bit dumb, which isn't something I've encountered before in a first person narrative.
Most of the time all of the internal monologue just relates to the immediate situation in the compound, but there are a small number of times when we get a bit more reflection. There's a bit I highlighted where Lilly talks about dreading leaving the show and going home, and it ends with:
> What did it matter to wake up at the same time every morning and wear the same clothes and try to eat more protein but less sugar, when an earthquake or a tsunami or a bomb might end it all at any minute? Or maybe we would all continue to boil, slowly but surely, in the mess that we pretended was an acceptable place to live.
I loved the writing style: tight pace, not overly flowery, but it had this creeping sense of unease or dread that made me want to keep reading, even when not much was happening. I haven't read that many thriller type books but I think this is something I really enjoy, when something manages to be a page turner without relying on plot twists or crazy stuff happening all the time.
Didn't overstay its welcome, the ending was ambiguous and open ended but I thought it was everything it needed to be.
In writing this I've _very nearly_ convinced myself to bump it up to five stars. Maybe I'll come back to this one.

View File

@@ -10,4 +10,4 @@ title = "Monotrope"
section = ["HTML", "RSS"] section = ["HTML", "RSS"]
[params] [params]
description = "A personal blog." description = "writing on software, books, and ideas."

View File

@@ -6,10 +6,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ if not .IsHome }}{{ .Title }} · {{ end }}{{ .Site.Title }}</title> <title>{{ if not .IsHome }}{{ .Title }} · {{ end }}{{ .Site.Title }}</title>
<meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}"> <meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/main.css">
{{ range .AlternativeOutputFormats -}} {{ range .AlternativeOutputFormats -}}
{{ printf ` {{ printf `<link rel="%s" type="%s" href="%s" title="%s">` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
<link rel="%s" type="%s" href="%s" title="%s">` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }} {{ end -}}
{{ if hugo.IsProduction }}
<script data-goatcounter="https://stats.monotrope.au/count" async src="https://stats.monotrope.au/count.js"></script>
{{ end -}} {{ end -}}
</head> </head>
@@ -18,7 +23,9 @@
<header class="site-header"> <header class="site-header">
<a class="site-title" href="/">{{ .Site.Title }}</a> <a class="site-title" href="/">{{ .Site.Title }}</a>
<nav class="site-nav"> <nav class="site-nav">
<a href="/posts/">Writing</a> <a href="/about/"{{ if hasPrefix .RelPermalink "/about" }} class="active"{{ end }}>about</a>
<a href="/posts/"{{ if hasPrefix .RelPermalink "/posts" }} class="active"{{ end }}>writing</a>
<a href="/reviews/"{{ if hasPrefix .RelPermalink "/reviews" }} class="active"{{ end }}>reading</a>
</nav> </nav>
</header> </header>
@@ -27,9 +34,10 @@
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<p>&copy; {{ now.Year }} Monotrope. Built with <a href="https://gohugo.io">Hugo</a>.</p> <span>monotrope.au</span>
<span>&copy; {{ now.Year }}</span>
</footer> </footer>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,10 +1,17 @@
{{ define "main" }} {{ define "main" }}
<h2 class="page-heading">{{ .Title }}</h2> <p class="section-label">{{ .Title | lower }}</p>
<ul class="post-list"> <ul class="post-list">
{{ range .Pages.ByDate.Reverse }} {{ range .Pages.ByDate.Reverse }}
<li> <li>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time> <time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time>
<a href="{{ .Permalink }}">{{ .Title }}</a> <div class="post-entry">
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
{{ with .Params.tags }}
<div class="tags">
{{ range . }}<a class="tag" href="/tags/{{ . | urlize }}/">{{ . }}</a>{{ end }}
</div>
{{ end }}
</div>
</li> </li>
{{ end }} {{ end }}
</ul> </ul>

View File

@@ -0,0 +1,39 @@
{{- $pctx := . -}}
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
{{- $pages := slice -}}
{{- if or $.IsHome $.IsSection -}}
{{- $pages = $pctx.RegularPages -}}
{{- else -}}
{{- $pages = $pctx.Pages -}}
{{- end -}}
{{- $pages = where $pages "Type" "ne" "page" -}}
{{- $limit := .Site.Config.Services.RSS.Limit -}}
{{- if ge $limit 1 -}}
{{- $pages = $pages | first $limit -}}
{{- end -}}
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{ . }} on {{ end }}{{ .Site.Title }}{{ end }}</title>
<link>{{ .Permalink }}</link>
<description>Recent content {{ if ne .Title .Site.Title }}in {{ .Title }} {{ end }}on {{ .Site.Title }}</description>
<generator>Hugo</generator>
<language>{{ site.Language.LanguageCode }}</language>
{{ with .Site.Copyright }}<copyright>{{ . }}</copyright>{{ end }}
{{- if not .Date.IsZero -}}
<lastBuildDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>
{{- end -}}
{{- with .OutputFormats.Get "RSS" -}}
{{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }}
{{- end -}}
{{ range $pages }}
<item>
<title>{{ .Title }}</title>
<link>{{ .Permalink }}</link>
<pubDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate>
<guid>{{ .Permalink }}</guid>
<description>{{ with .Description }}{{ . | html }}{{ else }}{{ .Summary | html }}{{ end }}</description>
</item>
{{ end }}
</channel>
</rss>

View File

@@ -1,9 +1,17 @@
{{ define "main" }} {{ define "main" }}
<article> <article>
<h1>{{ .Title }}</h1> <h1>{{ .Title }}</h1>
<p class="post-meta"> <div class="post-meta">
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2 January 2006" }}</time> <time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2 January 2006" }}</time>
</p> {{ with .ReadingTime }}<span>{{ . }} min read</span>{{ end }}
</div>
{{ .Content }} {{ .Content }}
{{ with .Params.tags }}
<div class="post-footer">
<div class="tags">
{{ range . }}<a class="tag" href="/tags/{{ . | urlize }}/">{{ . }}</a>{{ end }}
</div>
</div>
{{ end }}
</article> </article>
{{ end }} {{ end }}

View File

@@ -1,10 +1,56 @@
{{ define "main" }} {{ define "main" }}
<ul class="post-list"> <div class="home-prompt">
{{ range (where .Site.RegularPages "Type" "posts").ByDate.Reverse }} <span class="prompt-line">whoami</span>
<li> <span class="prompt-response">{{ .Site.Params.description | default "writing on technology, books, and ideas."
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time> }}</span>
<a href="{{ .Permalink }}">{{ .Title }}</a> <span class="prompt-line"><span class="cursor"></span></span>
</li> </div>
{{ end }}
</ul> <div class="home-sections">
{{ end }}
<section>
<div class="home-section-head">
<h2>writing</h2>
<a class="see-all" href="/posts/">all posts</a>
</div>
<ul class="post-list">
{{ range first 5 (where .Site.RegularPages "Section" "posts").ByDate.Reverse }}
<li>
<time datetime="{{ .Date.Format " 2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time>
<div class="post-entry">
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
{{ with .Params.tags }}
<div class="tags">
{{ range . }}<a class="tag" href="/tags/{{ . | urlize }}/">{{ . }}</a>{{ end }}
</div>
{{ end }}
</div>
</li>
{{ end }}
</ul>
</section>
<section>
<div class="home-section-head">
<h2>reading</h2>
<a class="see-all" href="/reviews/">all reviews</a>
</div>
<ul class="review-list">
{{ range first 3 (where .Site.RegularPages "Section" "reviews").ByDate.Reverse }}
{{ $rating := .Params.rating | default 0 }}
<li>
<span class="review-date">{{ .Params.date_read | default (.Date.Format "2006-01") }}</span>
<div class="review-entry">
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
{{ with .Params.book_author }}<span class="review-author">{{ . }}</span>{{ end }}
<span class="review-rating">
{{- strings.Repeat $rating "▓" -}}{{- strings.Repeat (sub 5 $rating) "░" -}}
</span>
</div>
</li>
{{ end }}
</ul>
</section>
</div>
{{ end }}

View File

@@ -0,0 +1,6 @@
{{ define "main" }}
<article>
<h1>{{ .Title }}</h1>
{{ .Content }}
</article>
{{ end }}

View File

@@ -0,0 +1,24 @@
{{ define "main" }}
<p class="section-label">reading</p>
<ul class="review-list">
{{ range .Pages.ByDate.Reverse }}
{{ $rating := .Params.rating | default 0 }}
<li>
<span class="review-date">{{ .Params.date_read | default (.Date.Format "2006-01") }}</span>
<div class="review-entry">
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
{{ with .Params.book_author }}<span class="review-author">{{ . }}</span>{{ end }}
<span class="review-rating">
{{- strings.Repeat $rating "▓" -}}{{- strings.Repeat (sub 5 $rating) "░" -}}
<span style="color: var(--muted); font-size: 0.8em; margin-left: 0.4rem;">{{ $rating }}/5</span>
</span>
{{ with .Params.tags }}
<div class="tags">
{{ range . }}<a class="tag" href="/tags/{{ . | urlize }}/">{{ . }}</a>{{ end }}
</div>
{{ end }}
</div>
</li>
{{ end }}
</ul>
{{ end }}

View File

@@ -0,0 +1,32 @@
{{ define "main" }}
{{ $rating := .Params.rating | default 0 }}
<article>
<h1>{{ .Title }}</h1>
<div class="book-meta">
{{ with .Params.book_author }}
<span class="meta-key">author</span>
<span class="meta-val author">{{ . }}</span>
{{ end }}
<span class="meta-key">read</span>
<span class="meta-val">{{ .Params.date_read | default (.Date.Format "January 2006") }}</span>
<span class="meta-key">rating</span>
<span class="meta-val">
<span class="rating-blocks">{{- strings.Repeat $rating "▓" -}}{{- strings.Repeat (sub 5 $rating) "░" -}}</span>
<span class="rating-num">{{ $rating }}/5</span>
</span>
</div>
{{ .Content }}
{{ with .Params.tags }}
<div class="post-footer">
<div class="tags">
{{ range . }}<a class="tag" href="/tags/{{ . | urlize }}/">{{ . }}</a>{{ end }}
</div>
</div>
{{ end }}
</article>
{{ end }}

View File

@@ -1,33 +1,32 @@
/* ── Reset & base ─────────────────────────────── */ /* ── Fonts ─────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&display=swap');
/* ── Reset ─────────────────────────────────────── */
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
/* ── Colour tokens ────────────────────────────── */ /* ── Tokens ────────────────────────────────────── */
:root { :root {
--bg: #ffffff; --bg: #0b0d0a;
--fg: #1a1a1a; --bg-raised: #111710;
--muted: #6b7280; --fg: #cde0ba;
--accent: #2563eb; --fg-dim: #84a070;
--border: #e5e7eb; --muted: #4d6340;
--code-bg: #f3f4f6; --green: #5bff8f;
--max-width: 680px; --green-dim: #2a7848;
--green-glow: rgba(91, 255, 143, 0.12);
--amber: #f0aa45;
--amber-dim: rgba(240, 170, 69, 0.15);
--border: #192417;
--max-w: 680px;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
--font-serif: 'Spectral', Georgia, serif;
} }
@media (prefers-color-scheme: dark) { /* ── Base ──────────────────────────────────────── */
:root {
--bg: #111111;
--fg: #e5e5e5;
--muted: #9ca3af;
--accent: #60a5fa;
--border: #2d2d2d;
--code-bg: #1e1e1e;
}
}
/* ── Typography ───────────────────────────────── */
html { html {
font-size: 18px; font-size: 18px;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
@@ -36,160 +35,505 @@ html {
body { body {
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
font-family: Georgia, "Times New Roman", serif; font-family: var(--font-serif);
line-height: 1.7; line-height: 1.75;
padding: 2rem 1.25rem; padding: 3rem 1.5rem;
min-height: 100vh;
} }
/* ── Layout ───────────────────────────────────── */ /* Scanline overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 3px,
rgba(0, 0, 0, 0.045) 3px,
rgba(0, 0, 0, 0.045) 4px
);
pointer-events: none;
z-index: 9999;
}
/* ── Layout ────────────────────────────────────── */
.site-wrap { .site-wrap {
max-width: var(--max-width); max-width: var(--max-w);
margin: 0 auto; margin: 0 auto;
} }
/* ── Header ───────────────────────────────────── */ /* ── Header ───────────────────────────────────── */
.site-header { .site-header {
margin-bottom: 3rem; margin-bottom: 4rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
display: flex; display: flex;
align-items: baseline; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem;
} }
.site-title { .site-title {
font-size: 1.1rem; font-family: var(--font-mono);
font-weight: bold; font-size: 0.9rem;
letter-spacing: 0.02em; font-weight: 400;
color: var(--green);
text-decoration: none; text-decoration: none;
color: var(--fg); letter-spacing: 0.02em;
transition: text-shadow 0.2s;
}
.site-title::before {
content: '~/';
color: var(--muted);
}
.site-title:hover {
text-shadow: 0 0 16px var(--green-glow), 0 0 32px var(--green-glow);
}
.site-nav {
display: flex;
gap: 0.25rem;
} }
.site-nav a { .site-nav a {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--fg-dim);
text-decoration: none;
padding: 0.2rem 0.6rem;
border: 1px solid transparent;
letter-spacing: 0.02em;
transition: color 0.15s, border-color 0.15s;
}
.site-nav a:hover,
.site-nav a.active {
color: var(--green);
border-color: var(--green-dim);
}
/* ── Footer ────────────────────────────────────── */
.site-footer {
margin-top: 5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--muted);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
letter-spacing: 0.02em;
}
.site-footer a {
color: var(--muted); color: var(--muted);
text-decoration: none; text-decoration: none;
font-size: 0.9rem;
font-family: system-ui, sans-serif;
margin-left: 1.25rem;
} }
.site-nav a:hover { .site-footer a:hover {
color: var(--accent); color: var(--fg-dim);
} }
/* ── Footer ───────────────────────────────────── */ /* ── Section label ─────────────────────────────── */
.site-footer { .section-label {
margin-top: 4rem; font-family: var(--font-mono);
padding-top: 1rem; font-size: 0.72rem;
border-top: 1px solid var(--border);
color: var(--muted); color: var(--muted);
font-size: 0.8rem; margin-bottom: 2rem;
font-family: system-ui, sans-serif; letter-spacing: 0.05em;
} }
/* ── Post list ────────────────────────────────── */ .section-label::before {
content: '// ';
color: var(--green-dim);
}
/* ── Tags ──────────────────────────────────────── */
.tag {
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--fg-dim);
border: 1px solid var(--border);
padding: 0.05rem 0.45rem;
letter-spacing: 0.03em;
text-decoration: none;
transition: color 0.15s, border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.tag::before {
content: '#';
color: var(--muted);
}
.tag:hover {
color: var(--amber);
border-color: var(--amber);
background: var(--amber-dim);
}
.tags {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-items: center;
}
/* ── Post list ─────────────────────────────────── */
.post-list { .post-list {
list-style: none; list-style: none;
} }
.post-list li { .post-list li {
margin-bottom: 1.5rem; display: grid;
display: flex; grid-template-columns: 5.5rem 1fr;
align-items: baseline; gap: 0 1rem;
gap: 1rem; margin-bottom: 1.75rem;
align-items: start;
} }
.post-list time { .post-list time {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--muted); color: var(--muted);
font-size: 0.85rem;
font-family: system-ui, sans-serif;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; padding-top: 0.25rem;
letter-spacing: 0.02em;
} }
.post-list a { .post-entry {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.post-entry a {
color: var(--fg); color: var(--fg);
text-decoration: none; text-decoration: none;
font-family: var(--font-serif);
font-size: 1rem;
line-height: 1.35;
transition: color 0.15s;
} }
.post-list a:hover { .post-entry a:hover {
color: var(--accent); color: var(--green);
} }
/* ── Article ──────────────────────────────────── */ /* ── Single article ────────────────────────────── */
article h1 { article h1 {
font-size: 1.75rem; font-family: var(--font-serif);
line-height: 1.25; font-size: 1.65rem;
margin-bottom: 0.4rem; font-weight: 600;
line-height: 1.2;
margin-bottom: 0.5rem;
color: var(--fg);
} }
.post-meta { .post-meta {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--muted); color: var(--muted);
font-size: 0.85rem; margin-bottom: 2.75rem;
font-family: system-ui, sans-serif; display: flex;
margin-bottom: 2rem; align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
letter-spacing: 0.02em;
} }
article h2 { font-size: 1.3rem; margin: 2rem 0 0.75rem; } article h2 {
article h3 { font-size: 1.1rem; margin: 1.75rem 0 0.5rem; } font-size: 1.2rem;
font-weight: 600;
margin: 2.5rem 0 0.75rem;
color: var(--fg);
}
article p { margin-bottom: 1.25rem; } article h3 {
font-size: 1rem;
font-weight: 600;
margin: 2rem 0 0.5rem;
color: var(--fg);
}
article p {
margin-bottom: 1.4rem;
}
article a { article a {
color: var(--accent); color: var(--green);
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 3px;
text-decoration-color: var(--green-dim);
transition: text-decoration-color 0.15s;
}
article a:hover {
text-decoration-color: var(--green);
} }
article ul, article ul,
article ol { article ol {
padding-left: 1.5rem; padding-left: 1.5rem;
margin-bottom: 1.25rem; margin-bottom: 1.4rem;
} }
article li { margin-bottom: 0.25rem; } article li {
margin-bottom: 0.3rem;
}
article blockquote { article blockquote {
border-left: 3px solid var(--border); border-left: 2px solid var(--green-dim);
padding-left: 1rem; padding-left: 1.25rem;
color: var(--muted); color: var(--fg-dim);
margin: 1.5rem 0; margin: 2rem 0;
font-style: italic;
} }
article code { article code {
background: var(--code-bg); background: var(--bg-raised);
padding: 0.1em 0.35em; color: var(--green);
border-radius: 3px; padding: 0.1em 0.4em;
font-size: 0.875em; border-radius: 2px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 0.8em;
font-family: var(--font-mono);
border: 1px solid var(--border);
} }
article pre { article pre {
background: var(--code-bg); background: var(--bg-raised);
padding: 1rem; border: 1px solid var(--border);
border-radius: 4px; border-left: 2px solid var(--green-dim);
padding: 1.25rem;
overflow-x: auto; overflow-x: auto;
margin-bottom: 1.25rem; margin-bottom: 1.4rem;
border-radius: 0 2px 2px 0;
} }
article pre code { article pre code {
background: none; background: none;
border: none;
padding: 0; padding: 0;
font-size: 0.8rem;
color: var(--fg);
} }
article hr { article hr {
border: none; border: none;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
margin: 2rem 0; margin: 2.5rem 0;
} }
/* ── Page heading ─────────────────────────────── */ .post-footer {
.page-heading { margin-top: 3rem;
font-size: 1.1rem; padding-top: 1.5rem;
color: var(--muted); border-top: 1px solid var(--border);
font-family: system-ui, sans-serif; }
font-weight: normal;
margin-bottom: 2rem; /* ── Book review ───────────────────────────────── */
.book-meta {
font-family: var(--font-mono);
font-size: 0.75rem;
border-left: 2px solid var(--green-dim);
padding: 0.75rem 0 0.75rem 1rem;
margin-bottom: 2.75rem;
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.4rem 1.25rem;
align-items: center;
}
.book-meta .meta-key {
color: var(--muted);
letter-spacing: 0.04em;
}
.book-meta .meta-val {
color: var(--fg-dim);
}
.book-meta .meta-val.author {
color: var(--fg);
font-style: italic;
}
.rating-blocks {
font-family: var(--font-mono);
color: var(--amber);
letter-spacing: 0.15em;
font-size: 0.9em;
}
.rating-num {
color: var(--muted);
margin-left: 0.5rem;
font-size: 0.85em;
}
/* ── Review list ───────────────────────────────── */
.review-list {
list-style: none;
}
.review-list li {
display: grid;
grid-template-columns: 5.5rem 1fr;
gap: 0 1rem;
margin-bottom: 2rem;
align-items: start;
}
.review-list .review-date {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--muted);
white-space: nowrap;
padding-top: 0.25rem;
letter-spacing: 0.02em;
}
.review-entry {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.review-entry a {
color: var(--fg);
text-decoration: none;
font-family: var(--font-serif);
font-size: 1rem;
line-height: 1.35;
transition: color 0.15s;
}
.review-entry a:hover {
color: var(--green);
}
.review-entry .review-author {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--fg-dim);
font-style: italic;
}
.review-entry .review-rating {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--amber);
letter-spacing: 0.1em;
}
/* ── Homepage ──────────────────────────────────── */
.home-prompt {
font-family: var(--font-mono);
font-size: 0.82rem;
line-height: 1.8;
color: var(--fg-dim);
margin-bottom: 3.5rem;
}
.prompt-line {
display: block;
}
.prompt-line::before {
content: '> ';
color: var(--green);
}
.prompt-response {
display: block;
color: var(--fg-dim);
padding-left: 1.25rem;
}
.cursor {
display: inline-block;
width: 0.5em;
height: 1.1em;
background: var(--green);
vertical-align: text-bottom;
margin-left: 1px;
animation: blink 1.1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.home-sections {
display: flex;
flex-direction: column;
gap: 3.5rem;
}
.home-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.home-section-head h2 {
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 400;
color: var(--muted);
letter-spacing: 0.06em;
}
.home-section-head h2::before {
content: '// ';
color: var(--green-dim);
}
.home-section-head .see-all {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--muted);
text-decoration: none;
letter-spacing: 0.03em;
transition: color 0.15s;
}
.home-section-head .see-all::after {
content: ' →';
}
.home-section-head .see-all:hover {
color: var(--green);
}
/* ── Responsive ────────────────────────────────── */
@media (max-width: 540px) {
html { font-size: 16px; }
body { padding: 2rem 1.1rem; }
.post-list li,
.review-list li {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.post-list time,
.review-list .review-date {
padding-top: 0;
}
.book-meta {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.book-meta .meta-key {
display: none;
}
} }