Compare commits
3 Commits
5a734d404b
...
0d4050c58c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d4050c58c | ||
|
|
6c7afecce1 | ||
|
|
0d7287dce1 |
100
CLAUDE.md
100
CLAUDE.md
@@ -2,97 +2,25 @@
|
||||
|
||||
Personal blog and server infrastructure for monotrope.au.
|
||||
|
||||
## Project Structure
|
||||
## Theme & Concept
|
||||
|
||||
```
|
||||
monotrope/
|
||||
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)
|
||||
```
|
||||
The name is a play on [monotropism](https://en.wikipedia.org/wiki/Monotropism) —
|
||||
the theory of autistic cognition as deep, singular focus. The site is built around
|
||||
that idea: deep attention, flow states, and resisting the fragmentation of modern
|
||||
(especially AI-mediated) work. It's also an exercise in ownership — writing and
|
||||
reviews live here instead of on corporate platforms.
|
||||
|
||||
## Tech Stack
|
||||
The tone is personal and reflective. Content includes writing (posts) and book
|
||||
reviews across all genres.
|
||||
|
||||
- **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 --minify` then `rsync` to `/var/www/monotrope`
|
||||
- **Provisioning:** Ansible (`infra/ansible/playbook.yml`)
|
||||
The terminal/CRT visual aesthetic is deliberate, not just decorative — it
|
||||
reinforces the themes of simplicity, focus, and rejecting modern web bloat.
|
||||
No JavaScript unless strictly necessary. No images or decorative elements beyond
|
||||
CSS. The design should feel minimal, typographic, and monospaced-first.
|
||||
|
||||
## Services
|
||||
## Hosting
|
||||
|
||||
| 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 |
|
||||
|
||||
## Ansible Playbook
|
||||
|
||||
**All server changes must go through Ansible.** Everything must be idempotent — no ad-hoc SSH changes.
|
||||
|
||||
The playbook is at `infra/ansible/playbook.yml`. Tags let individual services be re-provisioned without touching the rest.
|
||||
|
||||
| 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)|
|
||||
|
||||
### Secrets
|
||||
|
||||
Pulled from environment variables, loaded from `.env` via Makefile:
|
||||
|
||||
```
|
||||
MONOTROPE_HOST
|
||||
MINIFLUX_DB_PASSWORD
|
||||
MINIFLUX_ADMIN_USER
|
||||
MINIFLUX_ADMIN_PASSWORD
|
||||
GOATCOUNTER_ADMIN_EMAIL
|
||||
GOATCOUNTER_ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
### GoatCounter
|
||||
|
||||
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.
|
||||
|
||||
## Makefile Targets
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
- `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)
|
||||
|
||||
## Site Layout
|
||||
|
||||
Content lives in `site/content/`:
|
||||
- `posts/` — writing
|
||||
- `reviews/` — book reviews
|
||||
- `about.md` — about page (uses `layouts/page/single.html`)
|
||||
|
||||
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`).
|
||||
DigitalOcean droplet, Sydney region, Ubuntu 24.04 LTS.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
11
Makefile
11
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: build serve deploy ssh setup miniflux goatcounter
|
||||
.PHONY: build serve deploy ssh setup miniflux gitea goatcounter enrich
|
||||
|
||||
# Load .env if it exists
|
||||
-include .env
|
||||
@@ -7,7 +7,7 @@ export
|
||||
DEPLOY_USER := deploy
|
||||
MONOTROPE_HOST ?=
|
||||
|
||||
build:
|
||||
build: enrich
|
||||
cd site && hugo --minify
|
||||
|
||||
serve:
|
||||
@@ -29,6 +29,13 @@ 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
|
||||
|
||||
gitea:
|
||||
@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 gitea
|
||||
|
||||
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
|
||||
|
||||
enrich:
|
||||
uv run enrich.py
|
||||
|
||||
133
enrich.py
Normal file
133
enrich.py
Normal file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "python-frontmatter",
|
||||
# ]
|
||||
# ///
|
||||
"""Enrich book reviews with ISBN and cover images from OpenLibrary."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
|
||||
REVIEWS_DIR = Path(__file__).parent / "site" / "content" / "reviews"
|
||||
COVERS_DIR = Path(__file__).parent / "site" / "static" / "covers"
|
||||
|
||||
OL_SEARCH = "https://openlibrary.org/search.json"
|
||||
OL_COVER = "https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg"
|
||||
|
||||
|
||||
def search_isbn(title: str, author: str) -> str | None:
|
||||
"""Search OpenLibrary for an ISBN by title and author."""
|
||||
params = {"title": title, "author": author, "limit": "3", "fields": "isbn"}
|
||||
url = f"{OL_SEARCH}?{urllib.parse.urlencode(params)}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "monotrope-enrich/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read())
|
||||
for doc in data.get("docs", []):
|
||||
for isbn in doc.get("isbn", []):
|
||||
if len(isbn) == 13:
|
||||
return isbn
|
||||
# fall back to ISBN-10 if no 13
|
||||
for doc in data.get("docs", []):
|
||||
for isbn in doc.get("isbn", []):
|
||||
if len(isbn) == 10:
|
||||
return isbn
|
||||
return None
|
||||
|
||||
|
||||
def fetch_cover(isbn: str, dest: Path) -> bool:
|
||||
"""Download a cover image for the given ISBN. Returns True on success."""
|
||||
url = OL_COVER.format(isbn=isbn)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "monotrope-enrich/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = resp.read()
|
||||
# OpenLibrary returns a tiny 1x1 placeholder when no cover exists
|
||||
if len(data) < 1000:
|
||||
return False
|
||||
dest.write_bytes(data)
|
||||
return True
|
||||
|
||||
|
||||
def enrich(path: Path, dry_run: bool = False) -> None:
|
||||
"""Enrich a single review file with ISBN and cover."""
|
||||
post = frontmatter.load(path)
|
||||
title = post.get("title", "")
|
||||
author = post.get("book_author", "")
|
||||
|
||||
has_isbn = bool(post.get("isbn"))
|
||||
has_cover = bool(post.get("cover"))
|
||||
|
||||
if has_isbn and has_cover:
|
||||
print(f" skip {path.name} (already enriched)")
|
||||
return
|
||||
|
||||
# ── ISBN lookup ──────────────────────────────────
|
||||
isbn = post.get("isbn", "")
|
||||
if not isbn:
|
||||
print(f" search title={title!r} author={author!r}")
|
||||
isbn = search_isbn(title, author)
|
||||
if not isbn:
|
||||
print(f" ✗ no ISBN found for {path.name}")
|
||||
return
|
||||
print(f" found ISBN {isbn}")
|
||||
|
||||
# ── Cover download ──────────────────────────────
|
||||
slug = path.stem
|
||||
cover_file = COVERS_DIR / f"{slug}.jpg"
|
||||
if not has_cover and not cover_file.exists():
|
||||
print(f" fetch cover → {cover_file.relative_to(Path(__file__).parent)}")
|
||||
if not dry_run:
|
||||
COVERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not fetch_cover(isbn, cover_file):
|
||||
print(f" ✗ no cover image available for ISBN {isbn}")
|
||||
cover_file = None
|
||||
else:
|
||||
cover_file = None
|
||||
elif cover_file.exists():
|
||||
print(f" ok cover already exists")
|
||||
|
||||
# ── Update frontmatter ──────────────────────────
|
||||
changed = False
|
||||
if not has_isbn:
|
||||
post["isbn"] = isbn
|
||||
changed = True
|
||||
if not has_cover and cover_file and cover_file.exists():
|
||||
post["cover"] = f"/covers/{slug}.jpg"
|
||||
changed = True
|
||||
|
||||
if changed and not dry_run:
|
||||
path.write_text(frontmatter.dumps(post) + "\n")
|
||||
print(f" ✓ updated {path.name}")
|
||||
elif changed:
|
||||
print(f" (dry run) would update {path.name}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
dry_run = "--dry-run" in sys.argv
|
||||
|
||||
reviews = sorted(REVIEWS_DIR.glob("*.md"))
|
||||
reviews = [r for r in reviews if r.name != "_index.md"]
|
||||
|
||||
if not reviews:
|
||||
print("No reviews found.")
|
||||
return
|
||||
|
||||
print(f"Enriching {len(reviews)} review(s)...\n")
|
||||
for path in reviews:
|
||||
print(f" ── {path.stem} ──")
|
||||
try:
|
||||
enrich(path, dry_run=dry_run)
|
||||
except Exception as e:
|
||||
print(f" ✗ error: {e}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -30,6 +30,13 @@ reader.monotrope.au {
|
||||
encode zstd gzip
|
||||
}
|
||||
|
||||
# Gitea
|
||||
git.monotrope.au {
|
||||
reverse_proxy localhost:3000
|
||||
|
||||
encode zstd gzip
|
||||
}
|
||||
|
||||
# GoatCounter analytics
|
||||
stats.monotrope.au {
|
||||
reverse_proxy localhost:8081
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
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') }}"
|
||||
gitea_db_password: "{{ lookup('env', 'GITEA_DB_PASSWORD') }}"
|
||||
goatcounter_version: "2.7.0"
|
||||
goatcounter_admin_email: "{{ lookup('env', 'GOATCOUNTER_ADMIN_EMAIL') }}"
|
||||
goatcounter_admin_password: "{{ lookup('env', 'GOATCOUNTER_ADMIN_PASSWORD') }}"
|
||||
@@ -66,6 +67,7 @@
|
||||
notify: Restart Caddy
|
||||
tags:
|
||||
- miniflux
|
||||
- gitea
|
||||
- goatcounter
|
||||
|
||||
- name: Enable and start Caddy
|
||||
@@ -113,6 +115,12 @@
|
||||
port: '443'
|
||||
proto: tcp
|
||||
|
||||
- name: Allow Gitea SSH
|
||||
ufw:
|
||||
rule: allow
|
||||
port: '2222'
|
||||
proto: tcp
|
||||
|
||||
- name: Enable UFW
|
||||
ufw:
|
||||
state: enabled
|
||||
@@ -197,6 +205,43 @@
|
||||
chdir: /opt/miniflux
|
||||
tags: miniflux
|
||||
|
||||
# ── Gitea ───────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Create Gitea directory
|
||||
file:
|
||||
path: /opt/gitea
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0750'
|
||||
tags: gitea
|
||||
|
||||
- name: Copy Gitea docker-compose.yml
|
||||
copy:
|
||||
src: ../gitea/docker-compose.yml
|
||||
dest: /opt/gitea/docker-compose.yml
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0640'
|
||||
tags: gitea
|
||||
|
||||
- name: Write Gitea .env
|
||||
copy:
|
||||
dest: /opt/gitea/.env
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0600'
|
||||
content: |
|
||||
GITEA_DB_PASSWORD={{ gitea_db_password }}
|
||||
no_log: true
|
||||
tags: gitea
|
||||
|
||||
- name: Pull and start Gitea
|
||||
command: docker compose up -d --pull always
|
||||
args:
|
||||
chdir: /opt/gitea
|
||||
tags: gitea
|
||||
|
||||
# ── GoatCounter ─────────────────────────────────────────────────────────
|
||||
|
||||
- name: Create goatcounter system user
|
||||
|
||||
45
infra/gitea/docker-compose.yml
Normal file
45
infra/gitea/docker-compose.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
- "2222:22"
|
||||
volumes:
|
||||
- gitea_data:/data
|
||||
environment:
|
||||
GITEA__database__DB_TYPE: postgres
|
||||
GITEA__database__HOST: db:5432
|
||||
GITEA__database__NAME: gitea
|
||||
GITEA__database__USER: gitea
|
||||
GITEA__database__PASSWD: "${GITEA_DB_PASSWORD}"
|
||||
GITEA__server__ROOT_URL: "https://git.monotrope.au/"
|
||||
GITEA__server__DOMAIN: "git.monotrope.au"
|
||||
GITEA__server__SSH_DOMAIN: "git.monotrope.au"
|
||||
GITEA__server__SSH_PORT: 2222
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- gitea_db:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: gitea
|
||||
POSTGRES_USER: gitea
|
||||
POSTGRES_PASSWORD: "${GITEA_DB_PASSWORD}"
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "gitea"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
gitea_data:
|
||||
gitea_db:
|
||||
@@ -3,8 +3,8 @@ 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.
|
||||
Monotrope is a play on the idea of [monotropism](https://en.wikipedia.org/wiki/Monotropism) -- the theory of autistic cognition as deep, singular focus. I created this site as an experiment in writing regularly, and also in trying to own my content instead of putting it on corporate platforms.
|
||||
|
||||
I read across all genres, and post my reviews here because I want to own them.
|
||||
I read across all genres, and post my reviews here.
|
||||
|
||||
I live on Djadjawurrung and Taungurong land.
|
||||
@@ -4,4 +4,4 @@ date: 2026-04-08
|
||||
draft: false
|
||||
---
|
||||
|
||||
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.
|
||||
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 as an experiment in direct ownership of my writing.
|
||||
@@ -1,26 +1,26 @@
|
||||
---
|
||||
title: "The Compound"
|
||||
book_author: "Aisling Rawle"
|
||||
date: 2026-03-01
|
||||
date_read: "March 2026"
|
||||
book_author: Aisling Rawle
|
||||
cover: /covers/the-compound.jpg
|
||||
date: 2026-04-08
|
||||
date_read: March 2026
|
||||
isbn: '9780008710088'
|
||||
rating: 4
|
||||
tags: ["dystopia", "satire"]
|
||||
tags:
|
||||
- dystopia
|
||||
- satire
|
||||
title: The Compound
|
||||
---
|
||||
|
||||
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 Compound_ is a hell of a vibe. It's compulsively readable while also being creepy as fuck.
|
||||
|
||||
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).
|
||||
It takes place in an indeterminate dystopian near-future, where we get only the tiniest glimpses of life outside the eponymous compound. Characters make reference to "the wars," and there is general sense that life as a whole has gone downhill. But it's entirely possible to imagine that these characters live in our world; nothing they describe about the dystopia is wholly incompatible with life in 2026, it's just a bit magnified or brought into focus.
|
||||
|
||||
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 setting is a reality TV show set in an isolated compound in the desert, a bit of Love Island meets Survivor. The characters are paired off for romantic drama, but also pushed to extremes by the producers who withhold food or water in the intrest of creating tension or pushing specific tasks or challenges.
|
||||
|
||||
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.
|
||||
The satire is more ambient than ever directly stated. To give one example, the contestants on the show get rewards for completing tasks, after which they're expected to walk up to a camera and thank the brand that provided the reward. This is never really commented on or used to make a point, it's simply there, an absurd reflection of late-stage consumer capitalism contrasted with the darkness of what the characters are being put through.
|
||||
|
||||
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:
|
||||
The protagonist and narrator, Lilly, is at once extremely shallow and more than a little stupid, while also having good intuitions about people and their motivations, and more self-awareness than you'd give her credit for were you a viewer of the show. Most of her inner monologue is in the moment, focused on the people and situations immediately in front of her, but very occasionally she dips into reflection. Here's the final few sentences of a longer passage I highlighted, in which Lilly talks about her dread of leaving the show and going home:
|
||||
|
||||
> 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.
|
||||
The style isn't flowery but it is sharp and expressive, and personally I found the pacing to be perfect: tense without the need for plot twists or constant action.
|
||||
@@ -2,6 +2,9 @@ baseURL = "https://monotrope.au"
|
||||
languageCode = "en-au"
|
||||
title = "Monotrope"
|
||||
|
||||
[markup.goldmark.extensions.typographer]
|
||||
disable = false
|
||||
|
||||
[markup.goldmark.renderer]
|
||||
unsafe = false
|
||||
|
||||
@@ -10,4 +13,4 @@ title = "Monotrope"
|
||||
section = ["HTML", "RSS"]
|
||||
|
||||
[params]
|
||||
description = "writing on software, books, and ideas."
|
||||
description = "writing on technology, books, and ideas."
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<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="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
{{ range .AlternativeOutputFormats -}}
|
||||
{{ printf `<link rel="%s" type="%s" href="%s" title="%s">` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
|
||||
|
||||
@@ -17,8 +17,13 @@
|
||||
<span class="rating-blocks">{{- strings.Repeat $rating "▓" -}}{{- strings.Repeat (sub 5 $rating) "░" -}}</span>
|
||||
<span class="rating-num">{{ $rating }}/5</span>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
|
||||
{{ with .Params.cover }}
|
||||
<img class="book-cover" src="{{ . }}" alt="Cover of {{ $.Title }}">
|
||||
{{ end }}
|
||||
|
||||
{{ .Content }}
|
||||
|
||||
{{ with .Params.tags }}
|
||||
|
||||
BIN
site/static/covers/the-compound.jpg
Normal file
BIN
site/static/covers/the-compound.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -336,6 +336,14 @@ article hr {
|
||||
}
|
||||
|
||||
/* ── Book review ───────────────────────────────── */
|
||||
.book-cover {
|
||||
float: right;
|
||||
width: 180px;
|
||||
margin: 0 0 1rem 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.book-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
@@ -528,6 +536,13 @@ article hr {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.book-cover {
|
||||
float: none;
|
||||
display: block;
|
||||
width: 100px;
|
||||
margin: 0 0 1.25rem 0;
|
||||
}
|
||||
|
||||
.book-meta {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
|
||||
16
site/static/favicon.svg
Normal file
16
site/static/favicon.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#0b0d0a"/>
|
||||
<!-- Pixel art M — each "pixel" is a 2x2 block on the 32x32 grid -->
|
||||
<!-- Left vertical stroke -->
|
||||
<rect x="6" y="8" width="4" height="18" fill="#5bff8f"/>
|
||||
<!-- Right vertical stroke -->
|
||||
<rect x="22" y="8" width="4" height="18" fill="#5bff8f"/>
|
||||
<!-- Left diagonal -->
|
||||
<rect x="10" y="10" width="2" height="4" fill="#5bff8f"/>
|
||||
<rect x="12" y="14" width="2" height="4" fill="#5bff8f"/>
|
||||
<!-- Right diagonal -->
|
||||
<rect x="20" y="10" width="2" height="4" fill="#5bff8f"/>
|
||||
<rect x="18" y="14" width="2" height="4" fill="#5bff8f"/>
|
||||
<!-- Center peak -->
|
||||
<rect x="14" y="16" width="4" height="4" fill="#5bff8f"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 722 B |
Reference in New Issue
Block a user