mirror of github:amycatgirl/amycatgirl.github.io
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Rewrite fallback script to common lisp (#3)

* wip: lay out skeleton for fallback script

Signed-off-by: Amy <amy+git@amogus.cloud>

* wip(fallback): implement DID resolution and git cmd wrappers

DID resolution works with both DID methods outlined in
https://atproto.com/specs/did#blessed-did-methods

The resolve-pds method allows more DID methods to be implemented when they are needed.

Signed-off-by: Amy <amy+git@amogus.cloud>

* wip: xrpc requests

Took a bit to lay out a good API, but it works well.
Next up is generating HTML elements & writing to the index.html page
inside the noscript block. I assume it's going to take a while to get
as good as possible, but things have been going smoothly so far, so I
don't think it will take me a long time.

Signed-off-by: Amy <amy+git@amogus.cloud>

* chore: get-in-plist macro

Convenience macro that automatically creates getf chains for accessing
deep nested plists.

Signed-off-by: Amy <amy+git@amogus.cloud>

* chore: remove debug logging for resolve-pds method

Signed-off-by: Amy <amy+git@amogus.cloud>

* chore: fill in values related to configuration options

Signed-off-by: Amy <amy+git@amogus.cloud>

* chore: update Depends on section in script information

Signed-off-by: Amy <amy+git@amogus.cloud>

* feat: generate html using spinneret

Datetime parsing is done by local-time. Last thing to do is to safely
replace the noscript block in the index.html file.

Emacs made this easy because it works with buffers directly, so
modifying files was trivial. However, this is not the case with common lisp.

Signed-off-by: Amy <amy+git@amogus.cloud>

* chore: make get-in macro generic, make html gen more robust

Also removes the publish function as this script will just run once
everything has been evaluated.

Signed-off-by: Amy <amy+git@amogus.cloud>

* feat: fallback generation script written in common lisp

The script has a few dependencies (namely, quicklisp) that need to be
handled in a CI environment but otherwise it's pretty lightweight and
removes the emacs dependency.

closes #1 and partialy solves #2

Signed-off-by: Amy <amy+git@amogus.cloud>

* fix: git-push not working due to malformed expression

Signed-off-by: Amy <amy+git@amogus.cloud>

* chore: gracefully handle git commit-ing

Signed-off-by: Amy <amy+git@amogus.cloud>

* chore: replace unwind-protect with handler-case

also return exit code 1 on fail!

Signed-off-by: Amy <amy+git@amogus.cloud>

* feat: support multiple publications

Now leaflet and offprint can co-exist together!

Signed-off-by: Amy <amy+git@amogus.cloud>

* feat(js): support multiple standard.site publications

Both sides are hardcoded to have as much control as I could possibly
get, just because I don't feel like making a second request to poll
publications is worth it.

Signed-off-by: Amy <amy+git@amogus.cloud>

---------

Signed-off-by: Amy <amy+git@amogus.cloud>

authored by

Amy and committed by
GitHub
69de513a 4980f6cf

+243 -73
+167
fallback.lisp
··· 1 + ;; amycatgirl.github.io no-js fallback 2 + ;; Last authored: Thu 19 Mar 18:41:03 AST 2026 3 + ;; Depends on: drakma, yason, uiop, cl-ppcre, trivia, alexandria, spinneret, local-time 4 + 5 + (ql:quickload '(drakma yason uiop cl-ppcre trivia alexandria spinneret local-time)) 6 + 7 + (defconstant +user-did+ "did:plc:gijpvbkdbr56kazbdjhfvb3d" 8 + "DID of the user to fetch entries from") 9 + 10 + (defconstant +viewers+ '(("3mi2fpvnluk2b" . "https://amybunny.offprint.app~A") 11 + ("3mbpuyp4pqk2j" . "https://amybunny.leaflet.pub/~A")) 12 + "An alist of publication keys -> URLs. 13 + ~A is passed to `format' with the `path' of the record.") 14 + 15 + (defconstant +max-entries+ 5 16 + "Maximum amount of entries fetched.") 17 + 18 + ;; utilities 19 + (defmacro run-command (command) 20 + `(uiop:run-program ,command :output '(:string :stripped t))) 21 + 22 + (defun keywordize (name) 23 + (intern (string-upcase name) "KEYWORD")) 24 + 25 + (defmacro get-in (place indicators &key (accessor-fn 'getf)) 26 + (let ((rev-indicators (reverse indicators))) 27 + (labels ((construct (y ys) (cond 28 + ((null ys) (list accessor-fn place y)) 29 + (t (list accessor-fn (construct (car ys) (cdr ys)) y))))) 30 + (construct (car rev-indicators) (cdr rev-indicators))))) 31 + 32 + ;; Git 33 + (defun git-add (&rest files) 34 + (run-command `("git" "add" ,@files))) 35 + 36 + (defun git-commit (message) 37 + (run-command `("git" "commit" "-m" ,(concatenate 'string "[fallback-gen] " message)))) 38 + 39 + (defun git-push () 40 + (run-command '("git" "push"))) 41 + ;; HTTP 42 + (defun build-query-params-from-plist (parameters) 43 + "Build a string of URL-encoded query parameters from a plist." 44 + ;; HACK: ~: consumes the argument, so we need to pass it twice for iteration to work 45 + (let ((params-cleaned (mapcar #'(lambda (value) 46 + (if (keywordp value) 47 + (string-downcase (symbol-name value)) 48 + value)) 49 + parameters))) 50 + (format nil "~:[~;?~{~(~A~)=~A~^&~}~]" parameters params-cleaned))) 51 + 52 + (defun make-request (where) 53 + (let ((stream (drakma:http-request where 54 + :want-stream t 55 + :user-agent " amycatgirl.github.io/1.0"))) 56 + (setf (flexi-streams:flexi-stream-external-format stream) :utf-8) 57 + (yason:parse stream :object-as :plist :object-key-fn #'keywordize))) 58 + 59 + ;; ATProto 60 + ;;; DID 61 + (defun resolve-did-document--plc (did) 62 + (make-request (format nil "https://plc.directory/~a" did))) 63 + 64 + (defun resolve-did-document--web (did) 65 + "Resolve a DID document via DID:WEB method. 66 + See https://w3c-ccg.github.io/did-method-web/ for specification details." 67 + (let* ((document-location (subseq did (length "did:web:"))) 68 + (qualified-path (concatenate 'string "https://" document-location "/.well-known/did.json")) 69 + (document (make-request qualified-path))) 70 + (if (equal (getf document :id) did) 71 + document 72 + (error "Could not resolve document from did:web ~S" did)))) 73 + 74 + (defun get-did-method (did) 75 + (cl-ppcre:register-groups-bind (method) ("^did:([a-z]+):[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$" did) 76 + method)) 77 + 78 + (defun resolve-pds (did) 79 + "Resolve a PDS by DID." 80 + (let* ((did-method (get-did-method did)) 81 + (document (trivia:match did-method 82 + ("web" (resolve-did-document--web did)) 83 + ("plc" (resolve-did-document--plc did)) 84 + (_ (error "Unsuported DID method ~S" did-method)))) 85 + (services (getf document :service))) 86 + (getf (find-if (lambda (service) 87 + (equal (getf service :id) "#atproto_pds")) 88 + services) 89 + :serviceEndpoint))) 90 + 91 + ;;; PDS 92 + (defun make-pds-request (pds method &rest query) 93 + "Make a unauthenticated XRPC call to the PDS, where `method' is the NSID of the endpoint to call. 94 + Aditional query parameters in the request must be passed inside of `query', where key-value pairs are denoted by keywords and values." 95 + (let ((url (format nil "~A/xrpc/~A~A" pds method (build-query-params-from-plist query)))) 96 + (make-request url))) 97 + 98 + (defun fetch-entries (did &optional (maximum 5)) 99 + (let* ((pds (resolve-pds did))) 100 + (make-pds-request pds "com.atproto.repo.listRecords" 101 + :repo did 102 + :collection "site.standard.document" 103 + :limit maximum))) 104 + 105 + ;; Standard.site 106 + (defun get-publication-rkey (site) 107 + (car (last (uiop:split-string 108 + site :separator '(#\/))))) 109 + 110 + ;; Generator 111 + (defun published-at-element (date) 112 + (let ((time (local-time:parse-timestring date))) 113 + (spinneret:with-html 114 + (:time.published-at :attrs (list :datetime date) 115 + (local-time:format-timestring nil time :format '((:day 2) "/" (:month 2) "/" :year ", " 116 + (:hour 2) #\: (:min 2))))))) 117 + 118 + (defun entry-element (entry) 119 + (let ((title (get-in entry (:value :title))) 120 + (description (or (get-in entry (:value :description)) "")) 121 + (date (get-in entry (:value :publishedat))) 122 + (link (format nil (cdr (assoc (get-publication-rkey (get-in entry (:value :site))) 123 + +viewers+ :test 'equal)) 124 + (get-in entry (:value :path)))) 125 + (spinneret:*html-style* :tree)) 126 + (spinneret:with-html-string 127 + (:div.entry 128 + (:a.base-anchor :href link 129 + (:h3.title title)) 130 + (:p.description description) 131 + (:div.metadata 132 + (published-at-element date)))))) 133 + 134 + (defun generate-html (entries) 135 + (format nil "~%~{~A~^~%~}~%" (mapcar #'entry-element entries))) 136 + 137 + (defun read-file-to-string (path) 138 + (with-open-file (stream path :direction :input :if-does-not-exist :error) 139 + (let ((buf (make-string (file-length stream)))) 140 + (read-sequence buf stream) 141 + buf))) 142 + 143 + (defun write-to-noscript-block (page entries) 144 + (let* ((fragment (generate-html entries)) 145 + (file-content (read-file-to-string page)) 146 + (start-pos (or (search "<noscript>" file-content) 147 + (error "Could not find opening tag in ~A" page))) 148 + (end-pos (or (search "</noscript>" file-content) 149 + (error "Could not find closing tag in ~A" page))) 150 + (insert-start (+ start-pos (length "<noscript>"))) 151 + (before (subseq file-content 0 insert-start)) 152 + (after (subseq file-content end-pos))) 153 + (with-open-file (file page :direction :output :if-exists :supersede) 154 + (write-string (concatenate 'string before fragment after) file)))) 155 + 156 + (defun publish () 157 + (let ((records (getf (fetch-entries +user-did+ 158 + +max-entries+) :records))) 159 + (write-to-noscript-block #p"./index.html" 160 + records)) 161 + (handler-case 162 + (progn (git-add "index.html") 163 + (git-commit "generate noscript fallback") 164 + (git-push)) 165 + (t () 166 + (format t "Either:~%- There were no changes between runs~%- Creating a commit failed for some reason (check git status)~%- Or the current branch doesn't have an upstream~2%Please try running the publishing steps manually or run the fallback script again") 167 + (uiop:quit 1))))
+76 -73
index.html
··· 34 34 <li><span class="handle">@amybunny.01</span> on signal</li> 35 35 </ul> 36 36 37 - <p>i also have a blog on <a href="https://amybunny.leaflet.pub">leaflet</a>, here are it's latest entries:</p> 37 + <p>i also have a blog on <a href="https://amybunny.leaflet.pub">leaflet</a> and <a href="https://amybunny.offprint.app">offprint</a>, here are it's latest entries:</p> 38 38 <div id="atproto-leaflet"> 39 39 <noscript><div class="entry"> 40 40 <a class="base-anchor" href="https://amybunny.leaflet.pub/3md2f4slp5c23"> ··· 205 205 </template> 206 206 207 207 <script type="module"> 208 - // First test if this browser supports templates 209 - if (!"content" in document.createElement("template")) { 210 - throw "This browser does not support HTML Template API, please update or change your browser." 211 - } 208 + // First test if this browser supports templates 209 + if (!"content" in document.createElement("template")) { 210 + throw "This browser does not support HTML Template API, please update or change your browser." 211 + } 212 + 213 + // Configuration options 214 + const PUBLICATION_MAP = { 215 + "3mi2fpvnluk2b": "https://amybunny.offprint.app", 216 + "3mbpuyp4pqk2j": "https://amybunny.leaflet.pub/" 217 + }; 218 + const COLLECTION_NSIDS = ["site.standard.document"]; 219 + const USER_DID = "did:plc:gijpvbkdbr56kazbdjhfvb3d"; 220 + const USER_PDS = "https://rose.madebydanny.uk"; 221 + const MAX_LATEST_POSTS = 5; 222 + 223 + // Definitions 224 + /** @type {HTMLTemplateElement} */ 225 + const ENTRY_TEMPLATE = document.getElementById("leaflet-entry") 226 + 227 + const DATE_FORMATTER = new Intl.DateTimeFormat("en-GB", { 228 + timeStyle: "short", 229 + dateStyle: "short" 230 + }) 231 + 232 + /** 233 + * @param {{ title: string, description: string, url: string, date: Date }} leaflet_entry 234 + * @returns {HTMLDivElement} 235 + */ 236 + function buildEntry(leaflet_entry) { 237 + const entry = document.importNode(ENTRY_TEMPLATE.content, true) 238 + entry.querySelector(".base-anchor").href = leaflet_entry.url; 239 + entry.querySelector(".title").textContent = leaflet_entry.title; 240 + const date_el = entry.querySelector(".published-at"); 241 + date_el.datetime = leaflet_entry.date.toISOString(); 242 + date_el.textContent = DATE_FORMATTER.format(leaflet_entry.date); 243 + entry.querySelector(".description").textContent = leaflet_entry.description; 244 + 245 + return entry; 246 + } 247 + 248 + /** 249 + * @param {string} nsid 250 + * @returns {Promise<object>} 251 + */ 252 + async function getLatestPosts(nsid) { 253 + const { records } = await (await fetch(`${USER_PDS}/xrpc/com.atproto.repo.listRecords?repo=${USER_DID}&collection=${nsid}&limit=${MAX_LATEST_POSTS}`)).json() 254 + 255 + return records.toReversed() 256 + } 257 + 258 + // HTML 259 + const leaflet_container = document.getElementById("atproto-leaflet"); 260 + 261 + const posts = (await Promise.all(COLLECTION_NSIDS.map(async (nsid) => await getLatestPosts(nsid)))) 262 + .flat(1) 263 + .sort((entry_a, entry_b) => { 264 + const date_a = new Date(entry_a.value.publishedAt) 265 + const date_b = new Date(entry_b.value.publishedAt) 266 + 267 + return date_b - date_a 268 + }); 269 + 270 + for (const document of posts) { 271 + const { path, site, title, description, publication, publishedAt } = document.value; 272 + const publication_rkey = site.split("/").at(-1); 273 + const url_start = PUBLICATION_MAP[publication_rkey]; 212 274 213 - // Configuration options 214 - const COLLECTION_NSIDS = ["site.standard.document"]; 215 - const USER_DID = "did:plc:gijpvbkdbr56kazbdjhfvb3d"; 216 - const USER_PDS = "https://rose.madebydanny.uk"; 217 - const MAX_LATEST_POSTS = 5; 218 - 219 - // Definitions 220 - /** @type {HTMLTemplateElement} */ 221 - const ENTRY_TEMPLATE = document.getElementById("leaflet-entry") 222 - 223 - const DATE_FORMATTER = new Intl.DateTimeFormat("en-GB", { 224 - timeStyle: "short", 225 - dateStyle: "short" 226 - }) 227 - 228 - /** 229 - * @param {{ title: string, description: string, url: string, date: Date }} leaflet_entry 230 - * @returns {HTMLDivElement} 231 - */ 232 - function buildEntry(leaflet_entry) { 233 - const entry = document.importNode(ENTRY_TEMPLATE.content, true) 234 - entry.querySelector(".base-anchor").href = leaflet_entry.url; 235 - entry.querySelector(".title").textContent = leaflet_entry.title; 236 - const date_el = entry.querySelector(".published-at"); 237 - date_el.datetime = leaflet_entry.date.toISOString(); 238 - date_el.textContent = DATE_FORMATTER.format(leaflet_entry.date); 239 - entry.querySelector(".description").textContent = leaflet_entry.description; 240 - 241 - return entry; 242 - } 243 - 244 - /** 245 - * @param {string} nsid 246 - * @returns {Promise<object>} 247 - */ 248 - async function getLatestPosts(nsid) { 249 - const { records } = await (await fetch(`${USER_PDS}/xrpc/com.atproto.repo.listRecords?repo=${USER_DID}&collection=${nsid}&limit=${MAX_LATEST_POSTS}`)).json() 250 - 251 - return records.toReversed() 252 - } 253 - 254 - // HTML 255 - const leaflet_container = document.getElementById("atproto-leaflet"); 256 - 257 - const posts = (await Promise.all(COLLECTION_NSIDS.map(async (nsid) => await getLatestPosts(nsid)))) 258 - .flat(1) 259 - .sort((entry_a, entry_b) => { 260 - const date_a = new Date(entry_a.value.publishedAt) 261 - const date_b = new Date(entry_b.value.publishedAt) 262 - 263 - console.log(date_a) 264 - console.log(date_b) 265 - 266 - return date_b - date_a 267 - }); 268 - 269 - for (const document of posts) { 270 - const { title, description, publication, publishedAt } = document.value; 271 - 272 - // TODO: make platform agnostic 273 - leaflet_container.append(buildEntry({ 274 - title: document.value.title, 275 - description: document.value.description, 276 - url: `https://amybunny.leaflet.pub/${document.uri.split("/").at(-1)}`, 277 - date: new Date(publishedAt) 278 - })) 279 - } 280 - 275 + 276 + // TODO: make platform agnostic 277 + leaflet_container.append(buildEntry({ 278 + title: document.value.title, 279 + description: document.value.description, 280 + url: url_start + path, 281 + date: new Date(publishedAt) 282 + })) 283 + } 281 284 </script> 282 285 </body> 283 286