Compare commits

..

18 Commits

Author SHA1 Message Date
aner 7ce011acb5 RSS
Build and Deploy / deploy (push) Failing after 1h16m10s
2026-06-12 01:59:24 +03:00
aner 864962a160 Alright!
Build and Deploy / deploy (push) Successful in 1h53m42s
2026-06-07 01:06:06 +03:00
aner aa9a9c75f8 Wrangle it up - wrangler directly in an attempt to work.
Build and Deploy / deploy (push) Successful in 1h14m49s
2026-05-20 10:56:33 +03:00
aner 4325641879 In an attempt to get deployment errors
Build and Deploy / deploy (push) Failing after 36s
2026-05-20 10:50:42 +03:00
aner da61b59feb So we can see errors in the deployment:wq
Build and Deploy / deploy (push) Failing after 51s
2026-05-20 10:47:45 +03:00
aner a3bf9f9064 Bumped word embedding golf
Build and Deploy / deploy (push) Failing after 20m42s
2026-05-20 10:18:30 +03:00
aner 81ed81e78a Better workflow
Build and Deploy / deploy (push) Failing after 1m52s
2026-05-20 09:50:26 +03:00
aner 818d253a07 Getting ready for CI/CD
Build and Deploy / deploy (push) Failing after 32s
2026-05-20 09:44:49 +03:00
aner 3b98043a61 Lots of updates - restructured, added things... 2026-05-14 20:38:40 +03:00
aner 74ada1c5cf Better contact, grammar changes, etc. 2026-05-13 15:30:58 +03:00
aner 65d41b148c More edits. 2026-05-13 09:38:27 +03:00
aner e219ad2235 Rewrote a bit. 2026-05-13 09:29:09 +03:00
aner 9500d484c2 envrc, gitignore updated and new blog post 2026-05-13 00:00:57 +03:00
aner 8995493730 Wrote blog entry further. 2026-05-12 23:44:54 +03:00
aner 38f2687983 Midnight rollover, and add 1/1000 chance for lightning to strike. 2026-05-08 22:38:18 +03:00
aner 52edac3c7d Auto blog post addition. 2026-05-08 22:31:33 +03:00
aner 84330b489a More content! 2026-05-08 01:09:26 +03:00
aner 8190aa15bf CGL background works! 2026-05-07 23:49:38 +03:00
15 changed files with 945 additions and 49 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+23
View File
@@ -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
+2
View File
@@ -3,3 +3,5 @@ public/
.emacs.d/
result
result-*
.direnv
.wrangler
+50 -1
View File
@@ -95,5 +95,54 @@ nix flake update
* Deployment
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.
** 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.
+204 -7
View File
@@ -10,22 +10,219 @@
org-html-head-include-scripts nil)
(setq site-nav
"<nav>
<a href=\"/\">Home</a>
<a href=\"/about.html\">About</a>
</nav>")
"<div class=\"site-contact\">
<a href=\"mailto:aner@zakobar.com\" 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\"><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>
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 "&" "&amp;" s))
(setq s (replace-regexp-in-string "<" "&lt;" s))
(setq s (replace-regexp-in-string ">" "&gt;" 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
`(("site-pages"
:base-directory "./content"
:base-extension "org"
:publishing-directory "./public"
: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
:html-head-include-default-style nil
:html-head-include-scripts nil
:html-head "<link rel=\"stylesheet\" href=\"/style.css\">"
:html-preamble ,site-nav
: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 ,blog-nav
:html-postamble nil
:with-author nil
:with-creator nil
@@ -41,6 +238,6 @@
:publishing-function org-publish-attachment)
("site"
:components ("site-pages" "site-static"))))
:components ("site-pages" "site-blog" "site-static"))))
(org-publish "site" t)
-6
View File
@@ -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 linehighlight 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
View File
@@ -1,12 +1,10 @@
#+TITLE: Aner Zakobar's Personal Waste of Bits
#+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
- [[file:about.org][About me]]
#+INCLUDE: generated/all-posts.org
Generated
+129 -1
View File
@@ -1,5 +1,41 @@
{
"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": {
"inputs": {
"systems": "systems"
@@ -18,6 +54,24 @@
"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": {
"locked": {
"lastModified": 1777918403,
@@ -34,10 +88,47 @@
"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": {
"inputs": {
"ar-globe-explorer": "ar-globe-explorer",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"word-embedding-golf": "word-embedding-golf"
}
},
"systems": {
@@ -54,6 +145,43 @@
"repo": "default",
"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",
+95 -16
View File
@@ -5,15 +5,15 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
# Sub-projects: uncomment and replace URLs when ready
# project-a = {
# url = "path:/path/to/project-a";
# inputs.nixpkgs.follows = "nixpkgs";
# };
# project-b = {
# url = "path:/path/to/project-b";
# inputs.nixpkgs.follows = "nixpkgs";
# };
word-embedding-golf = {
url = "github:anerisgreat/word-embedding-golf";
inputs.nixpkgs.follows = "nixpkgs";
};
ar-globe-explorer = {
url = "github:anerisgreat/ar-world-border-viewer";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, ... } @ inputs:
@@ -21,9 +21,9 @@
let
pkgs = import nixpkgs { inherit system; };
# Sub-project outputs — uncomment when inputs above are added
# subProjectA = inputs.project-a.packages.${system}.default;
# subProjectB = inputs.project-b.packages.${system}.default;
wordEmbeddingGolfPkg = inputs.word-embedding-golf.packages.${system}.default;
arGlobeExplorerPkg = inputs.ar-globe-explorer.packages.${system}.default;
site = pkgs.stdenv.mkDerivation {
name = "personal-site";
@@ -40,9 +40,19 @@
installPhase = ''
cp -r public/. $out/
# Sub-project integration: when adding a sub-project, add lines like:
# mkdir -p $out/project-a
# cp -r <subProjectA-store-path>/. $out/project-a/
mkdir -p $out/word-embedding-golf
cp -r ${wordEmbeddingGolfPkg}/. $out/word-embedding-golf/
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 = ''
fuser -k 8080/tcp 2>/dev/null || true
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 {
packages.default = site;
devShells.default = pkgs.mkShell {
packages = [ new-post new-thing pkgs.nodejs ];
};
apps = {
default = {
type = "app";
+243
View File
@@ -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); // 410 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
View File
@@ -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 ───────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; -webkit-text-size-adjust: 100%; }
@@ -36,8 +48,7 @@ body {
padding: 0 1.25rem;
}
/* ── Layout — center preamble and content ────────────── */
#preamble,
/* ── Layout — center content ─────────────────────────── */
#content,
#postamble {
max-width: var(--max-width);
@@ -45,24 +56,36 @@ body {
margin-right: auto;
}
/* ── Navigation ──────────────────────────────────────── */
#preamble nav {
/* ── Contact header ──────────────────────────────────── */
.site-contact {
max-width: var(--max-width);
margin-left: auto;
margin-right: auto;
display: flex;
gap: 1.5rem;
justify-content: space-between;
align-items: center;
padding: 1.25rem 0;
border-bottom: 1px solid var(--border);
margin-bottom: 2.5rem;
}
#preamble nav a {
color: var(--fg);
text-decoration: none;
font-weight: 500;
font-size: 0.95rem;
letter-spacing: 0.01em;
/* ── Blog back nav ───────────────────────────────────── */
.blog-nav {
max-width: var(--max-width);
margin-left: auto;
margin-right: auto;
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 {
@@ -180,3 +203,66 @@ th {
}
.tag { display: none; }
#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;
}
+4
View File
@@ -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
+4
View File
@@ -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