Compare commits
18 Commits
d183adaf0a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ce011acb5 | |||
| 864962a160 | |||
| aa9a9c75f8 | |||
| 4325641879 | |||
| da61b59feb | |||
| a3bf9f9064 | |||
| 81ed81e78a | |||
| 818d253a07 | |||
| 3b98043a61 | |||
| 74ada1c5cf | |||
| 65d41b148c | |||
| e219ad2235 | |||
| 9500d484c2 | |||
| 8995493730 | |||
| 38f2687983 | |||
| 52edac3c7d | |||
| 84330b489a | |||
| 8190aa15bf |
@@ -0,0 +1,23 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: nix
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: nix --extra-experimental-features 'nix-command flakes' build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Pages
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
nix run nixpkgs#wrangler -- pages deploy result/ \
|
||||||
|
--project-name=personal-site \
|
||||||
|
--commit-dirty=true
|
||||||
@@ -3,3 +3,5 @@ public/
|
|||||||
.emacs.d/
|
.emacs.d/
|
||||||
result
|
result
|
||||||
result-*
|
result-*
|
||||||
|
.direnv
|
||||||
|
.wrangler
|
||||||
|
|||||||
+50
-1
@@ -95,5 +95,54 @@ nix flake update
|
|||||||
* Deployment
|
* Deployment
|
||||||
|
|
||||||
The site is deployed to Cloudflare Pages automatically on push via a Gitea Actions
|
The site is deployed to Cloudflare Pages automatically on push via a Gitea Actions
|
||||||
workflow running on a Raspberry Pi runner (=aarch64-linux=).
|
workflow (=.gitea/workflows/deploy.yml=) running on a Raspberry Pi runner (=aarch64-linux=).
|
||||||
The =nix build= output is uploaded directly as the Pages deployment artifact.
|
The =nix build= output is uploaded directly as the Pages deployment artifact.
|
||||||
|
|
||||||
|
** First-time setup
|
||||||
|
|
||||||
|
*** 1. Create a Cloudflare Pages project
|
||||||
|
|
||||||
|
In the Cloudflare dashboard → Pages → Create a project → *Direct Upload*.
|
||||||
|
Name it =personal-site= (must match the =--project-name= flag in the workflow).
|
||||||
|
Skip the initial upload — CI will handle it.
|
||||||
|
|
||||||
|
Alternatively, create it with Wrangler locally:
|
||||||
|
|
||||||
|
#+begin_src sh
|
||||||
|
npx wrangler pages project create personal-site
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
*** 2. Get your Cloudflare credentials
|
||||||
|
|
||||||
|
- *Account ID*: Cloudflare dashboard → right sidebar, or any zone overview page.
|
||||||
|
- *API Token*: My Profile → API Tokens → Create Token.
|
||||||
|
Use the *Edit Cloudflare Pages* template, or create a custom token with:
|
||||||
|
- Permission: =Cloudflare Pages= → =Edit=
|
||||||
|
- Account resource: your account
|
||||||
|
|
||||||
|
*** 3. Add secrets to Gitea
|
||||||
|
|
||||||
|
In the Gitea repo → Settings → Secrets → Actions, add:
|
||||||
|
|
||||||
|
| Secret name | Value |
|
||||||
|
|--------------------------+------------------------------|
|
||||||
|
| =CLOUDFLARE_API_TOKEN= | the API token from step 2 |
|
||||||
|
| =CLOUDFLARE_ACCOUNT_ID= | your numeric account ID |
|
||||||
|
|
||||||
|
*** 4. Verify the runner label
|
||||||
|
|
||||||
|
The workflow uses =runs-on: ubuntu-latest=.
|
||||||
|
Check your Gitea runner's label in *Site Administration → Runners* and update the
|
||||||
|
workflow if your runner uses a different label (e.g. =nix=, =self-hosted=, etc.).
|
||||||
|
|
||||||
|
The runner needs *Nix* (with flakes enabled) and *Node.js* on =PATH=.
|
||||||
|
If Node.js is not available system-wide you can prepend the build step with:
|
||||||
|
|
||||||
|
#+begin_src sh
|
||||||
|
nix-shell -p nodejs --run "npx --yes wrangler@3 pages deploy ..."
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
*** 5. Push to master
|
||||||
|
|
||||||
|
The workflow triggers on every push to =master=.
|
||||||
|
Check the Actions tab in Gitea to monitor runs.
|
||||||
|
|||||||
@@ -10,22 +10,219 @@
|
|||||||
org-html-head-include-scripts nil)
|
org-html-head-include-scripts nil)
|
||||||
|
|
||||||
(setq site-nav
|
(setq site-nav
|
||||||
"<nav>
|
"<div class=\"site-contact\">
|
||||||
<a href=\"/\">Home</a>
|
<a href=\"mailto:aner@zakobar.com\" class=\"contact-link\">
|
||||||
<a href=\"/about.html\">About</a>
|
<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"/><path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/></svg>
|
||||||
</nav>")
|
aner@zakobar.com
|
||||||
|
</a>
|
||||||
|
<a href=\"https://www.linkedin.com/in/aner-zakobar/\" class=\"contact-link\" target=\"_blank\" rel=\"noopener\">
|
||||||
|
<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z\"/><rect x=\"2\" y=\"9\" width=\"4\" height=\"12\"/><circle cx=\"4\" cy=\"4\" r=\"2\"/></svg>
|
||||||
|
linkedin.com/in/aner-zakobar
|
||||||
|
</a>
|
||||||
|
<a href=\"/feed.xml\" class=\"contact-link\">
|
||||||
|
<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M4 11a9 9 0 0 1 9 9\"/><path d=\"M4 4a16 16 0 0 1 16 16\"/><circle cx=\"5\" cy=\"19\" r=\"1\" fill=\"currentColor\" stroke=\"none\"/></svg>
|
||||||
|
RSS
|
||||||
|
</a>
|
||||||
|
</div>")
|
||||||
|
|
||||||
|
(setq blog-nav
|
||||||
|
"<div class=\"blog-nav\">
|
||||||
|
<a href=\"/\" class=\"back-link\">← aner.zakobar.com</a>
|
||||||
|
</div>")
|
||||||
|
|
||||||
|
(defun things-extract-keyword (file keyword)
|
||||||
|
"Extract #+KEYWORD: value from FILE, or nil if absent/empty."
|
||||||
|
(with-temp-buffer
|
||||||
|
(insert-file-contents file)
|
||||||
|
(goto-char (point-min))
|
||||||
|
(when (re-search-forward
|
||||||
|
(concat "^#\\+" keyword ":[ \t]*\\(.*\\)$") nil t)
|
||||||
|
(let ((v (string-trim (match-string 1))))
|
||||||
|
(unless (string= v "") v)))))
|
||||||
|
|
||||||
|
(defun things-generate-page (_project)
|
||||||
|
"Scan things/ and write content/generated/things-body.org."
|
||||||
|
(let* ((things-dir "./things")
|
||||||
|
(gen-dir "./content/generated")
|
||||||
|
(files (when (file-directory-p things-dir)
|
||||||
|
(file-expand-wildcards (concat things-dir "/*.org") t))))
|
||||||
|
(make-directory gen-dir t)
|
||||||
|
(with-temp-file (concat gen-dir "/things-body.org")
|
||||||
|
(insert "#+BEGIN_EXPORT html\n<div class=\"things-grid\">\n")
|
||||||
|
(dolist (file files)
|
||||||
|
(let* ((title (things-extract-keyword file "THINGS_TITLE"))
|
||||||
|
(desc (things-extract-keyword file "THINGS_DESC"))
|
||||||
|
(path (things-extract-keyword file "THINGS_PATH"))
|
||||||
|
)
|
||||||
|
(when (and title desc path)
|
||||||
|
(insert
|
||||||
|
(concat
|
||||||
|
"<div class=\"things-card\">\n"
|
||||||
|
" <div class=\"things-card-body\">\n"
|
||||||
|
" <h3><a href=\"/" path "/\">" title "</a></h3>\n"
|
||||||
|
" <p>" desc "</p>\n"
|
||||||
|
" </div>\n"
|
||||||
|
"</div>\n")))))
|
||||||
|
(insert "</div>\n#+END_EXPORT\n"))))
|
||||||
|
|
||||||
|
(defun blog-escape-xml (str)
|
||||||
|
"Escape XML special characters in STR."
|
||||||
|
(let ((s (or str "")))
|
||||||
|
(setq s (replace-regexp-in-string "&" "&" s))
|
||||||
|
(setq s (replace-regexp-in-string "<" "<" s))
|
||||||
|
(setq s (replace-regexp-in-string ">" ">" s))
|
||||||
|
s))
|
||||||
|
|
||||||
|
(defun blog-date-to-rfc822 (date-str)
|
||||||
|
"Convert YYYY-MM-DD string to RFC 822 format."
|
||||||
|
(let* ((parts (split-string date-str "-"))
|
||||||
|
(year (string-to-number (nth 0 parts)))
|
||||||
|
(month (string-to-number (nth 1 parts)))
|
||||||
|
(day (string-to-number (nth 2 parts)))
|
||||||
|
(month-names '("Jan" "Feb" "Mar" "Apr" "May" "Jun"
|
||||||
|
"Jul" "Aug" "Sep" "Oct" "Nov" "Dec"))
|
||||||
|
(day-names '("Sun" "Mon" "Tue" "Wed" "Thu" "Fri" "Sat"))
|
||||||
|
(time (encode-time 0 0 0 day month year))
|
||||||
|
(decoded (decode-time time))
|
||||||
|
(dow (nth 6 decoded)))
|
||||||
|
(format "%s, %02d %s %04d 00:00:00 +0000"
|
||||||
|
(nth dow day-names)
|
||||||
|
day
|
||||||
|
(nth (1- month) month-names)
|
||||||
|
year)))
|
||||||
|
|
||||||
|
(defun blog-generate-rss (_project)
|
||||||
|
"Write public/feed.xml from all blog posts."
|
||||||
|
(let* ((blog-dir "./content/blog")
|
||||||
|
(base-url "https://aner.zakobar.com")
|
||||||
|
(pattern (concat blog-dir "/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.org"))
|
||||||
|
(files (sort (file-expand-wildcards pattern t)
|
||||||
|
(lambda (a b) (string> (file-name-base a)
|
||||||
|
(file-name-base b))))))
|
||||||
|
(make-directory "./public" t)
|
||||||
|
(with-temp-file "./public/feed.xml"
|
||||||
|
(insert "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
|
||||||
|
(insert "<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n")
|
||||||
|
(insert " <channel>\n")
|
||||||
|
(insert " <title>Aner Zakobar</title>\n")
|
||||||
|
(insert (format " <link>%s</link>\n" base-url))
|
||||||
|
(insert " <description>Personal blog of Aner Zakobar</description>\n")
|
||||||
|
(insert (format " <atom:link href=\"%s/feed.xml\" rel=\"self\" type=\"application/rss+xml\"/>\n" base-url))
|
||||||
|
(dolist (file files)
|
||||||
|
(let* ((base (file-name-base file))
|
||||||
|
(date (substring base 0 10))
|
||||||
|
(title (blog-escape-xml (blog-extract-title file)))
|
||||||
|
(excerpt (blog-escape-xml (blog-extract-excerpt file)))
|
||||||
|
(url (format "%s/blog/%s.html" base-url base)))
|
||||||
|
(insert " <item>\n")
|
||||||
|
(insert (format " <title>%s</title>\n" title))
|
||||||
|
(insert (format " <link>%s</link>\n" url))
|
||||||
|
(insert (format " <guid>%s</guid>\n" url))
|
||||||
|
(insert (format " <pubDate>%s</pubDate>\n" (blog-date-to-rfc822 date)))
|
||||||
|
(insert (format " <description>%s</description>\n" excerpt))
|
||||||
|
(insert " </item>\n")))
|
||||||
|
(insert " </channel>\n")
|
||||||
|
(insert "</rss>\n"))))
|
||||||
|
|
||||||
|
(defun site-prepare (project)
|
||||||
|
"Run all site preparation steps."
|
||||||
|
(blog-generate-listings project)
|
||||||
|
(blog-generate-rss project)
|
||||||
|
(things-generate-page project))
|
||||||
|
|
||||||
|
(defun blog-extract-title (file)
|
||||||
|
"Extract #+TITLE: value from FILE."
|
||||||
|
(with-temp-buffer
|
||||||
|
(insert-file-contents file)
|
||||||
|
(goto-char (point-min))
|
||||||
|
(if (re-search-forward "^#\\+TITLE:[ \t]*\\(.+\\)$" nil t)
|
||||||
|
(string-trim (match-string 1))
|
||||||
|
(file-name-base file))))
|
||||||
|
|
||||||
|
(defun blog-extract-excerpt (file &optional max-chars)
|
||||||
|
"Extract first body paragraph from FILE, up to MAX-CHARS characters."
|
||||||
|
(let ((max (or max-chars 250)))
|
||||||
|
(with-temp-buffer
|
||||||
|
(insert-file-contents file)
|
||||||
|
(goto-char (point-min))
|
||||||
|
;; Skip metadata lines and headings
|
||||||
|
(while (and (not (eobp))
|
||||||
|
(looking-at "^\\(#\\+\\|\\*\\|[ \t]*$\\)"))
|
||||||
|
(forward-line 1))
|
||||||
|
(let ((start (point)))
|
||||||
|
;; Collect lines until blank line or heading
|
||||||
|
(while (and (not (eobp))
|
||||||
|
(not (looking-at "^\\(\\*\\|[ \t]*$\\)")))
|
||||||
|
(forward-line 1))
|
||||||
|
(let ((text (string-trim (buffer-substring-no-properties start (point)))))
|
||||||
|
;; Collapse whitespace
|
||||||
|
(setq text (replace-regexp-in-string "[ \t\n]+" " " text))
|
||||||
|
(if (> (length text) max)
|
||||||
|
(concat (substring text 0 max) "...")
|
||||||
|
text))))))
|
||||||
|
|
||||||
|
(defun blog-generate-listings (_project)
|
||||||
|
"Scan content/blog/ and generate recent-posts.org and all-posts.org."
|
||||||
|
(let* ((blog-dir "./content/blog")
|
||||||
|
(gen-dir "./content/generated")
|
||||||
|
(pattern (concat blog-dir "/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.org"))
|
||||||
|
(files (sort (file-expand-wildcards pattern t)
|
||||||
|
(lambda (a b) (string> (file-name-base a)
|
||||||
|
(file-name-base b)))))
|
||||||
|
(make-entry
|
||||||
|
(lambda (file)
|
||||||
|
(let* ((base (file-name-base file))
|
||||||
|
(date (substring base 0 10))
|
||||||
|
(title (blog-extract-title file))
|
||||||
|
(excerpt (blog-extract-excerpt file))
|
||||||
|
(link (concat "file:../blog/" base ".org")))
|
||||||
|
(concat
|
||||||
|
"*[[" link "][" title "]]* — " date "\n\n"
|
||||||
|
excerpt "\n\n"
|
||||||
|
"[[" link "][Read more →]]\n\n")))))
|
||||||
|
(make-directory gen-dir t)
|
||||||
|
(with-temp-file (concat gen-dir "/recent-posts.org")
|
||||||
|
(let ((first t))
|
||||||
|
(dolist (file (seq-take files 3))
|
||||||
|
(unless first (insert "-----\n\n"))
|
||||||
|
(insert (funcall make-entry file))
|
||||||
|
(setq first nil))))
|
||||||
|
(with-temp-file (concat gen-dir "/all-posts.org")
|
||||||
|
(let ((first t))
|
||||||
|
(dolist (file files)
|
||||||
|
(unless first (insert "-----\n\n"))
|
||||||
|
(insert (funcall make-entry file))
|
||||||
|
(setq first nil))))))
|
||||||
(setq org-publish-project-alist
|
(setq org-publish-project-alist
|
||||||
`(("site-pages"
|
`(("site-pages"
|
||||||
:base-directory "./content"
|
:base-directory "./content"
|
||||||
:base-extension "org"
|
:base-extension "org"
|
||||||
:publishing-directory "./public"
|
:publishing-directory "./public"
|
||||||
:recursive t
|
:recursive t
|
||||||
|
:exclude "generated/\\|blog/"
|
||||||
|
:publishing-function org-html-publish-to-html
|
||||||
|
:preparation-function site-prepare
|
||||||
|
:html-head-include-default-style nil
|
||||||
|
:html-head-include-scripts nil
|
||||||
|
:html-head "<link rel=\"stylesheet\" href=\"/style.css\"><script src=\"/animation.js\" defer></script><link rel=\"alternate\" type=\"application/rss+xml\" title=\"Aner Zakobar\" href=\"/feed.xml\">"
|
||||||
|
:html-preamble ,site-nav
|
||||||
|
:html-postamble nil
|
||||||
|
:with-author nil
|
||||||
|
:with-creator nil
|
||||||
|
:with-timestamps nil
|
||||||
|
:section-numbers nil
|
||||||
|
:with-toc nil)
|
||||||
|
|
||||||
|
("site-blog"
|
||||||
|
:base-directory "./content/blog"
|
||||||
|
:base-extension "org"
|
||||||
|
:publishing-directory "./public/blog"
|
||||||
|
:recursive nil
|
||||||
:publishing-function org-html-publish-to-html
|
:publishing-function org-html-publish-to-html
|
||||||
:html-head-include-default-style nil
|
:html-head-include-default-style nil
|
||||||
:html-head-include-scripts nil
|
:html-head-include-scripts nil
|
||||||
:html-head "<link rel=\"stylesheet\" href=\"/style.css\">"
|
:html-head "<link rel=\"stylesheet\" href=\"/style.css\"><script src=\"/animation.js\" defer></script><link rel=\"alternate\" type=\"application/rss+xml\" title=\"Aner Zakobar\" href=\"/feed.xml\">"
|
||||||
:html-preamble ,site-nav
|
:html-preamble ,blog-nav
|
||||||
:html-postamble nil
|
:html-postamble nil
|
||||||
:with-author nil
|
:with-author nil
|
||||||
:with-creator nil
|
:with-creator nil
|
||||||
@@ -41,6 +238,6 @@
|
|||||||
:publishing-function org-publish-attachment)
|
:publishing-function org-publish-attachment)
|
||||||
|
|
||||||
("site"
|
("site"
|
||||||
:components ("site-pages" "site-static"))))
|
:components ("site-pages" "site-blog" "site-static"))))
|
||||||
|
|
||||||
(org-publish "site" t)
|
(org-publish "site" t)
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
#+TITLE: About
|
|
||||||
#+DESCRIPTION: About me
|
|
||||||
|
|
||||||
* About me
|
|
||||||
|
|
||||||
A few things about me.
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#+TITLE: Nix Selfhosting in the Age of LLMs
|
||||||
|
#+DESCRIPTION: Blog posts about selfhosting with nix and LLMs
|
||||||
|
|
||||||
|
* My use case
|
||||||
|
|
||||||
|
I recently had to redo my homelab. Homelab might be a very strong word for what is just a Raspberry Pi 4 8GB hooked up to a 2TB storage drive. Regardless, lately it has been slugging along at unacceptable speeds. I don't know if it was system rot or just a buildup of dust, but my home run services were becoming slow. I wanted to run maintenance on it, but I was sort of afraid to touch anything from what I built.
|
||||||
|
|
||||||
|
I based all my work on Helm charts to be deployed on Kubernetes (k3s to be exact). I reasoned it was the cutting edge way to self-host, it was highly secure, and that Helm made everything declarative. In the end I found it unwieldy, and the Helm charts weren't as declarative and pure as I would have hoped. The system would still get stuck mid-deployment, previous configuration options wouldn't always go away and services needed to be manually deleted. It always felt like a great beast I was trying to wrestle into submission, which is not how I want to feel about a service backing up pictures of my children.
|
||||||
|
|
||||||
|
* Starting the migration
|
||||||
|
|
||||||
|
It was time for a change. I wanted to restart my homelab and make it something very maintainable. This drove me to use two technologies that were new to me since my last attempt at self hosting:
|
||||||
|
|
||||||
|
- _The NIX ecosystem_: I migrated my own laptop to NixOS over a year ago at this point. I migrated my work setup to Nix and now almost all of my projects use Nix to manage dependencies, builds, and deployments. Now, I had an opportunity to manage my homelab using NixOS and declare everything.
|
||||||
|
- _LLMs and Agentic Co-Programming_: A lot of the work I did in the past involved painstaking port definitions and reverse-proxy configurations. There was no reason to think this time would be different in this regard, but knowing I could partially rely on some form of coding agent made the process significantly less daunting.
|
||||||
|
|
||||||
|
* Quick success story
|
||||||
|
|
||||||
|
I started to make a migration plan - I wanted to keep relevant data from my old homelab and migrate it into my new setup. Within a day I had backups ready to be ported. Within two more days I had my initial services running, albeit with some bugs. It took another day to iron it all out (keep in mind, my time spent working on this project was 10PM to 11PM only, I consider this quick progress).
|
||||||
|
|
||||||
|
Sure enough, it all worked! I backed up all my files and ran my little suite of automations within a few days! It was working faster than ever before, and I felt more in control than ever, even though I delegated 50% of the coding work to a machine. I attribute this to the fact that, since everything was strictly declarative, I could always tear it all down and rebuild it exactly the way I want very easily.
|
||||||
|
|
||||||
|
* Why NIX is so good, even on its own
|
||||||
|
|
||||||
|
** Fully declarative
|
||||||
|
|
||||||
|
The state of my homelab is set directly from my project. With the exception of some service specific settings that can't be set from files, everything can be changed by changing a file in the repo, and everything can be inferred from reading the repo itself. This means I am never in the dark about the state of my homelab, I can just see what should be running.
|
||||||
|
|
||||||
|
** Saving tools and commands
|
||||||
|
|
||||||
|
Something I find myself doing for everyone of my Nix projects is declaring a development shell with special commands for tooling. Every time I encounter myself writing long commands to do specific tasks (checking state of backups remotely, remote build and deploy, running commands inside Podman hosted services), I just write a custom command that gets loaded into the shell.
|
||||||
|
|
||||||
|
In the previous version, this was accomplished by writing a bunch of bash files. This does work and achieves the same basic principle, but this feels more approachable, and the commands feel more accessible. I find myself using this tool much more within the NIX ecosystem.
|
||||||
|
|
||||||
|
* The extra benefit of the LLM
|
||||||
|
|
||||||
|
Of course using an agentic coding agent helps with self-hosting related tasks. This is true also for a Kubernetes based helm-charted repo like my previous setup. But there ARE some DEFINITE benefits of using this specific combination.
|
||||||
|
|
||||||
|
For starters, the fact that my system is fully declarative means that the LLM knows exactly what is and isn't installed just by looking at the project itself. It doesn't have to hunt around and search for active processes or guess which config file is being read - it knows the file layout and can infer immediately which services are running and how they are configured. Compare this to bloating your context by first reading a bunch of YAML files in the repo and then running a bunch fo commands to make sure everything exists in the repo and making sure it matches the YAML.
|
||||||
|
|
||||||
|
An additional benefit came along with all the custom commands I created for myself - turns out if they're useful for me, they're useful for the LLM! This allows it to perform routine tasks repeatably and correctly, instead of guessing what the best way to do so is.
|
||||||
|
|
||||||
|
As an example, I already have a command that does all the work for checking the state of my backups. The command queries relevant services and logs, and summarizes it for me in one output. that checks all the relevant services and returns the information together. The LLM, instead of learning about the structure of the backup service every time, can just run the command and look at the output.
|
||||||
|
|
||||||
|
* Words of caution
|
||||||
|
|
||||||
|
Of course LLMs did not solve all my woes. Worse, they introduced new ones.
|
||||||
|
|
||||||
|
When setting up the new homelab Claude decided to be pretty lax on security. Network isolation wasn't that important so it could just skip it, file permissions were a bit of a pain to deal with, so why bother... You get the picture.
|
||||||
|
|
||||||
|
Needless to say this was not cutting it. Once I realized this was going on, addressing it with the help of Claude was easy, but I had to first see this issues myself. Even with LLMs there is no replacement for understanding the code you have.
|
||||||
|
|
||||||
|
Another thing is the actual design of the repo itself and how it is organized. Claude can easily spaghettify a codebase if not kept in check. A disorganized codebase makes it harder for Claude itself to work, but more importantly, it makes it harder for me to understand the code I am using, which makes it harder to control Claude. It was critical for me to babysit Claude and give sensible repo design guidance so my code would be kept in check. Just because you're using AI doesn't mean you should let it make messy code.
|
||||||
|
|
||||||
|
Even with these gripes - using an LLM saved me loads of time and allowed me to make this migration even if the time I have to spend on this topic is very, very limited.
|
||||||
|
|
||||||
|
* Conclusion
|
||||||
|
|
||||||
|
This is an ongoing exercise in bad sys-admining - but I am enjoying it very much. Everything so far is running smoothly, and within a short amount of time I surpassed the state my homelab was in - and not once did I have to delete and redeploy an ingress configuration. I even managed to set up services that I gave up deploying before.
|
||||||
|
|
||||||
|
I will keep using this for now. In the end, I don't want to have to rely on the LLM for every task I want to do with my homelab. Over time, I will simplify and restructure my codebase further to make it easily maintainable by me, a silly old human. That being said, even now, it is in an excellent state, and I am optimistic about the future of my self-hosted services.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#+TITLE: Deploying evil-hl-line and highly visual indicators
|
||||||
|
#+DATE: 2026-05-12
|
||||||
|
|
||||||
|
* TL;DR
|
||||||
|
|
||||||
|
I just released a public Emacs package, `evil-hl-line`, that adds a bright line‑highlight to indicate the current Evil state. [[https://github.com/anerisgreat/evil-hl-line][GitHub]]
|
||||||
|
|
||||||
|
* Why I made this package
|
||||||
|
|
||||||
|
I am an addicted evil-mode user. I mainlined vim (well, Neovim) and tmux for a year before switching to Emacs, so by the time I became enlightened I was set on using vim-style key bindings, and so I found myself to be an evil man.
|
||||||
|
|
||||||
|
Early on in my vim journey I kept hitting the wrong keys because I was not sure what mode I was in. The same continued to happen in Emacs with Evil mode. Especially with many windows open, constantly changing modes and pages and tabs means constantly forgetting which mode I am in, and pressing the wrong keys, resulting in unwated consequences. I would start typing thinking I am in insert mode, and accidentally delete a few paragraphs! The time these mistakes waste is annoying, but the most frustrating part is the drailement of my train of thought.
|
||||||
|
|
||||||
|
Of course, there already exists visual indicator that tells us which mode is active. In Emacs, using evil mode introduces a little text inside the mode line that says what the current mode is (In vim such a thing exists too, of course). Even with these, I still found myself using the wrong mode often.
|
||||||
|
|
||||||
|
* A fix - highly visual indicators
|
||||||
|
|
||||||
|
Just because something is visible doesn't mean it is seen. I don't think this is a controversial take by any means - but it often isn't taken into account when designing user interfaces.
|
||||||
|
|
||||||
|
When using vim+tmux, one of the biggest problems was knowing which window I was in. I would start typing in one shell or window and realized that I completely trashed another window. My solution was to make the active buffer background brighter, so that at a glance it would be obvious which window was selected. Once moving to Emacs I had to settle for only changing the color of the mode line, along with using hl-line-mode. Either way - it worked. I was far less confused as to which window was receiving my inputs because of this journey.
|
||||||
|
|
||||||
|
I think my main guide is this - information that is necessary to act on at high frequency needs to be shown very visibly. There is a clear benefit to making the current state of a system not only understandable, but _clearly obvious_ at a _glance_.
|
||||||
|
|
||||||
|
Knowing this, it seemed only natural to fix the mode confusion with a very obvious indicator as to which mode I am in. I have been using some version of =evil-hl-line= for a while, and am very pleased with the results.
|
||||||
|
|
||||||
|
If you use evil mode in Emacs, I think you may find this approach helpful, and I hope you will find this package useful!
|
||||||
|
|
||||||
+4
-6
@@ -1,12 +1,10 @@
|
|||||||
#+TITLE: Aner Zakobar's Personal Waste of Bits
|
#+TITLE: Aner Zakobar's Personal Waste of Bits
|
||||||
#+DESCRIPTION: Personal website
|
#+DESCRIPTION: Personal website
|
||||||
|
|
||||||
* Welcome!
|
* Things
|
||||||
|
|
||||||
Welcome to my personal site. I'm Aner.
|
#+INCLUDE: generated/things-body.org
|
||||||
|
|
||||||
This is a place for writing, projects, and whatever else I find worth putting down.
|
* Blog
|
||||||
|
|
||||||
* Projects
|
#+INCLUDE: generated/all-posts.org
|
||||||
|
|
||||||
- [[file:about.org][About me]]
|
|
||||||
|
|||||||
Generated
+129
-1
@@ -1,5 +1,41 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"ar-globe-explorer": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778763816,
|
||||||
|
"narHash": "sha256-V1CZ3p0nEtPtp9gDBgaR3csBlD1rcQiNDlEma/iOjmE=",
|
||||||
|
"owner": "anerisgreat",
|
||||||
|
"repo": "ar-world-border-viewer",
|
||||||
|
"rev": "8e2ed6cd72fa69ccb690dfcefeddf11ef27b6762",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "anerisgreat",
|
||||||
|
"repo": "ar-world-border-viewer",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1696426674,
|
||||||
|
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
@@ -18,6 +54,24 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"flake-utils_2": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777918403,
|
"lastModified": 1777918403,
|
||||||
@@ -34,10 +88,47 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nixpkgs-python": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1733319315,
|
||||||
|
"narHash": "sha256-cFQBdRmtIZFVjr2P6NkaCOp7dddF93BC0CXBwFZFaN0=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "nixpkgs-python",
|
||||||
|
"rev": "01263eeb28c09f143d59cd6b0b7c4cc8478efd48",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "nixpkgs-python",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1719253556,
|
||||||
|
"narHash": "sha256-A/76RFUVxZ/7Y8+OMVL1Lc8LRhBxZ8ZE2bpMnvZ1VpY=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "fc07dc3bdf2956ddd64f24612ea7fc894933eb2e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-24.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"ar-globe-explorer": "ar-globe-explorer",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs",
|
||||||
|
"word-embedding-golf": "word-embedding-golf"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
@@ -54,6 +145,43 @@
|
|||||||
"repo": "default",
|
"repo": "default",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"systems_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"word-embedding-golf": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils_2",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"nixpkgs-python": "nixpkgs-python"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1779261342,
|
||||||
|
"narHash": "sha256-8eurlefKgVfgWBaU0UIvlX+sZyZjLNWwWr0rAQpwo9g=",
|
||||||
|
"owner": "anerisgreat",
|
||||||
|
"repo": "word-embedding-golf",
|
||||||
|
"rev": "b93ecc7fc2b0f00ab9ccab07ea7b1a3d2ef39983",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "anerisgreat",
|
||||||
|
"repo": "word-embedding-golf",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
@@ -5,15 +5,15 @@
|
|||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
|
||||||
# Sub-projects: uncomment and replace URLs when ready
|
word-embedding-golf = {
|
||||||
# project-a = {
|
url = "github:anerisgreat/word-embedding-golf";
|
||||||
# url = "path:/path/to/project-a";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
# inputs.nixpkgs.follows = "nixpkgs";
|
};
|
||||||
# };
|
|
||||||
# project-b = {
|
ar-globe-explorer = {
|
||||||
# url = "path:/path/to/project-b";
|
url = "github:anerisgreat/ar-world-border-viewer";
|
||||||
# inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
# };
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils, ... } @ inputs:
|
outputs = { self, nixpkgs, flake-utils, ... } @ inputs:
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
# Sub-project outputs — uncomment when inputs above are added
|
wordEmbeddingGolfPkg = inputs.word-embedding-golf.packages.${system}.default;
|
||||||
# subProjectA = inputs.project-a.packages.${system}.default;
|
|
||||||
# subProjectB = inputs.project-b.packages.${system}.default;
|
arGlobeExplorerPkg = inputs.ar-globe-explorer.packages.${system}.default;
|
||||||
|
|
||||||
site = pkgs.stdenv.mkDerivation {
|
site = pkgs.stdenv.mkDerivation {
|
||||||
name = "personal-site";
|
name = "personal-site";
|
||||||
@@ -40,9 +40,19 @@
|
|||||||
installPhase = ''
|
installPhase = ''
|
||||||
cp -r public/. $out/
|
cp -r public/. $out/
|
||||||
|
|
||||||
# Sub-project integration: when adding a sub-project, add lines like:
|
mkdir -p $out/word-embedding-golf
|
||||||
# mkdir -p $out/project-a
|
cp -r ${wordEmbeddingGolfPkg}/. $out/word-embedding-golf/
|
||||||
# cp -r <subProjectA-store-path>/. $out/project-a/
|
|
||||||
|
mkdir -p $out/ar-globe-explorer
|
||||||
|
cp -r ${arGlobeExplorerPkg}/. $out/ar-globe-explorer/
|
||||||
|
|
||||||
|
# Inject a "back to site" banner into all sub-project HTML pages
|
||||||
|
BANNER='<div id="back-banner" style="position:fixed;top:0;left:0;right:0;padding:.4rem 1rem;background:rgba(0,0,0,.82);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:9999;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:.82rem;border-bottom:1px solid rgba(255,255,255,.08);"><a href="/" style="color:#93c5fd;text-decoration:none;font-weight:500;">← aner.zakobar.com</a></div>'
|
||||||
|
for dir in $out/word-embedding-golf $out/ar-globe-explorer; do
|
||||||
|
find "$dir" -name "*.html" | while IFS= read -r f; do
|
||||||
|
sed -i -E "s|<body([^>]*)>|<body\1>$BANNER|" "$f"
|
||||||
|
done
|
||||||
|
done
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,7 +62,7 @@
|
|||||||
text = ''
|
text = ''
|
||||||
fuser -k 8080/tcp 2>/dev/null || true
|
fuser -k 8080/tcp 2>/dev/null || true
|
||||||
echo "Serving site at http://localhost:8080"
|
echo "Serving site at http://localhost:8080"
|
||||||
python -m http.server --bind 0.0.0.0 8080 -d ${site}
|
exec python -m http.server --bind 0.0.0.0 8080 -d ${site}
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,9 +105,78 @@
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
new-thing = pkgs.writeShellApplication {
|
||||||
|
name = "new-thing";
|
||||||
|
runtimeInputs = [ pkgs.coreutils pkgs.gnused ];
|
||||||
|
excludeShellChecks = [ "SC2001" ];
|
||||||
|
text = ''
|
||||||
|
if [ $# -lt 3 ]; then
|
||||||
|
echo "Usage: new-thing \"Title\" \"flake-url\" \"Short description\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
title="$1"
|
||||||
|
flake_url="$2"
|
||||||
|
desc="$3"
|
||||||
|
slug=$(echo "$title" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//;s/-$//')
|
||||||
|
camel=$(echo "$slug" | sed 's/-\([a-z]\)/\u\1/g')
|
||||||
|
filename="things/''${slug}.org"
|
||||||
|
mkdir -p things
|
||||||
|
cat > "$filename" <<EOF
|
||||||
|
#+THINGS_TITLE: $title
|
||||||
|
#+THINGS_DESC: $desc
|
||||||
|
#+THINGS_PATH: $slug
|
||||||
|
#+THINGS_FLAKE: $flake_url
|
||||||
|
EOF
|
||||||
|
echo "Created: $filename"
|
||||||
|
echo ""
|
||||||
|
echo "Add to flake.nix inputs:"
|
||||||
|
echo " ''${slug} = {"
|
||||||
|
echo " url = \"$flake_url\";"
|
||||||
|
echo " inputs.nixpkgs.follows = \"nixpkgs\";"
|
||||||
|
echo " };"
|
||||||
|
echo ""
|
||||||
|
echo "Add let binding before 'site =':"
|
||||||
|
echo " ''${camel}Pkg = inputs.''${slug}.packages.\''${system}.default;"
|
||||||
|
echo ""
|
||||||
|
echo "Add to installPhase after 'cp -r public/. \$out/':"
|
||||||
|
echo " mkdir -p \$out/''${slug}"
|
||||||
|
echo " cp -r \''${''${camel}Pkg}/. \$out/''${slug}/"
|
||||||
|
echo ""
|
||||||
|
echo "Then run: nix flake lock"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
new-post = pkgs.writeShellApplication {
|
||||||
|
name = "new-post";
|
||||||
|
runtimeInputs = [ pkgs.coreutils pkgs.gnused ];
|
||||||
|
text = ''
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "Usage: new-post \"Post Title\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
title="$*"
|
||||||
|
slug=$(echo "$title" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//;s/-$//')
|
||||||
|
date=$(date +%Y-%m-%d)
|
||||||
|
filename="content/blog/''${date}-''${slug}.org"
|
||||||
|
mkdir -p content/blog
|
||||||
|
cat > "$filename" <<EOF
|
||||||
|
#+TITLE: $title
|
||||||
|
#+DATE: $date
|
||||||
|
|
||||||
|
*
|
||||||
|
|
||||||
|
EOF
|
||||||
|
echo "Created: $filename"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
in {
|
in {
|
||||||
packages.default = site;
|
packages.default = site;
|
||||||
|
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = [ new-post new-thing pkgs.nodejs ];
|
||||||
|
};
|
||||||
|
|
||||||
apps = {
|
apps = {
|
||||||
default = {
|
default = {
|
||||||
type = "app";
|
type = "app";
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Conway's Game of Life background animation
|
||||||
|
// 100×100 toroidal grid, 1cm cells with 0.1cm gaps, tiled to fill the viewport.
|
||||||
|
// State persists in a cookie until midnight; cells fade in/out on state change.
|
||||||
|
|
||||||
|
const COOKIE_STATE = 'cgol_state';
|
||||||
|
const ROWS = 100;
|
||||||
|
const COLS = 100;
|
||||||
|
const CELL_CM = 1.0; // cell size in cm
|
||||||
|
const PAD_CM = 0.1; // gap between cells in cm
|
||||||
|
const FADE_SPEED = 4.0; // opacity units/sec → full transition in 0.25s
|
||||||
|
const STEP_MS = 1000; // game step interval in ms
|
||||||
|
const CELL_ALPHA = 0.03; // max cell opacity (keeps it a subtle background)
|
||||||
|
|
||||||
|
let canvas, ctx;
|
||||||
|
let cmPx = 38; // pixels per cm, measured at setup
|
||||||
|
let cellColor = '#2f4f4f';
|
||||||
|
let board = null; // Uint8Array ROWS*COLS — 0=dead, 1=alive
|
||||||
|
let opacity = null; // Float32Array ROWS*COLS — current fade value [0,1]
|
||||||
|
let animFrame = null;
|
||||||
|
let lastStepTime = 0;
|
||||||
|
let lastFrameTime = 0;
|
||||||
|
let initDateStr = ''; // date string when animation was last initialized
|
||||||
|
|
||||||
|
// ── Board helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function idx(r, c) { return r * COLS + c; }
|
||||||
|
|
||||||
|
function randomBoard() {
|
||||||
|
const b = new Uint8Array(ROWS * COLS);
|
||||||
|
for (let i = 0; i < b.length; i++) b[i] = Math.random() < 0.3 ? 1 : 0;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepConway(b) {
|
||||||
|
const next = new Uint8Array(ROWS * COLS);
|
||||||
|
for (let r = 0; r < ROWS; r++) {
|
||||||
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
let n = 0;
|
||||||
|
for (let dr = -1; dr <= 1; dr++) {
|
||||||
|
for (let dc = -1; dc <= 1; dc++) {
|
||||||
|
if (dr === 0 && dc === 0) continue;
|
||||||
|
n += b[idx((r + dr + ROWS) % ROWS, (c + dc + COLS) % COLS)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const alive = b[idx(r, c)];
|
||||||
|
next[idx(r, c)] = alive
|
||||||
|
? (n === 2 || n === 3 ? 1 : 0)
|
||||||
|
: (n === 3 ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Serialisation (bit-packed → base64url, ~1667 chars for 100×100) ────────
|
||||||
|
|
||||||
|
function serializeBoard(b) {
|
||||||
|
const bytes = new Uint8Array(Math.ceil(ROWS * COLS / 8));
|
||||||
|
for (let i = 0; i < ROWS * COLS; i++) {
|
||||||
|
if (b[i]) bytes[i >> 3] |= 1 << (i & 7);
|
||||||
|
}
|
||||||
|
let s = '';
|
||||||
|
for (const byte of bytes) s += String.fromCharCode(byte);
|
||||||
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializeBoard(b64) {
|
||||||
|
const s = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
const b = new Uint8Array(ROWS * COLS);
|
||||||
|
for (let i = 0; i < ROWS * COLS; i++) {
|
||||||
|
b[i] = (s.charCodeAt(i >> 3) >> (i & 7)) & 1;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cookie (expires at midnight tonight) ──────────────────────────────────
|
||||||
|
|
||||||
|
function saveToCookie() {
|
||||||
|
if (!board) return;
|
||||||
|
const midnight = new Date();
|
||||||
|
midnight.setHours(24, 0, 0, 0);
|
||||||
|
const data = encodeURIComponent(serializeBoard(board));
|
||||||
|
document.cookie =
|
||||||
|
`${COOKIE_STATE}=${data};expires=${midnight.toUTCString()};path=/;SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromCookie() {
|
||||||
|
const match = document.cookie.match(
|
||||||
|
new RegExp('(?:^|; )' + COOKIE_STATE + '=([^;]*)')
|
||||||
|
);
|
||||||
|
if (!match) return null;
|
||||||
|
try { return deserializeBoard(decodeURIComponent(match[1])); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lightning strike ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function maybeLightningStrike() {
|
||||||
|
if (Math.random() >= 0.001) return;
|
||||||
|
const r = Math.floor(Math.random() * ROWS);
|
||||||
|
const c = Math.floor(Math.random() * COLS);
|
||||||
|
const s = 4 + Math.floor(Math.random() * 7); // 4–10 inclusive
|
||||||
|
for (let dr = 0; dr < s; dr++) {
|
||||||
|
for (let dc = 0; dc < s; dc++) {
|
||||||
|
board[idx((r + dr) % ROWS, (c + dc) % COLS)] = Math.random() < 0.5 ? 1 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Midnight rollover ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function todayStr() {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDateRollover() {
|
||||||
|
if (todayStr() === initDateStr) return;
|
||||||
|
// New day — reinitialize with a fresh random board
|
||||||
|
initDateStr = todayStr();
|
||||||
|
board = randomBoard();
|
||||||
|
opacity = new Float32Array(ROWS * COLS); // all zeros → cells fade in
|
||||||
|
saveToCookie();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Setup ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function measureCmPx() {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.cssText = 'position:absolute;width:1cm;height:0;visibility:hidden';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
const px = el.offsetWidth || 38;
|
||||||
|
document.body.removeChild(el);
|
||||||
|
return px;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
cmPx = measureCmPx();
|
||||||
|
cellColor = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--fg').trim() || '#2f4f4f';
|
||||||
|
|
||||||
|
canvas = document.createElement('canvas');
|
||||||
|
canvas.id = 'bg-animation';
|
||||||
|
document.body.prepend(canvas);
|
||||||
|
ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
initDateStr = todayStr();
|
||||||
|
board = loadFromCookie() || randomBoard();
|
||||||
|
opacity = new Float32Array(ROWS * COLS);
|
||||||
|
// Initialise opacity to match loaded state (no fade-in on first load)
|
||||||
|
for (let i = 0; i < ROWS * COLS; i++) opacity[i] = board[i];
|
||||||
|
|
||||||
|
lastStepTime = performance.now();
|
||||||
|
lastFrameTime = performance.now();
|
||||||
|
animFrame = requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') checkDateRollover();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Animation loop ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function frame(ts) {
|
||||||
|
const dt = Math.min((ts - lastFrameTime) / 1000, 0.1); // cap delta at 100ms
|
||||||
|
lastFrameTime = ts;
|
||||||
|
|
||||||
|
if (ts - lastStepTime >= STEP_MS) {
|
||||||
|
checkDateRollover();
|
||||||
|
maybeLightningStrike();
|
||||||
|
board = stepConway(board);
|
||||||
|
lastStepTime = ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nudge each cell's opacity toward its target (0 or 1)
|
||||||
|
const fade = FADE_SPEED * dt;
|
||||||
|
for (let i = 0; i < ROWS * COLS; i++) {
|
||||||
|
if (board[i]) {
|
||||||
|
if (opacity[i] < 1) opacity[i] = Math.min(1, opacity[i] + fade);
|
||||||
|
} else {
|
||||||
|
if (opacity[i] > 0) opacity[i] = Math.max(0, opacity[i] - fade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
|
animFrame = requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const cellPx = cmPx * CELL_CM;
|
||||||
|
const stepPx = cmPx * (CELL_CM + PAD_CM);
|
||||||
|
const gridW = COLS * stepPx;
|
||||||
|
const gridH = ROWS * stepPx;
|
||||||
|
|
||||||
|
ctx.fillStyle = cellColor;
|
||||||
|
|
||||||
|
// Tile grid to fill canvas; start one full grid before origin to cover edges.
|
||||||
|
for (let tileY = -gridH; tileY < h; tileY += gridH) {
|
||||||
|
for (let tileX = -gridW; tileX < w; tileX += gridW) {
|
||||||
|
for (let r = 0; r < ROWS; r++) {
|
||||||
|
const y = tileY + r * stepPx;
|
||||||
|
if (y + cellPx < 0 || y > h) continue;
|
||||||
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
const op = opacity[idx(r, c)];
|
||||||
|
if (op < 0.005) continue;
|
||||||
|
const x = tileX + c * stepPx;
|
||||||
|
if (x + cellPx < 0 || x > w) continue;
|
||||||
|
ctx.globalAlpha = op * CELL_ALPHA;
|
||||||
|
ctx.fillRect(x, y, cellPx, cellPx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Teardown ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function teardown() {
|
||||||
|
saveToCookie();
|
||||||
|
cancelAnimationFrame(animFrame);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
if (canvas) canvas.remove();
|
||||||
|
canvas = ctx = board = opacity = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', setup);
|
||||||
|
window.addEventListener('pagehide', teardown);
|
||||||
+98
-12
@@ -22,6 +22,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Background animation canvas ────────────────────── */
|
||||||
|
#bg-animation {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Reset ───────────────────────────────────────────── */
|
/* ── Reset ───────────────────────────────────────────── */
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html { font-size: 16px; -webkit-text-size-adjust: 100%; }
|
html { font-size: 16px; -webkit-text-size-adjust: 100%; }
|
||||||
@@ -36,8 +48,7 @@ body {
|
|||||||
padding: 0 1.25rem;
|
padding: 0 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Layout — center preamble and content ────────────── */
|
/* ── Layout — center content ─────────────────────────── */
|
||||||
#preamble,
|
|
||||||
#content,
|
#content,
|
||||||
#postamble {
|
#postamble {
|
||||||
max-width: var(--max-width);
|
max-width: var(--max-width);
|
||||||
@@ -45,24 +56,36 @@ body {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Navigation ──────────────────────────────────────── */
|
/* ── Contact header ──────────────────────────────────── */
|
||||||
#preamble nav {
|
.site-contact {
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
padding: 1.25rem 0;
|
padding: 1.25rem 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#preamble nav a {
|
/* ── Blog back nav ───────────────────────────────────── */
|
||||||
color: var(--fg);
|
.blog-nav {
|
||||||
text-decoration: none;
|
max-width: var(--max-width);
|
||||||
font-weight: 500;
|
margin-left: auto;
|
||||||
font-size: 0.95rem;
|
margin-right: auto;
|
||||||
letter-spacing: 0.01em;
|
padding: 1.25rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#preamble nav a:hover { color: var(--accent); }
|
.back-link {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover { color: var(--accent); }
|
||||||
|
|
||||||
/* ── Content ─────────────────────────────────────────── */
|
/* ── Content ─────────────────────────────────────────── */
|
||||||
#content {
|
#content {
|
||||||
@@ -180,3 +203,66 @@ th {
|
|||||||
}
|
}
|
||||||
.tag { display: none; }
|
.tag { display: none; }
|
||||||
#table-of-contents { margin-bottom: 2rem; }
|
#table-of-contents { margin-bottom: 2rem; }
|
||||||
|
|
||||||
|
/* ── Contact links ───────────────────────────────────── */
|
||||||
|
.contact-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-link svg {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-link:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Things grid ─────────────────────────────────────── */
|
||||||
|
.things-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.things-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--code-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.things-card-body {
|
||||||
|
padding: 0.9rem 1rem 1rem;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.things-card-body h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.things-card-body h3 a {
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.things-card-body h3 a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.things-card-body p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#+THINGS_TITLE: AR Globe Explorer
|
||||||
|
#+THINGS_DESC: View real-world locations of other countries relative to your position - do you know where other countries REALLY are?
|
||||||
|
#+THINGS_PATH: ar-globe-explorer
|
||||||
|
#+THINGS_FLAKE: https://github.com/anerisgreat/ar-world-border-viewer
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#+THINGS_TITLE: Word Embedding Golf
|
||||||
|
#+THINGS_DESC: Learn about word embeddings in this tiny time waster!
|
||||||
|
#+THINGS_PATH: word-embedding-golf
|
||||||
|
#+THINGS_FLAKE: github:anerisgreat/word-embedding-golf
|
||||||
Reference in New Issue
Block a user