From 5a734d404b60c9fa23f116ca50ae896dc528524f Mon Sep 17 00:00:00 2001 From: Louis Simoneau Date: Thu, 9 Apr 2026 15:09:53 +1000 Subject: [PATCH] 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 --- CLAUDE.md | 137 ++--- Makefile | 10 +- infra/Caddyfile | 14 + infra/ansible/playbook.yml | 150 ++++++ infra/miniflux/docker-compose.yml | 38 ++ site/content/about.md | 10 + site/content/posts/hello-world.md | 2 +- site/content/posts/thoughts-on-ai-apr-26.md | 16 + site/content/reviews/_index.md | 3 + site/content/reviews/the-compound.md | 26 + site/hugo.toml | 2 +- site/layouts/_default/baseof.html | 18 +- site/layouts/_default/list.html | 11 +- site/layouts/_default/rss.xml | 39 ++ site/layouts/_default/single.html | 12 +- site/layouts/index.html | 64 ++- site/layouts/page/single.html | 6 + site/layouts/reviews/list.html | 24 + site/layouts/reviews/single.html | 32 ++ site/static/css/main.css | 532 ++++++++++++++++---- 20 files changed, 963 insertions(+), 183 deletions(-) create mode 100644 infra/miniflux/docker-compose.yml create mode 100644 site/content/about.md create mode 100644 site/content/posts/thoughts-on-ai-apr-26.md create mode 100644 site/content/reviews/_index.md create mode 100644 site/content/reviews/the-compound.md create mode 100644 site/layouts/_default/rss.xml create mode 100644 site/layouts/page/single.html create mode 100644 site/layouts/reviews/list.html create mode 100644 site/layouts/reviews/single.html diff --git a/CLAUDE.md b/CLAUDE.md index 5ceac65..fa41bf9 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,97 +6,98 @@ Personal blog and server infrastructure for monotrope.au. ``` monotrope/ - site/ # Hugo site (content, templates, config) - infra/ # Server setup scripts and config files - deploy.sh # Build + rsync to production - Makefile # Common tasks + site/ # Hugo site (content, templates, CSS) + infra/ + ansible/playbook.yml # Single playbook for all server provisioning + 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 -- **Static site generator:** Hugo +- **Static site generator:** Hugo (no theme — templates built from scratch) - **Web server:** Caddy (automatic HTTPS via Let's Encrypt) - **Hosting:** DigitalOcean droplet (Sydney region, Ubuntu 24.04 LTS) -- **Deployment:** `hugo build` then `rsync` to server -- **Future services:** Docker Compose for anything beyond the blog +- **Deployment:** `hugo --minify` then `rsync` to `/var/www/monotrope` +- **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 -- `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"` +## Ansible Playbook -### 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`) -- 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 +The playbook is at `infra/ansible/playbook.yml`. Tags let individual services be re-provisioned without touching the rest. -### 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 -- 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` +Pulled from environment variables, loaded from `.env` via Makefile: -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/` -- `rsync -avz --delete site/public/ deploy@:/var/www/monotrope/` -- Print the URL on success +## Makefile Targets -The droplet IP should come from an environment variable `MONOTROPE_HOST` or a `.env` file (not committed). - -### 5. Makefile - -Targets: -- `make build` — build the site locally -- `make serve` — `hugo server` for local dev with live reload -- `make deploy` — run `deploy.sh` -- `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/`) +``` +make build # hugo --minify +make serve # hugo server --buildDrafts (local dev) +make deploy # build + rsync to production +make ssh # SSH as deploy user +make setup # Full Ansible provisioning (fresh droplet) +make miniflux # Ansible --tags miniflux +make goatcounter # Ansible --tags goatcounter +``` ## DNS -The user will configure DNS separately via their registrar. The server expects: - `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` -- No containerisation of the blog itself — it's static files -- No database -- No analytics (for now) -- No comments system (for now) +Templates are in `site/layouts/`. No JavaScript unless strictly necessary. +The GoatCounter analytics script is injected in `baseof.html` and only loads +in production builds (`hugo.IsProduction`). + +## 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` diff --git a/Makefile b/Makefile index 8478c9d..4e9b58c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build serve deploy ssh setup +.PHONY: build serve deploy ssh setup miniflux goatcounter # Load .env if it exists -include .env @@ -24,3 +24,11 @@ ssh: setup: @test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1) 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 diff --git a/infra/Caddyfile b/infra/Caddyfile index fc86e6c..c70679c 100644 --- a/infra/Caddyfile +++ b/infra/Caddyfile @@ -22,3 +22,17 @@ monotrope.au { www.monotrope.au { 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 +} diff --git a/infra/ansible/playbook.yml b/infra/ansible/playbook.yml index 6f2008f..167eca9 100644 --- a/infra/ansible/playbook.yml +++ b/infra/ansible/playbook.yml @@ -7,6 +7,12 @@ site_dir: /var/www/monotrope deploy_user: deploy 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: @@ -58,6 +64,9 @@ group: caddy mode: '0640' notify: Restart Caddy + tags: + - miniflux + - goatcounter - name: Enable and start Caddy systemd: @@ -149,6 +158,142 @@ enabled: true 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 ────────────────────────────────────────────────────────── - name: Create deploy user @@ -186,3 +331,8 @@ systemd: name: caddy state: restarted + + - name: Restart GoatCounter + systemd: + name: goatcounter + state: restarted diff --git a/infra/miniflux/docker-compose.yml b/infra/miniflux/docker-compose.yml new file mode 100644 index 0000000..61f4df7 --- /dev/null +++ b/infra/miniflux/docker-compose.yml @@ -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: diff --git a/site/content/about.md b/site/content/about.md new file mode 100644 index 0000000..fb35488 --- /dev/null +++ b/site/content/about.md @@ -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. \ No newline at end of file diff --git a/site/content/posts/hello-world.md b/site/content/posts/hello-world.md index 5d47743..2165d50 100644 --- a/site/content/posts/hello-world.md +++ b/site/content/posts/hello-world.md @@ -4,4 +4,4 @@ date: 2026-04-08 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. \ No newline at end of file diff --git a/site/content/posts/thoughts-on-ai-apr-26.md b/site/content/posts/thoughts-on-ai-apr-26.md new file mode 100644 index 0000000..05e5431 --- /dev/null +++ b/site/content/posts/thoughts-on-ai-apr-26.md @@ -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 + diff --git a/site/content/reviews/_index.md b/site/content/reviews/_index.md new file mode 100644 index 0000000..11464a3 --- /dev/null +++ b/site/content/reviews/_index.md @@ -0,0 +1,3 @@ +--- +title: Reading +--- diff --git a/site/content/reviews/the-compound.md b/site/content/reviews/the-compound.md new file mode 100644 index 0000000..b7d8dc4 --- /dev/null +++ b/site/content/reviews/the-compound.md @@ -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. \ No newline at end of file diff --git a/site/hugo.toml b/site/hugo.toml index bd7555f..8f90578 100644 --- a/site/hugo.toml +++ b/site/hugo.toml @@ -10,4 +10,4 @@ title = "Monotrope" section = ["HTML", "RSS"] [params] - description = "A personal blog." + description = "writing on software, books, and ideas." diff --git a/site/layouts/_default/baseof.html b/site/layouts/_default/baseof.html index 4139eed..d1d9b94 100644 --- a/site/layouts/_default/baseof.html +++ b/site/layouts/_default/baseof.html @@ -6,10 +6,15 @@ {{ if not .IsHome }}{{ .Title }} · {{ end }}{{ .Site.Title }} + + + {{ range .AlternativeOutputFormats -}} - {{ printf ` - ` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }} + {{ printf `` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }} + {{ end -}} + {{ if hugo.IsProduction }} + {{ end -}} @@ -18,7 +23,9 @@ @@ -27,9 +34,10 @@
-

© {{ now.Year }} Monotrope. Built with Hugo.

+ monotrope.au + © {{ now.Year }}
- \ No newline at end of file + diff --git a/site/layouts/_default/list.html b/site/layouts/_default/list.html index 3450a38..c875be3 100644 --- a/site/layouts/_default/list.html +++ b/site/layouts/_default/list.html @@ -1,10 +1,17 @@ {{ define "main" }} -

{{ .Title }}

+
    {{ range .Pages.ByDate.Reverse }}
  • - {{ .Title }} +
    + {{ .Title }} + {{ with .Params.tags }} +
    + {{ range . }}{{ . }}{{ end }} +
    + {{ end }} +
  • {{ end }}
diff --git a/site/layouts/_default/rss.xml b/site/layouts/_default/rss.xml new file mode 100644 index 0000000..ddeb271 --- /dev/null +++ b/site/layouts/_default/rss.xml @@ -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 "" | safeHTML }} + + + {{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{ . }} on {{ end }}{{ .Site.Title }}{{ end }} + {{ .Permalink }} + Recent content {{ if ne .Title .Site.Title }}in {{ .Title }} {{ end }}on {{ .Site.Title }} + Hugo + {{ site.Language.LanguageCode }} + {{ with .Site.Copyright }}{{ . }}{{ end }} + {{- if not .Date.IsZero -}} + {{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }} + {{- end -}} + {{- with .OutputFormats.Get "RSS" -}} + {{ printf "" .Permalink .MediaType | safeHTML }} + {{- end -}} + {{ range $pages }} + + {{ .Title }} + {{ .Permalink }} + {{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }} + {{ .Permalink }} + {{ with .Description }}{{ . | html }}{{ else }}{{ .Summary | html }}{{ end }} + + {{ end }} + + diff --git a/site/layouts/_default/single.html b/site/layouts/_default/single.html index dfb5d4a..16cc22e 100644 --- a/site/layouts/_default/single.html +++ b/site/layouts/_default/single.html @@ -1,9 +1,17 @@ {{ define "main" }}

{{ .Title }}

- {{ .Content }} + {{ with .Params.tags }} + + {{ end }}
{{ end }} diff --git a/site/layouts/index.html b/site/layouts/index.html index 3caf0aa..70f3828 100644 --- a/site/layouts/index.html +++ b/site/layouts/index.html @@ -1,10 +1,56 @@ {{ define "main" }} -
    - {{ range (where .Site.RegularPages "Type" "posts").ByDate.Reverse }} -
  • - - {{ .Title }} -
  • - {{ end }} -
-{{ end }} +
+ whoami + {{ .Site.Params.description | default "writing on technology, books, and ideas." + }} + +
+ +
+ +
+
+

writing

+ all posts +
+
    + {{ range first 5 (where .Site.RegularPages "Section" "posts").ByDate.Reverse }} +
  • + +
    + {{ .Title }} + {{ with .Params.tags }} +
    + {{ range . }}{{ . }}{{ end }} +
    + {{ end }} +
    +
  • + {{ end }} +
+
+ +
+
+

reading

+ all reviews +
+
    + {{ range first 3 (where .Site.RegularPages "Section" "reviews").ByDate.Reverse }} + {{ $rating := .Params.rating | default 0 }} +
  • + {{ .Params.date_read | default (.Date.Format "2006-01") }} +
    + {{ .Title }} + {{ with .Params.book_author }}{{ . }}{{ end }} + + {{- strings.Repeat $rating "▓" -}}{{- strings.Repeat (sub 5 $rating) "░" -}} + +
    +
  • + {{ end }} +
+
+ +
+{{ end }} \ No newline at end of file diff --git a/site/layouts/page/single.html b/site/layouts/page/single.html new file mode 100644 index 0000000..a628b2c --- /dev/null +++ b/site/layouts/page/single.html @@ -0,0 +1,6 @@ +{{ define "main" }} +
+

{{ .Title }}

+ {{ .Content }} +
+{{ end }} diff --git a/site/layouts/reviews/list.html b/site/layouts/reviews/list.html new file mode 100644 index 0000000..7721ab3 --- /dev/null +++ b/site/layouts/reviews/list.html @@ -0,0 +1,24 @@ +{{ define "main" }} + +
    + {{ range .Pages.ByDate.Reverse }} + {{ $rating := .Params.rating | default 0 }} +
  • + {{ .Params.date_read | default (.Date.Format "2006-01") }} +
    + {{ .Title }} + {{ with .Params.book_author }}{{ . }}{{ end }} + + {{- strings.Repeat $rating "▓" -}}{{- strings.Repeat (sub 5 $rating) "░" -}} + {{ $rating }}/5 + + {{ with .Params.tags }} +
    + {{ range . }}{{ . }}{{ end }} +
    + {{ end }} +
    +
  • + {{ end }} +
+{{ end }} diff --git a/site/layouts/reviews/single.html b/site/layouts/reviews/single.html new file mode 100644 index 0000000..e382feb --- /dev/null +++ b/site/layouts/reviews/single.html @@ -0,0 +1,32 @@ +{{ define "main" }} +{{ $rating := .Params.rating | default 0 }} +
+

{{ .Title }}

+ +
+ {{ with .Params.book_author }} + author + {{ . }} + {{ end }} + + read + {{ .Params.date_read | default (.Date.Format "January 2006") }} + + rating + + {{- strings.Repeat $rating "▓" -}}{{- strings.Repeat (sub 5 $rating) "░" -}} + {{ $rating }}/5 + +
+ + {{ .Content }} + + {{ with .Params.tags }} + + {{ end }} +
+{{ end }} diff --git a/site/static/css/main.css b/site/static/css/main.css index f7935d0..875a0a6 100644 --- a/site/static/css/main.css +++ b/site/static/css/main.css @@ -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 { box-sizing: border-box; margin: 0; padding: 0; } -/* ── Colour tokens ────────────────────────────── */ +/* ── Tokens ────────────────────────────────────── */ :root { - --bg: #ffffff; - --fg: #1a1a1a; - --muted: #6b7280; - --accent: #2563eb; - --border: #e5e7eb; - --code-bg: #f3f4f6; - --max-width: 680px; + --bg: #0b0d0a; + --bg-raised: #111710; + --fg: #cde0ba; + --fg-dim: #84a070; + --muted: #4d6340; + --green: #5bff8f; + --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) { - :root { - --bg: #111111; - --fg: #e5e5e5; - --muted: #9ca3af; - --accent: #60a5fa; - --border: #2d2d2d; - --code-bg: #1e1e1e; - } -} - -/* ── Typography ───────────────────────────────── */ +/* ── Base ──────────────────────────────────────── */ html { font-size: 18px; -webkit-text-size-adjust: 100%; @@ -36,160 +35,505 @@ html { body { background: var(--bg); color: var(--fg); - font-family: Georgia, "Times New Roman", serif; - line-height: 1.7; - padding: 2rem 1.25rem; + font-family: var(--font-serif); + line-height: 1.75; + 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 { - max-width: var(--max-width); + max-width: var(--max-w); margin: 0 auto; } -/* ── Header ───────────────────────────────────── */ +/* ── Header ────────────────────────────────────── */ .site-header { - margin-bottom: 3rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--border); + margin-bottom: 4rem; display: flex; - align-items: baseline; + align-items: center; justify-content: space-between; + gap: 1rem; flex-wrap: wrap; - gap: 0.5rem; } .site-title { - font-size: 1.1rem; - font-weight: bold; - letter-spacing: 0.02em; + font-family: var(--font-mono); + font-size: 0.9rem; + font-weight: 400; + color: var(--green); 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 { + 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); text-decoration: none; - font-size: 0.9rem; - font-family: system-ui, sans-serif; - margin-left: 1.25rem; } -.site-nav a:hover { - color: var(--accent); +.site-footer a:hover { + color: var(--fg-dim); } -/* ── Footer ───────────────────────────────────── */ -.site-footer { - margin-top: 4rem; - padding-top: 1rem; - border-top: 1px solid var(--border); +/* ── Section label ─────────────────────────────── */ +.section-label { + font-family: var(--font-mono); + font-size: 0.72rem; color: var(--muted); - font-size: 0.8rem; - font-family: system-ui, sans-serif; + margin-bottom: 2rem; + 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 { list-style: none; } .post-list li { - margin-bottom: 1.5rem; - display: flex; - align-items: baseline; - gap: 1rem; + display: grid; + grid-template-columns: 5.5rem 1fr; + gap: 0 1rem; + margin-bottom: 1.75rem; + align-items: start; } .post-list time { + font-family: var(--font-mono); + font-size: 0.7rem; color: var(--muted); - font-size: 0.85rem; - font-family: system-ui, sans-serif; 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); text-decoration: none; + font-family: var(--font-serif); + font-size: 1rem; + line-height: 1.35; + transition: color 0.15s; } -.post-list a:hover { - color: var(--accent); +.post-entry a:hover { + color: var(--green); } -/* ── Article ──────────────────────────────────── */ +/* ── Single article ────────────────────────────── */ article h1 { - font-size: 1.75rem; - line-height: 1.25; - margin-bottom: 0.4rem; + font-family: var(--font-serif); + font-size: 1.65rem; + font-weight: 600; + line-height: 1.2; + margin-bottom: 0.5rem; + color: var(--fg); } .post-meta { + font-family: var(--font-mono); + font-size: 0.72rem; color: var(--muted); - font-size: 0.85rem; - font-family: system-ui, sans-serif; - margin-bottom: 2rem; + margin-bottom: 2.75rem; + display: flex; + 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 h3 { font-size: 1.1rem; margin: 1.75rem 0 0.5rem; } +article h2 { + 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 { - color: var(--accent); + color: var(--green); 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 ol { 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 { - border-left: 3px solid var(--border); - padding-left: 1rem; - color: var(--muted); - margin: 1.5rem 0; + border-left: 2px solid var(--green-dim); + padding-left: 1.25rem; + color: var(--fg-dim); + margin: 2rem 0; + font-style: italic; } article code { - background: var(--code-bg); - padding: 0.1em 0.35em; - border-radius: 3px; - font-size: 0.875em; - font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + background: var(--bg-raised); + color: var(--green); + padding: 0.1em 0.4em; + border-radius: 2px; + font-size: 0.8em; + font-family: var(--font-mono); + border: 1px solid var(--border); } article pre { - background: var(--code-bg); - padding: 1rem; - border-radius: 4px; + background: var(--bg-raised); + border: 1px solid var(--border); + border-left: 2px solid var(--green-dim); + padding: 1.25rem; overflow-x: auto; - margin-bottom: 1.25rem; + margin-bottom: 1.4rem; + border-radius: 0 2px 2px 0; } article pre code { background: none; + border: none; padding: 0; + font-size: 0.8rem; + color: var(--fg); } article hr { border: none; border-top: 1px solid var(--border); - margin: 2rem 0; + margin: 2.5rem 0; } -/* ── Page heading ─────────────────────────────── */ -.page-heading { - font-size: 1.1rem; - color: var(--muted); - font-family: system-ui, sans-serif; - font-weight: normal; - margin-bottom: 2rem; +.post-footer { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); +} + +/* ── 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; + } }