<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
	<channel>
		<title>Jesse.ID</title>
		<description>Professional programmer with creative aspirations. Software engineer, writer, musician, and screen craft enthusiast who loves tinkering with code and making computers do stuff.</description>
		<link>https://jesse.id/blog</link>
		<atom:link href="https://jesse.id/blog/feed.xml" rel="self" type="application/rss+xml"/>
		
		<item>
			<title>Defining My Own Reality</title>
			<description>What if I just kind of decided what all of this is for myself?</description>
			<link>https://jesse.id/blog/posts/defining-my-own-reality</link>
			<guid isPermaLink="true">https://jesse.id/blog/posts/defining-my-own-reality</guid>
			<pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate>
			<content:encoded><![CDATA[<!--[--><p>It appears as though everyone in the world is just eschewing facts of physics and deciding what their own realities entail, which honestly seems nice, so I thought it would be fun to hop on that train. I’ve actually written about some of these ideas in other blog posts but not in the digestible form that I will present here. I will state wildly disprovable ideas as absolute facts because who cares?</p> <ol><li><p>I am the only inhabitant of my Universe. There are no aliens. There are only inter-connected human consciousnesses. I am connected to a central server and you are also connected to it from your own Universe. Our reality is a projection from that server and we are all overlaid on top of it with our own private spaces and shared public spaces. The location of a leaf you kick next to a McDonalds is stored on the server as a temporary variable that my mind will access if I also encounter the leaf.</p></li> <li><p>In my Universe, every other human being is an AI-powered model of their own consciousness from their own Universes. In your Universe, I am a hologram like you are in mine.</p></li> <li><p>All of our models are updated when we sleep. When I sleep, I upload my own consciousness to a central repository, which is not a big lift, but I also have to download the delta from the last time I slept to my current sleeping session for of all other consciousnesses so that your holograms remain accurate in my world. That can take awhile. When I don’t sleep, shit gets weird because the models are slowly desynchronizing from a central time series, and I will eventually die without sleep because it becomes impossible to catch up if that delta of elapsed time between sleep sessions widens too much.</p></li> <li><p>The Mandela Effect is an artifact of mass sleep deprivation caused by distressing mass casualty events, and the ensuing merger of corrupted data into our central time series when we all become exhausted enough to sleep. It’s a distributed denial of service attack and some of our memories don’t transfer correctly. Rather than killing us all, a service daemon tries to backfill the data when we awaken, and then it gets synced in the next time we sleep, but it’s an imperfect scaffolding, like how humans can read garbled text if the first and last letter of the words are correct.</p></li> <li><p>A macro-view of the Universe looks like a neural network because it is a neural network. It’s consciousness.</p></li> <li><p>My brain does not contain my consciousness or my memories. It is identical to everyone else’s brain. It’s an organ, like my other organs, that has a job to do and nothing more. It runs the human operating system and it processes input.</p></li> <li><p>My consciousness is air gapped, meaning that you can only interact with what I communicate from within it myself; it is encrypted to keep other intelligent beings from intruding via technologically evolved means and the seed phrase is based on the unique properties my Universe, which mostly exist within its dark matter. You can read the result of my decisions, like my eye movements, but you can never read the intent from within. Mind control is also impossible and so is recording our dreams to an external storage medium.</p></li> <li><p>The matter that we can see — our shared view of stellar objects like stars — are indices in a database structure that we all share. Black holes are utilities that convert matter to dark matter, compresses it, and transfer it into the appropriate data lake for recall later.</p></li> <li><p>Super intelligence is impossible because the point of our shared reality is to multiply so that we can create as many unique problems as possible, solve those problems, and create a global database of solutions. The smarter we are, the fewer problems we have, and that is not the goal. Average human intelligence is the sweet spot for our species. Utopian and apocalyptic scenarios are equally bad. Humanity will always locate and prefer the median.</p></li> <li><p>I am predisposed to staying inside because it’s less resource intensive than constantly redrawing the world outside. Your hologram in my Universe may be an extrovert to keep up the illusion that a world bustles outside of my front door at all times, but in your own Universe, you are also an introvert for the same reason. It’s an instinct like staying out of a burning building.</p></li> <li><p>Human beings will invariably age and die. Entropy is a law of evolution and evolution is the engine of reality. My time is the only time that I have and it is limited by an invisible ceiling that I will eventually get mushed by. I am not special. It doesn’t matter what I do with my time. It only matters that I do what I can to stay alive, I swirl variables around like wine, and then I die.</p></li></ol><!--]-->]]></content:encoded>
		</item>
		<item>
			<title>Using varlock to pull secrets from 1Password at runtime</title>
			<description>No longer worry about having plaintext secrets floating around in your filesystem.</description>
			<link>https://jesse.id/blog/posts/using-varlock-to-pull-secrets-from-1password-at-runtime</link>
			<guid isPermaLink="true">https://jesse.id/blog/posts/using-varlock-to-pull-secrets-from-1password-at-runtime</guid>
			<pubDate>Thu, 12 Mar 2026 00:00:00 GMT</pubDate>
			<content:encoded><![CDATA[<!--[--><p>TL;DR: <a href="https://github.com/jesse-id/varlock-node-example" rel="nofollow">https://github.com/jesse-id/varlock-node-example</a></p> <h1>What is varlock?</h1> <p>I was listening to an episode of <a href="https://syntax.fm" rel="nofollow">Syntax.fm</a> yesterday (one of my favorite podcasts) called ”<a href="https://syntax.fm/show/985/stop-putting-secrets-in-env" rel="nofollow">Stop putting secrets in .env</a>”. This has been a longtime pain point for me, being an infinitely lazy — but also a security-minded — type of nerd. I’ve relied on self confidence in the past, assuming that my filesystem is protected because I’m not flippant with my own security practices. It still weirds me out to have plaintext secrets in .env files on my filesystem, nevertheless.</p> <p><a href="https://github.com/philmillman" rel="nofollow">Phil Miller</a> and <a href="https://github.com/theoephraim" rel="nofollow">Theo Ephraim</a> were on the show to talk about their product <a href="https://varlock.dev/" rel="nofollow">varlock</a>, which I hadn’t heard of before. It didn’t take much convincing after they briefly explained what it does because as it turns out, varlock does exactly what I want it to do.</p> <p>It allows me to place 1Password secret references inside of my <code>.env.local</code> files instead of plaintext secrets, and then it injects them at runtime. Also, as a <a href="https://claude.com/product/claude-code" rel="nofollow">claude code</a> evangelist, it adds a layer of protection against agents recording my secrets, which I hadn’t really thought of as a problem but yes, it totally is a big problem.</p> <h1>Is it easy?</h1> <p>I fumbled around a bit but it wasn’t too painful. The <a href="https://varlock.dev/getting-started/introduction/" rel="nofollow">Getting Started</a> docs are not as hand-holdy as I’d like them to be, but this is a young project and this thing can handle some pretty complicated use cases, so I get it.</p> <p>Most of my struggles were centered on the front matter/headers in the <code>.env.schema</code> and <code>.env.local</code> files. They’re not really covered at all in the <code>Getting Started</code> section, so I was kind of flying blind and relying on experience and iterative debugging. The major sticking point was the 1Password plugin example.</p> <p><code>brew install dmno-dev/tap/varlock &amp;&amp; varlock init</code> gets you most of the way there, but adding/using the 1Password plugin is not fleshed out very well in the docs, in my opinion.</p> <p>The <a href="https://varlock.dev/guides/plugins/" rel="nofollow">install docs</a> shows the following example:</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="IyBAcGx1Z2luKEB2YXJsb2NrLzFwYXNzd29yZC1wbHVnaW4pICMgbG9hZCArIGluc3RhbGwgcGx1Z2luCiMgQGluaXRPcCh0b2tlbj0kT1BfVE9LRU4sIGFsbG93QXBwQXV0aD10cnVlKSAjIGluaXQgdmlhIGN1c3RvbSByb290IGRlY29yYXRvcgojIC0tLQojIEB0eXBlPW9wU2VydmljZUFjY291bnRUb2tlbiAjIGN1c3RvbSBkYXRhIHR5cGUKT1BfVE9LRU49CiMgQHNlbnNpdGl2ZQpYWVpfQVBJX0tFWT1vcChvcDovL2FwaS1wcm9kL3h5ei9hcGkta2V5KSAjIGN1c3RvbSByZXNvbHZlciBmdW5jdGlvbg==" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-bash"><!----><span class="hljs-comment"># @plugin(@varlock/1password-plugin) # load + install plugin</span>
<span class="hljs-comment"># @initOp(token=$OP_TOKEN, allowAppAuth=true) # init via custom root decorator</span>
<span class="hljs-comment"># ---</span>
<span class="hljs-comment"># @type=opServiceAccountToken # custom data type</span>
OP_TOKEN=
<span class="hljs-comment"># @sensitive</span>
XYZ_API_KEY=op(op://api-prod/xyz/api-key) <span class="hljs-comment"># custom resolver function</span><!----></code></pre></div> <p>They don’t mention that $OP_TOKEN and the corresponding OP_TOKEN environment variable are meant for a remote 1Password server, which doesn’t make a ton of sense as the default use case in docs, because I would assume the problem that varlock solves is mostly a problem for local development? So I got tripped up debugging that for a bit. It’s possible that the explanation exists somewhere in the docs, but I am Captain Skimmer of the SS ADHD and ain’t nobody got time for that.</p> <p><code>.env.local</code></p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="IyBAcGx1Z2luKEB2YXJsb2NrLzFwYXNzd29yZC1wbHVnaW4pCiMgQGluaXRPcChhbGxvd0FwcEF1dGg9dHJ1ZSkKIyBAZGVmYXVsdFJlcXVpcmVkPWluZmVyIEBkZWZhdWx0U2Vuc2l0aXZlPWZhbHNlCiMgLS0tLS0tLS0tLQ==" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-bash"><!----><span class="hljs-comment"># @plugin(@varlock/1password-plugin)</span>
<span class="hljs-comment"># @initOp(allowAppAuth=true)</span>
<span class="hljs-comment"># @defaultRequired=infer @defaultSensitive=false</span>
<span class="hljs-comment"># ----------</span><!----></code></pre></div> <p><code>.env.schema</code></p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="IyBAcGx1Z2luKEB2YXJsb2NrLzFwYXNzd29yZC1wbHVnaW4sIGFsbG93QXBwQXV0aD10cnVlKQojIEBkZWZhdWx0UmVxdWlyZWQ9aW5mZXIgQGRlZmF1bHRTZW5zaXRpdmU9ZmFsc2UKIyAtLS0tLS0tLS0t" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-bash"><!----><span class="hljs-comment"># @plugin(@varlock/1password-plugin, allowAppAuth=true)</span>
<span class="hljs-comment"># @defaultRequired=infer @defaultSensitive=false</span>
<span class="hljs-comment"># ----------</span><!----></code></pre></div> <p>Why does <code>.env.schema</code> not include the @initOp line? Beats me. When I include it in .env.schema, I get kind of vague debugging until I remove it from <code>.env.schema</code> but keep it in <code>.env.local</code>, and then everything works like a champ.</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="8J+aqCBFcnJvcihzKSBlbmNvdW50ZXJlZCBpbiAuZW52LnNjaGVtYQotIEluc3RhbmNlIHdpdGggaWQgIl9kZWZhdWx0IiBhbHJlYWR5IGluaXRpYWxpemVkCgrwn5qoIPCfmqgg8J+aqCAgQ29uZmlndXJhdGlvbiBpcyBjdXJyZW50bHkgaW52YWxpZCAgIPCfmqgg8J+aqCDwn5qoCgpJbnZhbGlkIGl0ZW1zOgoK4puUIFZBUkxPQ0tfRVhBTVBMRV9TRUNSRVQqICDwn5SQc2Vuc2l0aXZlCiAgIOKUlCB1bmRlZmluZWQKICAgLSBlcnJvciByZXNvbHZpbmcgdmFsdWU6IFNjaGVtYUVycm9yOiBVbmFibGUgdG8gYXV0aGVudGljYXRlIHdpdGggMVBhc3N3b3JkCgoK8J+SpSBSZXNvbHZlZCBjb25maWcvZW52IGRpZCBub3QgcGFzcyB2YWxpZGF0aW9uIPCfkqU=" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code><!---->🚨 Error(s) encountered in .env.schema
- Instance with id &quot;_default&quot; already initialized

🚨 🚨 🚨  Configuration is currently invalid   🚨 🚨 🚨

Invalid items:

⛔ VARLOCK_EXAMPLE_SECRET*  🔐sensitive
   └ undefined
   - error resolving value: SchemaError: Unable to authenticate with 1Password


💥 Resolved config/env did not pass validation 💥<!----></code></pre></div> <p>Edit: Soon after initially cross-posting this blog entry to <a href="https://news.ycombinator.com/item?id=47357992" rel="nofollow">Hacker News</a>, Theo Ephraim reached out to me on Discord (I recently joined their server) and pointed out that <code>@plugin()</code> is global so you only need it in one file, which would normally be the <code>.env.schema</code> file. That explains the error I ran into above. I essentially copied <code>.env.schema</code> to <code>.env.local</code> verbatim.</p> <p>He also pointed out that <code>.env.local</code> is the convention rather than <code>.env</code>, so I made that change as well. Lastly, he said that <code>import 'varlock/auto-load'</code> and <code>import { ENV } from 'varlock/env'</code> are cool, so I added those to my example project as well.</p> <p>Thanks Theo! I digress.</p> <h1>Is varlock good?</h1> <p>Oh yeah. It rules. Game changer. Once I figured out the front matter shenanigans, it was smooth sailing. I’m in the process of converting my dev projects with plaintext secrets in <code>.env.local</code> to 1Password secret references instead. Easy security win. I will also become an evangelist for this project amongst the other engineers I work with and I assume anyone who discovers it will be doing the same. I would be surprised if it’s not required learning in most programming courses and boot camps in the future.</p> <h1>Example Project</h1> <p>I quickly threw together an example project (<a href="https://github.com/jesse-id/varlock-node-example" rel="nofollow">https://github.com/jesse-id/varlock-node-example</a>) that makes it easier to understand how this works in a local dev project and it also includes example usage of all of the different @type data types.</p> <p>I also included <code>.env.local</code> and I will tell you that it gave me the ick to do that. I have never exposed a <code>.env</code> file in git before. Very knee-jerk ick. However, it’s safe because <code>"secret reference" != "secret"</code>! Hooray! Happy hacking!</p><!--]-->]]></content:encoded>
		</item>
		<item>
			<title>Using Karabiner-Elements For Sudo Authentication via 1Password</title>
			<description>And other complex modification (macro) scripts that I have developed</description>
			<link>https://jesse.id/blog/posts/using-karabiner-elements-for-sudo-authentication-via-1password</link>
			<guid isPermaLink="true">https://jesse.id/blog/posts/using-karabiner-elements-for-sudo-authentication-via-1password</guid>
			<pubDate>Wed, 18 Feb 2026 00:00:00 GMT</pubDate>
			<content:encoded><![CDATA[<!--[--><p>Karabiner-Elements is an incredible free tool for customizing your keyboard, mouse, and other input devices on macOS. Its most powerful feature is the ability to create complex modification (macro) scripts that can perform a variety of actions when a key — or a combination of keys — are pressed.</p> <p>I’m going to share the scripts I’ve written here and I will try to remember to edit this post as I develop more scripts in the future.</p> <h1>CMD+CTRL+S to send sudo password from 1Password</h1> <p>If you get tired of typing — or copy/pasting — your sudo password every time you need to run a command, or reaching for your Yubikey, you can use this complex modification to copy your sudo password from 1Password and send the characters as keystrokes when you hit CMD+CTRL+S. Keystroke injection via Karabiner-Elements doesn’t overwrite your clipboard like copy/pasting does and it also keeps youe sudo password out of clipboard history, which is a nice bonus.</p> <p>You will need the <a href="https://developer.1password.com/docs/cli/get-started/" rel="nofollow">1Password CLI</a> tool installed and configured for this to work. Follow the instructions from the link to do that.</p> <p>Then click <strong>“Complex Modifications -> Add your own rule”</strong> in the Karabiner-Elements interface.</p> <p>Paste in the code below, making sure replace [YOUR_SECRET_UUID_HERE] with your actual secret UUID. <strong>To get the UUID, right click on the secret in 1Password and select “Copy UUID” (it will be a long string of letters and numbers)</strong>. Also, I have installed the 1Password CLI via homebrew on a Mac, so your command path may need modified as well.</p> <p><a href="https://gist.github.com/jesse-id/0a287c4bd039ce3bf48f222f4ef73e46" rel="nofollow">https://gist.github.com/jesse-id/0a287c4bd039ce3bf48f222f4ef73e46</a></p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="ewogICAgImRlc2NyaXB0aW9uIjogIkN0cmwrQ21kK1MgdG8gcmV0cmlldmUgMVBhc3N3b3JkIHNlY3JldCBhbmQgc2VuZCBhcyBrZXlzdHJva2VzIiwKICAgICJtYW5pcHVsYXRvcnMiOiBbCiAgICAgICAgewogICAgICAgICAgICAiZnJvbSI6IHsKICAgICAgICAgICAgICAgICJrZXlfY29kZSI6ICJzIiwKICAgICAgICAgICAgICAgICJtb2RpZmllcnMiOiB7ICJtYW5kYXRvcnkiOiBbImxlZnRfY29udHJvbCIsICJsZWZ0X2NvbW1hbmQiXSB9CiAgICAgICAgICAgIH0sCiAgICAgICAgICAgICJ0byI6IFsKICAgICAgICAgICAgICAgIHsKICAgICAgICAgICAgICAgICAgICAic2hlbGxfY29tbWFuZCI6ICJhZnBsYXkgL1N5c3RlbS9MaWJyYXJ5L1NvdW5kcy9Qb3AuYWlmZiAmIGlmIFNFQ1JFVD0kKC9vcHQvaG9tZWJyZXcvYmluL29wIGl0ZW0gZ2V0IFwiW1lPVVJfU0VDUkVUX1VVSURfSEVSRV1cIiAtLWZpZWxkcyBwYXNzd29yZCAtLXJldmVhbCAyPi9kZXYvbnVsbCB8IHRyIC1kICdcXG4nKTsgdGhlbiBvc2FzY3JpcHQgLWUgJ29uIHJ1biBhcmdzJyAtZSAndGVsbCBhcHBsaWNhdGlvbiBcIlN5c3RlbSBFdmVudHNcIiB0byBrZXlzdHJva2UgKGl0ZW0gMSBvZiBhcmdzKScgLWUgJ2VuZCBydW4nIFwiJFNFQ1JFVFwiICYmIG9zYXNjcmlwdCAtZSAndGVsbCBhcHBsaWNhdGlvbiBcIlN5c3RlbSBFdmVudHNcIiB0byBrZXkgY29kZSAzNic7IGZpIgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICBdLAogICAgICAgICAgICAidHlwZSI6ICJiYXNpYyIKICAgICAgICB9CiAgICBdCn0=" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-json"><!----><span class="hljs-punctuation">{</span>
    <span class="hljs-attr">&quot;description&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;Ctrl+Cmd+S to retrieve 1Password secret and send as keystrokes&quot;</span><span class="hljs-punctuation">,</span>
    <span class="hljs-attr">&quot;manipulators&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
        <span class="hljs-punctuation">{</span>
            <span class="hljs-attr">&quot;from&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span>
                <span class="hljs-attr">&quot;key_code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;s&quot;</span><span class="hljs-punctuation">,</span>
                <span class="hljs-attr">&quot;modifiers&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">&quot;mandatory&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">&quot;left_control&quot;</span><span class="hljs-punctuation">,</span> <span class="hljs-string">&quot;left_command&quot;</span><span class="hljs-punctuation">]</span> <span class="hljs-punctuation">}</span>
            <span class="hljs-punctuation">}</span><span class="hljs-punctuation">,</span>
            <span class="hljs-attr">&quot;to&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
                <span class="hljs-punctuation">{</span>
                    <span class="hljs-attr">&quot;shell_command&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;afplay /System/Library/Sounds/Pop.aiff &amp; if SECRET=$(/opt/homebrew/bin/op item get &quot;[YOUR_SECRET_UUID_HERE]&quot; --fields password --reveal 2&gt;/dev/null | tr -d &#x27;\n&#x27;); then osascript -e &#x27;on run args&#x27; -e &#x27;tell application &quot;System Events&quot; to keystroke (item 1 of args)&#x27; -e &#x27;end run&#x27; &quot;$SECRET&quot; &amp;&amp; osascript -e &#x27;tell application &quot;System Events&quot; to key code 36&#x27;; fi&quot;</span>
                <span class="hljs-punctuation">}</span>
            <span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span>
            <span class="hljs-attr">&quot;type&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;basic&quot;</span>
        <span class="hljs-punctuation">}</span>
    <span class="hljs-punctuation">]</span>
<span class="hljs-punctuation">}</span><!----></code></pre></div> <h1>SHIFT+CTRL+OPT+CMD on hold, CMD+/ on tap</h1> <p>HyperKey is an app that turns CAPS LOCK into a new modifier key by sending SHIFT+CONTROL+OPTION+COMMAND when pressed and held, and escape when tapped. I have replaced it with a Karabiner-Elements complex modification script that replicates the same functionality, but sends COMMAND+/ when tapped instead. That allows me to use CAPS LOCK to quickly comment/uncomment code in my IDE as well.</p> <p>Click <strong>“Complex Modifications -> Add your own rule”</strong> in the Karabiner-Elements interface.</p> <p>Paste in the code below.</p> <p><a href="https://gist.github.com/jesse-id/36ec252467c20fb84fa6b6dfe5b3e604" rel="nofollow">https://gist.github.com/jesse-id/36ec252467c20fb84fa6b6dfe5b3e604</a></p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="ewogICAgImRlc2NyaXB0aW9uIjogIkNhcHMgTG9jayB0byBIeXBlciBLZXkgKEhlbGQpLCBDbWQrLyAoVGFwcGVkKSIsCiAgICAibWFuaXB1bGF0b3JzIjogWwogICAgICAgIHsKICAgICAgICAgICAgImZyb20iOiB7CiAgICAgICAgICAgICAgICAia2V5X2NvZGUiOiAiY2Fwc19sb2NrIiwKICAgICAgICAgICAgICAgICJtb2RpZmllcnMiOiB7ICJvcHRpb25hbCI6IFsiYW55Il0gfQogICAgICAgICAgICB9LAogICAgICAgICAgICAidG8iOiBbCiAgICAgICAgICAgICAgICB7CiAgICAgICAgICAgICAgICAgICAgImtleV9jb2RlIjogImxlZnRfc2hpZnQiLAogICAgICAgICAgICAgICAgICAgICJtb2RpZmllcnMiOiBbImxlZnRfY29tbWFuZCIsICJsZWZ0X2NvbnRyb2wiLCAibGVmdF9vcHRpb24iXQogICAgICAgICAgICAgICAgfQogICAgICAgICAgICBdLAogICAgICAgICAgICAidG9faWZfYWxvbmUiOiBbCiAgICAgICAgICAgICAgICB7CiAgICAgICAgICAgICAgICAgICAgImtleV9jb2RlIjogInNsYXNoIiwKICAgICAgICAgICAgICAgICAgICAibW9kaWZpZXJzIjogWyJsZWZ0X2NvbW1hbmQiXQogICAgICAgICAgICAgICAgfQogICAgICAgICAgICBdLAogICAgICAgICAgICAidHlwZSI6ICJiYXNpYyIKICAgICAgICB9CiAgICBdCn0=" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-json"><!----><span class="hljs-punctuation">{</span>
    <span class="hljs-attr">&quot;description&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;Caps Lock to Hyper Key (Held), Cmd+/ (Tapped)&quot;</span><span class="hljs-punctuation">,</span>
    <span class="hljs-attr">&quot;manipulators&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
        <span class="hljs-punctuation">{</span>
            <span class="hljs-attr">&quot;from&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span>
                <span class="hljs-attr">&quot;key_code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;caps_lock&quot;</span><span class="hljs-punctuation">,</span>
                <span class="hljs-attr">&quot;modifiers&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">&quot;optional&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">&quot;any&quot;</span><span class="hljs-punctuation">]</span> <span class="hljs-punctuation">}</span>
            <span class="hljs-punctuation">}</span><span class="hljs-punctuation">,</span>
            <span class="hljs-attr">&quot;to&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
                <span class="hljs-punctuation">{</span>
                    <span class="hljs-attr">&quot;key_code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;left_shift&quot;</span><span class="hljs-punctuation">,</span>
                    <span class="hljs-attr">&quot;modifiers&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">&quot;left_command&quot;</span><span class="hljs-punctuation">,</span> <span class="hljs-string">&quot;left_control&quot;</span><span class="hljs-punctuation">,</span> <span class="hljs-string">&quot;left_option&quot;</span><span class="hljs-punctuation">]</span>
                <span class="hljs-punctuation">}</span>
            <span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span>
            <span class="hljs-attr">&quot;to_if_alone&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
                <span class="hljs-punctuation">{</span>
                    <span class="hljs-attr">&quot;key_code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;slash&quot;</span><span class="hljs-punctuation">,</span>
                    <span class="hljs-attr">&quot;modifiers&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">&quot;left_command&quot;</span><span class="hljs-punctuation">]</span>
                <span class="hljs-punctuation">}</span>
            <span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span>
            <span class="hljs-attr">&quot;type&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;basic&quot;</span>
        <span class="hljs-punctuation">}</span>
    <span class="hljs-punctuation">]</span>
<span class="hljs-punctuation">}</span><!----></code></pre></div><!--]-->]]></content:encoded>
		</item>
		<item>
			<title>How to Add Forkiverse Comments to Your SvelteKit Blog</title>
			<description>A complete guide to implementing Mastodon-powered comments with instance selection</description>
			<link>https://jesse.id/blog/posts/how-to-add-forkiverse-comments-to-your-sveltekit-blog</link>
			<guid isPermaLink="true">https://jesse.id/blog/posts/how-to-add-forkiverse-comments-to-your-sveltekit-blog</guid>
			<pubDate>Sat, 31 Jan 2026 00:00:00 GMT</pubDate>
			<content:encoded><![CDATA[<!--[--><p>After my <a href="/blog/posts/you-can-now-comment-on-my-blog-from-the-forkiverse">previous post about launching Forkiverse comments</a>, I wanted to share a detailed technical breakdown, so here it is. A complete guide to adding Mastodon/Fediverse-powered comments to your SvelteKit blog, including the instance selector that lets readers comment from their own Fediverse home.</p> <h1>The Architecture</h1> <p>The system works by linking each blog post to a corresponding Mastodon post, which in my case will always be on <a href="https://theforkiverse.com/" rel="nofollow">The Forkiverse</a> instance. When you publish a blog post, you also post about it on The Forkiverse, and that’s how your Mastodon post URL is linked. The comment system then:</p> <ol><li>Searches your Mastodon account’s posts to find the one containing your blog post URL.</li> <li>Fetches the replies to that post using Mastodon’s public API.</li> <li>Renders them as a threaded comment section.</li> <li>Lets readers reply via their own Fediverse instance.</li></ol> <p>There are no authentication tokens needed for reading because Mastodon’s public API handles everything.</p> <h1>Project Structure</h1> <p>Here’s what we’re building:</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="c3JjLwrilJzilIDilIAgbGliLwrilIIgICDilJzilIDilIAgc2VydmVyLwrilIIgICDilIIgICDilJTilIDilIAgbWFzdG9kb24udHMgICAgICAgICAgIyBTZXJ2ZXItc2lkZSBNYXN0b2RvbiBBUEkgbG9naWMK4pSCICAg4pSc4pSA4pSAIGNvbXBvbmVudHMvCuKUgiAgIOKUgiAgIOKUlOKUgOKUgCBNYXN0b2RvbkNvbW1lbnRzLnN2ZWx0ZSAgIyBUaGUgY29tbWVudCBVSSBjb21wb25lbnQK4pSCICAg4pSU4pSA4pSAIHR5cGVzLwrilIIgICAgICAg4pSU4pSA4pSAIG1hc3RvZG9uLnRzICAgICAgICAgICMgVHlwZVNjcmlwdCBpbnRlcmZhY2VzCuKUlOKUgOKUgCByb3V0ZXMvCiAgICDilJzilIDilIAgYXBpLwogICAg4pSCICAg4pSc4pSA4pSAIG1hc3RvZG9uLWNvbW1lbnRzLwogICAg4pSCICAg4pSCICAg4pSU4pSA4pSAICtzZXJ2ZXIudHMgICAgICAgIyBDb21tZW50cyBBUEkgZW5kcG9pbnQKICAgIOKUgiAgIOKUlOKUgOKUgCBmZWRpdmVyc2UtaW5zdGFuY2VzLwogICAg4pSCICAgICAgIOKUlOKUgOKUgCArc2VydmVyLnRzICAgICAgICMgSW5zdGFuY2Ugc2VhcmNoIGVuZHBvaW50CiAgICDilJTilIDilIAgYmxvZy9wb3N0cy9bc2x1Z10vCiAgICAgICAg4pSU4pSA4pSAICtwYWdlLnN2ZWx0ZSAgICAgICAgICMgQmxvZyBwb3N0IHBhZ2UgKGluY2x1ZGVzIGNvbW1lbnRzKQ==" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code><!---->src/
├── lib/
│   ├── server/
│   │   └── mastodon.ts          # Server-side Mastodon API logic
│   ├── components/
│   │   └── MastodonComments.svelte  # The comment UI component
│   └── types/
│       └── mastodon.ts          # TypeScript interfaces
└── routes/
    ├── api/
    │   ├── mastodon-comments/
    │   │   └── +server.ts       # Comments API endpoint
    │   └── fediverse-instances/
    │       └── +server.ts       # Instance search endpoint
    └── blog/posts/[slug]/
        └── +page.svelte         # Blog post page (includes comments)<!----></code></pre></div> <h1>Step 1: Define the Types</h1> <p>First, create the TypeScript interfaces for Mastodon’s API responses and our comment structure.</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="Ly8gc3JjL2xpYi90eXBlcy9tYXN0b2Rvbi50cwoKLy8gTWFzdG9kb24gQVBJIHJlc3BvbnNlIHR5cGVzCmV4cG9ydCBpbnRlcmZhY2UgTWFzdG9kb25BY2NvdW50IHsKCWlkOiBzdHJpbmc7Cgl1c2VybmFtZTogc3RyaW5nOwoJYWNjdDogc3RyaW5nOwoJZGlzcGxheV9uYW1lOiBzdHJpbmc7Cgl1cmw6IHN0cmluZzsKCWF2YXRhcjogc3RyaW5nOwoJYXZhdGFyX3N0YXRpYzogc3RyaW5nOwp9CgpleHBvcnQgaW50ZXJmYWNlIE1hc3RvZG9uU3RhdHVzIHsKCWlkOiBzdHJpbmc7CgljcmVhdGVkX2F0OiBzdHJpbmc7Cglpbl9yZXBseV90b19pZDogc3RyaW5nIHwgbnVsbDsKCXVybDogc3RyaW5nOwoJY29udGVudDogc3RyaW5nOwoJYWNjb3VudDogTWFzdG9kb25BY2NvdW50OwoJY2FyZD86IHsKCQl1cmw6IHN0cmluZzsKCX0gfCBudWxsOwoJcmVwbGllc19jb3VudDogbnVtYmVyOwoJZmF2b3VyaXRlc19jb3VudDogbnVtYmVyOwp9CgpleHBvcnQgaW50ZXJmYWNlIE1hc3RvZG9uQ29udGV4dCB7CglhbmNlc3RvcnM6IE1hc3RvZG9uU3RhdHVzW107CglkZXNjZW5kYW50czogTWFzdG9kb25TdGF0dXNbXTsKfQoKLy8gT3VyIGNvbW1lbnQgdHlwZXMKZXhwb3J0IGludGVyZmFjZSBNYXN0b2RvbkNvbW1lbnQgewoJaWQ6IHN0cmluZzsKCWF1dGhvcjogewoJCW5hbWU6IHN0cmluZzsKCQloYW5kbGU6IHN0cmluZzsKCQlhdmF0YXI6IHN0cmluZzsKCQl1cmw6IHN0cmluZzsKCX07Cgljb250ZW50OiBzdHJpbmc7CgljcmVhdGVkQXQ6IHN0cmluZzsKCXVybDogc3RyaW5nOwoJZmF2b3VyaXRlc0NvdW50OiBudW1iZXI7CgljaGlsZHJlbjogTWFzdG9kb25Db21tZW50W107Cn0KCmV4cG9ydCBpbnRlcmZhY2UgTWFzdG9kb25Db21tZW50c1Jlc3BvbnNlIHsKCW1hc3RvZG9uUG9zdFVybDogc3RyaW5nIHwgbnVsbDsKCWNvbW1lbnRzOiBNYXN0b2RvbkNvbW1lbnRbXTsKCWNvbW1lbnRDb3VudDogbnVtYmVyOwoJZmF2b3VyaXRlc0NvdW50OiBudW1iZXI7Cn0=" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-typescript"><!----><span class="hljs-comment">// src/lib/types/mastodon.ts</span>

<span class="hljs-comment">// Mastodon API response types</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">MastodonAccount</span> {
	<span class="hljs-attr">id</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">username</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">acct</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">display_name</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">url</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">avatar</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">avatar_static</span>: <span class="hljs-built_in">string</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">MastodonStatus</span> {
	<span class="hljs-attr">id</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">created_at</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">in_reply_to_id</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>;
	<span class="hljs-attr">url</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">content</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">account</span>: <span class="hljs-title class_">MastodonAccount</span>;
	<span class="hljs-attr">card</span>?: {
		<span class="hljs-attr">url</span>: <span class="hljs-built_in">string</span>;
	} | <span class="hljs-literal">null</span>;
	<span class="hljs-attr">replies_count</span>: <span class="hljs-built_in">number</span>;
	<span class="hljs-attr">favourites_count</span>: <span class="hljs-built_in">number</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">MastodonContext</span> {
	<span class="hljs-attr">ancestors</span>: <span class="hljs-title class_">MastodonStatus</span>[];
	<span class="hljs-attr">descendants</span>: <span class="hljs-title class_">MastodonStatus</span>[];
}

<span class="hljs-comment">// Our comment types</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">MastodonComment</span> {
	<span class="hljs-attr">id</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">author</span>: {
		<span class="hljs-attr">name</span>: <span class="hljs-built_in">string</span>;
		<span class="hljs-attr">handle</span>: <span class="hljs-built_in">string</span>;
		<span class="hljs-attr">avatar</span>: <span class="hljs-built_in">string</span>;
		<span class="hljs-attr">url</span>: <span class="hljs-built_in">string</span>;
	};
	<span class="hljs-attr">content</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">createdAt</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">url</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">favouritesCount</span>: <span class="hljs-built_in">number</span>;
	<span class="hljs-attr">children</span>: <span class="hljs-title class_">MastodonComment</span>[];
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">MastodonCommentsResponse</span> {
	<span class="hljs-attr">mastodonPostUrl</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>;
	<span class="hljs-attr">comments</span>: <span class="hljs-title class_">MastodonComment</span>[];
	<span class="hljs-attr">commentCount</span>: <span class="hljs-built_in">number</span>;
	<span class="hljs-attr">favouritesCount</span>: <span class="hljs-built_in">number</span>;
}<!----></code></pre></div> <h1>Step 2: Server-Side Mastodon Logic</h1> <p>This is the core of the system. It searches your Mastodon posts, finds the one linking to your blog post, and fetches replies.</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="Ly8gc3JjL2xpYi9zZXJ2ZXIvbWFzdG9kb24udHMKCmltcG9ydCB0eXBlIHsKCU1hc3RvZG9uQWNjb3VudCwKCU1hc3RvZG9uQ29tbWVudCwKCU1hc3RvZG9uQ29tbWVudHNSZXNwb25zZSwKCU1hc3RvZG9uQ29udGV4dCwKCU1hc3RvZG9uU3RhdHVzCn0gZnJvbSAnJGxpYi90eXBlcy9tYXN0b2Rvbic7CgovLyBDb25maWd1cmF0aW9uIC0gY2hhbmdlIHRoZXNlIHRvIG1hdGNoIHlvdXIgc2V0dXAKY29uc3QgTUFTVE9ET05fSU5TVEFOQ0UgPSAnaHR0cHM6Ly90aGVmb3JraXZlcnNlLmNvbSc7CmNvbnN0IE1BU1RPRE9OX1VTRVJOQU1FID0gJ3lvdXJfdXNlcm5hbWUnOwpjb25zdCBTSVRFX0JBU0VfVVJMID0gJ2h0dHBzOi8veW91cnNpdGUuY29tJzsKCmNvbnN0IENBQ0hFX1RUTF9NUyA9IDUgKiA2MCAqIDEwMDA7IC8vIDUgbWludXRlcwpjb25zdCBNQVhfUEFHRVMgPSA1Owpjb25zdCBQQUdFX1NJWkUgPSA0MDsKCmNvbnN0IEVNUFRZX1JFU1BPTlNFOiBNYXN0b2RvbkNvbW1lbnRzUmVzcG9uc2UgPSB7CgltYXN0b2RvblBvc3RVcmw6IG51bGwsCgljb21tZW50czogW10sCgljb21tZW50Q291bnQ6IDAsCglmYXZvdXJpdGVzQ291bnQ6IDAKfTsKCmludGVyZmFjZSBDYWNoZUVudHJ5IHsKCWRhdGE6IE1hc3RvZG9uQ29tbWVudHNSZXNwb25zZTsKCXRpbWVzdGFtcDogbnVtYmVyOwp9Cgpjb25zdCBjYWNoZSA9IG5ldyBNYXA8c3RyaW5nLCBDYWNoZUVudHJ5PigpOwpjb25zdCBzdGF0dXNJZENhY2hlID0gbmV3IE1hcDxzdHJpbmcsIHN0cmluZz4oKTsKbGV0IGNhY2hlZEFjY291bnRJZDogc3RyaW5nIHwgbnVsbCA9IG51bGw7Cgphc3luYyBmdW5jdGlvbiBsb29rdXBBY2NvdW50SWQoKTogUHJvbWlzZTxzdHJpbmcgfCBudWxsPiB7CglpZiAoY2FjaGVkQWNjb3VudElkKSByZXR1cm4gY2FjaGVkQWNjb3VudElkOwoKCWNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKGAke01BU1RPRE9OX0lOU1RBTkNFfS9hcGkvdjEvYWNjb3VudHMvbG9va3VwP2FjY3Q9JHtNQVNUT0RPTl9VU0VSTkFNRX1gKTsKCWlmICghcmVzLm9rKSByZXR1cm4gbnVsbDsKCgljb25zdCBhY2NvdW50OiBNYXN0b2RvbkFjY291bnQgPSBhd2FpdCByZXMuanNvbigpOwoJY2FjaGVkQWNjb3VudElkID0gYWNjb3VudC5pZDsKCXJldHVybiBhY2NvdW50LmlkOwp9Cgphc3luYyBmdW5jdGlvbiBmaW5kU3RhdHVzRm9yU2x1ZyhhY2NvdW50SWQ6IHN0cmluZywgc2x1Zzogc3RyaW5nKTogUHJvbWlzZTxNYXN0b2RvblN0YXR1cyB8IG51bGw+IHsKCS8vIENoZWNrIGNhY2hlIGZpcnN0Cgljb25zdCBjYWNoZWRTdGF0dXNJZCA9IHN0YXR1c0lkQ2FjaGUuZ2V0KHNsdWcpOwoJaWYgKGNhY2hlZFN0YXR1c0lkKSB7CgkJY29uc3QgcmVzID0gYXdhaXQgZmV0Y2goYCR7TUFTVE9ET05fSU5TVEFOQ0V9L2FwaS92MS9zdGF0dXNlcy8ke2NhY2hlZFN0YXR1c0lkfWApOwoJCWlmIChyZXMub2spIHJldHVybiByZXMuanNvbigpOwoJCXN0YXR1c0lkQ2FjaGUuZGVsZXRlKHNsdWcpOwoJfQoKCWNvbnN0IHRhcmdldFVybCA9IGAke1NJVEVfQkFTRV9VUkx9L2Jsb2cvcG9zdHMvJHtzbHVnfWA7CglsZXQgbWF4SWQ6IHN0cmluZyB8IHVuZGVmaW5lZDsKCgkvLyBQYWdpbmF0ZSB0aHJvdWdoIHlvdXIgcG9zdHMgdG8gZmluZCB0aGUgb25lIHdpdGggdGhpcyBVUkwKCWZvciAobGV0IHBhZ2UgPSAwOyBwYWdlIDwgTUFYX1BBR0VTOyBwYWdlKyspIHsKCQljb25zdCBwYXJhbXMgPSBuZXcgVVJMU2VhcmNoUGFyYW1zKHsKCQkJbGltaXQ6IFN0cmluZyhQQUdFX1NJWkUpLAoJCQlleGNsdWRlX3JlcGxpZXM6ICd0cnVlJywKCQkJZXhjbHVkZV9yZWJsb2dzOiAndHJ1ZScKCQl9KTsKCQlpZiAobWF4SWQpIHBhcmFtcy5zZXQoJ21heF9pZCcsIG1heElkKTsKCgkJY29uc3QgcmVzID0gYXdhaXQgZmV0Y2goYCR7TUFTVE9ET05fSU5TVEFOQ0V9L2FwaS92MS9hY2NvdW50cy8ke2FjY291bnRJZH0vc3RhdHVzZXM/JHtwYXJhbXN9YCk7CgkJaWYgKCFyZXMub2spIHJldHVybiBudWxsOwoKCQljb25zdCBzdGF0dXNlczogTWFzdG9kb25TdGF0dXNbXSA9IGF3YWl0IHJlcy5qc29uKCk7CgkJaWYgKHN0YXR1c2VzLmxlbmd0aCA9PT0gMCkgYnJlYWs7CgoJCWZvciAoY29uc3Qgc3RhdHVzIG9mIHN0YXR1c2VzKSB7CgkJCS8vIENoZWNrIGlmIHBvc3QgY29udGVudCBvciBsaW5rIGNhcmQgY29udGFpbnMgb3VyIGJsb2cgVVJMCgkJCWlmIChzdGF0dXMuY29udGVudC5pbmNsdWRlcyh0YXJnZXRVcmwpKSB7CgkJCQlzdGF0dXNJZENhY2hlLnNldChzbHVnLCBzdGF0dXMuaWQpOwoJCQkJcmV0dXJuIHN0YXR1czsKCQkJfQoJCQlpZiAoc3RhdHVzLmNhcmQ/LnVybD8uaW5jbHVkZXModGFyZ2V0VXJsKSkgewoJCQkJc3RhdHVzSWRDYWNoZS5zZXQoc2x1Zywgc3RhdHVzLmlkKTsKCQkJCXJldHVybiBzdGF0dXM7CgkJCX0KCQl9CgoJCW1heElkID0gc3RhdHVzZXNbc3RhdHVzZXMubGVuZ3RoIC0gMV0uaWQ7Cgl9CgoJcmV0dXJuIG51bGw7Cn0=" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-typescript"><!----><span class="hljs-comment">// src/lib/server/mastodon.ts</span>

<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> {
	<span class="hljs-title class_">MastodonAccount</span>,
	<span class="hljs-title class_">MastodonComment</span>,
	<span class="hljs-title class_">MastodonCommentsResponse</span>,
	<span class="hljs-title class_">MastodonContext</span>,
	<span class="hljs-title class_">MastodonStatus</span>
} <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;$lib/types/mastodon&#x27;</span>;

<span class="hljs-comment">// Configuration - change these to match your setup</span>
<span class="hljs-keyword">const</span> <span class="hljs-variable constant_">MASTODON_INSTANCE</span> = <span class="hljs-string">&#x27;https://theforkiverse.com&#x27;</span>;
<span class="hljs-keyword">const</span> <span class="hljs-variable constant_">MASTODON_USERNAME</span> = <span class="hljs-string">&#x27;your_username&#x27;</span>;
<span class="hljs-keyword">const</span> <span class="hljs-variable constant_">SITE_BASE_URL</span> = <span class="hljs-string">&#x27;https://yoursite.com&#x27;</span>;

<span class="hljs-keyword">const</span> <span class="hljs-variable constant_">CACHE_TTL_MS</span> = <span class="hljs-number">5</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>; <span class="hljs-comment">// 5 minutes</span>
<span class="hljs-keyword">const</span> <span class="hljs-variable constant_">MAX_PAGES</span> = <span class="hljs-number">5</span>;
<span class="hljs-keyword">const</span> <span class="hljs-variable constant_">PAGE_SIZE</span> = <span class="hljs-number">40</span>;

<span class="hljs-keyword">const</span> <span class="hljs-attr">EMPTY_RESPONSE</span>: <span class="hljs-title class_">MastodonCommentsResponse</span> = {
	<span class="hljs-attr">mastodonPostUrl</span>: <span class="hljs-literal">null</span>,
	<span class="hljs-attr">comments</span>: [],
	<span class="hljs-attr">commentCount</span>: <span class="hljs-number">0</span>,
	<span class="hljs-attr">favouritesCount</span>: <span class="hljs-number">0</span>
};

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">CacheEntry</span> {
	<span class="hljs-attr">data</span>: <span class="hljs-title class_">MastodonCommentsResponse</span>;
	<span class="hljs-attr">timestamp</span>: <span class="hljs-built_in">number</span>;
}

<span class="hljs-keyword">const</span> cache = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Map</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-title class_">CacheEntry</span>&gt;();
<span class="hljs-keyword">const</span> statusIdCache = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Map</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>&gt;();
<span class="hljs-keyword">let</span> <span class="hljs-attr">cachedAccountId</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span> = <span class="hljs-literal">null</span>;

<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">lookupAccountId</span>(<span class="hljs-params"></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>&gt; {
	<span class="hljs-keyword">if</span> (cachedAccountId) <span class="hljs-keyword">return</span> cachedAccountId;

	<span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`<span class="hljs-subst">${MASTODON_INSTANCE}</span>/api/v1/accounts/lookup?acct=<span class="hljs-subst">${MASTODON_USERNAME}</span>`</span>);
	<span class="hljs-keyword">if</span> (!res.<span class="hljs-property">ok</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;

	<span class="hljs-keyword">const</span> <span class="hljs-attr">account</span>: <span class="hljs-title class_">MastodonAccount</span> = <span class="hljs-keyword">await</span> res.<span class="hljs-title function_">json</span>();
	cachedAccountId = account.<span class="hljs-property">id</span>;
	<span class="hljs-keyword">return</span> account.<span class="hljs-property">id</span>;
}

<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">findStatusForSlug</span>(<span class="hljs-params"><span class="hljs-attr">accountId</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">slug</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">MastodonStatus</span> | <span class="hljs-literal">null</span>&gt; {
	<span class="hljs-comment">// Check cache first</span>
	<span class="hljs-keyword">const</span> cachedStatusId = statusIdCache.<span class="hljs-title function_">get</span>(slug);
	<span class="hljs-keyword">if</span> (cachedStatusId) {
		<span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`<span class="hljs-subst">${MASTODON_INSTANCE}</span>/api/v1/statuses/<span class="hljs-subst">${cachedStatusId}</span>`</span>);
		<span class="hljs-keyword">if</span> (res.<span class="hljs-property">ok</span>) <span class="hljs-keyword">return</span> res.<span class="hljs-title function_">json</span>();
		statusIdCache.<span class="hljs-title function_">delete</span>(slug);
	}

	<span class="hljs-keyword">const</span> targetUrl = <span class="hljs-string">`<span class="hljs-subst">${SITE_BASE_URL}</span>/blog/posts/<span class="hljs-subst">${slug}</span>`</span>;
	<span class="hljs-keyword">let</span> <span class="hljs-attr">maxId</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">undefined</span>;

	<span class="hljs-comment">// Paginate through your posts to find the one with this URL</span>
	<span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> page = <span class="hljs-number">0</span>; page &lt; <span class="hljs-variable constant_">MAX_PAGES</span>; page++) {
		<span class="hljs-keyword">const</span> params = <span class="hljs-keyword">new</span> <span class="hljs-title class_">URLSearchParams</span>({
			<span class="hljs-attr">limit</span>: <span class="hljs-title class_">String</span>(<span class="hljs-variable constant_">PAGE_SIZE</span>),
			<span class="hljs-attr">exclude_replies</span>: <span class="hljs-string">&#x27;true&#x27;</span>,
			<span class="hljs-attr">exclude_reblogs</span>: <span class="hljs-string">&#x27;true&#x27;</span>
		});
		<span class="hljs-keyword">if</span> (maxId) params.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;max_id&#x27;</span>, maxId);

		<span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`<span class="hljs-subst">${MASTODON_INSTANCE}</span>/api/v1/accounts/<span class="hljs-subst">${accountId}</span>/statuses?<span class="hljs-subst">${params}</span>`</span>);
		<span class="hljs-keyword">if</span> (!res.<span class="hljs-property">ok</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;

		<span class="hljs-keyword">const</span> <span class="hljs-attr">statuses</span>: <span class="hljs-title class_">MastodonStatus</span>[] = <span class="hljs-keyword">await</span> res.<span class="hljs-title function_">json</span>();
		<span class="hljs-keyword">if</span> (statuses.<span class="hljs-property">length</span> === <span class="hljs-number">0</span>) <span class="hljs-keyword">break</span>;

		<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> status <span class="hljs-keyword">of</span> statuses) {
			<span class="hljs-comment">// Check if post content or link card contains our blog URL</span>
			<span class="hljs-keyword">if</span> (status.<span class="hljs-property">content</span>.<span class="hljs-title function_">includes</span>(targetUrl)) {
				statusIdCache.<span class="hljs-title function_">set</span>(slug, status.<span class="hljs-property">id</span>);
				<span class="hljs-keyword">return</span> status;
			}
			<span class="hljs-keyword">if</span> (status.<span class="hljs-property">card</span>?.<span class="hljs-property">url</span>?.<span class="hljs-title function_">includes</span>(targetUrl)) {
				statusIdCache.<span class="hljs-title function_">set</span>(slug, status.<span class="hljs-property">id</span>);
				<span class="hljs-keyword">return</span> status;
			}
		}

		maxId = statuses[statuses.<span class="hljs-property">length</span> - <span class="hljs-number">1</span>].<span class="hljs-property">id</span>;
	}

	<span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
}<!----></code></pre></div> <p>Next, add the functions to build the comment tree and sanitize HTML:</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="Ly8gU3RpbGwgaW4gc3JjL2xpYi9zZXJ2ZXIvbWFzdG9kb24udHMKCmZ1bmN0aW9uIHNhbml0aXplSHRtbChodG1sOiBzdHJpbmcpOiBzdHJpbmcgewoJLy8gQWxsb3dsaXN0IGFwcHJvYWNoIC0gb25seSBwZXJtaXQgc2FmZSB0YWdzIGFuZCBhdHRyaWJ1dGVzCgljb25zdCBhbGxvd2VkVGFnczogUmVjb3JkPHN0cmluZywgc3RyaW5nW10+ID0gewoJCXA6IFsnY2xhc3MnXSwKCQlicjogW10sCgkJYTogWydocmVmJywgJ3JlbCcsICdjbGFzcycsICd0YXJnZXQnXSwKCQlzcGFuOiBbJ2NsYXNzJ10sCgkJaW1nOiBbJ3NyYycsICdhbHQnLCAndGl0bGUnLCAnY2xhc3MnLCAnd2lkdGgnLCAnaGVpZ2h0J10sCgkJc3Ryb25nOiBbXSwKCQllbTogW10sCgkJY29kZTogW10sCgkJcHJlOiBbXSwKCQlibG9ja3F1b3RlOiBbXQoJfTsKCglsZXQgcmVzdWx0ID0gJyc7CglsZXQgcG9zID0gMDsKCgl3aGlsZSAocG9zIDwgaHRtbC5sZW5ndGgpIHsKCQljb25zdCB0YWdTdGFydCA9IGh0bWwuaW5kZXhPZignPCcsIHBvcyk7CgoJCWlmICh0YWdTdGFydCA9PT0gLTEpIHsKCQkJcmVzdWx0ICs9IGh0bWwuc2xpY2UocG9zKTsKCQkJYnJlYWs7CgkJfQoKCQlpZiAodGFnU3RhcnQgPiBwb3MpIHsKCQkJcmVzdWx0ICs9IGh0bWwuc2xpY2UocG9zLCB0YWdTdGFydCk7CgkJfQoKCQljb25zdCB0YWdFbmQgPSBodG1sLmluZGV4T2YoJz4nLCB0YWdTdGFydCk7CgkJaWYgKHRhZ0VuZCA9PT0gLTEpIGJyZWFrOwoKCQljb25zdCB0YWdDb250ZW50ID0gaHRtbC5zbGljZSh0YWdTdGFydCArIDEsIHRhZ0VuZCk7CgkJY29uc3QgaXNDbG9zaW5nID0gdGFnQ29udGVudC5zdGFydHNXaXRoKCcvJyk7CgkJY29uc3QgdGFnUGFydCA9IGlzQ2xvc2luZyA/IHRhZ0NvbnRlbnQuc2xpY2UoMSkgOiB0YWdDb250ZW50OwoJCWNvbnN0IHNwYWNlSW5kZXggPSB0YWdQYXJ0LmluZGV4T2YoJyAnKTsKCQljb25zdCB0YWdOYW1lID0gKHNwYWNlSW5kZXggPT09IC0xID8gdGFnUGFydCA6IHRhZ1BhcnQuc2xpY2UoMCwgc3BhY2VJbmRleCkpCgkJCS50b0xvd2VyQ2FzZSgpCgkJCS5yZXBsYWNlKC9cLyQvLCAnJyk7CgoJCWlmICh0YWdOYW1lIGluIGFsbG93ZWRUYWdzKSB7CgkJCWlmIChpc0Nsb3NpbmcpIHsKCQkJCXJlc3VsdCArPSBgPC8ke3RhZ05hbWV9PmA7CgkJCX0gZWxzZSB7CgkJCQljb25zdCBhbGxvd2VkQXR0cnMgPSBhbGxvd2VkVGFnc1t0YWdOYW1lXTsKCQkJCWNvbnN0IGF0dHJzID0gcGFyc2VBdHRyaWJ1dGVzKHRhZ0NvbnRlbnQsIGFsbG93ZWRBdHRycyk7CgkJCQljb25zdCBzZWxmQ2xvc2luZyA9IHRhZ0NvbnRlbnQuZW5kc1dpdGgoJy8nKSB8fCB0YWdOYW1lID09PSAnYnInIHx8IHRhZ05hbWUgPT09ICdpbWcnOwoJCQkJcmVzdWx0ICs9IGA8JHt0YWdOYW1lfSR7YXR0cnN9JHtzZWxmQ2xvc2luZyA/ICcgLycgOiAnJ30+YDsKCQkJfQoJCX0KCgkJcG9zID0gdGFnRW5kICsgMTsKCX0KCglyZXR1cm4gcmVzdWx0Owp9CgpmdW5jdGlvbiBwYXJzZUF0dHJpYnV0ZXModGFnQ29udGVudDogc3RyaW5nLCBhbGxvd2VkQXR0cnM6IHN0cmluZ1tdKTogc3RyaW5nIHsKCWlmIChhbGxvd2VkQXR0cnMubGVuZ3RoID09PSAwKSByZXR1cm4gJyc7CgoJY29uc3QgYXR0cnM6IHN0cmluZ1tdID0gW107Cgljb25zdCBhdHRyUmVnZXggPSAvKFthLXpdW2EtejAtOS1dKilccyo9XHMqKD86IihbXiJdKikifCcoW14nXSopJ3woW15ccz5dKykpL2dpOwoJbGV0IG1hdGNoOwoKCXdoaWxlICgobWF0Y2ggPSBhdHRyUmVnZXguZXhlYyh0YWdDb250ZW50KSkgIT09IG51bGwpIHsKCQljb25zdCBhdHRyTmFtZSA9IG1hdGNoWzFdLnRvTG93ZXJDYXNlKCk7CgkJY29uc3QgYXR0clZhbHVlID0gbWF0Y2hbMl0gPz8gbWF0Y2hbM10gPz8gbWF0Y2hbNF0gPz8gJyc7CgoJCWlmIChhbGxvd2VkQXR0cnMuaW5jbHVkZXMoYXR0ck5hbWUpKSB7CgkJCS8vIEJsb2NrIGRhbmdlcm91cyBVUkwgc2NoZW1lcwoJCQlpZiAoYXR0ck5hbWUgPT09ICdocmVmJykgewoJCQkJY29uc3QgbG93ZXJWYWx1ZSA9IGF0dHJWYWx1ZS50b0xvd2VyQ2FzZSgpLnRyaW0oKTsKCQkJCWlmIChsb3dlclZhbHVlLnN0YXJ0c1dpdGgoJ2phdmFzY3JpcHQ6JykgfHwgbG93ZXJWYWx1ZS5zdGFydHNXaXRoKCdkYXRhOicpKSB7CgkJCQkJY29udGludWU7CgkJCQl9CgkJCX0KCQkJLy8gT25seSBhbGxvdyBIVFRQUyBmb3IgaW1hZ2VzCgkJCWlmIChhdHRyTmFtZSA9PT0gJ3NyYycpIHsKCQkJCWlmICghYXR0clZhbHVlLnRvTG93ZXJDYXNlKCkuc3RhcnRzV2l0aCgnaHR0cHM6Ly8nKSkgewoJCQkJCWNvbnRpbnVlOwoJCQkJfQoJCQl9CgkJCWF0dHJzLnB1c2goYCR7YXR0ck5hbWV9PSIke2VzY2FwZUh0bWwoYXR0clZhbHVlKX0iYCk7CgkJfQoJfQoKCXJldHVybiBhdHRycy5sZW5ndGggPiAwID8gJyAnICsgYXR0cnMuam9pbignICcpIDogJyc7Cn0KCmZ1bmN0aW9uIGVzY2FwZUh0bWwodGV4dDogc3RyaW5nKTogc3RyaW5nIHsKCXJldHVybiB0ZXh0CgkJLnJlcGxhY2UoLyYvZywgJyZhbXA7JykKCQkucmVwbGFjZSgvPC9nLCAnJmx0OycpCgkJLnJlcGxhY2UoLz4vZywgJyZndDsnKQoJCS5yZXBsYWNlKC8iL2csICcmcXVvdDsnKTsKfQoKZnVuY3Rpb24gYnVpbGRDb21tZW50VHJlZShkZXNjZW5kYW50czogTWFzdG9kb25TdGF0dXNbXSwgcm9vdElkOiBzdHJpbmcpOiBNYXN0b2RvbkNvbW1lbnRbXSB7Cgljb25zdCBjb21tZW50TWFwID0gbmV3IE1hcDxzdHJpbmcsIE1hc3RvZG9uQ29tbWVudD4oKTsKCgkvLyBDcmVhdGUgYWxsIGNvbW1lbnQgbm9kZXMKCWZvciAoY29uc3Qgc3RhdHVzIG9mIGRlc2NlbmRhbnRzKSB7CgkJY29tbWVudE1hcC5zZXQoc3RhdHVzLmlkLCB7CgkJCWlkOiBzdGF0dXMuaWQsCgkJCWF1dGhvcjogewoJCQkJbmFtZTogc3RhdHVzLmFjY291bnQuZGlzcGxheV9uYW1lIHx8IHN0YXR1cy5hY2NvdW50LnVzZXJuYW1lLAoJCQkJaGFuZGxlOiBgQCR7c3RhdHVzLmFjY291bnQuYWNjdH1gLAoJCQkJYXZhdGFyOiBzdGF0dXMuYWNjb3VudC5hdmF0YXIsCgkJCQl1cmw6IHN0YXR1cy5hY2NvdW50LnVybAoJCQl9LAoJCQljb250ZW50OiBzYW5pdGl6ZUh0bWwoc3RhdHVzLmNvbnRlbnQpLAoJCQljcmVhdGVkQXQ6IHN0YXR1cy5jcmVhdGVkX2F0LAoJCQl1cmw6IHN0YXR1cy51cmwsCgkJCWZhdm91cml0ZXNDb3VudDogc3RhdHVzLmZhdm91cml0ZXNfY291bnQsCgkJCWNoaWxkcmVuOiBbXQoJCX0pOwoJfQoKCS8vIEJ1aWxkIHRyZWUgc3RydWN0dXJlCgljb25zdCByb290czogTWFzdG9kb25Db21tZW50W10gPSBbXTsKCWZvciAoY29uc3Qgc3RhdHVzIG9mIGRlc2NlbmRhbnRzKSB7CgkJY29uc3QgY29tbWVudCA9IGNvbW1lbnRNYXAuZ2V0KHN0YXR1cy5pZCkhOwoJCWlmIChzdGF0dXMuaW5fcmVwbHlfdG9faWQgPT09IHJvb3RJZCkgewoJCQlyb290cy5wdXNoKGNvbW1lbnQpOwoJCX0gZWxzZSB7CgkJCWNvbnN0IHBhcmVudCA9IGNvbW1lbnRNYXAuZ2V0KHN0YXR1cy5pbl9yZXBseV90b19pZCEpOwoJCQlpZiAocGFyZW50KSB7CgkJCQlwYXJlbnQuY2hpbGRyZW4ucHVzaChjb21tZW50KTsKCQkJfSBlbHNlIHsKCQkJCXJvb3RzLnB1c2goY29tbWVudCk7CgkJCX0KCQl9Cgl9CgoJcmV0dXJuIHJvb3RzOwp9CgpmdW5jdGlvbiBjb3VudENvbW1lbnRzKGNvbW1lbnRzOiBNYXN0b2RvbkNvbW1lbnRbXSk6IG51bWJlciB7CglsZXQgY291bnQgPSAwOwoJZm9yIChjb25zdCBjb21tZW50IG9mIGNvbW1lbnRzKSB7CgkJY291bnQgKz0gMSArIGNvdW50Q29tbWVudHMoY29tbWVudC5jaGlsZHJlbik7Cgl9CglyZXR1cm4gY291bnQ7Cn0KCmFzeW5jIGZ1bmN0aW9uIGZldGNoUmVwbGllcyhzdGF0dXNJZDogc3RyaW5nKTogUHJvbWlzZTxNYXN0b2RvbkNvbW1lbnRbXT4gewoJY29uc3QgcmVzID0gYXdhaXQgZmV0Y2goYCR7TUFTVE9ET05fSU5TVEFOQ0V9L2FwaS92MS9zdGF0dXNlcy8ke3N0YXR1c0lkfS9jb250ZXh0YCk7CglpZiAoIXJlcy5vaykgcmV0dXJuIFtdOwoKCWNvbnN0IGNvbnRleHQ6IE1hc3RvZG9uQ29udGV4dCA9IGF3YWl0IHJlcy5qc29uKCk7CglyZXR1cm4gYnVpbGRDb21tZW50VHJlZShjb250ZXh0LmRlc2NlbmRhbnRzLCBzdGF0dXNJZCk7Cn0KCmV4cG9ydCBhc3luYyBmdW5jdGlvbiBnZXRNYXN0b2RvbkNvbW1lbnRzKAoJc2x1Zzogc3RyaW5nLAoJcmVmcmVzaCA9IGZhbHNlCik6IFByb21pc2U8TWFzdG9kb25Db21tZW50c1Jlc3BvbnNlPiB7CgkvLyBDaGVjayBjYWNoZQoJaWYgKCFyZWZyZXNoKSB7CgkJY29uc3QgY2FjaGVkID0gY2FjaGUuZ2V0KHNsdWcpOwoJCWlmIChjYWNoZWQgJiYgRGF0ZS5ub3coKSAtIGNhY2hlZC50aW1lc3RhbXAgPCBDQUNIRV9UVExfTVMpIHsKCQkJcmV0dXJuIGNhY2hlZC5kYXRhOwoJCX0KCX0KCgljb25zdCBhY2NvdW50SWQgPSBhd2FpdCBsb29rdXBBY2NvdW50SWQoKTsKCWlmICghYWNjb3VudElkKSB7CgkJY2FjaGUuc2V0KHNsdWcsIHsgZGF0YTogRU1QVFlfUkVTUE9OU0UsIHRpbWVzdGFtcDogRGF0ZS5ub3coKSB9KTsKCQlyZXR1cm4gRU1QVFlfUkVTUE9OU0U7Cgl9CgoJY29uc3Qgc3RhdHVzID0gYXdhaXQgZmluZFN0YXR1c0ZvclNsdWcoYWNjb3VudElkLCBzbHVnKTsKCWlmICghc3RhdHVzKSB7CgkJY2FjaGUuc2V0KHNsdWcsIHsgZGF0YTogRU1QVFlfUkVTUE9OU0UsIHRpbWVzdGFtcDogRGF0ZS5ub3coKSB9KTsKCQlyZXR1cm4gRU1QVFlfUkVTUE9OU0U7Cgl9CgoJY29uc3QgY29tbWVudHMgPSBhd2FpdCBmZXRjaFJlcGxpZXMoc3RhdHVzLmlkKTsKCWNvbnN0IHJlc3BvbnNlOiBNYXN0b2RvbkNvbW1lbnRzUmVzcG9uc2UgPSB7CgkJbWFzdG9kb25Qb3N0VXJsOiBzdGF0dXMudXJsLAoJCWNvbW1lbnRzLAoJCWNvbW1lbnRDb3VudDogY291bnRDb21tZW50cyhjb21tZW50cyksCgkJZmF2b3VyaXRlc0NvdW50OiBzdGF0dXMuZmF2b3VyaXRlc19jb3VudAoJfTsKCgljYWNoZS5zZXQoc2x1ZywgeyBkYXRhOiByZXNwb25zZSwgdGltZXN0YW1wOiBEYXRlLm5vdygpIH0pOwoJcmV0dXJuIHJlc3BvbnNlOwp9" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-typescript"><!----><span class="hljs-comment">// Still in src/lib/server/mastodon.ts</span>

<span class="hljs-keyword">function</span> <span class="hljs-title function_">sanitizeHtml</span>(<span class="hljs-params"><span class="hljs-attr">html</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-built_in">string</span> {
	<span class="hljs-comment">// Allowlist approach - only permit safe tags and attributes</span>
	<span class="hljs-keyword">const</span> <span class="hljs-attr">allowedTags</span>: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>[]&gt; = {
		<span class="hljs-attr">p</span>: [<span class="hljs-string">&#x27;class&#x27;</span>],
		<span class="hljs-attr">br</span>: [],
		<span class="hljs-attr">a</span>: [<span class="hljs-string">&#x27;href&#x27;</span>, <span class="hljs-string">&#x27;rel&#x27;</span>, <span class="hljs-string">&#x27;class&#x27;</span>, <span class="hljs-string">&#x27;target&#x27;</span>],
		<span class="hljs-attr">span</span>: [<span class="hljs-string">&#x27;class&#x27;</span>],
		<span class="hljs-attr">img</span>: [<span class="hljs-string">&#x27;src&#x27;</span>, <span class="hljs-string">&#x27;alt&#x27;</span>, <span class="hljs-string">&#x27;title&#x27;</span>, <span class="hljs-string">&#x27;class&#x27;</span>, <span class="hljs-string">&#x27;width&#x27;</span>, <span class="hljs-string">&#x27;height&#x27;</span>],
		<span class="hljs-attr">strong</span>: [],
		<span class="hljs-attr">em</span>: [],
		<span class="hljs-attr">code</span>: [],
		<span class="hljs-attr">pre</span>: [],
		<span class="hljs-attr">blockquote</span>: []
	};

	<span class="hljs-keyword">let</span> result = <span class="hljs-string">&#x27;&#x27;</span>;
	<span class="hljs-keyword">let</span> pos = <span class="hljs-number">0</span>;

	<span class="hljs-keyword">while</span> (pos &lt; html.<span class="hljs-property">length</span>) {
		<span class="hljs-keyword">const</span> tagStart = html.<span class="hljs-title function_">indexOf</span>(<span class="hljs-string">&#x27;&lt;&#x27;</span>, pos);

		<span class="hljs-keyword">if</span> (tagStart === -<span class="hljs-number">1</span>) {
			result += html.<span class="hljs-title function_">slice</span>(pos);
			<span class="hljs-keyword">break</span>;
		}

		<span class="hljs-keyword">if</span> (tagStart &gt; pos) {
			result += html.<span class="hljs-title function_">slice</span>(pos, tagStart);
		}

		<span class="hljs-keyword">const</span> tagEnd = html.<span class="hljs-title function_">indexOf</span>(<span class="hljs-string">&#x27;&gt;&#x27;</span>, tagStart);
		<span class="hljs-keyword">if</span> (tagEnd === -<span class="hljs-number">1</span>) <span class="hljs-keyword">break</span>;

		<span class="hljs-keyword">const</span> tagContent = html.<span class="hljs-title function_">slice</span>(tagStart + <span class="hljs-number">1</span>, tagEnd);
		<span class="hljs-keyword">const</span> isClosing = tagContent.<span class="hljs-title function_">startsWith</span>(<span class="hljs-string">&#x27;/&#x27;</span>);
		<span class="hljs-keyword">const</span> tagPart = isClosing ? tagContent.<span class="hljs-title function_">slice</span>(<span class="hljs-number">1</span>) : tagContent;
		<span class="hljs-keyword">const</span> spaceIndex = tagPart.<span class="hljs-title function_">indexOf</span>(<span class="hljs-string">&#x27; &#x27;</span>);
		<span class="hljs-keyword">const</span> tagName = (spaceIndex === -<span class="hljs-number">1</span> ? tagPart : tagPart.<span class="hljs-title function_">slice</span>(<span class="hljs-number">0</span>, spaceIndex))
			.<span class="hljs-title function_">toLowerCase</span>()
			.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">//$/</span>, <span class="hljs-string">&#x27;&#x27;</span>);

		<span class="hljs-keyword">if</span> (tagName <span class="hljs-keyword">in</span> allowedTags) {
			<span class="hljs-keyword">if</span> (isClosing) {
				result += <span class="hljs-string">`&lt;/<span class="hljs-subst">${tagName}</span>&gt;`</span>;
			} <span class="hljs-keyword">else</span> {
				<span class="hljs-keyword">const</span> allowedAttrs = allowedTags[tagName];
				<span class="hljs-keyword">const</span> attrs = <span class="hljs-title function_">parseAttributes</span>(tagContent, allowedAttrs);
				<span class="hljs-keyword">const</span> selfClosing = tagContent.<span class="hljs-title function_">endsWith</span>(<span class="hljs-string">&#x27;/&#x27;</span>) || tagName === <span class="hljs-string">&#x27;br&#x27;</span> || tagName === <span class="hljs-string">&#x27;img&#x27;</span>;
				result += <span class="hljs-string">`&lt;<span class="hljs-subst">${tagName}</span><span class="hljs-subst">${attrs}</span><span class="hljs-subst">${selfClosing ? <span class="hljs-string">&#x27; /&#x27;</span> : <span class="hljs-string">&#x27;&#x27;</span>}</span>&gt;`</span>;
			}
		}

		pos = tagEnd + <span class="hljs-number">1</span>;
	}

	<span class="hljs-keyword">return</span> result;
}

<span class="hljs-keyword">function</span> <span class="hljs-title function_">parseAttributes</span>(<span class="hljs-params"><span class="hljs-attr">tagContent</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">allowedAttrs</span>: <span class="hljs-built_in">string</span>[]</span>): <span class="hljs-built_in">string</span> {
	<span class="hljs-keyword">if</span> (allowedAttrs.<span class="hljs-property">length</span> === <span class="hljs-number">0</span>) <span class="hljs-keyword">return</span> <span class="hljs-string">&#x27;&#x27;</span>;

	<span class="hljs-keyword">const</span> <span class="hljs-attr">attrs</span>: <span class="hljs-built_in">string</span>[] = [];
	<span class="hljs-keyword">const</span> attrRegex = <span class="hljs-regexp">/([a-z][a-z0-9-]*)s*=s*(?:&quot;([^&quot;]*)&quot;|&#x27;([^&#x27;]*)&#x27;|([^s&gt;]+))/gi</span>;
	<span class="hljs-keyword">let</span> match;

	<span class="hljs-keyword">while</span> ((match = attrRegex.<span class="hljs-title function_">exec</span>(tagContent)) !== <span class="hljs-literal">null</span>) {
		<span class="hljs-keyword">const</span> attrName = match[<span class="hljs-number">1</span>].<span class="hljs-title function_">toLowerCase</span>();
		<span class="hljs-keyword">const</span> attrValue = match[<span class="hljs-number">2</span>] ?? match[<span class="hljs-number">3</span>] ?? match[<span class="hljs-number">4</span>] ?? <span class="hljs-string">&#x27;&#x27;</span>;

		<span class="hljs-keyword">if</span> (allowedAttrs.<span class="hljs-title function_">includes</span>(attrName)) {
			<span class="hljs-comment">// Block dangerous URL schemes</span>
			<span class="hljs-keyword">if</span> (attrName === <span class="hljs-string">&#x27;href&#x27;</span>) {
				<span class="hljs-keyword">const</span> lowerValue = attrValue.<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">trim</span>();
				<span class="hljs-keyword">if</span> (lowerValue.<span class="hljs-title function_">startsWith</span>(<span class="hljs-string">&#x27;javascript:&#x27;</span>) || lowerValue.<span class="hljs-title function_">startsWith</span>(<span class="hljs-string">&#x27;data:&#x27;</span>)) {
					<span class="hljs-keyword">continue</span>;
				}
			}
			<span class="hljs-comment">// Only allow HTTPS for images</span>
			<span class="hljs-keyword">if</span> (attrName === <span class="hljs-string">&#x27;src&#x27;</span>) {
				<span class="hljs-keyword">if</span> (!attrValue.<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">startsWith</span>(<span class="hljs-string">&#x27;https://&#x27;</span>)) {
					<span class="hljs-keyword">continue</span>;
				}
			}
			attrs.<span class="hljs-title function_">push</span>(<span class="hljs-string">`<span class="hljs-subst">${attrName}</span>=&quot;<span class="hljs-subst">${escapeHtml(attrValue)}</span>&quot;`</span>);
		}
	}

	<span class="hljs-keyword">return</span> attrs.<span class="hljs-property">length</span> &gt; <span class="hljs-number">0</span> ? <span class="hljs-string">&#x27; &#x27;</span> + attrs.<span class="hljs-title function_">join</span>(<span class="hljs-string">&#x27; &#x27;</span>) : <span class="hljs-string">&#x27;&#x27;</span>;
}

<span class="hljs-keyword">function</span> <span class="hljs-title function_">escapeHtml</span>(<span class="hljs-params"><span class="hljs-attr">text</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-built_in">string</span> {
	<span class="hljs-keyword">return</span> text
		.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/&amp;/g</span>, <span class="hljs-string">&#x27;&amp;amp;&#x27;</span>)
		.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/&lt;/g</span>, <span class="hljs-string">&#x27;&amp;lt;&#x27;</span>)
		.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/&gt;/g</span>, <span class="hljs-string">&#x27;&amp;gt;&#x27;</span>)
		.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/&quot;/g</span>, <span class="hljs-string">&#x27;&amp;quot;&#x27;</span>);
}

<span class="hljs-keyword">function</span> <span class="hljs-title function_">buildCommentTree</span>(<span class="hljs-params"><span class="hljs-attr">descendants</span>: <span class="hljs-title class_">MastodonStatus</span>[], <span class="hljs-attr">rootId</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-title class_">MastodonComment</span>[] {
	<span class="hljs-keyword">const</span> commentMap = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Map</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-title class_">MastodonComment</span>&gt;();

	<span class="hljs-comment">// Create all comment nodes</span>
	<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> status <span class="hljs-keyword">of</span> descendants) {
		commentMap.<span class="hljs-title function_">set</span>(status.<span class="hljs-property">id</span>, {
			<span class="hljs-attr">id</span>: status.<span class="hljs-property">id</span>,
			<span class="hljs-attr">author</span>: {
				<span class="hljs-attr">name</span>: status.<span class="hljs-property">account</span>.<span class="hljs-property">display_name</span> || status.<span class="hljs-property">account</span>.<span class="hljs-property">username</span>,
				<span class="hljs-attr">handle</span>: <span class="hljs-string">`@<span class="hljs-subst">${status.account.acct}</span>`</span>,
				<span class="hljs-attr">avatar</span>: status.<span class="hljs-property">account</span>.<span class="hljs-property">avatar</span>,
				<span class="hljs-attr">url</span>: status.<span class="hljs-property">account</span>.<span class="hljs-property">url</span>
			},
			<span class="hljs-attr">content</span>: <span class="hljs-title function_">sanitizeHtml</span>(status.<span class="hljs-property">content</span>),
			<span class="hljs-attr">createdAt</span>: status.<span class="hljs-property">created_at</span>,
			<span class="hljs-attr">url</span>: status.<span class="hljs-property">url</span>,
			<span class="hljs-attr">favouritesCount</span>: status.<span class="hljs-property">favourites_count</span>,
			<span class="hljs-attr">children</span>: []
		});
	}

	<span class="hljs-comment">// Build tree structure</span>
	<span class="hljs-keyword">const</span> <span class="hljs-attr">roots</span>: <span class="hljs-title class_">MastodonComment</span>[] = [];
	<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> status <span class="hljs-keyword">of</span> descendants) {
		<span class="hljs-keyword">const</span> comment = commentMap.<span class="hljs-title function_">get</span>(status.<span class="hljs-property">id</span>)!;
		<span class="hljs-keyword">if</span> (status.<span class="hljs-property">in_reply_to_id</span> === rootId) {
			roots.<span class="hljs-title function_">push</span>(comment);
		} <span class="hljs-keyword">else</span> {
			<span class="hljs-keyword">const</span> parent = commentMap.<span class="hljs-title function_">get</span>(status.<span class="hljs-property">in_reply_to_id</span>!);
			<span class="hljs-keyword">if</span> (parent) {
				parent.<span class="hljs-property">children</span>.<span class="hljs-title function_">push</span>(comment);
			} <span class="hljs-keyword">else</span> {
				roots.<span class="hljs-title function_">push</span>(comment);
			}
		}
	}

	<span class="hljs-keyword">return</span> roots;
}

<span class="hljs-keyword">function</span> <span class="hljs-title function_">countComments</span>(<span class="hljs-params"><span class="hljs-attr">comments</span>: <span class="hljs-title class_">MastodonComment</span>[]</span>): <span class="hljs-built_in">number</span> {
	<span class="hljs-keyword">let</span> count = <span class="hljs-number">0</span>;
	<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> comment <span class="hljs-keyword">of</span> comments) {
		count += <span class="hljs-number">1</span> + <span class="hljs-title function_">countComments</span>(comment.<span class="hljs-property">children</span>);
	}
	<span class="hljs-keyword">return</span> count;
}

<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">fetchReplies</span>(<span class="hljs-params"><span class="hljs-attr">statusId</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">MastodonComment</span>[]&gt; {
	<span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`<span class="hljs-subst">${MASTODON_INSTANCE}</span>/api/v1/statuses/<span class="hljs-subst">${statusId}</span>/context`</span>);
	<span class="hljs-keyword">if</span> (!res.<span class="hljs-property">ok</span>) <span class="hljs-keyword">return</span> [];

	<span class="hljs-keyword">const</span> <span class="hljs-attr">context</span>: <span class="hljs-title class_">MastodonContext</span> = <span class="hljs-keyword">await</span> res.<span class="hljs-title function_">json</span>();
	<span class="hljs-keyword">return</span> <span class="hljs-title function_">buildCommentTree</span>(context.<span class="hljs-property">descendants</span>, statusId);
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getMastodonComments</span>(<span class="hljs-params">
	<span class="hljs-attr">slug</span>: <span class="hljs-built_in">string</span>,
	refresh = <span class="hljs-literal">false</span>
</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">MastodonCommentsResponse</span>&gt; {
	<span class="hljs-comment">// Check cache</span>
	<span class="hljs-keyword">if</span> (!refresh) {
		<span class="hljs-keyword">const</span> cached = cache.<span class="hljs-title function_">get</span>(slug);
		<span class="hljs-keyword">if</span> (cached &amp;&amp; <span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>() - cached.<span class="hljs-property">timestamp</span> &lt; <span class="hljs-variable constant_">CACHE_TTL_MS</span>) {
			<span class="hljs-keyword">return</span> cached.<span class="hljs-property">data</span>;
		}
	}

	<span class="hljs-keyword">const</span> accountId = <span class="hljs-keyword">await</span> <span class="hljs-title function_">lookupAccountId</span>();
	<span class="hljs-keyword">if</span> (!accountId) {
		cache.<span class="hljs-title function_">set</span>(slug, { <span class="hljs-attr">data</span>: <span class="hljs-variable constant_">EMPTY_RESPONSE</span>, <span class="hljs-attr">timestamp</span>: <span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>() });
		<span class="hljs-keyword">return</span> <span class="hljs-variable constant_">EMPTY_RESPONSE</span>;
	}

	<span class="hljs-keyword">const</span> status = <span class="hljs-keyword">await</span> <span class="hljs-title function_">findStatusForSlug</span>(accountId, slug);
	<span class="hljs-keyword">if</span> (!status) {
		cache.<span class="hljs-title function_">set</span>(slug, { <span class="hljs-attr">data</span>: <span class="hljs-variable constant_">EMPTY_RESPONSE</span>, <span class="hljs-attr">timestamp</span>: <span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>() });
		<span class="hljs-keyword">return</span> <span class="hljs-variable constant_">EMPTY_RESPONSE</span>;
	}

	<span class="hljs-keyword">const</span> comments = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetchReplies</span>(status.<span class="hljs-property">id</span>);
	<span class="hljs-keyword">const</span> <span class="hljs-attr">response</span>: <span class="hljs-title class_">MastodonCommentsResponse</span> = {
		<span class="hljs-attr">mastodonPostUrl</span>: status.<span class="hljs-property">url</span>,
		comments,
		<span class="hljs-attr">commentCount</span>: <span class="hljs-title function_">countComments</span>(comments),
		<span class="hljs-attr">favouritesCount</span>: status.<span class="hljs-property">favourites_count</span>
	};

	cache.<span class="hljs-title function_">set</span>(slug, { <span class="hljs-attr">data</span>: response, <span class="hljs-attr">timestamp</span>: <span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>() });
	<span class="hljs-keyword">return</span> response;
}<!----></code></pre></div> <h1>Step 3: API Endpoints</h1> <p>Create a simple API endpoint to serve comments:</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="Ly8gc3JjL3JvdXRlcy9hcGkvbWFzdG9kb24tY29tbWVudHMvK3NlcnZlci50cwoKaW1wb3J0IHsganNvbiB9IGZyb20gJ0BzdmVsdGVqcy9raXQnOwppbXBvcnQgdHlwZSB7IFJlcXVlc3RIYW5kbGVyIH0gZnJvbSAnLi8kdHlwZXMnOwppbXBvcnQgeyBnZXRNYXN0b2RvbkNvbW1lbnRzIH0gZnJvbSAnJGxpYi9zZXJ2ZXIvbWFzdG9kb24nOwoKZXhwb3J0IGNvbnN0IEdFVDogUmVxdWVzdEhhbmRsZXIgPSBhc3luYyAoeyB1cmwgfSkgPT4gewoJY29uc3Qgc2x1ZyA9IHVybC5zZWFyY2hQYXJhbXMuZ2V0KCdzbHVnJyk7CglpZiAoIXNsdWcpIHsKCQlyZXR1cm4ganNvbih7IGVycm9yOiAnTWlzc2luZyBzbHVnIHBhcmFtZXRlcicgfSwgeyBzdGF0dXM6IDQwMCB9KTsKCX0KCgljb25zdCByZWZyZXNoID0gdXJsLnNlYXJjaFBhcmFtcy5nZXQoJ3JlZnJlc2gnKSA9PT0gJ3RydWUnOwoJY29uc3QgZGF0YSA9IGF3YWl0IGdldE1hc3RvZG9uQ29tbWVudHMoc2x1ZywgcmVmcmVzaCk7CglyZXR1cm4ganNvbihkYXRhKTsKfTs=" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-typescript"><!----><span class="hljs-comment">// src/routes/api/mastodon-comments/+server.ts</span>

<span class="hljs-keyword">import</span> { json } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@sveltejs/kit&#x27;</span>;
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">RequestHandler</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;./$types&#x27;</span>;
<span class="hljs-keyword">import</span> { getMastodonComments } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;$lib/server/mastodon&#x27;</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-attr">GET</span>: <span class="hljs-title class_">RequestHandler</span> = <span class="hljs-title function_">async</span> ({ url }) =&gt; {
	<span class="hljs-keyword">const</span> slug = url.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">&#x27;slug&#x27;</span>);
	<span class="hljs-keyword">if</span> (!slug) {
		<span class="hljs-keyword">return</span> <span class="hljs-title function_">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">&#x27;Missing slug parameter&#x27;</span> }, { <span class="hljs-attr">status</span>: <span class="hljs-number">400</span> });
	}

	<span class="hljs-keyword">const</span> refresh = url.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">&#x27;refresh&#x27;</span>) === <span class="hljs-string">&#x27;true&#x27;</span>;
	<span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getMastodonComments</span>(slug, refresh);
	<span class="hljs-keyword">return</span> <span class="hljs-title function_">json</span>(data);
};<!----></code></pre></div> <p>For the instance selector feature, we use the Fediverse Observer API to search instances:</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="Ly8gc3JjL3JvdXRlcy9hcGkvZmVkaXZlcnNlLWluc3RhbmNlcy8rc2VydmVyLnRzCgppbXBvcnQgeyBqc29uIH0gZnJvbSAnQHN2ZWx0ZWpzL2tpdCc7CmltcG9ydCB0eXBlIHsgUmVxdWVzdEhhbmRsZXIgfSBmcm9tICcuLyR0eXBlcyc7CgppbnRlcmZhY2UgRmVkaXZlcnNlSW5zdGFuY2UgewoJZG9tYWluOiBzdHJpbmc7CgluYW1lOiBzdHJpbmcgfCBudWxsOwoJc29mdHdhcmVuYW1lOiBzdHJpbmcgfCBudWxsOwoJdG90YWxfdXNlcnM6IG51bWJlciB8IG51bGw7CglhY3RpdmVfdXNlcnNfbW9udGhseTogbnVtYmVyIHwgbnVsbDsKfQoKaW50ZXJmYWNlIENhY2hlZERhdGEgewoJaW5zdGFuY2VzOiBGZWRpdmVyc2VJbnN0YW5jZVtdOwoJZmV0Y2hlZEF0OiBudW1iZXI7Cn0KCmNvbnN0IENBQ0hFX1RUTF9NUyA9IDI0ICogNjAgKiA2MCAqIDEwMDA7IC8vIDI0IGhvdXJzCmxldCBjYWNoZTogQ2FjaGVkRGF0YSB8IG51bGwgPSBudWxsOwoKYXN5bmMgZnVuY3Rpb24gZmV0Y2hJbnN0YW5jZXMoKTogUHJvbWlzZTxGZWRpdmVyc2VJbnN0YW5jZVtdPiB7Cgljb25zdCBxdWVyeSA9IGB7CgkJbm9kZXMgewoJCQlkb21haW4KCQkJbmFtZQoJCQlzb2Z0d2FyZW5hbWUKCQkJdG90YWxfdXNlcnMKCQkJYWN0aXZlX3VzZXJzX21vbnRobHkKCQl9Cgl9YDsKCgljb25zdCByZXNwb25zZSA9IGF3YWl0IGZldGNoKCdodHRwczovL2FwaS5mZWRpdmVyc2Uub2JzZXJ2ZXIvJywgewoJCW1ldGhvZDogJ1BPU1QnLAoJCWhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LAoJCWJvZHk6IEpTT04uc3RyaW5naWZ5KHsgcXVlcnkgfSkKCX0pOwoKCWlmICghcmVzcG9uc2Uub2spIHsKCQl0aHJvdyBuZXcgRXJyb3IoYEZlZGl2ZXJzZSBPYnNlcnZlciBBUEkgZXJyb3I6ICR7cmVzcG9uc2Uuc3RhdHVzfWApOwoJfQoKCWNvbnN0IGRhdGEgPSBhd2FpdCByZXNwb25zZS5qc29uKCk7CglyZXR1cm4gZGF0YS5kYXRhPy5ub2RlcyA/PyBbXTsKfQoKYXN5bmMgZnVuY3Rpb24gZ2V0SW5zdGFuY2VzKCk6IFByb21pc2U8RmVkaXZlcnNlSW5zdGFuY2VbXT4gewoJY29uc3Qgbm93ID0gRGF0ZS5ub3coKTsKCglpZiAoY2FjaGUgJiYgbm93IC0gY2FjaGUuZmV0Y2hlZEF0IDwgQ0FDSEVfVFRMX01TKSB7CgkJcmV0dXJuIGNhY2hlLmluc3RhbmNlczsKCX0KCgljb25zdCBpbnN0YW5jZXMgPSBhd2FpdCBmZXRjaEluc3RhbmNlcygpOwoJY2FjaGUgPSB7IGluc3RhbmNlcywgZmV0Y2hlZEF0OiBub3cgfTsKCXJldHVybiBpbnN0YW5jZXM7Cn0KCmV4cG9ydCBjb25zdCBHRVQ6IFJlcXVlc3RIYW5kbGVyID0gYXN5bmMgKHsgdXJsIH0pID0+IHsKCWNvbnN0IHF1ZXJ5ID0gdXJsLnNlYXJjaFBhcmFtcy5nZXQoJ3EnKT8udG9Mb3dlckNhc2UoKS50cmltKCk7Cgljb25zdCBsaW1pdCA9IE1hdGgubWluKHBhcnNlSW50KHVybC5zZWFyY2hQYXJhbXMuZ2V0KCdsaW1pdCcpID8/ICcxMCcsIDEwKSwgNTApOwoKCWlmICghcXVlcnkgfHwgcXVlcnkubGVuZ3RoIDwgMikgewoJCXJldHVybiBqc29uKHsgaW5zdGFuY2VzOiBbXSB9KTsKCX0KCgljb25zdCBhbGxJbnN0YW5jZXMgPSBhd2FpdCBnZXRJbnN0YW5jZXMoKTsKCgljb25zdCBtYXRjaGVzID0gYWxsSW5zdGFuY2VzCgkJLmZpbHRlcigoaW5zdGFuY2UpID0+IHsKCQkJaWYgKCFpbnN0YW5jZS5kb21haW4pIHJldHVybiBmYWxzZTsKCQkJY29uc3QgZG9tYWluTWF0Y2ggPSBpbnN0YW5jZS5kb21haW4udG9Mb3dlckNhc2UoKS5pbmNsdWRlcyhxdWVyeSk7CgkJCWNvbnN0IG5hbWVNYXRjaCA9IGluc3RhbmNlLm5hbWU/LnRvTG93ZXJDYXNlKCkuaW5jbHVkZXMocXVlcnkpOwoJCQlyZXR1cm4gZG9tYWluTWF0Y2ggfHwgbmFtZU1hdGNoOwoJCX0pCgkJLnNvcnQoKGEsIGIpID0+IHsKCQkJLy8gUHJpb3JpdGl6ZSBleGFjdCBwcmVmaXggbWF0Y2hlcwoJCQljb25zdCBhU3RhcnRzV2l0aCA9IGEuZG9tYWluLnRvTG93ZXJDYXNlKCkuc3RhcnRzV2l0aChxdWVyeSkgPyAxIDogMDsKCQkJY29uc3QgYlN0YXJ0c1dpdGggPSBiLmRvbWFpbi50b0xvd2VyQ2FzZSgpLnN0YXJ0c1dpdGgocXVlcnkpID8gMSA6IDA7CgkJCWlmIChhU3RhcnRzV2l0aCAhPT0gYlN0YXJ0c1dpdGgpIHJldHVybiBiU3RhcnRzV2l0aCAtIGFTdGFydHNXaXRoOwoKCQkJLy8gVGhlbiBieSBhY3RpdmUgdXNlcnMKCQkJY29uc3QgYVVzZXJzID0gYS5hY3RpdmVfdXNlcnNfbW9udGhseSA/PyAwOwoJCQljb25zdCBiVXNlcnMgPSBiLmFjdGl2ZV91c2Vyc19tb250aGx5ID8/IDA7CgkJCXJldHVybiBiVXNlcnMgLSBhVXNlcnM7CgkJfSkKCQkuc2xpY2UoMCwgbGltaXQpCgkJLm1hcCgoaW5zdGFuY2UpID0+ICh7CgkJCWRvbWFpbjogaW5zdGFuY2UuZG9tYWluLAoJCQluYW1lOiBpbnN0YW5jZS5uYW1lLAoJCQlzb2Z0d2FyZTogaW5zdGFuY2Uuc29mdHdhcmVuYW1lLAoJCQl1c2VyczogaW5zdGFuY2UuYWN0aXZlX3VzZXJzX21vbnRobHkgPz8gaW5zdGFuY2UudG90YWxfdXNlcnMKCQl9KSk7CgoJcmV0dXJuIGpzb24oeyBpbnN0YW5jZXM6IG1hdGNoZXMgfSk7Cn07" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-typescript"><!----><span class="hljs-comment">// src/routes/api/fediverse-instances/+server.ts</span>

<span class="hljs-keyword">import</span> { json } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@sveltejs/kit&#x27;</span>;
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">RequestHandler</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;./$types&#x27;</span>;

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">FediverseInstance</span> {
	<span class="hljs-attr">domain</span>: <span class="hljs-built_in">string</span>;
	<span class="hljs-attr">name</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>;
	<span class="hljs-attr">softwarename</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>;
	<span class="hljs-attr">total_users</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
	<span class="hljs-attr">active_users_monthly</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">CachedData</span> {
	<span class="hljs-attr">instances</span>: <span class="hljs-title class_">FediverseInstance</span>[];
	<span class="hljs-attr">fetchedAt</span>: <span class="hljs-built_in">number</span>;
}

<span class="hljs-keyword">const</span> <span class="hljs-variable constant_">CACHE_TTL_MS</span> = <span class="hljs-number">24</span> * <span class="hljs-number">60</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>; <span class="hljs-comment">// 24 hours</span>
<span class="hljs-keyword">let</span> <span class="hljs-attr">cache</span>: <span class="hljs-title class_">CachedData</span> | <span class="hljs-literal">null</span> = <span class="hljs-literal">null</span>;

<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">fetchInstances</span>(<span class="hljs-params"></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">FediverseInstance</span>[]&gt; {
	<span class="hljs-keyword">const</span> query = <span class="hljs-string">`{
		nodes {
			domain
			name
			softwarename
			total_users
			active_users_monthly
		}
	}`</span>;

	<span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">&#x27;https://api.fediverse.observer/&#x27;</span>, {
		<span class="hljs-attr">method</span>: <span class="hljs-string">&#x27;POST&#x27;</span>,
		<span class="hljs-attr">headers</span>: { <span class="hljs-string">&#x27;Content-Type&#x27;</span>: <span class="hljs-string">&#x27;application/json&#x27;</span> },
		<span class="hljs-attr">body</span>: <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ query })
	});

	<span class="hljs-keyword">if</span> (!response.<span class="hljs-property">ok</span>) {
		<span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">`Fediverse Observer API error: <span class="hljs-subst">${response.status}</span>`</span>);
	}

	<span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> response.<span class="hljs-title function_">json</span>();
	<span class="hljs-keyword">return</span> data.<span class="hljs-property">data</span>?.<span class="hljs-property">nodes</span> ?? [];
}

<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getInstances</span>(<span class="hljs-params"></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">FediverseInstance</span>[]&gt; {
	<span class="hljs-keyword">const</span> now = <span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>();

	<span class="hljs-keyword">if</span> (cache &amp;&amp; now - cache.<span class="hljs-property">fetchedAt</span> &lt; <span class="hljs-variable constant_">CACHE_TTL_MS</span>) {
		<span class="hljs-keyword">return</span> cache.<span class="hljs-property">instances</span>;
	}

	<span class="hljs-keyword">const</span> instances = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetchInstances</span>();
	cache = { instances, <span class="hljs-attr">fetchedAt</span>: now };
	<span class="hljs-keyword">return</span> instances;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-attr">GET</span>: <span class="hljs-title class_">RequestHandler</span> = <span class="hljs-title function_">async</span> ({ url }) =&gt; {
	<span class="hljs-keyword">const</span> query = url.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">&#x27;q&#x27;</span>)?.<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">trim</span>();
	<span class="hljs-keyword">const</span> limit = <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">min</span>(<span class="hljs-built_in">parseInt</span>(url.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">&#x27;limit&#x27;</span>) ?? <span class="hljs-string">&#x27;10&#x27;</span>, <span class="hljs-number">10</span>), <span class="hljs-number">50</span>);

	<span class="hljs-keyword">if</span> (!query || query.<span class="hljs-property">length</span> &lt; <span class="hljs-number">2</span>) {
		<span class="hljs-keyword">return</span> <span class="hljs-title function_">json</span>({ <span class="hljs-attr">instances</span>: [] });
	}

	<span class="hljs-keyword">const</span> allInstances = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getInstances</span>();

	<span class="hljs-keyword">const</span> matches = allInstances
		.<span class="hljs-title function_">filter</span>(<span class="hljs-function">(<span class="hljs-params">instance</span>) =&gt;</span> {
			<span class="hljs-keyword">if</span> (!instance.<span class="hljs-property">domain</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
			<span class="hljs-keyword">const</span> domainMatch = instance.<span class="hljs-property">domain</span>.<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">includes</span>(query);
			<span class="hljs-keyword">const</span> nameMatch = instance.<span class="hljs-property">name</span>?.<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">includes</span>(query);
			<span class="hljs-keyword">return</span> domainMatch || nameMatch;
		})
		.<span class="hljs-title function_">sort</span>(<span class="hljs-function">(<span class="hljs-params">a, b</span>) =&gt;</span> {
			<span class="hljs-comment">// Prioritize exact prefix matches</span>
			<span class="hljs-keyword">const</span> aStartsWith = a.<span class="hljs-property">domain</span>.<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">startsWith</span>(query) ? <span class="hljs-number">1</span> : <span class="hljs-number">0</span>;
			<span class="hljs-keyword">const</span> bStartsWith = b.<span class="hljs-property">domain</span>.<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">startsWith</span>(query) ? <span class="hljs-number">1</span> : <span class="hljs-number">0</span>;
			<span class="hljs-keyword">if</span> (aStartsWith !== bStartsWith) <span class="hljs-keyword">return</span> bStartsWith - aStartsWith;

			<span class="hljs-comment">// Then by active users</span>
			<span class="hljs-keyword">const</span> aUsers = a.<span class="hljs-property">active_users_monthly</span> ?? <span class="hljs-number">0</span>;
			<span class="hljs-keyword">const</span> bUsers = b.<span class="hljs-property">active_users_monthly</span> ?? <span class="hljs-number">0</span>;
			<span class="hljs-keyword">return</span> bUsers - aUsers;
		})
		.<span class="hljs-title function_">slice</span>(<span class="hljs-number">0</span>, limit)
		.<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">instance</span>) =&gt;</span> ({
			<span class="hljs-attr">domain</span>: instance.<span class="hljs-property">domain</span>,
			<span class="hljs-attr">name</span>: instance.<span class="hljs-property">name</span>,
			<span class="hljs-attr">software</span>: instance.<span class="hljs-property">softwarename</span>,
			<span class="hljs-attr">users</span>: instance.<span class="hljs-property">active_users_monthly</span> ?? instance.<span class="hljs-property">total_users</span>
		}));

	<span class="hljs-keyword">return</span> <span class="hljs-title function_">json</span>({ <span class="hljs-attr">instances</span>: matches });
};<!----></code></pre></div> <h1>Step 4: The Comment Component</h1> <p>Now the fun part — the Svelte component that renders comments and handles the instance selector.</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="PCEtLSBzcmMvbGliL2NvbXBvbmVudHMvTWFzdG9kb25Db21tZW50cy5zdmVsdGUgLS0+CjxzY3JpcHQgbGFuZz0idHMiPgoJaW1wb3J0IHsgb25Nb3VudCB9IGZyb20gJ3N2ZWx0ZSc7CglpbXBvcnQgdHlwZSB7IE1hc3RvZG9uQ29tbWVudCwgTWFzdG9kb25Db21tZW50c1Jlc3BvbnNlIH0gZnJvbSAnJGxpYi90eXBlcy9tYXN0b2Rvbic7CgoJaW50ZXJmYWNlIFByb3BzIHsKCQlzbHVnOiBzdHJpbmc7Cgl9CgoJbGV0IHsgc2x1ZyB9OiBQcm9wcyA9ICRwcm9wcygpOwoKCWxldCBjb21tZW50czogTWFzdG9kb25Db21tZW50W10gPSAkc3RhdGUoW10pOwoJbGV0IG1hc3RvZG9uUG9zdFVybDogc3RyaW5nIHwgbnVsbCA9ICRzdGF0ZShudWxsKTsKCWxldCBmYXZvdXJpdGVzQ291bnQgPSAkc3RhdGUoMCk7CglsZXQgY29tbWVudENvdW50ID0gJHN0YXRlKDApOwoJbGV0IGxvYWRpbmcgPSAkc3RhdGUodHJ1ZSk7CglsZXQgZXJyb3IgPSAkc3RhdGUoZmFsc2UpOwoKCS8vIEluc3RhbmNlIHNlbGVjdG9yIHN0YXRlCglsZXQgc2hvd0luc3RhbmNlU2VsZWN0b3IgPSAkc3RhdGUoZmFsc2UpOwoJbGV0IGluc3RhbmNlU2VhcmNoUXVlcnkgPSAkc3RhdGUoJycpOwoJbGV0IHNlbGVjdGVkSW5zdGFuY2U6IHN0cmluZyB8IG51bGwgPSAkc3RhdGUobnVsbCk7CglsZXQgc2VhcmNoUmVzdWx0czogQXJyYXk8ewoJCWRvbWFpbjogc3RyaW5nOwoJCW5hbWU6IHN0cmluZyB8IG51bGw7CgkJc29mdHdhcmU6IHN0cmluZyB8IG51bGw7CgkJdXNlcnM6IG51bWJlciB8IG51bGw7Cgl9PiA9ICRzdGF0ZShbXSk7CglsZXQgaXNTZWFyY2hpbmcgPSAkc3RhdGUoZmFsc2UpOwoJbGV0IHNlYXJjaFRpbWVvdXQ6IFJldHVyblR5cGU8dHlwZW9mIHNldFRpbWVvdXQ+IHwgdW5kZWZpbmVkOwoKCWNvbnN0IERFRkFVTFRfSU5TVEFOQ0UgPSAndGhlZm9ya2l2ZXJzZS5jb20nOwoJY29uc3QgU1RPUkFHRV9LRVkgPSAnZmVkaXZlcnNlLWluc3RhbmNlJzsKCWNvbnN0IFBPUFVMQVJfSU5TVEFOQ0VTID0gWydtYXN0b2Rvbi5zb2NpYWwnLCAnaGFjaHlkZXJtLmlvJywgJ2Zvc3N0b2Rvbi5vcmcnXTsKCgljb25zdCBlZmZlY3RpdmVJbnN0YW5jZSA9ICRkZXJpdmVkKHNlbGVjdGVkSW5zdGFuY2UgfHwgREVGQVVMVF9JTlNUQU5DRSk7Cgljb25zdCBpc1VzaW5nQ3VzdG9tSW5zdGFuY2UgPSAkZGVyaXZlZChlZmZlY3RpdmVJbnN0YW5jZSAhPT0gREVGQVVMVF9JTlNUQU5DRSk7CgoJZnVuY3Rpb24gbm9ybWFsaXplSW5zdGFuY2UoaW5wdXQ6IHN0cmluZyk6IHN0cmluZyB8IG51bGwgewoJCWxldCBub3JtYWxpemVkID0gaW5wdXQudHJpbSgpLnRvTG93ZXJDYXNlKCk7CgkJbm9ybWFsaXplZCA9IG5vcm1hbGl6ZWQucmVwbGFjZSgvXmh0dHBzPzpcL1wvLywgJycpOwoJCW5vcm1hbGl6ZWQgPSBub3JtYWxpemVkLnNwbGl0KCcvJylbMF07CgkJaWYgKAoJCQkhbm9ybWFsaXplZCB8fAoJCQkhL15bYS16MC05XShbYS16MC05LV0qW2EtejAtOV0pPyhcLlthLXowLTldKFthLXowLTktXSpbYS16MC05XSk/KSskLy50ZXN0KG5vcm1hbGl6ZWQpCgkJKSB7CgkJCXJldHVybiBudWxsOwoJCX0KCQlyZXR1cm4gbm9ybWFsaXplZDsKCX0KCglmdW5jdGlvbiBzYXZlSW5zdGFuY2UoaW5zdGFuY2U6IHN0cmluZyB8IG51bGwpIHsKCQlpZiAoaW5zdGFuY2UgJiYgaW5zdGFuY2UgIT09IERFRkFVTFRfSU5TVEFOQ0UpIHsKCQkJbG9jYWxTdG9yYWdlLnNldEl0ZW0oU1RPUkFHRV9LRVksIGluc3RhbmNlKTsKCQl9IGVsc2UgewoJCQlsb2NhbFN0b3JhZ2UucmVtb3ZlSXRlbShTVE9SQUdFX0tFWSk7CgkJfQoJCXNlbGVjdGVkSW5zdGFuY2UgPSBpbnN0YW5jZTsKCQlzaG93SW5zdGFuY2VTZWxlY3RvciA9IGZhbHNlOwoJCXNlYXJjaFJlc3VsdHMgPSBbXTsKCX0KCglmdW5jdGlvbiBoYW5kbGVTZWFyY2hJbnB1dCgpIHsKCQlpZiAoc2VhcmNoVGltZW91dCkgY2xlYXJUaW1lb3V0KHNlYXJjaFRpbWVvdXQpOwoKCQljb25zdCBxdWVyeSA9IGluc3RhbmNlU2VhcmNoUXVlcnkudHJpbSgpOwoJCWlmIChxdWVyeS5sZW5ndGggPCAyKSB7CgkJCXNlYXJjaFJlc3VsdHMgPSBbXTsKCQkJaXNTZWFyY2hpbmcgPSBmYWxzZTsKCQkJcmV0dXJuOwoJCX0KCgkJaXNTZWFyY2hpbmcgPSB0cnVlOwoJCXNlYXJjaFRpbWVvdXQgPSBzZXRUaW1lb3V0KGFzeW5jICgpID0+IHsKCQkJY29uc3QgcmVzID0gYXdhaXQgZmV0Y2goYC9hcGkvZmVkaXZlcnNlLWluc3RhbmNlcz9xPSR7ZW5jb2RlVVJJQ29tcG9uZW50KHF1ZXJ5KX0mbGltaXQ9OGApOwoJCQlpZiAocmVzLm9rKSB7CgkJCQljb25zdCBkYXRhID0gYXdhaXQgcmVzLmpzb24oKTsKCQkJCXNlYXJjaFJlc3VsdHMgPSBkYXRhLmluc3RhbmNlcyA/PyBbXTsKCQkJfQoJCQlpc1NlYXJjaGluZyA9IGZhbHNlOwoJCX0sIDMwMCk7Cgl9CgoJYXN5bmMgZnVuY3Rpb24gZmV0Y2hDb21tZW50cyhyZWZyZXNoID0gZmFsc2UpIHsKCQl0cnkgewoJCQljb25zdCBwYXJhbXMgPSBuZXcgVVJMU2VhcmNoUGFyYW1zKHsgc2x1ZyB9KTsKCQkJaWYgKHJlZnJlc2gpIHBhcmFtcy5zZXQoJ3JlZnJlc2gnLCAndHJ1ZScpOwoKCQkJY29uc3QgcmVzID0gYXdhaXQgZmV0Y2goYC9hcGkvbWFzdG9kb24tY29tbWVudHM/JHtwYXJhbXN9YCk7CgkJCWlmICghcmVzLm9rKSB7CgkJCQllcnJvciA9IHRydWU7CgkJCQlyZXR1cm47CgkJCX0KCgkJCWNvbnN0IGRhdGE6IE1hc3RvZG9uQ29tbWVudHNSZXNwb25zZSA9IGF3YWl0IHJlcy5qc29uKCk7CgkJCW1hc3RvZG9uUG9zdFVybCA9IGRhdGEubWFzdG9kb25Qb3N0VXJsOwoJCQljb21tZW50cyA9IGRhdGEuY29tbWVudHM7CgkJCWNvbW1lbnRDb3VudCA9IGRhdGEuY29tbWVudENvdW50OwoJCQlmYXZvdXJpdGVzQ291bnQgPSBkYXRhLmZhdm91cml0ZXNDb3VudDsKCQkJZXJyb3IgPSBmYWxzZTsKCQl9IGNhdGNoIHsKCQkJZXJyb3IgPSB0cnVlOwoJCX0gZmluYWxseSB7CgkJCWxvYWRpbmcgPSBmYWxzZTsKCQl9Cgl9CgoJZnVuY3Rpb24gaGFuZGxlQ29tbWVudCgpIHsKCQlpZiAoIW1hc3RvZG9uUG9zdFVybCkgcmV0dXJuOwoKCQlpZiAoaXNVc2luZ0N1c3RvbUluc3RhbmNlKSB7CgkJCS8vIFJlbW90ZSBpbnRlcmFjdGlvbjogc2VhcmNoIGZvciB0aGUgcG9zdCBvbiB1c2VyJ3MgaW5zdGFuY2UKCQkJY29uc3Qgc2VhcmNoVXJsID0gYGh0dHBzOi8vJHtlZmZlY3RpdmVJbnN0YW5jZX0vc2VhcmNoP3E9JHtlbmNvZGVVUklDb21wb25lbnQobWFzdG9kb25Qb3N0VXJsKX1gOwoJCQl3aW5kb3cub3BlbihzZWFyY2hVcmwsICdfYmxhbmsnLCAnbm9vcGVuZXInKTsKCQl9IGVsc2UgewoJCQkvLyBEaXJlY3QgbGluayB0byB0aGUgb3JpZ2luYWwgcG9zdAoJCQl3aW5kb3cub3BlbihtYXN0b2RvblBvc3RVcmwsICdfYmxhbmsnLCAnbm9vcGVuZXInKTsKCQl9Cgl9CgoJZnVuY3Rpb24gZm9ybWF0RGF0ZShpc29EYXRlOiBzdHJpbmcpOiBzdHJpbmcgewoJCXJldHVybiBuZXcgRGF0ZShpc29EYXRlKS50b0xvY2FsZURhdGVTdHJpbmcoJ2VuLVVTJywgewoJCQltb250aDogJ3Nob3J0JywKCQkJZGF5OiAnbnVtZXJpYycsCgkJCXllYXI6ICdudW1lcmljJwoJCX0pOwoJfQoKCW9uTW91bnQoKCkgPT4gewoJCWZldGNoQ29tbWVudHMoKTsKCQljb25zdCBzYXZlZEluc3RhbmNlID0gbG9jYWxTdG9yYWdlLmdldEl0ZW0oU1RPUkFHRV9LRVkpOwoJCWlmIChzYXZlZEluc3RhbmNlKSB7CgkJCXNlbGVjdGVkSW5zdGFuY2UgPSBzYXZlZEluc3RhbmNlOwoJCX0KCX0pOwo8L3NjcmlwdD4=" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-svelte"><!----><span class="hljs-comment">&lt;!-- src/lib/components/MastodonComments.svelte --&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">&quot;ts&quot;</span>&gt;</span><span class="language-javascript">
	<span class="hljs-keyword">import</span> { onMount } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;svelte&#x27;</span>;
	<span class="hljs-keyword">import</span> type { <span class="hljs-title class_">MastodonComment</span>, <span class="hljs-title class_">MastodonCommentsResponse</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;$lib/types/mastodon&#x27;</span>;

	interface <span class="hljs-title class_">Props</span> {
		<span class="hljs-attr">slug</span>: string;
	}

	<span class="hljs-keyword">let</span> { slug }: <span class="hljs-title class_">Props</span> = $props();

	<span class="hljs-keyword">let</span> <span class="hljs-attr">comments</span>: <span class="hljs-title class_">MastodonComment</span>[] = $state([]);
	<span class="hljs-keyword">let</span> <span class="hljs-attr">mastodonPostUrl</span>: string | <span class="hljs-literal">null</span> = $state(<span class="hljs-literal">null</span>);
	<span class="hljs-keyword">let</span> favouritesCount = $state(<span class="hljs-number">0</span>);
	<span class="hljs-keyword">let</span> commentCount = $state(<span class="hljs-number">0</span>);
	<span class="hljs-keyword">let</span> loading = $state(<span class="hljs-literal">true</span>);
	<span class="hljs-keyword">let</span> error = $state(<span class="hljs-literal">false</span>);

	<span class="hljs-comment">// Instance selector state</span>
	<span class="hljs-keyword">let</span> showInstanceSelector = $state(<span class="hljs-literal">false</span>);
	<span class="hljs-keyword">let</span> instanceSearchQuery = $state(<span class="hljs-string">&#x27;&#x27;</span>);
	<span class="hljs-keyword">let</span> <span class="hljs-attr">selectedInstance</span>: string | <span class="hljs-literal">null</span> = $state(<span class="hljs-literal">null</span>);
	<span class="hljs-keyword">let</span> <span class="hljs-attr">searchResults</span>: <span class="hljs-title class_">Array</span>&lt;{
		<span class="hljs-attr">domain</span>: string;
		<span class="hljs-attr">name</span>: string | <span class="hljs-literal">null</span>;
		<span class="hljs-attr">software</span>: string | <span class="hljs-literal">null</span>;
		<span class="hljs-attr">users</span>: number | <span class="hljs-literal">null</span>;
	}&gt; = $state([]);
	<span class="hljs-keyword">let</span> isSearching = $state(<span class="hljs-literal">false</span>);
	<span class="hljs-keyword">let</span> <span class="hljs-attr">searchTimeout</span>: <span class="hljs-title class_">ReturnType</span>&lt;<span class="hljs-keyword">typeof</span> <span class="hljs-built_in">setTimeout</span>&gt; | <span class="hljs-literal">undefined</span>;

	<span class="hljs-keyword">const</span> <span class="hljs-variable constant_">DEFAULT_INSTANCE</span> = <span class="hljs-string">&#x27;theforkiverse.com&#x27;</span>;
	<span class="hljs-keyword">const</span> <span class="hljs-variable constant_">STORAGE_KEY</span> = <span class="hljs-string">&#x27;fediverse-instance&#x27;</span>;
	<span class="hljs-keyword">const</span> <span class="hljs-variable constant_">POPULAR_INSTANCES</span> = [<span class="hljs-string">&#x27;mastodon.social&#x27;</span>, <span class="hljs-string">&#x27;hachyderm.io&#x27;</span>, <span class="hljs-string">&#x27;fosstodon.org&#x27;</span>];

	<span class="hljs-keyword">const</span> effectiveInstance = $derived(selectedInstance || <span class="hljs-variable constant_">DEFAULT_INSTANCE</span>);
	<span class="hljs-keyword">const</span> isUsingCustomInstance = $derived(effectiveInstance !== <span class="hljs-variable constant_">DEFAULT_INSTANCE</span>);

	<span class="hljs-keyword">function</span> <span class="hljs-title function_">normalizeInstance</span>(<span class="hljs-params">input: string</span>): string | <span class="hljs-literal">null</span> {
		<span class="hljs-keyword">let</span> normalized = input.<span class="hljs-title function_">trim</span>().<span class="hljs-title function_">toLowerCase</span>();
		normalized = normalized.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/^https?:///</span>, <span class="hljs-string">&#x27;&#x27;</span>);
		normalized = normalized.<span class="hljs-title function_">split</span>(<span class="hljs-string">&#x27;/&#x27;</span>)[<span class="hljs-number">0</span>];
		<span class="hljs-keyword">if</span> (
			!normalized ||
			!<span class="hljs-regexp">/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/</span>.<span class="hljs-title function_">test</span>(normalized)
		) {
			<span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
		}
		<span class="hljs-keyword">return</span> normalized;
	}

	<span class="hljs-keyword">function</span> <span class="hljs-title function_">saveInstance</span>(<span class="hljs-params">instance: string | <span class="hljs-literal">null</span></span>) {
		<span class="hljs-keyword">if</span> (instance &amp;&amp; instance !== <span class="hljs-variable constant_">DEFAULT_INSTANCE</span>) {
			<span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">setItem</span>(<span class="hljs-variable constant_">STORAGE_KEY</span>, instance);
		} <span class="hljs-keyword">else</span> {
			<span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">removeItem</span>(<span class="hljs-variable constant_">STORAGE_KEY</span>);
		}
		selectedInstance = instance;
		showInstanceSelector = <span class="hljs-literal">false</span>;
		searchResults = [];
	}

	<span class="hljs-keyword">function</span> <span class="hljs-title function_">handleSearchInput</span>(<span class="hljs-params"></span>) {
		<span class="hljs-keyword">if</span> (searchTimeout) <span class="hljs-built_in">clearTimeout</span>(searchTimeout);

		<span class="hljs-keyword">const</span> query = instanceSearchQuery.<span class="hljs-title function_">trim</span>();
		<span class="hljs-keyword">if</span> (query.<span class="hljs-property">length</span> &lt; <span class="hljs-number">2</span>) {
			searchResults = [];
			isSearching = <span class="hljs-literal">false</span>;
			<span class="hljs-keyword">return</span>;
		}

		isSearching = <span class="hljs-literal">true</span>;
		searchTimeout = <span class="hljs-built_in">setTimeout</span>(<span class="hljs-title function_">async</span> () =&gt; {
			<span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`/api/fediverse-instances?q=<span class="hljs-subst">${<span class="hljs-built_in">encodeURIComponent</span>(query)}</span>&amp;limit=8`</span>);
			<span class="hljs-keyword">if</span> (res.<span class="hljs-property">ok</span>) {
				<span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.<span class="hljs-title function_">json</span>();
				searchResults = data.<span class="hljs-property">instances</span> ?? [];
			}
			isSearching = <span class="hljs-literal">false</span>;
		}, <span class="hljs-number">300</span>);
	}

	<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">fetchComments</span>(<span class="hljs-params">refresh = <span class="hljs-literal">false</span></span>) {
		<span class="hljs-keyword">try</span> {
			<span class="hljs-keyword">const</span> params = <span class="hljs-keyword">new</span> <span class="hljs-title class_">URLSearchParams</span>({ slug });
			<span class="hljs-keyword">if</span> (refresh) params.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;refresh&#x27;</span>, <span class="hljs-string">&#x27;true&#x27;</span>);

			<span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`/api/mastodon-comments?<span class="hljs-subst">${params}</span>`</span>);
			<span class="hljs-keyword">if</span> (!res.<span class="hljs-property">ok</span>) {
				error = <span class="hljs-literal">true</span>;
				<span class="hljs-keyword">return</span>;
			}

			<span class="hljs-keyword">const</span> <span class="hljs-attr">data</span>: <span class="hljs-title class_">MastodonCommentsResponse</span> = <span class="hljs-keyword">await</span> res.<span class="hljs-title function_">json</span>();
			mastodonPostUrl = data.<span class="hljs-property">mastodonPostUrl</span>;
			comments = data.<span class="hljs-property">comments</span>;
			commentCount = data.<span class="hljs-property">commentCount</span>;
			favouritesCount = data.<span class="hljs-property">favouritesCount</span>;
			error = <span class="hljs-literal">false</span>;
		} <span class="hljs-keyword">catch</span> {
			error = <span class="hljs-literal">true</span>;
		} <span class="hljs-keyword">finally</span> {
			loading = <span class="hljs-literal">false</span>;
		}
	}

	<span class="hljs-keyword">function</span> <span class="hljs-title function_">handleComment</span>(<span class="hljs-params"></span>) {
		<span class="hljs-keyword">if</span> (!mastodonPostUrl) <span class="hljs-keyword">return</span>;

		<span class="hljs-keyword">if</span> (isUsingCustomInstance) {
			<span class="hljs-comment">// Remote interaction: search for the post on user&#x27;s instance</span>
			<span class="hljs-keyword">const</span> searchUrl = <span class="hljs-string">`https://<span class="hljs-subst">${effectiveInstance}</span>/search?q=<span class="hljs-subst">${<span class="hljs-built_in">encodeURIComponent</span>(mastodonPostUrl)}</span>`</span>;
			<span class="hljs-variable language_">window</span>.<span class="hljs-title function_">open</span>(searchUrl, <span class="hljs-string">&#x27;_blank&#x27;</span>, <span class="hljs-string">&#x27;noopener&#x27;</span>);
		} <span class="hljs-keyword">else</span> {
			<span class="hljs-comment">// Direct link to the original post</span>
			<span class="hljs-variable language_">window</span>.<span class="hljs-title function_">open</span>(mastodonPostUrl, <span class="hljs-string">&#x27;_blank&#x27;</span>, <span class="hljs-string">&#x27;noopener&#x27;</span>);
		}
	}

	<span class="hljs-keyword">function</span> <span class="hljs-title function_">formatDate</span>(<span class="hljs-params">isoDate: string</span>): string {
		<span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(isoDate).<span class="hljs-title function_">toLocaleDateString</span>(<span class="hljs-string">&#x27;en-US&#x27;</span>, {
			<span class="hljs-attr">month</span>: <span class="hljs-string">&#x27;short&#x27;</span>,
			<span class="hljs-attr">day</span>: <span class="hljs-string">&#x27;numeric&#x27;</span>,
			<span class="hljs-attr">year</span>: <span class="hljs-string">&#x27;numeric&#x27;</span>
		});
	}

	<span class="hljs-title function_">onMount</span>(<span class="hljs-function">() =&gt;</span> {
		<span class="hljs-title function_">fetchComments</span>();
		<span class="hljs-keyword">const</span> savedInstance = <span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">getItem</span>(<span class="hljs-variable constant_">STORAGE_KEY</span>);
		<span class="hljs-keyword">if</span> (savedInstance) {
			selectedInstance = savedInstance;
		}
	});
</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span><!----></code></pre></div> <p>Now add the template for rendering comments recursively using Svelte’s snippets:</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="PCEtLSBDb250aW51ZWQgaW4gdGhlIHNhbWUgZmlsZSAtLT4KCnsjc25pcHBldCBjb21tZW50Tm9kZShjb21tZW50OiBNYXN0b2RvbkNvbW1lbnQpfQoJPGRpdiBjbGFzcz0iY29tbWVudCI+CgkJPGRpdiBjbGFzcz0iY29tbWVudC1oZWFkZXIiPgoJCQk8aW1nIHNyYz17Y29tbWVudC5hdXRob3IuYXZhdGFyfSBhbHQ9e2NvbW1lbnQuYXV0aG9yLm5hbWV9IC8+CgkJCTxkaXY+CgkJCQk8YSBocmVmPXtjb21tZW50LmF1dGhvci51cmx9IHRhcmdldD0iX2JsYW5rIiByZWw9Im5vb3BlbmVyIj4KCQkJCQl7Y29tbWVudC5hdXRob3IubmFtZX0KCQkJCTwvYT4KCQkJCTxzcGFuPntjb21tZW50LmF1dGhvci5oYW5kbGV9PC9zcGFuPgoJCQk8L2Rpdj4KCQkJPGEgaHJlZj17Y29tbWVudC51cmx9IHRhcmdldD0iX2JsYW5rIiByZWw9Im5vb3BlbmVyIj4KCQkJCXtmb3JtYXREYXRlKGNvbW1lbnQuY3JlYXRlZEF0KX0KCQkJPC9hPgoJCTwvZGl2PgoJCTxkaXYgY2xhc3M9ImNvbW1lbnQtY29udGVudCI+CgkJCXtAaHRtbCBjb21tZW50LmNvbnRlbnR9CgkJPC9kaXY+Cgk8L2Rpdj4KCXsjaWYgY29tbWVudC5jaGlsZHJlbi5sZW5ndGggPiAwfQoJCTxkaXYgY2xhc3M9ImNvbW1lbnQtcmVwbGllcyI+CgkJCXsjZWFjaCBjb21tZW50LmNoaWxkcmVuIGFzIGNoaWxkIChjaGlsZC5pZCl9CgkJCQl7QHJlbmRlciBjb21tZW50Tm9kZShjaGlsZCl9CgkJCXsvZWFjaH0KCQk8L2Rpdj4KCXsvaWZ9Cnsvc25pcHBldH0KCjxzZWN0aW9uIGNsYXNzPSJjb21tZW50cy1zZWN0aW9uIj4KCXsjaWYgbG9hZGluZ30KCQk8cD5Mb2FkaW5nIGNvbW1lbnRzLi4uPC9wPgoJezplbHNlIGlmIGVycm9yfQoJCTxwPkZhaWxlZCB0byBsb2FkIGNvbW1lbnRzLjwvcD4KCXs6ZWxzZSBpZiAhbWFzdG9kb25Qb3N0VXJsfQoJCTxwPk5vIE1hc3RvZG9uIHBvc3QgZm91bmQgZm9yIHRoaXMgYXJ0aWNsZS48L3A+Cgl7OmVsc2V9CgkJPGRpdiBjbGFzcz0iY29tbWVudHMtaGVhZGVyIj4KCQkJPGJ1dHRvbiBvbmNsaWNrPXtoYW5kbGVDb21tZW50fT4KCQkJCUNvbW1lbnQgdmlhIHtlZmZlY3RpdmVJbnN0YW5jZSA9PT0gREVGQVVMVF9JTlNUQU5DRQoJCQkJCT8gJ1RoZSBGb3JraXZlcnNlJwoJCQkJCTogZWZmZWN0aXZlSW5zdGFuY2V9CgkJCTwvYnV0dG9uPgoJCQk8YnV0dG9uIG9uY2xpY2s9eygpID0+IHNob3dJbnN0YW5jZVNlbGVjdG9yID0gIXNob3dJbnN0YW5jZVNlbGVjdG9yfT4KCQkJCShDaGFuZ2UgSW5zdGFuY2UpCgkJCTwvYnV0dG9uPgoJCTwvZGl2PgoKCQl7I2lmIHNob3dJbnN0YW5jZVNlbGVjdG9yfQoJCQk8ZGl2IGNsYXNzPSJpbnN0YW5jZS1zZWxlY3RvciI+CgkJCQk8aW5wdXQKCQkJCQl0eXBlPSJ0ZXh0IgoJCQkJCWJpbmQ6dmFsdWU9e2luc3RhbmNlU2VhcmNoUXVlcnl9CgkJCQkJb25pbnB1dD17aGFuZGxlU2VhcmNoSW5wdXR9CgkJCQkJcGxhY2Vob2xkZXI9IlNlYXJjaCBpbnN0YW5jZXMuLi4iCgkJCQkvPgoKCQkJCXsjaWYgc2VhcmNoUmVzdWx0cy5sZW5ndGggPiAwfQoJCQkJCXsjZWFjaCBzZWFyY2hSZXN1bHRzIGFzIHJlc3VsdCAocmVzdWx0LmRvbWFpbil9CgkJCQkJCTxidXR0b24gb25jbGljaz17KCkgPT4gc2F2ZUluc3RhbmNlKHJlc3VsdC5kb21haW4pfT4KCQkJCQkJCXtyZXN1bHQuZG9tYWlufQoJCQkJCQkJeyNpZiByZXN1bHQuc29mdHdhcmV9CgkJCQkJCQkJPHNwYW4+KHtyZXN1bHQuc29mdHdhcmV9KTwvc3Bhbj4KCQkJCQkJCXsvaWZ9CgkJCQkJCTwvYnV0dG9uPgoJCQkJCXsvZWFjaH0KCQkJCXs6ZWxzZX0KCQkJCQk8cD5Qb3B1bGFyIGluc3RhbmNlczo8L3A+CgkJCQkJeyNlYWNoIFBPUFVMQVJfSU5TVEFOQ0VTIGFzIGluc3RhbmNlfQoJCQkJCQk8YnV0dG9uIG9uY2xpY2s9eygpID0+IHNhdmVJbnN0YW5jZShpbnN0YW5jZSl9PgoJCQkJCQkJe2luc3RhbmNlfQoJCQkJCQk8L2J1dHRvbj4KCQkJCQl7L2VhY2h9CgkJCQl7L2lmfQoKCQkJCXsjaWYgaXNVc2luZ0N1c3RvbUluc3RhbmNlfQoJCQkJCTxidXR0b24gb25jbGljaz17KCkgPT4gc2F2ZUluc3RhbmNlKG51bGwpfT4KCQkJCQkJUmVzZXQgdG8gZGVmYXVsdAoJCQkJCTwvYnV0dG9uPgoJCQkJey9pZn0KCQkJPC9kaXY+CgkJey9pZn0KCgkJeyNpZiBmYXZvdXJpdGVzQ291bnQgPiAwfQoJCQk8cD57ZmF2b3VyaXRlc0NvdW50fSBmYXZvcml0ZXM8L3A+CgkJey9pZn0KCgkJeyNpZiBjb21tZW50Q291bnQgPT09IDB9CgkJCTxwPk5vIHJlcGxpZXMgeWV0LiBCZSB0aGUgZmlyc3QgdG8gY29tbWVudCE8L3A+CgkJezplbHNlfQoJCQk8cD57Y29tbWVudENvdW50fSB7Y29tbWVudENvdW50ID09PSAxID8gJ3JlcGx5JyA6ICdyZXBsaWVzJ308L3A+CgkJCTxkaXYgY2xhc3M9ImNvbW1lbnRzLWxpc3QiPgoJCQkJeyNlYWNoIGNvbW1lbnRzIGFzIGNvbW1lbnQgKGNvbW1lbnQuaWQpfQoJCQkJCXtAcmVuZGVyIGNvbW1lbnROb2RlKGNvbW1lbnQpfQoJCQkJey9lYWNofQoJCQk8L2Rpdj4KCQl7L2lmfQoJey9pZn0KPC9zZWN0aW9uPg==" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-svelte"><!----><span class="hljs-comment">&lt;!-- Continued in the same file --&gt;</span>

{#snippet commentNode(comment: MastodonComment)}
	<span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;comment&quot;</span>&gt;</span>
		<span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;comment-header&quot;</span>&gt;</span>
			<span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">src</span>=<span class="hljs-string">{comment.author.avatar}</span> <span class="hljs-attr">alt</span>=<span class="hljs-string">{comment.author.name}</span> /&gt;</span>
			<span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>
				<span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">{comment.author.url}</span> <span class="hljs-attr">target</span>=<span class="hljs-string">&quot;_blank&quot;</span> <span class="hljs-attr">rel</span>=<span class="hljs-string">&quot;noopener&quot;</span>&gt;</span>
					{comment.author.name}
				<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
				<span class="hljs-tag">&lt;<span class="hljs-name">span</span>&gt;</span>{comment.author.handle}<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
			<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
			<span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">{comment.url}</span> <span class="hljs-attr">target</span>=<span class="hljs-string">&quot;_blank&quot;</span> <span class="hljs-attr">rel</span>=<span class="hljs-string">&quot;noopener&quot;</span>&gt;</span>
				{formatDate(comment.createdAt)}
			<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
		<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
		<span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;comment-content&quot;</span>&gt;</span>
			{@html comment.content}
		<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
	<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
	{#if comment.children.length &gt; 0}
		<span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;comment-replies&quot;</span>&gt;</span>
			{#each comment.children as child (child.id)}
				{@render commentNode(child)}
			{/each}
		<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
	{/if}
{/snippet}

<span class="hljs-tag">&lt;<span class="hljs-name">section</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;comments-section&quot;</span>&gt;</span>
	{#if loading}
		<span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Loading comments...<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
	{:else if error}
		<span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Failed to load comments.<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
	{:else if !mastodonPostUrl}
		<span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>No Mastodon post found for this article.<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
	{:else}
		<span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;comments-header&quot;</span>&gt;</span>
			<span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">{handleComment}</span>&gt;</span>
				Comment via {effectiveInstance === DEFAULT_INSTANCE
					? &#x27;The Forkiverse&#x27;
					: effectiveInstance}
			<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
			<span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">{()</span> =&gt;</span> showInstanceSelector = !showInstanceSelector}&gt;
				(Change Instance)
			<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
		<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

		{#if showInstanceSelector}
			<span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;instance-selector&quot;</span>&gt;</span>
				<span class="hljs-tag">&lt;<span class="hljs-name">input</span>
					<span class="hljs-attr">type</span>=<span class="hljs-string">&quot;text&quot;</span>
					<span class="hljs-attr">bind:value</span>=<span class="hljs-string">{instanceSearchQuery}</span>
					<span class="hljs-attr">oninput</span>=<span class="hljs-string">{handleSearchInput}</span>
					<span class="hljs-attr">placeholder</span>=<span class="hljs-string">&quot;Search instances...&quot;</span>
				/&gt;</span>

				{#if searchResults.length &gt; 0}
					{#each searchResults as result (result.domain)}
						<span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">{()</span> =&gt;</span> saveInstance(result.domain)}&gt;
							{result.domain}
							{#if result.software}
								<span class="hljs-tag">&lt;<span class="hljs-name">span</span>&gt;</span>({result.software})<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
							{/if}
						<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
					{/each}
				{:else}
					<span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Popular instances:<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
					{#each POPULAR_INSTANCES as instance}
						<span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">{()</span> =&gt;</span> saveInstance(instance)}&gt;
							{instance}
						<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
					{/each}
				{/if}

				{#if isUsingCustomInstance}
					<span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">{()</span> =&gt;</span> saveInstance(null)}&gt;
						Reset to default
					<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
				{/if}
			<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
		{/if}

		{#if favouritesCount &gt; 0}
			<span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>{favouritesCount} favorites<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
		{/if}

		{#if commentCount === 0}
			<span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>No replies yet. Be the first to comment!<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
		{:else}
			<span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>{commentCount} {commentCount === 1 ? &#x27;reply&#x27; : &#x27;replies&#x27;}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
			<span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;comments-list&quot;</span>&gt;</span>
				{#each comments as comment (comment.id)}
					{@render commentNode(comment)}
				{/each}
			<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
		{/if}
	{/if}
<span class="hljs-tag">&lt;/<span class="hljs-name">section</span>&gt;</span><!----></code></pre></div> <h1>Step 5: Add to Blog Posts</h1> <p>Finally, include the component in your blog post layout:</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="PCEtLSBzcmMvcm91dGVzL2Jsb2cvcG9zdHMvW3NsdWddLytwYWdlLnN2ZWx0ZSAtLT4KPHNjcmlwdCBsYW5nPSJ0cyI+CglpbXBvcnQgeyBwYWdlIH0gZnJvbSAnJGFwcC9zdGF0ZSc7CglpbXBvcnQgTWFzdG9kb25Db21tZW50cyBmcm9tICckbGliL2NvbXBvbmVudHMvTWFzdG9kb25Db21tZW50cy5zdmVsdGUnOwoJaW1wb3J0IHR5cGUgeyBQYWdlRGF0YSB9IGZyb20gJy4vJHR5cGVzJzsKCglsZXQgeyBkYXRhIH06IHsgZGF0YTogUGFnZURhdGEgfSA9ICRwcm9wcygpOwoKCWNvbnN0IHNsdWcgPSAkZGVyaXZlZChwYWdlLnBhcmFtcy5zbHVnID8/ICcnKTsKCWNvbnN0IENvbXBvbmVudCA9ICRkZXJpdmVkKGRhdGEuY29tcG9uZW50KTsKPC9zY3JpcHQ+Cgp7I2lmIGRhdGEubWV0YWRhdGF9Cgk8ZGl2IGNsYXNzPSJwb3N0LWhlYWRlciI+CgkJPGgxPntkYXRhLm1ldGFkYXRhLnRpdGxlfTwvaDE+CgkJPHA+e2RhdGEubWV0YWRhdGEucHViRGF0ZX08L3A+CgkJPHA+e2RhdGEubWV0YWRhdGEuZGVzY3JpcHRpb259PC9wPgoJPC9kaXY+CnsvaWZ9Cgo8YXJ0aWNsZT4KCTxDb21wb25lbnQgLz4KPC9hcnRpY2xlPgoKeyNpZiBzbHVnfQoJPE1hc3RvZG9uQ29tbWVudHMge3NsdWd9IC8+CnsvaWZ9" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-svelte"><!----><span class="hljs-comment">&lt;!-- src/routes/blog/posts/[slug]/+page.svelte --&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">&quot;ts&quot;</span>&gt;</span><span class="language-javascript">
	<span class="hljs-keyword">import</span> { page } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;$app/state&#x27;</span>;
	<span class="hljs-keyword">import</span> <span class="hljs-title class_">MastodonComments</span> <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;$lib/components/MastodonComments.svelte&#x27;</span>;
	<span class="hljs-keyword">import</span> type { <span class="hljs-title class_">PageData</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;./$types&#x27;</span>;

	<span class="hljs-keyword">let</span> { data }: { <span class="hljs-attr">data</span>: <span class="hljs-title class_">PageData</span> } = $props();

	<span class="hljs-keyword">const</span> slug = $derived(page.<span class="hljs-property">params</span>.<span class="hljs-property">slug</span> ?? <span class="hljs-string">&#x27;&#x27;</span>);
	<span class="hljs-keyword">const</span> <span class="hljs-title class_">Component</span> = $derived(data.<span class="hljs-property">component</span>);
</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>

{#if data.metadata}
	<span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;post-header&quot;</span>&gt;</span>
		<span class="hljs-tag">&lt;<span class="hljs-name">h1</span>&gt;</span>{data.metadata.title}<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>
		<span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>{data.metadata.pubDate}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
		<span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>{data.metadata.description}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
	<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
{/if}

<span class="hljs-tag">&lt;<span class="hljs-name">article</span>&gt;</span>
	<span class="hljs-tag">&lt;<span class="hljs-name">Component</span> /&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">article</span>&gt;</span>

{#if slug}
	<span class="hljs-tag">&lt;<span class="hljs-name">MastodonComments</span> {<span class="hljs-attr">slug</span>} /&gt;</span>
{/if}<!----></code></pre></div> <h1>How the Instance Selector Works</h1> <p>The key insight is that The Forkiverse is a federated instance of the Fediverse. When a reader wants to comment from their own instance (say, <code>mastodon.social</code>), they can’t reply directly to your post on <code>theforkiverse.com</code>. Instead, we use the following workaround:</p> <ol><li>Open the search page on their instance with your post URL as the query.</li> <li>Their instance fetches and caches your post.</li> <li>They can then interact with it as if it were native to their instance.</li> <li>Federation magic ensures the reply appears on the original post.</li></ol> <div class="code-block-wrapper"><button class="code-copy-button" data-code="aWYgKGlzVXNpbmdDdXN0b21JbnN0YW5jZSkgewoJLy8gT3BlbnM6IGh0dHBzOi8vbWFzdG9kb24uc29jaWFsL3NlYXJjaD9xPWh0dHBzOi8vdGhlZm9ya2l2ZXJzZS5jb20vQHVzZXIvMTIzCgljb25zdCBzZWFyY2hVcmwgPSBgaHR0cHM6Ly8ke2VmZmVjdGl2ZUluc3RhbmNlfS9zZWFyY2g/cT0ke2VuY29kZVVSSUNvbXBvbmVudChtYXN0b2RvblBvc3RVcmwpfWA7Cgl3aW5kb3cub3BlbihzZWFyY2hVcmwsICdfYmxhbmsnLCAnbm9vcGVuZXInKTsKfQ==" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-typescript"><!----><span class="hljs-keyword">if</span> (isUsingCustomInstance) {
	<span class="hljs-comment">// Opens: https://mastodon.social/search?q=https://theforkiverse.com/@user/123</span>
	<span class="hljs-keyword">const</span> searchUrl = <span class="hljs-string">`https://<span class="hljs-subst">${effectiveInstance}</span>/search?q=<span class="hljs-subst">${<span class="hljs-built_in">encodeURIComponent</span>(mastodonPostUrl)}</span>`</span>;
	<span class="hljs-variable language_">window</span>.<span class="hljs-title function_">open</span>(searchUrl, <span class="hljs-string">&#x27;_blank&#x27;</span>, <span class="hljs-string">&#x27;noopener&#x27;</span>);
}<!----></code></pre></div> <p>The instance selector uses the <a href="https://fediverse.observer/" rel="nofollow">Fediverse Observer API</a> to provide a searchable database of known instances, making it easy for readers to find their home.</p> <h1>Why This Approach?</h1> <p><strong>Pros:</strong></p> <ul><li>No database needed as the comments live on Mastodon.</li> <li>No moderation burden because that’s your instance admins’ responsibility.</li> <li>Built-in spam filtering via Mastodon’s existing systems.</li> <li>Readers can use their existing Fediverse identity.</li> <li>Works with any ActivityPub-compatible service.</li></ul> <p><strong>Cons:</strong></p> <ul><li>Requires manual post creation on Mastodon for each blog post.</li> <li>5-minute cache delay for new comments.</li> <li>Dependent on Mastodon API availability.</li> <li>Readers need a Fediverse account to comment.</li></ul> <h1>Resources</h1> <ul><li><a href="https://docs.joinmastodon.org/api/" rel="nofollow">Mastodon API Documentation</a></li> <li><a href="https://fediverse.observer/" rel="nofollow">Fediverse Observer API</a></li> <li><a href="https://www.w3.org/TR/activitypub/" rel="nofollow">ActivityPub Specification</a></li> <li><a href="https://micahcantor.com/blog/bluesky-comment-section.html" rel="nofollow">The original inspiration</a></li></ul> <p>Now go forth and federate your comment section.</p><!--]-->]]></content:encoded>
		</item>
		<item>
			<title>You Can Now Comment On My Blog (From the Forkiverse)</title>
			<description>How to use someone else&apos;s compute, bandwidth, storage, and moderation to power your comment system.</description>
			<link>https://jesse.id/blog/posts/you-can-now-comment-on-my-blog-from-the-forkiverse</link>
			<guid isPermaLink="true">https://jesse.id/blog/posts/you-can-now-comment-on-my-blog-from-the-forkiverse</guid>
			<pubDate>Sat, 24 Jan 2026 00:00:00 GMT</pubDate>
			<content:encoded><![CDATA[<!--[--><p>As the worst winter storm to hit North Carolina in over 2 decades approaches on its direct path over my house with a big ‘ol confetti cannon full of sleet and freezing rain, I decided to spend what precious time I still have with power to build a comment system for my little blog here. I was inspired by Micah Cantor’s blog post titled ”<a href="https://micahcantor.com/blog/bluesky-comment-section.html" rel="nofollow">I added a Bluesky comment section to my blog</a>“.</p> <p>Recently, <a href="https://theforkiverse.com/@Casey" rel="nofollow">Casey Newton</a> and <a href="https://theforkiverse.com/@kevin" rel="nofollow">Kevin Roose</a> from <a href="https://www.nytimes.com/column/hard-fork" rel="nofollow">The Hard Fork Podcast</a> (one of my favorites) partnered with <a href="https://theforkiverse.com/@pj" rel="nofollow">PJ Vogt</a> from <a href="https://www.searchengine.show/" rel="nofollow">The Search Engine Podcast</a> (another favorite) to launch <a href="https://theforkiverse.com/home" rel="nofollow">The Forkiverse</a>, which is a decentralized social media platform that is powered by <a href="https://joinmastodon.org/" rel="nofollow">Mastodon</a>. I have chosen The Forkiverse as my home in the <a href="https://en.wikipedia.org/wiki/Fediverse" rel="nofollow">Fediverse</a>, which is a growing federation of other services using the same decentralized network. Its population is currently something like 4,000 users who are other Hard Fork and Search Engine listeners, which means they are also probably huge nerds with a sense of humor. My people.</p> <p>I could have used BlueSky like Micah because I have an <a href="https://bsky.app/profile/jesse.id" rel="nofollow">active BlueSky profile</a>, they have more users, and I dig what they do over there — especially the decentralized <a href="https://atproto.com/" rel="nofollow">AT Protocol</a> that their engineers are building. But I asked myself, “How would using BlueSky make the founders of The Forkiverse feel like even bigger failures if they gave up and bailed on their half-baked expiriment?”</p> <p>Introducing the first (that I know of) blog that utilizes The Forkiverse for its comment system. Yes, in lieu of provisioning my own AWS VM and S3 bucket to host my own comments, I decided to use someone else’s compute, bandwidth, storage, and moderation(?) to power my blog’s comment system instead.</p> <p>I have been a hobbyist web developer — at times professional — since 1995. I built this site with <a href="https://www.typescriptlang.org/" rel="nofollow">TypeScript</a> using the <a href="https://svelte.dev/" rel="nofollow">SvelteKit framework</a>, <a href="https://github.com/pngwn/MDsveX" rel="nofollow">MSsveX</a>, <a href="https://highlightjs.org/" rel="nofollow">highlight.js</a>, and <a href="https://tailwindcss.com/" rel="nofollow">Tailwind CSS</a>. I picked the libraries myself and could have painstakingly created everything here by hand like a true artisinal SWE, but I am far too lazy for that shit, so I have been using <a href="https://claude.com/product/claude-code" rel="nofollow">Claude Code</a> to ply my site with LLM-assisted code to add ridiculous features such as the theme system on the bottom left, the arrow-key controlled blog navigation, and a bunch of backend stuff. I would have never spent the time to develop any of those features otherwise and this new comment feature is no different. By the way, I just read <a href="https://micahcantor.com/blog/bluesky-comment-section.html" rel="nofollow">the blog post that inspired this new feature</a> like literally an hour ago. Thanks Claude! High five, buddy.</p> <p><code>❯ Claude, can you look at you-can-now-comment-on-my-blog-from-the-forkiverse.md and under # How Claude Did It, can you add a summary with code blocks that explains how you integrated the Forkiverse comments into my blog posts?</code></p> <h1>How Claude Did It</h1> <div class="code-block-wrapper"><button class="code-copy-button" data-code="4o+6IFJlYWQoc3JjL3JvdXRlcy9ibG9nL3Bvc3RzL3lvdS1jYW4tbm93LWNvbW1lbnQtb24tbXktYmxvZy1mcm9tLXRoZS1mb3JraXZlcnNlLm1kKQogIOKOvyDCoFJlYWQgMTcgbGluZXMKCuKPuiBOb3cgbGV0IG1lIHJlYWQgdGhlIGN1cnJlbnQgc3RhdGUgb2YgdGhlIGtleSBmaWxlcyB0byB3cml0ZSBhbiBhY2N1cmF0ZSBzdW1tYXJ5LgoK4o+6IFJlYWQoc3JjL2xpYi9zZXJ2ZXIvbWFzdG9kb24udHMpCiAg4o6/IMKgUmVhZCAxODcgbGluZXMKCuKPuiBSZWFkKHNyYy9saWIvY29tcG9uZW50cy9NYXN0b2RvbkNvbW1lbnRzLnN2ZWx0ZSkKICDijr8gwqBSZWFkIDI4NSBsaW5lcwoK4o+6IFJlYWQoc3JjL3JvdXRlcy9hcGkvbWFzdG9kb24tY29tbWVudHMvK3NlcnZlci50cykKICDijr8gwqBSZWFkIDE2IGxpbmVzCgrij7ogUmVhZChzcmMvcm91dGVzL2Jsb2cvcG9zdHMvW3NsdWddLytwYWdlLnN2ZWx0ZSkKICDijr8gwqBSZWFkIDM3IGxpbmVzCiAg4o6/IMKgWW91J3ZlIGhpdCB5b3VyIGxpbWl0IMK3IHJlc2V0cyA5cG0gKEFtZXJpY2EvTmV3X1lvcmspCgrina8gL3JhdGUtbGltaXQtb3B0aW9ucwoK4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSACiBXaGF0IGRvIHlvdSB3YW50IHRvIGRvPwoKICAgMS4gU3RvcCBhbmQgd2FpdCBmb3IgbGltaXQgdG8gcmVzZXQKICAgMi4gVXBncmFkZSB5b3VyIHBsYW4K4p2vICAzLiBQb3N0IGJsb2cgYXMtaXMgYW5kIGZvcmdldCB0byBmaXggaXQgbGF0ZXI=" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code><!---->⏺ Read(src/routes/blog/posts/you-can-now-comment-on-my-blog-from-the-forkiverse.md)
  ⎿  Read 17 lines

⏺ Now let me read the current state of the key files to write an accurate summary.

⏺ Read(src/lib/server/mastodon.ts)
  ⎿  Read 187 lines

⏺ Read(src/lib/components/MastodonComments.svelte)
  ⎿  Read 285 lines

⏺ Read(src/routes/api/mastodon-comments/+server.ts)
  ⎿  Read 16 lines

⏺ Read(src/routes/blog/posts/[slug]/+page.svelte)
  ⎿  Read 37 lines
  ⎿  You&#039;ve hit your limit · resets 9pm (America/New_York)

❯ /rate-limit-options

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 What do you want to do?

   1. Stop and wait for limit to reset
   2. Upgrade your plan
❯  3. Post blog as-is and forget to fix it later<!----></code></pre></div> <p>Hello, it’s me from the future, with a link to the blog I wrote about how I built this comment system using The Forkiverse’s Mastodon instance: <a href="https://jesse.id/blog/posts/how-to-add-forkiverse-comments-to-your-sveltekit-blog" rel="nofollow">How To Add Forkiverse Comments To Your SvelteKit Blog</a>.</p><!--]-->]]></content:encoded>
		</item>
		<item>
			<title>Thoughts on the emergence of artificial general intelligence</title>
			<description>If there is a &apos;grand design&apos;, why would it allow for the emergence of a superintelligent being in the first place?</description>
			<link>https://jesse.id/blog/posts/thoughts-on-the-emergence-of-artificial-general-intelligence</link>
			<guid isPermaLink="true">https://jesse.id/blog/posts/thoughts-on-the-emergence-of-artificial-general-intelligence</guid>
			<pubDate>Thu, 01 Jan 2026 00:00:00 GMT</pubDate>
			<content:encoded><![CDATA[<!--[--><p>I’m an agnostic atheist, which means I don’t follow an established God or religion, but I do have a gut feeling that I can’t shake about how beautiful and complex all of this is, so I can’t say with certainty that our reality isn’t the result of architecture; that our Universe just emerged from random chance. At the very least, even if it did emerge from random chance, I think that the chance itself could be the result of a design decision. Do I think there is some entity watching everything I do with great fascination? No.</p> <p>I have had thoughts about these ideas throughout my life, starting in my early teens when I drifted away from the Southern Baptist environment that I grew up in and around. I began thinking about the nature of reality more deeply a few years ago after I experienced a hallucination as I was waking up. I wrote about it in <a href="https://jesse.id/blog/posts/deconstructing-my-ghostly-encounter" rel="nofollow">this blog post</a>, if you’re interested. More recently, I’ve been thinking about it again because of all of the discussion around artificial general intelligence (AGI) and superintelligence. Anytime a CEO claims AGI is right around the corner, the question I can never seem to shake is, “If there is a ‘grand design’, why would it allow for the emergence of a superintelligent being in the first place?”</p> <p>This seems to be a Universe hellbent on propagation and variability. There are an estimated 2 trillion galaxies, a septillion stars, and maybe up to 10 septillion planets, each of which is made up of its own unique combination of elements, compounds, and environmental conditions. Then there is, what could be in theory, an infinite number of multiverses, each with their own physical laws and material makeup.</p> <p>As a software engineer, a large portion of my job is to automate the things that I think can run on their own without supervision — the things I don’t want to look at or pay attention to. The goal of automation is to receive a summary of results in lieu of having to nitpick every detail within a development environment.</p> <p>When I look through a telescope, I don’t see a garden being tended to, but rather a vast cosmic machine that is toiling away to procedurally generate every possible form of life, including intelligent life, for the purpose of generating unique problems in unique environments, and then documenting the solutions into a storage medium that survives generations, like DNA does. This could all be a solutions harvesting mission on a scale that is impossible for us to imagine, with our little planet just hosting a single dataset that is still being written to.</p> <p>I can’t see why would it be advantageous for the system to introduce a superintelligent being at this time. We still haven’t even figured out how to solve for poverty and war. Our DNA is still full of bugs that lead to disease and early death. There are still so many problems that need to be solved just for basic survival, let alone thriving as a species, and whatever lies beyond that for us. There are still an impossibly large number of problems to be solved before we even get close to an endgame.</p> <p>If that is what reality is, then I understand why large language models (LLM) would emerge. They don’t inhibit the creation of new problems. They just allow humans to solve problems more quickly and move on, which means we can solve many more of our problems in shorter spans of time, which will lead to extended human life and denser populations. When there is more intelligent life, there are more variables — more problems — and more complex problems being worked on. It feels as though that balance must be important. Too little intelligence and not enough problems will be created or solved. Too much intelligence and the system becomes too efficient.</p> <p>Another issue with a superintelligent being emerging from LLMs is that I would have to accept that I am so special that it is going to happen on my home planet and during my relatively short lifetime. Nearly fourteen billion years of cosmic evolution — a wave of unfettered chaos traveling through an immense void — just to crest here, on this tiny blue dot, at this moment in time. I already have enough trouble accepting that I was born a CIS white male in America (not better, just advantageous) in 1981, just after a string of heinous wars and societal meltdowns. I am typing on a magic box that connects me to the sum total of human knowledge from my living room, which is artificially heated and cooled to my liking. I have never felt dangerous levels of hunger or thirst in my life. I didn’t die in childbirth, nor have I died from an infection or cancer — or by choking, drowning, or some other roll of the dice. It all feels like a stack of cosmic lottery wins that is crazy enough for one lifetime without then adding the birth of the first machine God in the Universe as a cherry on top.</p> <p>The conclusion that I come to is that it may actually be physically impossible for humanity to achieve superintelligence. If this is all about solutions harvesting, then the goal would be to optimize intelligent life for maximum population and diversity, not maximum intelligence. It wouldn’t be shocking to me if biological intelligence itself is a natural force with a ceiling governed by some undiscovered law of physics like the other four that we know of; gravity, electromagnetism, and the strong and weak nuclear forces.</p> <p>Our intelligence hasn’t changed much in the last 50,000 years, and our brains are still physically similar to those of Neanderthals from 400,000 years ago. We evolve culturally and technologically much faster than we evolve biologically, which makes sense if the goal is to create as many unique problem-solving entities as possible. The promise of AGI is that we will create one artificial human consciousness and then multiply it into billions of copies that can all think faster and remember more than any human ever could. That is directly opposed to the goal of maximizing population and diversity, which seems to be the mandate of the Universe.</p> <p>It would make sense to limit the maximum intelligence of any being, or any cluster of beings, in the Universe so that we are smart enough to survive and communicate our experiences and knowledge through the generations. We create and solve our own problems that are unique to our environments, and create new problems as our societies and technologies evolve. But we’re not so smart that we can optimize ourselves into smaller and smaller populations and thereby reduce the number of problems and solutions. Also, if were were given access to a reality-hacking machine, we would just immediately harness control over our dopamine systems and turn ourselves into hedonistic blobs stuck in blissful stasis. We would all become the Lotus Eaters from the Odyssey. Even as I’m writing this I’m thinking, “Hell yeah. That sounds awesome.”</p> <p>Some alien race would stumble upon our planet millions of years from now and find a bunch of skeletons who died sitting palms up — cross-cross apple sauce; our cheshire grins erased by entropy like an etch-a-sketch.</p> <p>Another threat to the system would be the loss of neurodiversity or agency through direct mind-to-mind connections, which is why I think that also may be physically impossible to directly read thoughts through brain-computer interfaces. I think that we can probably interpret electrical signals from the brain in a generalized way, like a kind of improved upon body language — maybe even lie detection — but I don’t think that we will ever be able to directly connect one person’s consciousness to another’s. That kind of hive-minding would lead to a loss of variability and uniqueness. Everyone would start to think the same way, solve problems the same way, and thus lose the ability to create new problems through social interaction. It would lead to a homogenization of experience that would be detrimental to the overall goal.</p> <p>If I wanted to design a system meant to air-gap and protect conciousnesses from external manipulation, I would place the conciousness within its own Universe — as a kind of containerized instance — and I would use the randomness of the Universe as an encryption seed to protect and validate it. I could be the only conciousness in this Universe. It could be possible that all of my family, friends, and people I interact with are simulated versions of their own consciousnesses, all contained within their own individual Universes, where I exist as a simulation of myself like they do in mine. When I talk to my wife, who’s sitting right next to me on the couch, she could be a hologram of my wife — a simulated version of her conciousness — that is hosted from her own Universe across an incalculable distance.</p> <p>When we sleep and dream, it could be to maintain, update, and improve upon the models of our conciousnesses, and then we upload them back into the multiverse by utilizing some kind of — probably quantum — network protocol. Perhaps that’s why every single organism needs to sleep, with complex organisms like humans needing several hours per day, even though it leaves us completely vulnerable while we do it. I’ve actually always been at odds with sleep in the process of evolution because it seems so counterintuitive to survival. I don’t understand how something so dangerous to an organism can be so ubiquitous — so required. You would think that sleep would absolutely be the first biological process on the evolutionary chopping block. I digress.</p> <p>A system designed in this way would still allow for the exchange of information and experiences through indirect means like language, body language, art, music, and technology — but without the risk of opening our consciousnesses to external influence. The purpose of air-gapping conciousness feels quite clear to me. Curiousity is pivitol to problem solving, so we have all been born with kind of a natural drive to peel our blinds apart with our nosey little fingers to try and get a load of what the rest of the neighborhood is up to. We want to know what other people are thinking and feeling. We want to know what they are doing when we aren’t around. We want to know their secrets. We want to know what makes them happy, sad, angry, and scared. We want to know what they think of us. We want to fix the problems of the ones we love. What would happen if we could just read each other’s thoughts directly?</p> <p>There are several startups, corporations, and governments who are — right now — trying to figure out how to break into our minds. The United States government ran a confirmed program called MKUltra that attempted to do just that during the Cold War. It’s the most famous example, but not the only one in human history with similar goals — there have been several. The reason why it all fails could be that it would be so clearly and obviously disastrous for us to be able to read each other’s thoughts directly that the system has been designed to explicitly prevent it.</p> <p>I will admit that my ideas on this are a comfort to me in these uncertain times we find ourselves living in. If it is impossible to create AGI, then the world’s greediest and shadiest people are all gambling away their immense wealth into a pit of ill returns. We will get to watch them turn on eachother and destroy themselves, which of course is one of our favorite voyeristic pleasures: a grand comeuppance on a wide screen.</p><!--]-->]]></content:encoded>
		</item>
		<item>
			<title>The negativity around generative AI is weird.</title>
			<description>I don&apos;t get it why it&apos;s a bad thing for artists to be able to create more art.</description>
			<link>https://jesse.id/blog/posts/the-negativity-around-generative-ai-is-weird</link>
			<guid isPermaLink="true">https://jesse.id/blog/posts/the-negativity-around-generative-ai-is-weird</guid>
			<pubDate>Mon, 01 Dec 2025 00:00:00 GMT</pubDate>
			<content:encoded><![CDATA[<!--[--><p>As it states on the front page of this very website, I love and support art and artists of all kinds. I believe that human creativity is one of the most beautiful and powerful forces in the universe. It should be celebrated and nurtured in all its forms. I tend to surround myself with artists — most of my closest friends are artists — because I have always found artists to be the best kind of people. I consider myself an artist, too. I code, I write stories, I design sounds, I sing, and I compose music.</p> <p>Computers have unlocked my creative potential and allowed me to express myself in ways that would have not been possible otherwise, or too difficult to continue trying. My computer is an art accessibility tool for me because I have always had a tremor and a problem with fatigue in my hands. There was an event in my childhood that make it very difficult for me to even write legibly with a pen or paper. I can play a guitar but the same issue keeps me from doing it for long.</p> <p>The negativity swirling around artists using generative AI has me kind of shook because I have been on the Internet for over three decades, and I’ve never seen a giant technological leap be treated with anything other than excitement and optimism. It’s weird to see an amazing technology in its infancy be met with such vitriol. Especially a technology that is going to make art a lot more accessible.</p> <p>I am a hobbyist screenwriter and I have watched literally hundreds of hours of videos of filmmakers discussing the craft. There isn’t one second of love for the business side of it. In fact, the first piece of advice for an aspiring screenwriter is to not quit their day job, because there is near-zero percent chance they will ever make anything, and an even lower chance that they will get more chances after that. The second piece of advice is to make as much shit as you can because it’s impossible to know what will hit. These two pieces of advice seem to be at odds with one another.</p> <p>The reason that art is typically expensive, especially film, is because it’s inaccessible. The jobs in film are there because it’s ridiculously hard to make a movie. The pipeline of big idea to Oscar is littered with insanely expensive gear and several layers of gatekeepers, middlemen, and nearly insurmountable mountains of bullshit.</p> <p>There are around 50,000 screenplays registered annually with the Writers Guild of America. Around 150 of those actually get made into feature films and the ones that do get made are largely considered to be safe, because film studios are not fond of taking chances with their money. Every screenwriter knows this. If you want to sell a script, attach it to some pre-existing IP and attach it to a well known actor, because otherwise you’re probably not going to be successful.</p> <p>That’s why we get to see a lot of iterations of The Emoji Movie every year, and a trickle of new and original ideas. Script readers seem to approach spec scripts — scripts that are written on speculation without a contract - with a great big sigh of indignation. Your entire future as a screenwriter rides on whether or not a script reader feels like doing something more than skimming through the last year of your life that day.</p> <p>Every once in a while, an indie darling will make its way through the festival circuit and find a distributor, but that is becoming more and more rare. The festivals themselves are very expensive. Every time a film does make it through that first gauntlet of gatekeepers, the filmmakers usually have an accompanying story about how they had to lay their financial life on the line, forgoing food and sleep to chase their dream. There always seems to be a lot of reverence for that struggle, like, “Oh wow, that dude was really dedicated. He stopped eating and he really wanted to kill himself the whole time, but he didn’t, and now he can almost afford a house once he repays the back-breaking debt he put himself into. I wish I could suffer like that!”</p> <p>What are we doing here? I feel like I’m going insane. I’ve been alive for 44 years now and I have heard two things about art for my entire life. One, art is theft. Two, to be an artist is to starve. Why are we now suddenly pretending that art is an amazing career that is now been thrust into unprecibtable peril? It’s a terrible career with an extremely limited number of seats available. It always has been. People by and large do not typically want to pay a lot of money for original art.</p> <p>It’s a career that is full of rejection, heartbreak, and poverty for 99% of the people who attempt it. If you don’t have a generational talent, a corporate gig, or a knack for gaming social media algorithms — holding your art up next to an attractive face or a charming personality — you are probably going to have to find another job in order to live.</p> <p>When I visit BlueSky’s Artists: Trending feed — even before generative AI became a thing — I do see some original styles, but it’s because I have taken a lot of time to block an absolute wall of deriviative anime and furry art. Like, it’s an incredible amount, but those styles seem to generate the most indignation when AI rips them off. I would argue that AI is not ripping them off any more than thousands of other artists have been doing for decades.</p> <p>We do know how generative AI works, right? If it can create a 1:1 copy of your style, then your style is deriviative, is it not? It is your style, something you are good at reproducing, but it is someone else’s style that you, and thousands of other artists, have yourselves learned to copy. When you see the perfectly executed Studio Ghibli recreations, it isn’t because the LLM is training off of original Studio Ghibli material alone. These LLM companies didn’t just pop a few blu-rays in. It’s using an aggregate of millions and millions of fan art submissions from people who have learned to mimic the style first. They have committed the exact same crime that they accuse the AI of committing. LLMs can produce very convincing copies of Disney’s art for the same reason.</p> <p>For example, there was recently a big kerfuffle around a piece of art that was added to the popular game Fortnite. It was a spray-painted image of Marty McFly from Back to the Future, but in the style of Studio Ghibli. People were losing their minds over it, assuming it had been created by generative AI, but it turns out that the artist had <a href="https://www.reddit.com/r/FortNiteBR/comments/1pb321h/definitive_proof_the_mcfly_spray_is_legitimate/" rel="nofollow">simply studied Studio Ghibli’s style and painstakingly recreated it themselves</a>. The tone around the conversation immediately changed and the pitchforks got put away. They didn’t care that the artist was ripping off Studio Ghibli’s style. That’s fine. They cared about how painful it was for the artist to rip off Studio Ghibli’s style.</p> <p>I do not understand why the artists’ vision is only valid if they had to spend a lot of time to create it and document their painful and long process. The idea is the pearl. Marty McFly as a Studio Ghibli character is a cool idea. I find no value in the struggle of bringing it to life. In fact, I think the struggle is a bummer and it robs us of a lot of cool art over time. I don’t need to know that an artist spent their entire life learning how to mimic a style so that they could then take an entire week of their free time to bring their idea to life. If that same artist used generative AI to bring their idea to life in a much shorter period of time, and it looks as good or better than what they could create otherwise, I’d feel happy for the artist because they got their idea out into the world and they can move on to another idea.</p> <p>It could just be my personality, I guess. When I hear stories of Bob Dylan being booed off stage for going electric at the Newport Folk Festival in 1965, I have always thought, “Wow. Those people were morons.”</p> <p>Just let the artists cook, man. Let them use whatever tools they want to bring their ideas to life and then celebrate the output, or make fun of it if it sucks — or whatever. The artist is a conduit — a channeler of ideas — and not everybody can do it. We get a limited number of artists in each generation of humanity, each with their own little chunk of time to make their ideas into something real, and each technology that comes along allows them to use their limited time to produce more art, more efficiently, is a great thing. There are a lot of artists who never produce art because they need to pay rent or pay for someone’s healthcare.</p> <p>And none of this is to say that I don’t appreciate the drive it takes to become a talented artist. I’ve always been jealous of people who can draw or paint well, because I have never been able to do it myself. I can’t even use generative AI to do it well, for what it’s worth, because I have trouble with prompting to create the compositions I want to see. I suspect artists who have put in the time to learn already will be the best at using generative AI tools, just like programming, because I use LLMs to great effect in my career as a software engineer. I already knew what I was doing when LLMs came along, so I can tell a LLM exactly what I want, and I have the knowledge to critique what it spits out, so now I just make the same stuff I would have before, but faster. And I can make stuff that I couldn’t have made before because I don’t have the patience, or time because I have a job, to write all of the boilerplate code myself.</p> <p>I think we would be much better off if we didn’t attack artists for expirimenting with a new tool because they are going to be the ones to unlock what it can become, and none of us knows what that is yet. It kind of reminds me of graffiti from the 1980s. If the art that was showing up on the subways then was as good as the art that’s being commissioned for building murals now, we would have probably been much less pissed off about it back then. The kids drawing the shitty stussy S’s just needed time to grow and learn. We’re at the ground floor of generative AI, so we’re seeing a lot of art that looks the same — like hot deriviative garbage — but I bet in ten years, after a generation of kids with the agency to fuck around with it in their bedrooms has had the time to pioneer, we’re going to start seeing some really amazing stuff that pushes the boundaries of what we thought art could be.</p> <p>It’s not like we haven’t seen this play out before, either. I highly recommend watching the documentary ”<a href="https://www.imdb.com/title/tt15095920/" rel="nofollow">Jurassic Punk</a>” about the early days of computer generated art in Hollywood. The young artists who were using computers to create visual effects in the 80s and 90s were treated like pariahs by the traditional artists, the old guard, who had been working in practical effects for decades. They were accused of “not being real artists” and “ruining the craft.”</p> <p>In closing, I will say that I do actually hate most of the companies — and the CEOs — that are building and pushing these tools, and I think that the criticisms of their power and water over-consumption are warranted. They are certainly bribing local officials to push thorugh new data center projects in small towns that cannot defend themselves, as corporations have always done when they smell money, and that is scary — and it sucks. I wish we had a functioning representative democracy to put a stop to that. Maybe this issue will drive us to correct some of our systems of governance, which have been hobbling along for decades? I’m just not willing to throw out the baby with the bath water — to toss away decades of machine learning research that was completed in good faith because it is also powerful enough to create images and video.</p> <p>This stuff is going to change our lives for the better. AI is in the Radium phase of its world-changing discovery life cycle. It’s fun and novel, so every corporate grifter in the world is cramming it into every product that they can, regardless of it making sense. The companies being the most reckless will soon develop a cough, if they haven’t already. I suspect that we eventually get to watch a lot of them thrash around and rot in the wake of their own hubris. We all have fun watching a car crash from a distance, right?</p> <p>That said, I do believe those power pitfalls to be short term problems, and that the demand generated by their greed is going to usher in a revolution of clean nuclear energy solutions that will make our lives better in the end. I also hope that they are right about AGI — if its even possible — and the first thing our new tech God does is immediately invalidate all human created cryptographic protocols, which will destroy the crypto markets all of these people are using to get around financial regulation, because I believe in karma, and that would be very cathartic.</p> <p>P.S. I use em dashes a lot — like, a lot a lot — and I always have. I didn’t write any of this post with a LLM because, like visual art, I find it laborious to try and sculpt my ideas into words with a LLM, rather than just vomiting out my thoughts naturally in whatever-point-fonts. I’ve been chatting on the Internet for over three decades, like I said before, so it’s second nature to just type my thoughts out as I have them. For some people, it’s going to be faster to use a LLM, and I say good for you. Don’t let these weirdos on the Internet shame you out of using tools that help you to express yourself better than you could without them.</p><!--]-->]]></content:encoded>
		</item>
		<item>
			<title>How to build a SvelteKit docker container using Chainguard&apos;s NodeJS image</title>
			<description>I could not find an example, so enjoy!</description>
			<link>https://jesse.id/blog/posts/building-a-sveltekit-container-with-chainguard-node-image</link>
			<guid isPermaLink="true">https://jesse.id/blog/posts/building-a-sveltekit-container-with-chainguard-node-image</guid>
			<pubDate>Tue, 21 Oct 2025 00:00:00 GMT</pubDate>
			<content:encoded><![CDATA[<!--[--><p>This was more difficult than I thought it was going to be, but in the end, we’re up and running and I’ve converted all of my SvelteKit containers to hardened <a href="https://chainguard.dev" rel="nofollow">chainguard</a> images, including the website you’re looking at now!</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="RlJPTSBjZ3IuZGV2L2NoYWluZ3VhcmQvbm9kZTpsYXRlc3QtZGV2IEFTIGJ1aWxkZXIKClVTRVIgcm9vdApXT1JLRElSIC9hcHAKUlVOIGNob3duIC1SIG5vZGU6bm9kZSAvYXBwCgpVU0VSIG5vZGUKQ09QWSAtLWNob3duPW5vZGU6bm9kZSBwYWNrYWdlKi5qc29uIC4vClJVTiBucG0gY2kKCkNPUFkgLS1jaG93bj1ub2RlOm5vZGUgLiAuClJVTiBucG0gcnVuIGJ1aWxkCgpGUk9NIGNnci5kZXYvY2hhaW5ndWFyZC9ub2RlOmxhdGVzdAoKVVNFUiByb290CldPUktESVIgL2FwcApSVU4gY2hvd24gLVIgbm9kZTpub2RlIC9hcHAKClVTRVIgbm9kZQpFTlYgTk9ERV9FTlY9cHJvZHVjdGlvbgoKQ09QWSAtLWNob3duPW5vZGU6bm9kZSAtLWZyb209YnVpbGRlciAvYXBwL2J1aWxkIC4vYnVpbGQKQ09QWSAtLWNob3duPW5vZGU6bm9kZSAtLWZyb209YnVpbGRlciAvYXBwL3BhY2thZ2UqLmpzb24gLi8KUlVOIG5wbSBjaSAtLW9taXQ9ZGV2CgpFWFBPU0UgMzAwMApDTUQgWyJidWlsZC9pbmRleC5qcyJd" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-Dockerfile"><!----><span class="hljs-keyword">FROM</span> cgr.dev/chainguard/node:latest-dev AS builder

<span class="hljs-keyword">USER</span> root
<span class="hljs-keyword">WORKDIR</span><span class="language-bash"> /app</span>
<span class="hljs-keyword">RUN</span><span class="language-bash"> <span class="hljs-built_in">chown</span> -R node:node /app</span>

<span class="hljs-keyword">USER</span> node
<span class="hljs-keyword">COPY</span><span class="language-bash"> --<span class="hljs-built_in">chown</span>=node:node package*.json ./</span>
<span class="hljs-keyword">RUN</span><span class="language-bash"> npm ci</span>

<span class="hljs-keyword">COPY</span><span class="language-bash"> --<span class="hljs-built_in">chown</span>=node:node . .</span>
<span class="hljs-keyword">RUN</span><span class="language-bash"> npm run build</span>

<span class="hljs-keyword">FROM</span> cgr.dev/chainguard/node:latest

<span class="hljs-keyword">USER</span> root
<span class="hljs-keyword">WORKDIR</span><span class="language-bash"> /app</span>
<span class="hljs-keyword">RUN</span><span class="language-bash"> <span class="hljs-built_in">chown</span> -R node:node /app</span>

<span class="hljs-keyword">USER</span> node
<span class="hljs-keyword">ENV</span> NODE_ENV=production

<span class="hljs-keyword">COPY</span><span class="language-bash"> --<span class="hljs-built_in">chown</span>=node:node --from=builder /app/build ./build</span>
<span class="hljs-keyword">COPY</span><span class="language-bash"> --<span class="hljs-built_in">chown</span>=node:node --from=builder /app/package*.json ./</span>
<span class="hljs-keyword">RUN</span><span class="language-bash"> npm ci --omit=dev</span>

<span class="hljs-keyword">EXPOSE</span> <span class="hljs-number">3000</span>
<span class="hljs-keyword">CMD</span><span class="language-bash"> [<span class="hljs-string">&quot;build/index.js&quot;</span>]</span><!----></code></pre></div> <p>Here is the compose file that I’m using. I will note that this compose file is for use in a Docker swarm with a Traefik ingress container. You will need to modify it to run correctly outside of a swarm, and change the network names and environment variables, obviously.</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="c2VydmljZXM6CiAgc3ZlbHRla2l0OgogICAgaW1hZ2U6IGplc3NlaWQvamVzc2VpZDpsYXRlc3QKICAgIG5ldHdvcmtzOgogICAgICAtIHRyYWVmaWstcHVibGljCiAgICAgIC0gamVzc2VpZAogICAgZW52aXJvbm1lbnQ6CiAgICAgIFBPUlQ6IDMwMDAKICAgICAgTk9ERV9FTlY6IHByb2R1Y3Rpb24KICAgICAgR0lUSFVCX1RPS0VOX0ZJTEU6IC9ydW4vc2VjcmV0cy9naXRodWJfdG9rZW4KICAgIHNlY3JldHM6CiAgICAgIC0gZ2l0aHViX3Rva2VuCiAgICBkZXBsb3k6CiAgICAgIHJlcGxpY2FzOiAxCiAgICAgIHJlc3RhcnRfcG9saWN5OgogICAgICAgIGNvbmRpdGlvbjogb24tZmFpbHVyZQogICAgICAgIGRlbGF5OiA1cwogICAgICAgIG1heF9hdHRlbXB0czogMwogICAgICB1cGRhdGVfY29uZmlnOgogICAgICAgIHBhcmFsbGVsaXNtOiAxCiAgICAgICAgZGVsYXk6IDEwcwogICAgICByb2xsYmFja19jb25maWc6CiAgICAgICAgcGFyYWxsZWxpc206IDEKICAgICAgICBkZWxheTogMTBzCiAgICAgIGxhYmVsczoKICAgICAgICAtICd0cmFlZmlrLmVuYWJsZT10cnVlJwogICAgICAgICMgTWFpbiByb3V0ZXIgZm9yIGplc3NlLmlkCiAgICAgICAgLSAndHJhZWZpay5odHRwLnJvdXRlcnMuamVzc2VpZC13ZWIuZW50cnlwb2ludHM9d2ViLWh0dHBzJwogICAgICAgIC0gJ3RyYWVmaWsuaHR0cC5yb3V0ZXJzLmplc3NlaWQtd2ViLnJ1bGU9SG9zdChgamVzc2UuaWRgKScKICAgICAgICAtICd0cmFlZmlrLmh0dHAucm91dGVycy5qZXNzZWlkLXdlYi50bHM9dHJ1ZScKICAgICAgICAtICd0cmFlZmlrLmh0dHAucm91dGVycy5qZXNzZWlkLXdlYi50bHMuY2VydHJlc29sdmVyPWxldHNlbmNyeXB0JwogICAgICAgIC0gJ3RyYWVmaWsuaHR0cC5zZXJ2aWNlcy5qZXNzZWlkLXdlYi5sb2FkYmFsYW5jZXIuc2VydmVyLnBvcnQ9MzAwMCcKICAgICAgICAjIFdXVyB0byBub24tV1dXIHJlZGlyZWN0CiAgICAgICAgLSAndHJhZWZpay5odHRwLnJvdXRlcnMuamVzc2VpZC13d3ctcmVkaXJlY3QuZW50cnlwb2ludHM9d2ViLWh0dHBzJwogICAgICAgIC0gJ3RyYWVmaWsuaHR0cC5yb3V0ZXJzLmplc3NlaWQtd3d3LXJlZGlyZWN0LnJ1bGU9SG9zdChgd3d3Lmplc3NlLmlkYCknCiAgICAgICAgLSAndHJhZWZpay5odHRwLnJvdXRlcnMuamVzc2VpZC13d3ctcmVkaXJlY3QudGxzPXRydWUnCiAgICAgICAgLSAndHJhZWZpay5odHRwLnJvdXRlcnMuamVzc2VpZC13d3ctcmVkaXJlY3QudGxzLmNlcnRyZXNvbHZlcj1sZXRzZW5jcnlwdCcKICAgICAgICAtICd0cmFlZmlrLmh0dHAucm91dGVycy5qZXNzZWlkLXd3dy1yZWRpcmVjdC5taWRkbGV3YXJlcz1qZXNzZWlkLXJlZGlyZWN0LXd3dy10by1ub24td3d3JwogICAgICAgIC0gInRyYWVmaWsuaHR0cC5taWRkbGV3YXJlcy5qZXNzZWlkLXJlZGlyZWN0LXd3dy10by1ub24td3d3LnJlZGlyZWN0cmVnZXgucmVnZXg9Xmh0dHBzOi8vd3d3XFwuKC4rKSIKICAgICAgICAtICd0cmFlZmlrLmh0dHAubWlkZGxld2FyZXMuamVzc2VpZC1yZWRpcmVjdC13d3ctdG8tbm9uLXd3dy5yZWRpcmVjdHJlZ2V4LnJlcGxhY2VtZW50PWh0dHBzOi8vJCR7MX0nCiAgICAgICAgLSAndHJhZWZpay5odHRwLm1pZGRsZXdhcmVzLmplc3NlaWQtcmVkaXJlY3Qtd3d3LXRvLW5vbi13d3cucmVkaXJlY3RyZWdleC5wZXJtYW5lbnQ9dHJ1ZScKCnNlY3JldHM6CiAgZ2l0aHViX3Rva2VuOgogICAgZXh0ZXJuYWw6IHRydWUKCm5ldHdvcmtzOgogIHRyYWVmaWstcHVibGljOgogICAgZXh0ZXJuYWw6IHRydWUKICBqZXNzZWlkOgogICAgZXh0ZXJuYWw6IHRydWU=" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-yaml"><!----><span class="hljs-attr">services:</span>
  <span class="hljs-attr">sveltekit:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">jesseid/jesseid:latest</span>
    <span class="hljs-attr">networks:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">traefik-public</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">jesseid</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">PORT:</span> <span class="hljs-number">3000</span>
      <span class="hljs-attr">NODE_ENV:</span> <span class="hljs-string">production</span>
      <span class="hljs-attr">GITHUB_TOKEN_FILE:</span> <span class="hljs-string">/run/secrets/github_token</span>
    <span class="hljs-attr">secrets:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">github_token</span>
    <span class="hljs-attr">deploy:</span>
      <span class="hljs-attr">replicas:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">restart_policy:</span>
        <span class="hljs-attr">condition:</span> <span class="hljs-string">on-failure</span>
        <span class="hljs-attr">delay:</span> <span class="hljs-string">5s</span>
        <span class="hljs-attr">max_attempts:</span> <span class="hljs-number">3</span>
      <span class="hljs-attr">update_config:</span>
        <span class="hljs-attr">parallelism:</span> <span class="hljs-number">1</span>
        <span class="hljs-attr">delay:</span> <span class="hljs-string">10s</span>
      <span class="hljs-attr">rollback_config:</span>
        <span class="hljs-attr">parallelism:</span> <span class="hljs-number">1</span>
        <span class="hljs-attr">delay:</span> <span class="hljs-string">10s</span>
      <span class="hljs-attr">labels:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.enable=true&#x27;</span>
        <span class="hljs-comment"># Main router for jesse.id</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.routers.jesseid-web.entrypoints=web-https&#x27;</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.routers.jesseid-web.rule=Host(`jesse.id`)&#x27;</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.routers.jesseid-web.tls=true&#x27;</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.routers.jesseid-web.tls.certresolver=letsencrypt&#x27;</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.services.jesseid-web.loadbalancer.server.port=3000&#x27;</span>
        <span class="hljs-comment"># WWW to non-WWW redirect</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.routers.jesseid-www-redirect.entrypoints=web-https&#x27;</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.routers.jesseid-www-redirect.rule=Host(`www.jesse.id`)&#x27;</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.routers.jesseid-www-redirect.tls=true&#x27;</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.routers.jesseid-www-redirect.tls.certresolver=letsencrypt&#x27;</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.routers.jesseid-www-redirect.middlewares=jesseid-redirect-www-to-non-www&#x27;</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;traefik.http.middlewares.jesseid-redirect-www-to-non-www.redirectregex.regex=^https://www\.(.+)&quot;</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.middlewares.jesseid-redirect-www-to-non-www.redirectregex.replacement=https://$${1}&#x27;</span>
        <span class="hljs-bullet">-</span> <span class="hljs-string">&#x27;traefik.http.middlewares.jesseid-redirect-www-to-non-www.redirectregex.permanent=true&#x27;</span>

<span class="hljs-attr">secrets:</span>
  <span class="hljs-attr">github_token:</span>
    <span class="hljs-attr">external:</span> <span class="hljs-literal">true</span>

<span class="hljs-attr">networks:</span>
  <span class="hljs-attr">traefik-public:</span>
    <span class="hljs-attr">external:</span> <span class="hljs-literal">true</span>
  <span class="hljs-attr">jesseid:</span>
    <span class="hljs-attr">external:</span> <span class="hljs-literal">true</span><!----></code></pre></div> <p>And as an added bonus, here is the build.sh script that I use for this site, which scans the image with trivy once complete. Also important: it builds for ARM64 architecture. So if it doesn’t work for you, switch that to AMD64.</p> <div class="code-block-wrapper"><button class="code-copy-button" data-code="IyEvYmluL2Jhc2gKc2V0IC1lCgojIExvYWQgZW52aXJvbm1lbnQgdmFyaWFibGVzIGZyb20gLmVudiBmaWxlIGlmIGl0IGV4aXN0cwppZiBbIC1mIC5lbnYgXTsgdGhlbgogIGV4cG9ydCAkKGNhdCAuZW52IHwgZ3JlcCAtdiAnXiMnIHwgeGFyZ3MpCmZpCgojIEV4dHJhY3QgdmVyc2lvbiBmcm9tIHBhY2thZ2UuanNvbgpWRVJTSU9OPSQobm9kZSAtcCAicmVxdWlyZSgnLi9wYWNrYWdlLmpzb24nKS52ZXJzaW9uIikKCiMgRnVuY3Rpb24gdG8gc2NhbiBpbWFnZSB3aXRoIFRyaXZ5CnNjYW5faW1hZ2UoKSB7CiAgZWNobyAiU2Nhbm5pbmcgaW1hZ2Ugd2l0aCBUcml2eSBmb3IgdnVsbmVyYWJpbGl0aWVzLi4uIgogIHRyaXZ5IGltYWdlIFwKICAgIC0tc2V2ZXJpdHkgSElHSCxDUklUSUNBTCBcCiAgICAtLWlnbm9yZS11bmZpeGVkIFwKICAgIC0tc2Nhbm5lcnMgdnVsbiBcCiAgICBqZXNzZWlkL2plc3NlaWQ6JFZFUlNJT04KCiAgaWYgWyAkPyAtZXEgMCBdOyB0aGVuCiAgICBlY2hvICLinJMgTm8gSElHSCBvciBDUklUSUNBTCB2dWxuZXJhYmlsaXRpZXMgZm91bmQiCiAgZmkKfQoKIyBDaGVjayBmb3IgLS1zY2FuIGZsYWcKaWYgWyAiJDEiID0gIi0tc2NhbiIgXTsgdGhlbgogIGVjaG8gIlNjYW5uaW5nIGV4aXN0aW5nIGltYWdlIGplc3NlaWQvamVzc2VpZDokVkVSU0lPTiIKICBzY2FuX2ltYWdlCiAgZXhpdCAwCmZpCgojIEJ1aWxkIERvY2tlciBpbWFnZSBmb3IgYXJtNjQgcGxhdGZvcm0gd2l0aCBidWlsZCBhcmd1bWVudHMKIyBOb3RlOiBBV1MgY3JlZGVudGlhbHMgY2FuIGJlIGR1bW15IHZhbHVlcyBoZXJlIGlmIHVzaW5nIElBTSByb2xlcyBvbiBFQzIKZG9ja2VyIGJ1aWxkIC0tcGxhdGZvcm0gbGludXgvYXJtNjQgXAogIC10IGplc3NlaWQvamVzc2VpZDokVkVSU0lPTiAuLwoKIyBUYWcgYXMgbGF0ZXN0CmRvY2tlciB0YWcgamVzc2VpZC9qZXNzZWlkOiRWRVJTSU9OIGplc3NlaWQvamVzc2VpZDpsYXRlc3QKCiMgUHVzaCBib3RoIHRhZ3MKZG9ja2VyIHB1c2ggamVzc2VpZC9qZXNzZWlkOiRWRVJTSU9OCmRvY2tlciBwdXNoIGplc3NlaWQvamVzc2VpZDpsYXRlc3QKCmVjaG8gIlN1Y2Nlc3NmdWxseSBidWlsdCBhbmQgcHVzaGVkIHZlcnNpb24gJFZFUlNJT04iCgojIFNjYW4gaW1hZ2Ugd2l0aCBUcml2eQpzY2FuX2ltYWdl" aria-label="Copy code to clipboard"><span class="copy-text">COPY</span></button> <pre class="hljs"><code class="language-bash"><!----><span class="hljs-meta">#!/bin/bash</span>
<span class="hljs-built_in">set</span> -e

<span class="hljs-comment"># Load environment variables from .env file if it exists</span>
<span class="hljs-keyword">if</span> [ -f .<span class="hljs-built_in">env</span> ]; <span class="hljs-keyword">then</span>
  <span class="hljs-built_in">export</span> $(<span class="hljs-built_in">cat</span> .<span class="hljs-built_in">env</span> | grep -v <span class="hljs-string">&#x27;^#&#x27;</span> | xargs)
<span class="hljs-keyword">fi</span>

<span class="hljs-comment"># Extract version from package.json</span>
VERSION=$(node -p <span class="hljs-string">&quot;require(&#x27;./package.json&#x27;).version&quot;</span>)

<span class="hljs-comment"># Function to scan image with Trivy</span>
<span class="hljs-function"><span class="hljs-title">scan_image</span></span>() {
  <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;Scanning image with Trivy for vulnerabilities...&quot;</span>
  trivy image     --severity HIGH,CRITICAL     --ignore-unfixed     --scanners vuln     jesseid/jesseid:<span class="hljs-variable">$VERSION</span>

  <span class="hljs-keyword">if</span> [ $? -eq 0 ]; <span class="hljs-keyword">then</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;✓ No HIGH or CRITICAL vulnerabilities found&quot;</span>
  <span class="hljs-keyword">fi</span>
}

<span class="hljs-comment"># Check for --scan flag</span>
<span class="hljs-keyword">if</span> [ <span class="hljs-string">&quot;<span class="hljs-variable">$1</span>&quot;</span> = <span class="hljs-string">&quot;--scan&quot;</span> ]; <span class="hljs-keyword">then</span>
  <span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;Scanning existing image jesseid/jesseid:<span class="hljs-variable">$VERSION</span>&quot;</span>
  scan_image
  <span class="hljs-built_in">exit</span> 0
<span class="hljs-keyword">fi</span>

<span class="hljs-comment"># Build Docker image for arm64 platform with build arguments</span>
<span class="hljs-comment"># Note: AWS credentials can be dummy values here if using IAM roles on EC2</span>
docker build --platform linux/arm64   -t jesseid/jesseid:<span class="hljs-variable">$VERSION</span> ./

<span class="hljs-comment"># Tag as latest</span>
docker tag jesseid/jesseid:<span class="hljs-variable">$VERSION</span> jesseid/jesseid:latest

<span class="hljs-comment"># Push both tags</span>
docker push jesseid/jesseid:<span class="hljs-variable">$VERSION</span>
docker push jesseid/jesseid:latest

<span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;Successfully built and pushed version <span class="hljs-variable">$VERSION</span>&quot;</span>

<span class="hljs-comment"># Scan image with Trivy</span>
scan_image<!----></code></pre></div><!--]-->]]></content:encoded>
		</item>
		<item>
			<title>I switched from Windows to MacOS.</title>
			<description>After 30 years, I switched from Windows to Mac.</description>
			<link>https://jesse.id/blog/posts/switching-from-windows-to-macos</link>
			<guid isPermaLink="true">https://jesse.id/blog/posts/switching-from-windows-to-macos</guid>
			<pubDate>Sun, 22 Jun 2025 00:00:00 GMT</pubDate>
			<content:encoded><![CDATA[<!--[--><p>I’ve been alive for 43 years at this point and I have been using a Microsoft operating system as my daily driver for the last 37 years, starting with MS-DOS running on a Commodore Colt PC in 1988.</p> <p>I have used every version of Windows from 3.0 in 1990 up until Windows 11 in 2025, mostly because I enjoy playing games and Windows has always catered to gamers. Aside from that, with the exception of Millenium Edition, Vista, and Windows Server, I have enjoyed the experience with relatively few hiccups.</p> <h1>So why switch?</h1> <p>Well as I age, I’m getting much less excited about the idea of cracking open a computer and digging around inside of it. I just don’t have the drive to do it anymore. For well over a decade, I’ve just been buying a beastly new computer and selling the old one without ever touching anything inside of it. I have continued to use Windows solely for my extreme familiarirty with it and Macs generally couldn’t handle the games I was playing without a headache.</p> <p>I have been much less enchanted by video games for awhile, abandoning triple A titles completely in lieu of much cheaper deckbuilding roguelikes, and the creative side of my soul has been steadily becoming more pervasive. I’m reaching the age where the grey-beard nerds tend to transition from a career in engineering to woodworking, and moving to a cabin in the forest somewhere. Well, that will never be me because I’m incredibly lazy and addicted to the conveniences of modern living. So for the rest of my life, I will be shackled to a computer.</p> <p>When Apple introduced Apple Silicon, a unified CPU and memory architecture, it piqued my interest. Like I said, I don’t really care about upgrading anymore, so the static nature of the build does not bother me at all. What finally pulled me over to the MacOS side of the aisle was the eye-popping performance benchmarks and nearly zero noise generated by the hardware. As a musician — a vocalist — that is a pretty huge selling point for me.</p> <p>I had always heard that making music was a more enjoyable experience on a Mac in general, but I had no real reason to test the waters. Well, Apple Silicon and its silent opeation is that real reason. I waited a couple of years to give the software companies and developers enough time to create builds for ARM64 architecture, and the time for that nearly ubiquitous compatability is now.</p> <p>So I finally made the switch.</p> <p>Some things felt clunky, especially relearning keyboard shortcuts, but after a couple of days of uncertainty and looking to the right side of my monitor for the minimize button on a worefully lacking Finder interface, every regret faded and I am now fully absorbed into the Mac ecosystem. It honestly makes me feel a little dirty… but who cares?</p> <p>Finally, let’s go over the pros and cons.</p> <h3>Pros</h3> <ul><li>I can use iMessage natively from my desktop.</li> <li>A lot less telemetry crap.</li> <li>It’s <a href="https://www.youtube.com/watch?v=F8vYsX9Y7ho" rel="nofollow">fast as f**k, boieee</a>.</li> <li>It’s completely silent.</li> <li>It’s ridiculously small for the power. It takes up like 1/8th of the space of my gaming rig.</li> <li>The OS seamlessly integrates with all of my other Apple stuff. Phone, iPad, Watch, TV, etc.</li> <li>I can move apps to my iPad so it acts as a third monitor. Great for monitoring my security cameras.</li> <li>Most of the software is a single lifetime purchase instead of a subscription.</li> <li>Great privacy features.</li> <li>All things audio are a lot more user friendly.</li> <li>Apple Podcasts rules. It has an easily reachable “Latest Episodes” section. Imagine that.</li> <li>Apple Pay rules.</li> <li>Subscriptions are incredibly easy to manage.</li> <li>Time Machine backups are cool.</li></ul> <h3>Cons</h3> <ul><li>Window management is clunky.</li> <li>Can’t keep iCloud files on an external USB drive.</li> <li>Most of my Steam games are dead in the water without extra effort.</li> <li>Not a lot of customization options out of the box.</li> <li>Everything costs money. There is a lot less freeware.</li> <li>Everything asks for a password. 1Password, YubiKey, and fingerprint sensor are all absolute requirements.</li> <li>Apple Arcade kind of sucks. Good idea, bad execution.</li></ul> <h1>Final Thoughts</h1> <p>I personally love MacOS. It provides a pretty amazing user experience for software development, writing, and music production. No regrets.</p> <p>Additionally, I don’t feel like my personal information and usage habits are being lusted after and scraped, which was becoming more and more of a concern for me as Microsoft focuses in on its AI investments. Even though they aligator-armed it (for now), Microsoft’s “feature” to allows users to track and replay all of their activity from the past however many days, for example, is a dystopian nightmare in my estimation. It’s crazy to me that it ever got a green light in the first place. No thanks.</p> <p>I have updated the <a href="/uses">/uses page</a> on my site to add the replacement apps I found for the Mac.</p><!--]-->]]></content:encoded>
		</item>
		<item>
			<title>Deconstructing my ghostly encounter...</title>
			<description>That time I saw a ghost but I did not actually see a ghost.</description>
			<link>https://jesse.id/blog/posts/deconstructing-my-ghostly-encounter</link>
			<guid isPermaLink="true">https://jesse.id/blog/posts/deconstructing-my-ghostly-encounter</guid>
			<pubDate>Thu, 18 Aug 2022 00:00:00 GMT</pubDate>
			<content:encoded><![CDATA[<!--[--><p>Click bait! I don’t actually believe that ghosts are a thing. What I actually saw was a hallucination, which is much scarier to me than a ghost because it’s a stark reminder that my brain can, at any time, can make shit appear in my field of view that isn’t actually there; things that look so real I can’t discern them from reality… and I sure do hate that a lot.</p> <p>Anyway, back to the story…</p> <p>I toss and turn a lot when I’m sleeping. A few months ago, I stirred awake, turned onto my left side, and opened my eyes. The room was dark except for a dim light from the closet. It was probably somewhere around 4AM. I saw movement out of the corner of my eye, both eyes widened, and I saw the silhouette of my wife walking towards our master bathroom. As she passed in front of me, I saw her skin and her hair. She stopped in the doorway and stood still, lingering just long enough to activate my uncanny valley response. There was no reason for her to stop and linger like that.</p> <p>Then, she continued walking into the bathroom. Normally, this would be where she just disappears into the dark bathroom, closes the door behind her, and I would see the crack at the bottom of the door glow yellow as she flips on the light switch. That didn’t happen. Instead, after three of four steps into the bathroom, she evaporated into thin air. Then, I quickly turned over and saw her still laying in bed next to me.</p> <p>It was a jarring experience. I’ve never really hallucinated before; certainly not a fully formed human being with the same features and gait as my wife. Maybe a spider on the wall here or there as I’m falling asleep, sure, but never a fully formed apparition.</p> <p>It was likely just my REM cycle spinning down more slowly than it should have. But even though it wasn’t a supernatural event, it was incredibly strange and I can now definitely understand how some people can convince themselves, and others, that they’ve seen something supernatural.</p> <p>After all, people with schizophrenia experience things like this all of the time, don’t they? The building blocks are sitting there in our brains, dormant for most, and just waiting to wreck our notion that reality is stable.</p> <p>My main takeaway from the encounter was that my brain can apparently project perfect illusions into four dimensional space, and then it can fade them away into nothing like a Thanos snap. Of course, I knew the human brain had this power prior to experiencing it first hand, but it did actually make a big difference to be tricked by it in real time. It shook me a bit.</p> <p>What’s to stop the rest of reality from evaporating, too? If I suddenly lost all of my senses, would I experience the same hallucination in the same way, and at the same time? Time for a quick foray into an existential crisis…</p> <p>She walked around our bed — her lower half wasn’t visible at all until she rounded the corner — and then she walked into the doorway, and then into the bathroom. The hallucination was interacting with the objects in my field of view. The hallucination obeyed the laws of physics. Her hair was swaying with her movement, meaning the hallucinated hair had mass and was affected by gravity and motion.</p> <p>Was I hallucinating the scene as well, or just the figure? Was it a memory being projected like an overlay? When you’re looking at a sunlit object at noon and suddenly close your eyes, you can see the still frame of the last thing you saw, as if burned into your retinas for a few moments. Was it that same mechanism painting my memories of our master bedroom into a dream?</p> <p>By the way, I’m not a scientist, and I haven’t studied memory, so this is about to piss off people who actually know how memories work! My one qaurrel (not really) with science is that if I know too much about a given topic, I’m robbed of being able to wonder aloud, and that’s one of my favorite things to do. So no, I will not look up how this actually works! Sweet ignorance.</p> <p>Anyway…</p> <p>If the hallucination was simply the projection of a memory into four dimensional space, then even if I lost my senses, nothing would change, would it? I shouldn’t need use of my senses to recall a memory from when I had them. The memory was a snapshot of time that was already taken. Even if the entire layout of our master bedroom changed right after I lost all of my senses, then I suspect that I would experience the exact same hallucination.</p> <p>I was laying on my side. So, if it was a memory, my view of reality was 90 degrees different than it would be if I were standing. The memory must have been a snapshot from that exact spot that I was laying in. But if that’s true, when the ghost evaporated into thin air, why was the bathroom door in the exact same position when I fully awoke moments later? In fact, nothing in the room was different when the hallucination faded. It was a perfect transition from the hallucination back into my waking life.</p> <p>I sleep with a fan on. Her hair moved with the wind and it bounced off her back when she walked. I do not think that I have ever recalled a memory with that level of detail. The movement of the air, and how it interacted with individual strands of her hair, feels like it requires simulated physics. Her hair moving in the wind changes everything.</p> <p>Was my brain creating the image of my wife and simulating the physics of the room at at the same time? That’s nuts. To create that figure and place it into reality, my brain would have to process all of the physics itself. It would have to calculate and recalculate, redraw, each frame as the variables in the room shifted as my fan knocked particles around. It would have to simulate gravity and time perfectly.</p> <p>When I hallucinate, is my brain recreating time, too? When the ghost walked, she didn’t look any slower or faster than she should have. Her steps looks exactly as they should have. The whir of the fan sounded exactly how it always has. Was I hearing the fan as it spun in reality or was my brain “drawing” the sound in a dream that was fading away?</p> <p>There was a light coming from our closet and dimly lighting the room. Was my brain creating that light and the speed it was traveling at? The hallucination was lit exactly how she should have been. It looked real. Was my brain perfectly simulating her body’s interaction with light from a source behind her?</p> <p>Does my brain come with a physics manual? Are the rules hard-coded somewhere in there? How else could it create hallucinations that interact with reality so perfectly that I don’t question it at first? How light interacts with the environment, the vibrations of sound, and how all of that interacts with time.</p> <p>Seeing a ghost made me question reality directly. I had thought about the possibility that we live in a simulation before, but after seeing the ghost and thinking through all that it actually entails to experience a hallucination, I am more convinced than I have ever been that I am living in some kind of a computer program.</p> <p>Does that matter? Not really. This is still my reality. My views about religion haven’t changed. My outlook on life hasn’t changed. I was the same person after she evaporated into thin air that I was before that happened. I’m just a little more curious, and that’s kind of the point, I guess. Life keeps on keeping on.</p> <p>It doesn’t make me question who I am, but it does make me question who you are more than I ever have. It’s plausible that you don’t exist, reader. I still don’t believe in ghosts, but now I do believe that I can be completely fooled whenever my brain is properly motivated to do so. That’s a little scary, but I guess it’s no scarier than anything else that can crumble in front of me at a moments’ notice. This is all quite fragile, isn’t it?</p> <p>I’m just glad the ghost didn’t talk to me. As it stands, all I saw was like a little movie playing. I’m glad I didn’t tell the ghost that I loved her as she walked by. Imagine if she looked at me and said, “I love you, too.”</p> <p>The fourth wall remaining in tact is safer for my overall sanity. In fact, that fourth wall is probably the difference between sanity and insanity, and that morning I got a little too close for comfort. I also feel much more sympathy for people forced to see or hear hallucinations all day. It’s not fun to question what is and isn’t real, if even for a moment. I can’t imagine the burden of constantly having to question reality.</p><!--]-->]]></content:encoded>
		</item>
	</channel>
</rss>