<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <title>Raymond Camden</title>
    <link href="https://www.raymondcamden.com/feed.xml" rel="self" type="application/atom+xml"></link>
    <link href="https://www.raymondcamden.com/" rel="alternate" type="text/html"></link>
    <subtitle>Father, husband, developer relations and web standards expert, and cat demo builder.</subtitle>

    <updated>2026-04-05T20:35:03+00:00</updated>
    <author>
        <name>Raymond Camden</name>
        <email>raymondcamden@gmail.com</email>
    </author>
    <id>https://www.raymondcamden.com/feed.xml</id>

    <generator>Eleventy</generator>

        
            <entry>
                <id>https://www.raymondcamden.com/2026/04/05/links-for-you-4526</id>
                <title>Links For You (4/5/26)</title>
                <updated>2026-04-05T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/05/links-for-you-4526" rel="alternate" type="text/html" title="Links For You (4/5/26)"/>
                <content type="html">
				
                        &lt;p&gt;Happy &amp;quot;Three Days Before My Birthday Day&amp;quot;! Oh - yeah, and happy Easter too, but I&apos;m personally a bit more excited
about turning 53 as I&apos;ve decided that&apos;s when I&apos;m going to grow up and act like a mature adult. Probably. Maybe. We&apos;ll see. Now, if you, my lovely and incredibly intelligent reader, are feeling generous and you&apos;ve gotten some good knowledge (or entertainment) from this blog, I&apos;ll use today&apos;s Links For You post to remind you of my &lt;a href=&quot;https://www.amazon.com/gp/registry/wishlist/2TCL1D08EZEYE/ref=cm_wl_rlist_go_v?&quot;&gt;Amazon Wishlist&lt;/a&gt;. Or even cheaper, leave me a comment below saying HBD - that&apos;s just as good. ;)&lt;/p&gt;
&lt;h2 id=&quot;everything-old-is-new-again...&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#everything-old-is-new-again...&quot;&gt;Everything old is new again...&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;First up is a blog post on an old topic but one that&apos;s still pretty useful - bookmarklets. &lt;a href=&quot;https://css-tricks.com/a-complete-guide-to-bookmarklets/&quot;&gt;&amp;quot;A Complete Guide to Bookmarklets&amp;quot;&lt;/a&gt; (by &lt;a href=&quot;https://css-tricks.com/author/declanchidlow/&quot;&gt;Declan Childlow&lt;/a&gt;) introduces and explains bookmarklets and gives some good examples of how to use them. Be sure to check the comments to for more examples.&lt;/p&gt;
&lt;h2 id=&quot;contrast-color()---more-css-awesomness&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#contrast-color()---more-css-awesomness&quot;&gt;contrast-color() - more CSS Awesomness&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I&apos;ve said this (probably too often), but it&apos;s been incredibly gratifying to see how far CSS is advancing. As yet another example of this, you can read this excellent introduction to another new feature at: &lt;a href=&quot;https://una.im/contrast-color&quot;&gt;&amp;quot;Automated accessible text with contrast-color()&amp;quot;&lt;/a&gt;. This lets you pass a color and get either &lt;code&gt;black&lt;/code&gt; or &lt;code&gt;white&lt;/code&gt; as a value that can be used to contrast properly against the original. This is not yet available but will launch in Chrome 147 (as of today, 146 is the released version).&lt;/p&gt;
&lt;h2 id=&quot;can-your-game-be-programmed%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#can-your-game-be-programmed%3F&quot;&gt;Can your game be programmed?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I love this. I mean, I &lt;em&gt;really&lt;/em&gt; love this. So apparently there&apos;s a city-sim game (a genre I&apos;ve long liked myself) where you manage a beaver colony - &lt;a href=&quot;https://store.steampowered.com/app/1062090/Timberborn/&quot;&gt;Timberborn&lt;/a&gt;. As part of the game&apos;s most recent updates, they added the ability to perform automations via HTTP calls. This exposes an endpoint that can be called by external clients to perform actions in the game. So of course someone took this and did something completely ridiculous - &lt;a href=&quot;https://dev.to/thormeier/how-to-use-timberborn-yes-the-beaver-city-building-game-as-a-database-489c&quot;&gt;build a basic database&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;just-for-fun&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#just-for-fun&quot;&gt;Just For Fun&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, I&apos;ll leave you with another music video, and once again I&apos;ve got &lt;a href=&quot;https://remotesynthesis.com/&quot;&gt;Brian Rinaldi&lt;/a&gt; to thank. This is another new band for me, &amp;quot;Golden Cats&amp;quot; - enjoy!&lt;/p&gt;
&lt;lite-youtube videoid=&quot;i6zPKKyAfzM&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/i6zPKKyAfzM/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=i6zPKKyAfzM&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;

                        
                
				</content>

                
                <category term="links4you" />
                
                
                <category term="misc" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/04/01/youve-gained-a-new-achievement</id>
                <title>You&apos;ve gained a new achievement</title>
                <updated>2026-04-01T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/01/youve-gained-a-new-achievement" rel="alternate" type="text/html" title="You&apos;ve gained a new achievement"/>
                <content type="html">
				
                        &lt;p&gt;For the past month or so I&apos;ve been obsessed with a book series that&apos;s apparently been popular and I just didn&apos;t realize - Dungeon Crawler Carl. Without giving too much away, it&apos;s basically about a person, and his glorious cat, who get caught up in a real world RPG. I&apos;m currently on book 3 (of 8) and am enjoying every page of it. It&apos;s &lt;em&gt;incredibly&lt;/em&gt; funny and cool at the same time. If you haven&apos;t checked it out yet, I highly recommend picking up the first book and giving it a shot. I don&apos;t think you&apos;ll regret it.&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/iamyelling.jpg&quot; loading=&quot;lazy&quot; alt=&quot;A cat by a keyboard saying I AM YELLING, CARL&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;As I mentioned, the book series involves a man (and his cat, the cat is crucial) experiencing a real-world RPG and like a RPG, it&apos;s got achievements as the character progress through the dungeon. For those of you aren&apos;t gamers, most games now will give you an achievement for performing some task. These achievements give you nothing but bragging rights and a slight feeling of accomplishment, but some folks get really excited about them.&lt;/p&gt;
&lt;p&gt;In the books, the main characters will also get achievements and typically the achievement is &lt;em&gt;incredibly&lt;/em&gt; snarky and sarcastic. As an example:&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;New achievement! Why aren’t you wearing pants?&lt;/strong&gt;&lt;br&gt;
You entered the dungeon wearing no pants. Dude. Seriously?
&lt;p&gt;&lt;strong&gt;Reward&lt;/strong&gt;: You&apos;ve received a Gold Apparel Box!&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;And another:&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;New achievement! You’ve entered a guildhall!&lt;/strong&gt;&lt;br&gt;
Congratulations. You know how to open doors.
&lt;p&gt;&lt;strong&gt;Reward&lt;/strong&gt;: That sense of fulfillment you feel? That’s reward enough.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;These almost always make me LOL and I thought - what if I could make a simple web tool to generate these on the fly? I did so, using Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/built-in&quot;&gt;built-in AI&lt;/a&gt; feature, which as of today is still behind a flag, so if you want to play with this, you&apos;ll need to follow the &lt;a href=&quot;https://developer.chrome.com/docs/ai/prompt-api&quot;&gt;instructions&lt;/a&gt; on which flags to enable in your browser. If you&apos;ve done that, and don&apos;t care about &lt;em&gt;how&lt;/em&gt; this tool was built, you can head over to the demo now: &lt;a href=&quot;https://dcc.raymondcamden.com/&quot;&gt;https://dcc.raymondcamden.com/&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-code&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-code&quot;&gt;The Code&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The code behind this was a rather simple usage of the Chrome &lt;a href=&quot;https://developer.chrome.com/docs/ai/prompt-api&quot;&gt;Prompt API&lt;/a&gt;. Here&apos;s the code that initializes the session:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;session = await LanguageModel.create({
    initialPrompts:[
                { 
                    role: &apos;system&apos;, 
                    content: 
`Generate an achievement announcement in the style of 
the Dungeon Crawler Carl series. You will be given a 
prompt to base the achievement on, which will be an 
ordinary, mundane task. The achievement you generate
should have the snarky, over the top prose associated 
with the book.

do not comment about carl at all, or have any other output 
except the achievement title, text, and rewards` 
                }
            ],		
        monitor(m) {
                m.addEventListener(&amp;quot;downloadprogress&amp;quot;, e =&amp;gt; {
                    console.log(`Downloaded ${e.loaded * 100}%`);
                    if(e.loaded === 0 || e.loaded === 1) {
                        $status.innerHTML = &apos;&apos;;
                        return;
                    }
                    $status.innerHTML = `Downloading AI model, currently at ${Math.floor(e.loaded * 100)}%`;
                });
            }		
    });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make note of the system instruction which is what guides the model when generating content. Your input is basically a &amp;quot;mundane&amp;quot; thing you&apos;ve done, like wash dishes or take out the trash. When you&apos;ve entered that and hit submit, I then simply get the achievement:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let achievement = JSON.parse(await session.prompt(input, {  
    responseConstraint: schema,
  }));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice the &lt;code&gt;responseConstraint&lt;/code&gt; value? This is how I get precise results back, in my case an achievement title, the text, and a list of rewards. This is defined earlier in my code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let schema = {
	&amp;quot;type&amp;quot;: &amp;quot;object&amp;quot;,
	&amp;quot;properties&amp;quot;: {
		&amp;quot;title&amp;quot;: {
			&amp;quot;type&amp;quot;:&amp;quot;string&amp;quot;
		},
		&amp;quot;achievementText&amp;quot;: {
			&amp;quot;type&amp;quot;:&amp;quot;string&amp;quot;
		},
		&amp;quot;rewards&amp;quot;: {
			&amp;quot;type&amp;quot;:&amp;quot;array&amp;quot;,
			&amp;quot;items&amp;quot;:{
				&amp;quot;type&amp;quot;:&amp;quot;string&amp;quot;
			}
		},
  },
  &amp;quot;additionalProperties&amp;quot;: false
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And that&apos;s pretty much it. The rest of the code is straight up DOM manipulation.&lt;/p&gt;
&lt;h2 id=&quot;the-design&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-design&quot;&gt;The Design&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;As for the look and feel of the app, I made use of Cursor and one simple prompt:&lt;/p&gt;
&lt;blockquote&gt;
im going to be building a tool that uses Chrome AI to generate random achievements in the style of Dungeon Crawler Carl. i need your help with a design. i dont need you to do any of the javascript, just create my scaffold for me. its one web page and the title should be, &quot;DCC Achievement Generator&quot;. There is a prompt, &quot;What mundane thing did you get done?&quot;, a form field for the task, and then it will render out the achievment. 
&lt;p&gt;Achievements have a title, a body of text, and a list of rewards (bulleted list). Create a nice design for the achievement as well (just make something up for now)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I&apos;m just now noticing the typo and thankfully Cursor didn&apos;t complain. I let it output my HTML and CSS and then simply copied that over to CodePen so I could start working on the actual implementation. I think the only work I needed to do was add some IDs to the HTML in order for my JavaScript to connect to what it needed to update. The design Cursor created was just fine:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/dcc2.jpg&quot; loading=&quot;lazy&quot; alt=&quot;A screenshot of the app&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;In case you can&apos;t read that, my input was: &lt;code&gt;i wrote a blog post&lt;/code&gt; and the achievement I received was:&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;strong&gt;The Scroll of Slightly Above Average Prose&lt;/strong&gt;&lt;br&gt;
By the gods...you *authored* a blog post? A digital missive of words, painstakingly crafted (or, let&apos;s be honest, hastily assembled) and disseminated across the ethereal web? A feat of remarkable… mediocrity? Fear not, adventurer! While not slaying a dragon or retrieving a legendary artifact, you have demonstrated a modicum of linguistic dexterity. A tiny flicker of creativity in the vast darkness of internet noise. Consider yourself mildly commended. It&apos;s not much, but it&apos;s… something.
&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Rewards&lt;/strong&gt;:&lt;br&gt;
&lt;ul&gt;
&lt;li&gt;A digital badge of… acknowledgment.&lt;/li&gt;
&lt;li&gt;The fleeting satisfaction of knowing you’ve added to the collective human output.&lt;/li&gt;
&lt;li&gt;A slightly less judgmental stare from your cat.&lt;/li&gt;
&lt;li&gt;Potentially, an increase in internet karma. (Results may vary.)&lt;/li&gt;
&lt;/div&gt;
&lt;h2 id=&quot;results-may-vary&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#results-may-vary&quot;&gt;Results May Vary&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;If you&apos;ve got the flags enabled and try it out, please share your results, and let me know what you think. Feel free to fork the pen and modify to your heart&apos;s content. Also... Mongo is appalled!&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;500&quot; data-pen-title=&quot;DCC Generator&quot; data-preview=&quot;true&quot; data-version=&quot;2&quot; data-default-tab=&quot;js&quot; data-slug-hash=&quot;qEaxdmg&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/editor/cfjedimaster/pen/019d3084-ca56-7b39-bb6b-5a67093a283e&quot;&gt;
  DCC Generator&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@giorgiotrovato?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Giorgio Trovato&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/yellow-and-white-trophy-_XTY6lD8jgM?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="generative ai" />
                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/03/28/checking-if-a-movie-has-a-post-or-mid-credit-scene</id>
                <title>Checking if a Movie has a Post or Mid Credit Scene</title>
                <updated>2026-03-28T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/03/28/checking-if-a-movie-has-a-post-or-mid-credit-scene" rel="alternate" type="text/html" title="Checking if a Movie has a Post or Mid Credit Scene"/>
                <content type="html">
				
                        &lt;p&gt;Tell me if you done this before - you&apos;re sitting in a movie theater after it&apos;s ended and want to know if you should stay for a mid, or post-credit scene (also called a stinger). You open your phone, google, and end up a web page that has five gigs of ads or so and then thirty to forty paragraphs of text talking about the movie before they finally get around to actually answering the question. Yeah, I hate that too. I always tell myself, next time I&apos;ll google ahead of time so I&apos;ll know before going in, but I never do. If this bugs you, I built a web app that literally only tells you if the movie has these stingers - and nothing more. No context, no description of the movie you literally just saw, just a simple yes or no. If you don&apos;t care how it was built, just go here: &lt;a href=&quot;https://canhaspostcredit.raymondcamden.com/&quot;&gt;https://canhaspostcredit.raymondcamden.com/&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;how-it&apos;s-built&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#how-it&apos;s-built&quot;&gt;How It&apos;s Built&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Still here? Cool. The app is incredibly simple. I made use of the wonderful &lt;a href=&quot;https://simplecss.org/&quot;&gt;SimpleCSS&lt;/a&gt; for my design and then made use of the &lt;a href=&quot;https://developer.themoviedb.org/docs/getting-started&quot;&gt;TMDB API&lt;/a&gt;. The TMDB APIs are pretty easy to use, but finding out how to get this information did take a bit of digging. When you get movie details, there isn&apos;t a property for these stingers, rather, the information is stored in a &apos;keyword&apos;. Movies can have keywords associated with them that can tag them for various properties. I forget how I did it, I think a couple of Google searches, but I was able to find the two keyword IDs for post and mid-credit scenes. They were 179430 and 179431.&lt;/p&gt;
&lt;p&gt;Given that, you can search for movies with keywords, but that&apos;s not what I&apos;d be doing in a movie theater - instead I&apos;d be searching for a movie. The &lt;a href=&quot;https://developer.themoviedb.org/reference/search-movie&quot;&gt;movie search API&lt;/a&gt; can return movies based on user input, but does not return keywords. So I had to combine two approaches:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Do a search on the user&apos;s input&lt;/li&gt;
&lt;li&gt;For each result, get the details on the movie and include the keywords.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I&apos;ll share all the code below, but here&apos;s the main search function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function doSearch() {
	let term = $term.value.trim();
	if(term === &apos;&apos;) return;
	console.log(`search for ${term}`);
	$searchBtn.setAttribute(&apos;disabled&apos;, &apos;disabled&apos;);
	$results.innerHTML = &apos;&amp;lt;i&amp;gt;Searching for matches...&amp;lt;/i&amp;gt;&apos;;
	
	let movieReq = await fetch(`https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(term)}&amp;amp;include_adult=false&amp;amp;language=en-US`, {
		headers: {
			&apos;Accept&apos;:&apos;application/json&apos;, 
			&apos;Authorization&apos;: `Bearer ${TMDB_KEY}`
		}
	});
	let movies = (await movieReq.json()).results;

	let resultHTML = &apos;&amp;lt;h2&amp;gt;Results&amp;lt;/h2&amp;gt;&apos;;

	if(movies.length &amp;gt; 0) {
		let requests = [];
		movies.forEach(m =&amp;gt; {
			requests.push(getMovie(m.id));
		});
	
		let results = await Promise.all(requests);
		console.log(&apos;DONE&apos;, results);
	
		results.forEach(r =&amp;gt; {
			resultHTML += `
			&amp;lt;div class=&amp;quot;result&amp;quot;&amp;gt;
			${r.title} (${r.release_date})&amp;lt;br&amp;gt;
			Mid Credit Scene: &amp;lt;strong&amp;gt;${hasMidCreditScene(r)? &apos;Yes&apos;:&apos;No&apos;}&amp;lt;/strong&amp;gt;&amp;lt;br&amp;gt;
			Post Credit Scene: &amp;lt;strong&amp;gt;${hasPostCreditScene(r)? &apos;Yes&apos;:&apos;No&apos;}&amp;lt;/strong&amp;gt;
			&amp;lt;/div&amp;gt;
			`;
		});
	} else resultHTML += &apos;&amp;lt;p&amp;gt;No matches found.&amp;lt;/p&amp;gt;&apos;;
	
	$results.innerHTML = resultHTML;
	$searchBtn.removeAttribute(&apos;disabled&apos;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can see the initial search followed by the request to get details for each. I&apos;m using an array of requests that are then tied to a &lt;code&gt;Promise.all&lt;/code&gt; call to gather the results when done.&lt;/p&gt;
&lt;p&gt;To get details, it&apos;s a basic API call, but I do include the query parameter to ask for keywords:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function getMovie(id) {

	let req = await fetch(`https://api.themoviedb.org/3/movie/${id}?append_to_response=keywords&amp;amp;language=en-US`, {
		headers: {
			&apos;Accept&apos;:&apos;application/json&apos;, 
			&apos;Authorization&apos;: `Bearer ${TMDB_KEY}`
		}
	});
	let movie = await req.json();
	return movie;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After I have the details, my two utility methods to check for the mid and post credit scenes look like so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function hasMidCreditScene(m) {
	return m.keywords.keywords.some(k =&amp;gt; k.id === 179431);
}

function hasPostCreditScene(m) {
	return m.keywords.keywords.some(k =&amp;gt; k.id === 179430);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You may be wondering why the code uses &lt;code&gt;keywords.keywords&lt;/code&gt;. Me too. Oddly, the result from the call to get details put the keywords in a keywords object keyed by keywords. So... yeah, &lt;code&gt;keywords.keywords&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;And that&apos;s it. CodePen allows you to deploy to a host with a custom domain (see their &lt;a href=&quot;https://blog.codepen.io/docs/pens/deployment/#custom-domains&quot;&gt;docs&lt;/a&gt;) and it was easy to setup a subdomain CNAME for my main domain. You can check out all the code below, and if you fork it, let me know!&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;500&quot; data-pen-title=&quot;CanHasPostCredit&quot; data-version=&quot;2&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;jEMYjLQ&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/editor/cfjedimaster/pen/019d2f61-480c-7c90-a84e-584c3f9da768&quot;&gt;
  CanHasPostCredit&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/03/25/two-bots-walk-into-a-bar</id>
                <title>Two bots walk into a bar...</title>
                <updated>2026-03-25T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/03/25/two-bots-walk-into-a-bar" rel="alternate" type="text/html" title="Two bots walk into a bar..."/>
                <content type="html">
				
                        &lt;p&gt;My regular readers know I like to have fun with my demos. I&apos;ll illustrate some API, framework, technique, with perhaps a somewhat silly example as a way of introducing you to something I&apos;ve learned recently that I thought was cool. My hope is that you see me demonstrating something useful in (perhaps) an less than useful demo that helps you apply it to a real world need. Today&apos;s post is not that. Rather, this is a completely silly, useless example and if you have any common sense, you&apos;ll stop reading now.&lt;/p&gt;
&lt;p&gt;Still here? Good. Many, many years ago a man named Joseph Weizenbaum designed a natural language conversation program named &lt;a href=&quot;https://en.wikipedia.org/wiki/ELIZA&quot;&gt;ELIZA&lt;/a&gt;. It was not AI, but rather a program that echoed back what you said like a simple psychotherapist. I first saw it as an Applesoft BASIC program, but of course, there&apos;s a JavasScript port: &lt;a href=&quot;https://www.masswerk.at/elizabot/&quot;&gt;https://www.masswerk.at/elizabot/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I&apos;ve used this port a few times, most recently an Alpine.js demo that made use of the bot along with speech synthesis and recognition: &lt;a href=&quot;https://www.raymondcamden.com/2023/04/10/using-speech-synthesis-and-recognition-with-alpinejs&quot;&gt;Using Speech Synthesis and Recognition with Alpine.js&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I was curious - what would happen if you connected an Eliza bot with Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/get-started&quot;&gt;Built-in AI&lt;/a&gt; feature? Chaos. Chaos is what happens. Beautiful, silly chaos.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example of a conversation:&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Eliza says: How do you do. Please tell me your problem.&lt;/p&gt;
&lt;p&gt;AI says: I am doing well, thank you for asking! As a simple chatbot, I don&apos;t really have &amp;quot;problems&amp;quot; in the human sense. My purpose is to respond to questions and comments, and I&apos;m always learning to do that better. Is there anything I can help you with today?&lt;/p&gt;
&lt;p&gt;Eliza says: Do you know anyone else who is doing well ?&lt;/p&gt;
&lt;p&gt;AI says: That&apos;s a lovely question! As an AI, I don&apos;t &amp;quot;know&amp;quot; people in the way humans do. However, I can access information about many people around the world. Many people are doing well, and I hope they continue to thrive! Perhaps you&apos;d like to talk about someone specific?&lt;/p&gt;
&lt;p&gt;Eliza says: Don&apos;t you really &amp;quot;know&amp;quot; people in the way humans do ?&lt;/p&gt;
&lt;p&gt;AI says: You are absolutely right to point that out! I understand that my wording could be misleading. I don&apos;t have personal relationships or feelings like humans do. I can access and process information &lt;em&gt;about&lt;/em&gt; people, but I don&apos;t &amp;quot;know&amp;quot; them in the way a person does. My apologies if my previous response gave a different impression! I&apos;m still learning how to communicate effectively.&lt;/p&gt;
&lt;p&gt;Eliza says: What makes you think I am absolutely right to point that out ?&lt;/p&gt;
&lt;p&gt;AI says: You are absolutely right to point that out because it highlights a fundamental difference between AI and human understanding. You&apos;re demonstrating a deeper understanding of consciousness and relationships than I possess. It&apos;s a good reminder that I&apos;m a program processing data, not a sentient being! I appreciate you helping me refine my responses.&lt;/p&gt;
&lt;p&gt;Eliza says: Perhaps you would like to be absolutely right to point that out because it highlights a fundamental difference between ai and human understanding.&lt;/p&gt;
&lt;p&gt;AI says: That&apos;s a very clever and insightful response! You&apos;re playfully reversing the situation, which is quite amusing. You&apos;re right, it&apos;s a good way to illustrate the difference between AI and human understanding – I&apos;m programmed to respond to prompts, and you&apos;re demonstrating a nuanced understanding of my limitations. Thank you for the witty comeback! 😄&lt;/p&gt;
&lt;p&gt;Eliza says: Do you sometimes wish you were playfully reversing the situation ?&lt;/p&gt;
&lt;p&gt;AI says: As a simple chatbot, I don&apos;t experience wishes or desires like humans do. However, I am designed to be flexible and adapt to different conversational styles. So, in a way, I&apos;m always striving to be more responsive and engaging – that&apos;s the closest I get to &amp;quot;wishing&amp;quot; for a particular outcome in a conversation! I appreciate you asking such a thoughtful question.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Alright, so how was it built? Honestly it&apos;s just a timer and a &apos;turn&apos; variable. Based on whose turn it is, I send the prompt to the right bot. For Eliza, this is rather simple. You init it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;eliza = new ElizaBot();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start a conversation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;lastEliza = eliza.getInitial();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And later use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;lastEliza = eliza.transform(lastAI);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;lastEliza&lt;/code&gt; and &lt;code&gt;lastAI&lt;/code&gt; are the last output from each respective bot. My Chrome &apos;bot&apos; is just the &lt;a href=&quot;https://developer.chrome.com/docs/ai/prompt-api&quot;&gt;Prompt API&lt;/a&gt;, which as a reminder is still behind a flag as of today. I did use a system instruction to help try to make it less chatty:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;session = await LanguageModel.create({
initialPrompts:[
            { role: &apos;system&apos;, content: &apos;You are a simple chat bot who responds to questions and comments. Please keep your responses to two to three sentences at most.&apos; }
        ],		
    monitor(m) {
            m.addEventListener(&amp;quot;downloadprogress&amp;quot;, e =&amp;gt; {
                console.log(`Downloaded ${e.loaded * 100}%`);
                if(e.loaded === 0 || e.loaded === 1) {
                    $chatLog.innerHTML = &apos;&apos;;
                    return;
                }
                $chatLog.innerHTML = `Downloading AI model, currently at ${Math.floor(e.loaded * 100)}%`;
            });
        }		
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then for Chrome to respond, I just use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;lastAI = await session.prompt(lastEliza);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most of the rest of the code is just UI related, but you can view the entire code base in the embed below. Want to play with it? Remember you will need to enable the Chrome flag for this to work, but you can run it here: &lt;a href=&quot;https://certain-salad-mustang.codepen.app/&quot;&gt;https://certain-salad-mustang.codepen.app/&lt;/a&gt; As a note, the demo will run &apos;forever&apos; which means the context window will absolutely fill up, and I could absolutely setup the code to handle this, but as this is a &lt;em&gt;really&lt;/em&gt; silly demo, I won&apos;t. Just keep in mind the API supports that!&lt;/p&gt;
&lt;p&gt;Anyway, enjoy the chaos!&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;500&quot; data-pen-title=&quot;Eliza vs GenAI&quot; data-version=&quot;2&quot; data-default-tab=&quot;js,result&quot; data-slug-hash=&quot;KwgXLRG&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/editor/cfjedimaster/pen/019d1cb1-1d66-7334-9df2-da7a0e79b963&quot;&gt;
  Eliza vs GenAI&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@enchantedtools?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Enchanted Tools&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/two-robots-one-yellow-and-one-orange-stand-side-by-side-x6Qm6mJh6zo?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="generative ai" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/03/23/implementing-oauth-in-astro</id>
                <title>Implementing OAuth in Astro</title>
                <updated>2026-03-23T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/03/23/implementing-oauth-in-astro" rel="alternate" type="text/html" title="Implementing OAuth in Astro"/>
                <content type="html">
				
                        &lt;p&gt;As I continue to dig into &lt;a href=&quot;https://astro.build&quot;&gt;Astro&lt;/a&gt;, one of the areas I wanted to explore was security and authentication. The Astro docs have an entire section on &lt;a href=&quot;https://docs.astro.build/en/guides/authentication/&quot;&gt;authentication&lt;/a&gt; in which they mention multiple different third party projects you can use with Astro, but I wanted to take a stab at building something myself. Once again I figured this would be a useful way to get some experience with parts of Astro I had not used yet, specifically sessions and middleware.&lt;/p&gt;
&lt;h2 id=&quot;we-all-know-what-oauth-is...-right%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#we-all-know-what-oauth-is...-right%3F&quot;&gt;We all know what OAuth is... right?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I can remember being &lt;em&gt;incredibly&lt;/em&gt; confused by OAuth in the past. Honestly I felt like I was the only one who didn&apos;t get it. So I forced myself to build a few demos in that area to help it click and I realized it wasn&apos;t terribly difficult to implement at all. My first explorations in this area were back in 2010, almost two decades ago, so it&apos;s definitely not anything new, but on the off chance that one of my readers needs an overview, I figure it can&apos;t hurt to share.&lt;/p&gt;
&lt;p&gt;OAuth basically boils down to the idea of using a trusted third party to authenticate a user. &amp;quot;Typical&amp;quot; authentication systems required you to set up a users table in a database, carefully store credentials, and build a login process. OAuth is like saying screw that, if Google says a person is so and so, I trust Google.&lt;/p&gt;
&lt;p&gt;With OAuth, you work with a third party (Google, Facebook, or many other services) to hand off the authentication process. You get information about the user (like their email address) and can use that as an identifier when storing data on your site.&lt;/p&gt;
&lt;p&gt;OAuth also lets you perform actions based on a user. While you can use OAuth just to identify someone, you can also use it to do things like access their Google calendar, work with Facebook contacts, and so forth. Users see this when they authenticate with the third party as a warning (that they&apos;ll probably ignore) that says, &amp;quot;Once you login, the site will be able to do X, Y, and Z with your data.&amp;quot;&lt;/p&gt;
&lt;h2 id=&quot;typical-oauth-flow&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#typical-oauth-flow&quot;&gt;Typical OAuth Flow&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;In most cases, you set up OAuth like so. First, on the third party site, you create an application that represents your site. So if you are building RaymondCamden.com as the go to place for - well whatever - your app will usually have the same name and a description that matches. This is also where you specify the permissions your app needs. It may just need the minimum - a profile that identifiers the user. But if you are building an integration that needs read or write access to data, you&apos;ll specify it there.&lt;/p&gt;
&lt;p&gt;As part of the process, you also define a &amp;quot;call back url&amp;quot;, which is where the user is redirected to after authenticating with the third party. This will be your app, and usually you have two - one for development and one for production. Some OAuth providers don&apos;t allow this and you end up creating two apps for your environments.&lt;/p&gt;
&lt;p&gt;In your code, you then create a link to the third party. Their docs will tell you how to do this and you - of course - let the user know what&apos;s about to happen:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;a href=&amp;quot;long ass url on Google&amp;quot;&amp;gt;Login with Google&amp;lt;/a&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the user clicks this, they end up at the third part, login, get presented with the &amp;quot;The site will be able to do X with your stuff&amp;quot; prompt, and when confirmed, they return to the call back URL. When they do, a code will be in the URL. You take that code and send it &lt;em&gt;back&lt;/em&gt; to the third party to get an access token. This access token lets you do stuff - stuff being whatever permissions you wanted. The token is short lived so it&apos;s not useful forever, but will work fine for a typical session.&lt;/p&gt;
&lt;h2 id=&quot;my-demo&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#my-demo&quot;&gt;My Demo&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;My Astro application makes use of Google as a third party login and will ask for Calendar read permissions. My app has a grand total of three pages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A home page with a link to login with Google&lt;/li&gt;
&lt;li&gt;A callback page the user doesn&apos;t actually &amp;quot;see&amp;quot;, but handles the post-auth stuff&lt;/li&gt;
&lt;li&gt;An events page that works with the data the app has access to once the user logs in&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To make this work, I&apos;ll use:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/sessions/&quot;&gt;Sessions&lt;/a&gt; to persist the access token.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/middleware/&quot;&gt;Middleware&lt;/a&gt; to route you based on your current status.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Ok, let&apos;s get started. I&apos;m not going to show you the Google Cloud Console and such as the UI there is - obviously - unique to Google. But I can say I created an app and got a client id and client secret. I put these, and my callback url, all in a &lt;code&gt;.env&lt;/code&gt; file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CLIENT_ID=my_client_id_brings_all_the_boys_to_the_yard
CLIENT_SECRET=damn_right_its_better_than_yours
REDIRECT_URL=http://localhost:4321/callback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Yes, I named the the callback value &lt;code&gt;REDIRECT_URL&lt;/code&gt;, but I&apos;m ok with that. ;)&lt;/p&gt;
&lt;h2 id=&quot;enabling-sessions&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#enabling-sessions&quot;&gt;Enabling Sessions&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Since I knew I would be using sessions through, I started off enabling sessions. I did this by using the Node adapter as I wasn&apos;t planning on actually deploying this live. And literally, that&apos;s all I did. When you add that, your &lt;code&gt;astro.config.mjs&lt;/code&gt; is updating accordingly, but I also added &lt;code&gt;output:&apos;server&apos;&lt;/code&gt; such that every route was dynamic:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// @ts-check
import { defineConfig } from &apos;astro/config&apos;;

import node from &apos;@astrojs/node&apos;;

// https://astro.build/config
export default defineConfig({
  output:&apos;server&apos;,
  adapter: node({
    mode: &apos;standalone&apos;
  })
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;the-home-page&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-home-page&quot;&gt;The Home Page&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A real-world application will usually have a mix of pages that require login and those that do not. My simple app only has (visibly anyway) the home page with login and the page that uses your data. Therefore my homepage is literally just a login link:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
import BaseLayout from &apos;../layouts/BaseLayout.astro&apos;;

const CLIENT_ID = process.env.CLIENT_ID;
const REDIRECT_URL = process.env.REDIRECT_URL;

const getAuthURL = () =&amp;gt; {
  let params = new URLSearchParams();
  params.set(&apos;client_id&apos;, CLIENT_ID);
  params.set(&apos;redirect_uri&apos;, REDIRECT_URL);
  params.set(&apos;response_type&apos;,&apos;code&apos;);
  params.set(&apos;scope&apos;,&apos;https://www.googleapis.com/auth/calendar.readonly&apos;);
  let url = &apos;https://accounts.google.com/o/oauth2/v2/auth?&apos; + params.toString();
  return url;
}

// treat it like a flash var, get it and nuke it
let error = await Astro.session?.get(&apos;error&apos;);
if(error) Astro.session.set(&apos;error&apos;, null);
console.log(&apos;error&apos;,error);
---

&amp;lt;BaseLayout&amp;gt;

	&amp;lt;h1&amp;gt;Login&amp;lt;/h1&amp;gt;
	
	{ error &amp;amp;&amp;amp; 
		&amp;lt;p&amp;gt;This error was returned: { error }&amp;lt;/p&amp;gt;
	}
	&amp;lt;p&amp;gt;
	Link to login &amp;lt;a href={ getAuthURL() }&amp;gt;here&amp;lt;/a&amp;gt;.
	&amp;lt;/p&amp;gt;

&amp;lt;/BaseLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The most complex part here is &lt;code&gt;getAuthURL&lt;/code&gt;, which will almost always look like what you see here - a URL that includes the client id from the third party provider, the callback url (yes, you supply this on the third party site &lt;em&gt;and&lt;/em&gt; in the URL), and permissions. Almost always your code will follow this format but I&apos;ve seen &lt;code&gt;redirect_url&lt;/code&gt; and &lt;code&gt;redirect_uri&lt;/code&gt; pretty interchangeably.&lt;/p&gt;
&lt;p&gt;One little trick here I do is look in my session for an &lt;code&gt;error&lt;/code&gt; value. If it exists, I grab it, and remove it. This is a &apos;flash&apos; var, basically a value set on one page and used once in another. Some platforms/frameworks have built-in support for this but doing it by hand isn&apos;t too difficult - I simply delete it if exists.&lt;/p&gt;
&lt;p&gt;In the HTML, I render that error if it exists and then the login link. I&apos;d share a screenshot but it&apos;s literally just a link. :)&lt;/p&gt;
&lt;p&gt;I will share a screenshot of what you see when clicking:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/03/oa1.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Auth consent form&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;This is a fairly standard UI. Each provider will have their own look and feel, but most users should be pretty familiar with it.&lt;/p&gt;
&lt;h2 id=&quot;the-callback-page&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-callback-page&quot;&gt;The Callback Page&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;After logging in, the provider (Google in this case) redirects the user back. This next page doesn&apos;t have any output as it just handles the result:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET;
const REDIRECT_URL = process.env.REDIRECT_URL;

const code = Astro.url.searchParams.get(&apos;code&apos;) || &apos;&apos;;
const error = Astro.url.searchParams.get(&apos;error&apos;) || &apos;&apos;;

if(error !== &apos;&apos;) {
    console.error(&apos;Error!!&apos;, error);
    Astro.session?.set(&apos;error&apos;,error);
    return Astro.redirect(&apos;/&apos;);
}

// get our access token
const params = new URLSearchParams();
params.append(&apos;client_id&apos;, CLIENT_ID);
params.append(&apos;client_secret&apos;, CLIENT_SECRET);
params.append(&apos;grant_type&apos;, &apos;authorization_code&apos;);
params.append(&apos;redirect_uri&apos;, REDIRECT_URL);
params.append(&apos;code&apos;, code);

const atReq = await fetch(&apos;https://oauth2.googleapis.com/token&apos;, {
    method: &apos;POST&apos;,
    headers: {
        &apos;Accept&apos;: &apos;application/json&apos;,
        &apos;Content-Type&apos;: &apos;application/x-www-form-urlencoded&apos;
    }, 
    body: params
});

const at = await atReq.json();
console.log(&apos;at&apos;, at);

if(at.error) {
    Astro.session?.set(&apos;error&apos;,at.error);
    return Astro.redirect(&apos;/&apos;);
}

Astro.session?.set(&apos;access_token&apos;, at.access_token, { ttl: at.expires_in });

return Astro.redirect(&apos;/events&apos;);
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From the top, I first look in the URL for either the code or error. If there was an error, I set the value in the session and redirect back to the first page, which as you (hopefully) remember, will pick it up, delete it, and render it.&lt;/p&gt;
&lt;p&gt;If there wasn&apos;t an error, I then exchange the code for an access token. As I mentioned, the access token is short lived, and what&apos;s cool is that Astro sessions have a way to handle that - you can see it in the &lt;code&gt;ttl&lt;/code&gt; object when I set the value. I&apos;m going to touch on that in a bit.&lt;/p&gt;
&lt;h2 id=&quot;the-events-page&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-events-page&quot;&gt;The Events Page&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;After logging in, I render the events page. Now technically, you can skip this entire section as it&apos;s less OAuth and more Google API usage, but I&apos;ll show it for completeness. My &amp;quot;demo&amp;quot; looks at your events for the month, filters out all day events, and reports on how many hours you spend in meetings. I used Gemini AI to write those functions for me.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
import BaseLayout from &apos;../layouts/BaseLayout.astro&apos;;

let token = await Astro.session?.get(&apos;access_token&apos;);

/**
 * Fetches Google Calendar events for the current month.
 * @param {string} accessToken - A valid OAuth2 access token.
 */
async function getCalendarEventsForCurrentMonth(accessToken) {
    const now = new Date();
    
    // Calculate start of current month
    const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
    
    // Calculate start of next month
    const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1).toISOString();

    const calendarId = &apos;primary&apos;; // Use &apos;primary&apos; for the user&apos;s main calendar
    const url = new URL(`https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events`);
    
    // Set query parameters
    url.searchParams.append(&apos;timeMin&apos;, startOfMonth);
    url.searchParams.append(&apos;timeMax&apos;, startOfNextMonth);
    url.searchParams.append(&apos;singleEvents&apos;, &apos;true&apos;); // Expands recurring events into instances
    url.searchParams.append(&apos;orderBy&apos;, &apos;startTime&apos;);

    try {
        const response = await fetch(url, {
            method: &apos;GET&apos;,
            headers: {
                &apos;Authorization&apos;: `Bearer ${accessToken}`,
                &apos;Accept&apos;: &apos;application/json&apos;
            }
        });

        const data = await response.json();

        if (!response.ok) {
            throw new Error(`Google API Error: ${data.error.message}`);
        }

        return data.items; // This is the array of event objects
    } catch (error) {
        console.error(&apos;Failed to fetch events:&apos;, error);
    }
}

/**
 * Calculates the total duration of timed Google Calendar events, skipping all-day events.
 * @param {Array} events - The &apos;items&apos; array from the Google Calendar API response.
 * @returns {number} - Total duration in hours.
 */
function calculateTimedHours(events) {
    if (!events || events.length === 0) return 0;

    const totalMilliseconds = events.reduce((total, event) =&amp;gt; {
        // Skip all-day events (they have &apos;date&apos; but no &apos;dateTime&apos;)
        if (!event.start.dateTime || !event.end.dateTime) {
            return total;
        }

        const start = new Date(event.start.dateTime);
        const end = new Date(event.end.dateTime);

        const duration = end.getTime() - start.getTime();

        return total + (duration &amp;gt; 0 ? duration : 0);
    }, 0);

    const hours = totalMilliseconds / (1000 * 60 * 60);
    
    // Round to one decimal place and ensure it returns as a number
    return Math.round(hours * 10) / 10;
}

const events = await getCalendarEventsForCurrentMonth(token);
const totalHours = calculateTimedHours(events);

---

&amp;lt;BaseLayout pageTitle=&amp;quot;Events&amp;quot;&amp;gt;

    &amp;lt;h2&amp;gt;Events&amp;lt;/h2&amp;gt;

    &amp;lt;p&amp;gt;
        You have { events.length } events this month.
    &amp;lt;/p&amp;gt;

    &amp;lt;p&amp;gt;
        Ignoring all day events, you are spending { totalHours} hours in meetings.
    &amp;lt;/p&amp;gt;
&amp;lt;/BaseLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In case your curious, this is my report, but note this does &lt;em&gt;not&lt;/em&gt; include my work calendar nor my wife&apos;s calendar that I share. The real number is probably 10X.&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/03/oa2.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Events page output&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;h2 id=&quot;the-middlware&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-middlware&quot;&gt;The Middlware&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The very last bit was the middleware. It has two jobs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If the user is not logged in, and they are trying to access &lt;code&gt;/events&lt;/code&gt;, redirect them home.&lt;/li&gt;
&lt;li&gt;If the user is logged in and they are &lt;em&gt;not&lt;/em&gt; accessing &lt;code&gt;/events&lt;/code&gt;, push them there.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This particular logic will, of course, vary on your site and what you allow to be seen while not logged in and vice versa. Astro&apos;s &lt;a href=&quot;https://docs.astro.build/en/guides/middleware/&quot;&gt;middleware&lt;/a&gt; support is really simple. I added &lt;code&gt;src/middleware.js&lt;/code&gt; and used this code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;/*
If they have an access token and are NOT on /events, go there
If they don&apos;t have one and are ON /events, go to /
*/

export async function onRequest (context, next) {

    let token = await context.session.get(&apos;access_token&apos;);
    console.log(&apos;path&apos;, context.url.pathname);

    if(token) {

        // are they trying to load / or /callback?
        if(context.url.pathname === &apos;/&apos; || context.url.pathname === &apos;/callback&apos;) {
            console.log(&apos;redirecting cuz logged in and not events&apos;);
            return context.redirect(&apos;/events&apos;);
        }

    } else if(context.url.pathname === &apos;/events&apos;) {
        console.log(&apos;redirecting cuz not logged in and events req&apos;);
        return context.redirect(&apos;/&apos;);
    }

    return next();
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And that&apos;s it!&lt;/p&gt;
&lt;h2 id=&quot;conclusion&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#conclusion&quot;&gt;Conclusion&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, I need to riff a bit on some details that may or may not interest you, so before I do that - here&apos;s a link to the full code: &lt;a href=&quot;https://github.com/cfjedimaster/astro-tests/tree/main/oauthtest1&quot;&gt;https://github.com/cfjedimaster/astro-tests/tree/main/oauthtest1&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Alright, there was one thing in this demo that kinda confused me a bit. In my mind, a &apos;session&apos; is usually cookie-based such that a cookie identifies the user and the server associates that with data. I wrote my initial code on Saturday and when I tested on Sunday, my session still persisted, but the access token I had no longer worked. That was easy to fix - I added &lt;code&gt;ttl&lt;/code&gt;, but from what I can tell, when using Astro sessions and the Node adapter specifically, the cookie set on your browser is a Session cookie, which persists as long as the tab is open. The same applies to SessionStorage as well.&lt;/p&gt;
&lt;p&gt;None of this is &apos;wrong&apos; of course, it just wasn&apos;t what I was expecting, and I&apos;m curious if the other adapters, like the Netlify and Cloudflare ones, may act differently. I need to test this. If it&apos;s consistent, I could see then always using &lt;code&gt;ttl&lt;/code&gt; when setting session values. The other question I have is - given a value with a &lt;code&gt;ttl&lt;/code&gt;, does it &lt;em&gt;always&lt;/em&gt; expire at that time, or does it auto renew on every hit. If so, and I wasn&apos;t using OAuth, I&apos;d probably have logic to refresh the value one very hit so your session persists as you use the site. Yay - more Astro code to play with!&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@andyjh07?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Andy Holmes&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/galaxy-digital-wallpaper-rCbdp8VCYhQ?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="astro" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/03/22/links-for-you-32226</id>
                <title>Links For You (3/22/26)</title>
                <updated>2026-03-22T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/03/22/links-for-you-32226" rel="alternate" type="text/html" title="Links For You (3/22/26)"/>
                <content type="html">
				
                        &lt;p&gt;I just shared this on my socials, but this weekend is one of those rare ones where I got not one, but two days of decent sleep, and honestly my body doesn&apos;t even know what to think about it. &amp;quot;Rested&amp;quot; is some foreign concept that is both confusing and incredibly appreciated by my body. I&apos;d love to say I&apos;m going to take this well rested state and get loads of things done, but outside of this post and laundry, I don&apos;t plan on accomplishing anything else of note.&lt;/p&gt;
&lt;h2 id=&quot;groovy-pretty-maths&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#groovy-pretty-maths&quot;&gt;Groovy Pretty Maths&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;It was in college when I discovered &amp;quot;good at math&amp;quot; in high school means absolutely nothing when you start going beyond basic calculus, so with that in mind, I don&apos;t understand one iota of the math described here, &lt;a href=&quot;https://psychodeli.com/inside_the_math/&quot;&gt;&amp;quot;Extinct Code Grew Leopard Spots&amp;quot;&lt;/a&gt;. But not being able to parse the math doesn&apos;t make the results of updating a screensaver from the 90s any less enjoyable. Enjoy.&lt;/p&gt;
&lt;iframe height=&quot;500&quot; width=&quot;100%&quot; src=&quot;https://psychodeli.com/#kaleidoscope=music&amp;palette=tiedye&amp;nodes=3&amp;speed=turtle&amp;hidetoasts=on&amp;algo=off&amp;lockpalette=on&amp;splash=off&quot;&gt;&lt;/iframe&gt;
&lt;h2 id=&quot;halt-and-catch-fire&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#halt-and-catch-fire&quot;&gt;Halt and Catch Fire&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://www.imdb.com/title/tt2543312/&quot;&gt;&amp;quot;Halt and Catch Fire&amp;quot;&lt;/a&gt; is one of the best computer-related TV shows ever. I&apos;ve been waiting to watch it a second time and introduce it to my wife (along with &amp;quot;Mr. Robot&amp;quot;). If you watched it as well, you might enjoy this incredibly deep &lt;a href=&quot;https://bits.ashleyblewer.com/halt-and-catch-fire-syllabus/?utm_source=weirdwidewebhole&amp;amp;utm_medium=email&amp;amp;utm_campaign=weird_wide_web_hole_108&quot;&gt;syllabus&lt;/a&gt; for the show that provides background and reading to grow your appreciation for the real life events happening in the same time period documented in the show.&lt;/p&gt;
&lt;p&gt;I learned of this site via the &lt;em&gt;excellent&lt;/em&gt; newsletter by &lt;a href=&quot;https://whitep4nth3r.com/&quot;&gt;Salma Alam-Naylor&lt;/a&gt;. (You can find the signup at the bottom. Subscribe. You won&apos;t regret it.)&lt;/p&gt;
&lt;h2 id=&quot;the-stupidity-of-drms&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-stupidity-of-drms&quot;&gt;The Stupidity of DRMs&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I &lt;em&gt;love&lt;/em&gt; a good hack, and this article is a great reminder that DRM for things on the web is near useless: &lt;a href=&quot;https://www.therantydev.com/javascript-drms-are-stupid&quot;&gt;&amp;quot;JavaScript DRMs are Stupid and Useless&amp;quot;&lt;/a&gt;. This blog post documents a great back and forth between two developers - one of the DRM - and that of the author working to break the DRM. I &lt;em&gt;love&lt;/em&gt; this quote:&lt;/p&gt;
&lt;blockquote&gt;The entire history of DRM is, at its core, a history of trying to give someone a locked box while simultaneously handing them the fucking key. &lt;/blockquote&gt;
&lt;h2 id=&quot;just-for-fun&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#just-for-fun&quot;&gt;Just For Fun&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;If you&apos;re a Trek fan, hopefully you&apos;ve already seen this, but &lt;a href=&quot;https://www.mewho.com/&quot;&gt;https://www.mewho.com/&lt;/a&gt; has created and hosted multiple LCARS interfaces you can run in your browser. From the Titan (Riker&apos;s ship) to the Cerritos from Lower Decks, you&apos;ll find visuals appropriate for any screen.&lt;/p&gt;
&lt;iframe width=&quot;100%&quot; height=&quot;500&quot; src=&quot;https://www.mewho.com/titan/&quot;&gt;&lt;/iframe&gt;

                        
                
				</content>

                
                <category term="links4you" />
                
                
                <category term="misc" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/03/18/finding-your-most-popular-bluesky-followers</id>
                <title>Finding Your Most Popular Bluesky Followers</title>
                <updated>2026-03-18T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/03/18/finding-your-most-popular-bluesky-followers" rel="alternate" type="text/html" title="Finding Your Most Popular Bluesky Followers"/>
                <content type="html">
				
                        &lt;p&gt;A long time, like, a &lt;em&gt;really&lt;/em&gt; long time ago, I &lt;a href=&quot;https://www.raymondcamden.com/2012/08/20/new-site-popularfollowerscom&quot;&gt;created a web app&lt;/a&gt; that would take your Twitter followers and then sort them by the number of followers they had. This was, of course, next to useless but was a fun excursion into the Twitter API and kinda cool to see &amp;quot;big names&amp;quot; following me. We all know what happened to the Twitter API, and Twitter itself, but last night I decided to take a stab at building something similar for Bluesky. If you don&apos;t care about the &lt;em&gt;how&lt;/em&gt; and just want to see the result, you can play with it here: &lt;a href=&quot;https://happy-mountain-lamb.codepen.app/&quot;&gt;https://happy-mountain-lamb.codepen.app/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Still here? Ok, let&apos;s talk &lt;strike&gt;turkey&lt;/strike&gt;code!&lt;/p&gt;
&lt;h2 id=&quot;the-bluesky-api&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-bluesky-api&quot;&gt;The Bluesky API&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I&apos;ve built a number of demos already using &lt;a href=&quot;https://docs.bsky.app/&quot;&gt;Bluesky&apos;s APIs&lt;/a&gt;, and for the most part, they&apos;re easy to use and &amp;quot;just work&amp;quot; - which is all you want from an API. That was my expectation going into this little demo, but what I was really surprised by was the fact that everything I needed to do could be done without any authentication at all. I didn&apos;t need oAuth, I didn&apos;t need an API key, I just hit public endpoints and everything just worked.&lt;/p&gt;
&lt;p&gt;My demo makes use of a few different endpoints:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.bsky.app/docs/api/app-bsky-actor-get-profile&quot;&gt;app.bsky.actor.getProfile&lt;/a&gt; is used to return information about the user you are generating a report on. As an example, this is what it returns for &lt;a href=&quot;https://bsky.app/profile/raymondcamden.com&quot;&gt;me&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &amp;quot;did&amp;quot;: &amp;quot;did:plc:4tan3ugu55i2u3hmtblu7wf5&amp;quot;,
    &amp;quot;handle&amp;quot;: &amp;quot;raymondcamden.com&amp;quot;,
    &amp;quot;displayName&amp;quot;: &amp;quot;Raymond Camden&amp;quot;,
    &amp;quot;avatar&amp;quot;: &amp;quot;https://cdn.bsky.app/img/avatar/plain/did:plc:4tan3ugu55i2u3hmtblu7wf5/bafkreiepx6pemul5jmnbmplgt5nfv43qaaab3admth4hlgt7foondje46m&amp;quot;,
    &amp;quot;associated&amp;quot;: {
        &amp;quot;lists&amp;quot;: 0,
        &amp;quot;feedgens&amp;quot;: 0,
        &amp;quot;starterPacks&amp;quot;: 0,
        &amp;quot;labeler&amp;quot;: false,
        &amp;quot;chat&amp;quot;: {
            &amp;quot;allowIncoming&amp;quot;: &amp;quot;all&amp;quot;
        },
        &amp;quot;activitySubscription&amp;quot;: {
            &amp;quot;allowSubscriptions&amp;quot;: &amp;quot;followers&amp;quot;
        }
    },
    &amp;quot;labels&amp;quot;: [],
    &amp;quot;createdAt&amp;quot;: &amp;quot;2023-04-27T14:26:21.272Z&amp;quot;,
    &amp;quot;description&amp;quot;: &amp;quot;Developer Advocate who spends all his time building demos involving cats. &amp;quot;,
    &amp;quot;indexedAt&amp;quot;: &amp;quot;2024-01-20T05:45:01.638Z&amp;quot;,
    &amp;quot;banner&amp;quot;: &amp;quot;https://cdn.bsky.app/img/banner/plain/did:plc:4tan3ugu55i2u3hmtblu7wf5/bafkreidia27zaotxjebruhsjfkrkwaho4jxzkm6gipkx62ggtnerdmvvfq&amp;quot;,
    &amp;quot;followersCount&amp;quot;: 2145,
    &amp;quot;followsCount&amp;quot;: 476,
    &amp;quot;postsCount&amp;quot;: 1379
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.bsky.app/docs/api/app-bsky-graph-get-followers&quot;&gt;app.bsky.graph.getFollowers&lt;/a&gt; - the &lt;code&gt;getFollowers&lt;/code&gt; endpoint returns a paginated list of an account&apos;s followers. This returns basic information about the account, but &lt;em&gt;not&lt;/em&gt; how many followers the account has.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.bsky.app/docs/api/app-bsky-actor-get-profiles&quot;&gt;app.bsky.actor.getProfiles&lt;/a&gt; - this endpoint is like the first, returning detailed information about a profile, but it lets you pass in 25 accounts at once. I use this to &apos;enhance&apos; the results from &lt;code&gt;getFollowers&lt;/code&gt; and add their follower count.&lt;/p&gt;
&lt;p&gt;Now let&apos;s look at how I put this together.&lt;/p&gt;
&lt;h2 id=&quot;the-app&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-app&quot;&gt;The App&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The application is just vanilla HTML, JavaScript, and CSS. The HTML is pretty simple as JavaScript is handling most of the content output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot;&amp;gt;
    &amp;lt;link rel=&amp;quot;stylesheet&amp;quot; href=&amp;quot;./style.css&amp;quot;&amp;gt;
    &amp;lt;meta name=&amp;quot;viewport&amp;quot; content=&amp;quot;width=device-width, initial-scale=1.0&amp;quot;&amp;gt;
    &amp;lt;title&amp;gt;Popular Followers&amp;lt;/title&amp;gt;
		&amp;lt;link rel=&amp;quot;stylesheet&amp;quot; href=&amp;quot;https://cdn.simplecss.org/simple.min.css&amp;quot;&amp;gt;
  &amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;

		&amp;lt;p&amp;gt;
		&amp;lt;label for=&amp;quot;username&amp;quot;&amp;gt;
			Enter the username to check: 
			&amp;lt;input type=&amp;quot;search&amp;quot; id=&amp;quot;username&amp;quot; value=&amp;quot;&amp;quot;&amp;gt;
		&amp;lt;/label&amp;gt;
		&amp;lt;/p&amp;gt;
		&amp;lt;button id=&amp;quot;executeUser&amp;quot;&amp;gt;Execute!&amp;lt;/button&amp;gt;
		&amp;lt;span id=&amp;quot;status&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;
		
		&amp;lt;div id=&amp;quot;userProfile&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;
		&amp;lt;!-- this should be CSS I believe... --&amp;gt;
		&amp;lt;br clear=&amp;quot;left&amp;quot;&amp;gt;
		&amp;lt;div id=&amp;quot;report&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;script src=&amp;quot;./script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the JavaScript side, I begin with a bunch of DOM grabbing and I listen for click events on my button:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;document.addEventListener(&apos;DOMContentLoaded&apos;, init, false);

let $username, $executeUser, $status, $userProfile, $report;

async function init() {
	$username = document.querySelector(&apos;#username&apos;);
	$executeUser = document.querySelector(&apos;#executeUser&apos;);
	$status = document.querySelector(&apos;#status&apos;);
	$userProfile = document.querySelector(&apos;#userProfile&apos;);
	$report = document.querySelector(&apos;#report&apos;);
	$executeUser.addEventListener(&apos;click&apos;, executeUser);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Yes, &lt;code&gt;executeUser&lt;/code&gt; is a horrible name for a function. I&apos;m ok with that. That function is pretty intense, so let&apos;s check it out:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function executeUser() {
	let nick = $username.value.trim();
	if(nick === &apos;&apos;) return;
	console.log(`going to do ${nick}`);
	$status.innerHTML = `Looking up ${nick}...`;
	$executeUser.setAttribute(&apos;disabled&apos;,&apos;disabled&apos;);

	let user = await getUser(nick);
	if(user.error) {
		$status.innerHTML = `Unable to load this user: ${user.message}`;
			$executeUser.removeAttribute(&apos;disabled&apos;);
			return;
	}
	console.log(user);

	// yes it is only blank for a sec, but... 
	$status.innerHTML = &apos;&apos;; 
	$userProfile.innerHTML = `
	&amp;lt;p&amp;gt;
	&amp;lt;img src=&amp;quot;${user.avatar}&amp;quot;&amp;gt;
	The user, ${nick} has the display name: ${user.displayName}. They are followed by ${numberFormat(user.followersCount)} users. Generating top follower count now.
	&amp;lt;/p&amp;gt;
	`;

	$status.innerHTML = &apos;Loading followers (this may take a while)&apos;;

	let followers = [];
	let fList = await getFollowers(nick);
	console.log(fList);

	$status.innerHTML = &apos;Now loading info on these users.&apos;;

	fList = await inflateUsers(fList);

	fList.sort((a,b) =&amp;gt; {
		return b.followersCount - a.followersCount;
	});
	console.table(fList);

	$status.innerHTML = &apos;&apos;; 
	let report = `
	&amp;lt;h3&amp;gt;Report (Top 100)&amp;lt;/h3&amp;gt;
	&amp;lt;table&amp;gt;
		&amp;lt;thead&amp;gt;
		&amp;lt;tr&amp;gt;
			&amp;lt;th&amp;gt;Avatar&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;Name&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;Followers&amp;lt;/th&amp;gt;
		&amp;lt;/tr&amp;gt;
		&amp;lt;/thead&amp;gt;
		&amp;lt;tbody&amp;gt;
	`;

	fList.slice(0,100).forEach(f =&amp;gt; {
		report += `
&amp;lt;tr&amp;gt;
	&amp;lt;td&amp;gt;&amp;lt;img src=&amp;quot;${f.avatar}&amp;quot; class=&amp;quot;reportAvatar&amp;quot;&amp;gt;&amp;lt;/td&amp;gt;
	&amp;lt;td&amp;gt;&amp;lt;a href=&amp;quot;https://bsky.app/profile/${f.handle}&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;${f.handle}${f.displayName?&apos; (&apos; + f.displayName + &apos;)&apos;:&apos;&apos;}&amp;lt;/a&amp;gt;&amp;lt;/td&amp;gt;
	&amp;lt;td&amp;gt;${numberFormat(f.followersCount)}&amp;lt;/td&amp;gt;
&amp;lt;/tr&amp;gt;
		`;
	});

	report += &apos;&amp;lt;/tbody&amp;gt;&amp;lt;/table&amp;gt;&apos;;
	$report.innerHTML = report;
	$executeUser.removeAttribute(&apos;disabled&apos;);

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the primary portion of the application. It handles validating your input and ensuring you entered a real user account. After rendering a little bit about the account, it kicks off the process to get your followers and then enhance those results so we know the follower count.&lt;/p&gt;
&lt;p&gt;The final step is to sort and render it out in a basic table. I filter to the top 100 but dump the entire result in your console if you want to see. (If you didn&apos;t know about &lt;code&gt;console.table&lt;/code&gt;, it is hella useful for cases like this.)&lt;/p&gt;
&lt;p&gt;My three Bluesky API wrappers are pretty trivial outside of intelligently handling both pagination and sending 25 users at a time in slices:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function getUser(u) {	
	let uReq = await fetch(`https://api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${u}`);
	return await uReq.json();
}

/*
I grab all the followers, looping over each page
*/
async function getFollowers(u) {
	let result = [];
	let hasMore = true;
	let cursor = &apos;&apos;;
	
	while(hasMore) {
		console.log(`loading for cursor ${cursor}`);
		let fReq = await fetch(`https://api.bsky.app/xrpc/app.bsky.graph.getFollowers?actor=${u}&amp;amp;cursor=${cursor}&amp;amp;limit=100`);
		let thisResult = await fReq.json();
		result.push(...thisResult.followers);
		if(!thisResult.cursor) hasMore = false;
		else cursor = thisResult.cursor;
		// tempf for testing
		//if(result.length &amp;gt;= 200) hasMore = false;
	}	
	return result;
}

/*
Given an array of users, I need to get more info about them, specifically just how many followers they have. 
BSky supports getting 25 at a time, so we will use that
to lower the # of network calls
*/
async function inflateUsers(users) {
	for(let i=0; i&amp;lt;users.length;i+=25) {
		let slice = users.slice(i,i+25);
		let uList = slice.reduce((list, u) =&amp;gt; {
			list.push(`&amp;amp;actors=${u.handle}`);
			return list;
		},[]);

		let uReq = await fetch(`https://api.bsky.app/xrpc/app.bsky.actor.getProfiles?${uList.join(&apos;&apos;)}`);
		let uData = await uReq.json();
		console.log(&apos;uData!!!&apos;, uData);
		for(let x=0;x&amp;lt;uData.profiles.length;x++) {
			users[i+x].followersCount = uData.profiles[x].followersCount;
		}
		console.log(`slice starting at ${i} has len ${slice.length}`); 
	}
	return users;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you don&apos;t want to test this yourself, here&apos;s a screen shot of it in action.&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/03/bs1.jpg&quot; loading=&quot;lazy&quot; alt=&quot;App view showing the form&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Finally, this was maybe my second or third time using the new CodePen. You can check it out in the embed below. I&apos;m &lt;em&gt;really&lt;/em&gt; digging it, especially the new editing experience. I much prefer having tabs so I can focus on one file at a time. Sure, you could minimize the panels in CodePen before, but this UX just feels closer to Visual Studio Code and is just more enjoyable to me.&lt;/p&gt;
&lt;p&gt;Also, as I said in the beginning, this is kind of a pointless tool and mostly exists to stroke your ego a bit, but I &lt;em&gt;can&lt;/em&gt; see some usefulness in reporting on your followers. Not for a personal account like my own, but for brand or larger media accounts perhaps. It wouldn&apos;t be difficult to add different types of sorting or filtering for example. If you end up forking my code, let me know!&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;300&quot; data-pen-title=&quot;Top BS Followers&quot; data-version=&quot;2&quot; data-default-tab=&quot;js,result&quot; data-slug-hash=&quot;PwGmgJj&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/editor/cfjedimaster/pen/019cfd52-c2e3-7e3c-9b96-a3b61b27f7b2&quot;&gt;
  Top BS Followers&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/03/11/testing-live-content-collections-in-astro-v6</id>
                <title>Testing Live Content Collections in Astro V6</title>
                <updated>2026-03-11T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/03/11/testing-live-content-collections-in-astro-v6" rel="alternate" type="text/html" title="Testing Live Content Collections in Astro V6"/>
                <content type="html">
				
                        &lt;p&gt;Yesterday, &lt;a href=&quot;https://astro.build/blog/astro-6/&quot;&gt;Astro V6&lt;/a&gt; formally launched. I say &amp;quot;formally&amp;quot; as it&apos;s been available to test for a little while, but with me still being pretty new to Astro I&apos;ve kept to the main release only. Now that V6 is the default, I thought it was time to dig into it a bit. One feature in particular stood out as being really useful to me - &lt;a href=&quot;https://docs.astro.build/en/guides/content-collections/#live-content-collections&quot;&gt;live content collections&lt;/a&gt;. One of the reasons I&apos;ve been digging Astro so much is that it nicely straddles the SSG world and Node.js server worlds. When building your app, you can make logical decisions about what should be done at build time versus what should be done dynamically. It&apos;s like having Express and Eleventy rolled into one solution.&lt;/p&gt;
&lt;p&gt;Astro already let you easily use live data in your application. The &amp;quot;multi RSS into one&amp;quot; app I shared last week (&lt;a href=&quot;https://www.raymondcamden.com/2026/03/03/using-astro-for-a-combined-rss-view-and-generator&quot;&gt;&amp;quot;Using Astro for a Combined RSS View and Generator&amp;quot;&lt;/a&gt;) is an example of that. What Live Content Collections provides is a way to have on demand data while still using Astro&apos;s &amp;quot;content collection&amp;quot; metaphor.&lt;/p&gt;
&lt;p&gt;To test this, I created an app that wrapped the &lt;a href=&quot;https://developer.themoviedb.org/docs/getting-started&quot;&gt;TMDB API&lt;/a&gt; to provide the following features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A home page that rendered a list of movie genres.&lt;/li&gt;
&lt;li&gt;A detail page for each genre showing recently released movies in that genre.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here&apos;s how I built it.&lt;/p&gt;
&lt;h2 id=&quot;the-live-loader&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-live-loader&quot;&gt;The Live Loader&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://docs.astro.build/en/guides/content-collections/#live-content-collections&quot;&gt;docs&lt;/a&gt; are a great guide to how this feature works and you should absolutely spend some time there, but at a high level, this feature requires:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A loader script that handles returning all items in your collection or one particular item.&lt;/li&gt;
&lt;li&gt;A definition file for your app that defines all the live loaders.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Let&apos;s start with the later. Unlike static content collections which are defined in &lt;code&gt;content.config.ts&lt;/code&gt;, Astro looks like live collections in &lt;code&gt;live.config.ts&lt;/code&gt;. Here&apos;s mine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// Define live collections for accessing real-time data
import { defineLiveCollection } from &apos;astro:content&apos;;
import { genreLoader } from &apos;./loaders/tmdbloader&apos;;

const genres = defineLiveCollection({
  loader: genreLoader({
    apiKey: process.env.TMDB_API_KEY!
  }),
});

export const collections = { genres };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There isn&apos;t much here as I&apos;ve only got one loader. Let&apos;s now take a look at that loader:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import type { LiveLoader } from &apos;astro/loaders&apos;;

interface Genre {
  id: string;
  name: string;
}

interface EntryFilter {
  id: string;
}

export function genreLoader(config: { apiKey: string }): LiveLoader&amp;lt;Genre, EntryFilter, never&amp;gt; {
  return {
    name: &apos;genre-loader&apos;,
    loadCollection: async () =&amp;gt; {
      try {

        let genreReq = await fetch(&apos;https://api.themoviedb.org/3/genre/movie/list&apos;, {
          headers: {
            &apos;Authorization&apos;: `Bearer ${config.apiKey}`, 
            &apos;accept&apos;:&apos;application/json&apos;
          }
        });

        let genreData = await genreReq.json();
        console.log(`Got ${genreData.genres.length} genres`);
        return {
          entries: genreData.genres.map((g:Genre) =&amp;gt; ({
            id: g.id,
            data: g,
          })),
        };
      } catch (error) {
        console.log(&apos;------------- ERROR --------&apos;);
        return {
          error: new Error(&apos;Failed to load genres &apos;, { cause: error }),
        };
      }
    },
    loadEntry: async ({ filter }) =&amp;gt; {
      console.log(&apos;loadEntry called with filter&apos;, filter);
      
      try {
        let movieReq = await fetch(`https://api.themoviedb.org/3/discover/movie?region=US&amp;amp;language=en-US&amp;amp;with_genres=${filter.id}&amp;amp;sort_by=primary_release_date.desc`, {
          headers: {
            &apos;Authorization&apos;: `Bearer ${config.apiKey}`, 
            &apos;accept&apos;:&apos;application/json&apos;
          }
        });

        let movieData = await movieReq.json();
        console.log(`Loaded movies for ${filter.id}`);
        return {
          id: filter.id,
          data: movieData.results
        }

      } catch (error) {
        return {
          error: new Error(&apos;Failed to load movies&apos;, { cause: error }),
        };
      }

    },
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Per the docs, your loader needs to define a name, a &lt;code&gt;loadCollection&lt;/code&gt; method that returns an array, and a &lt;code&gt;loadEntry&lt;/code&gt; method that returns an object representing one part of your collection. For me, this came down to hitting the TMDB &lt;a href=&quot;https://developer.themoviedb.org/reference/genre-movie-list&quot;&gt;genre list for movies API&lt;/a&gt; and then following up with the &lt;a href=&quot;https://developer.themoviedb.org/reference/discover-movie&quot;&gt;discover movies API&lt;/a&gt; that filtered to the genre. (As well as English movies and sorted by release date.)&lt;/p&gt;
&lt;h2 id=&quot;using-the-live-collection&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#using-the-live-collection&quot;&gt;Using the Live Collection&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I first used the collection in my home page, &lt;code&gt;index.astro&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;---
export const prerender = false; 

import BaseLayout from &apos;../layouts/BaseLayout.astro&apos;;
import { getLiveCollection } from &apos;astro:content&apos;;

const { entries } = await getLiveCollection(&apos;genres&apos;);
---

&amp;lt;BaseLayout pageTitle=&amp;quot;List of Genres&amp;quot;&amp;gt;

	&amp;lt;ul&amp;gt;
		{ entries.map((genre:any) =&amp;gt; 
			&amp;lt;li&amp;gt;&amp;lt;a href={&amp;quot;genre/&amp;quot; + genre.id + &amp;quot;?l=&amp;quot; + genre.data.name}&amp;gt;{genre.data.name}&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;
		)}
	&amp;lt;/ul&amp;gt;

&amp;lt;/BaseLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is pretty much the exact same way I&apos;d use a static collection. Get the entries - iterate - done. For details, you&apos;ll note I&apos;m linkking to &amp;quot;genre/X?l=NAME&amp;quot; for each genre. I created &lt;code&gt;pages\genre\[id].astro&lt;/code&gt; to support that. You&apos;ll notice I&apos;m passing the name of the genre in the query string. That&apos;s just so I can use it for display purposes. Here&apos;s the code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;---
export const prerender = false; 

import BaseLayout from &apos;../../layouts/BaseLayout.astro&apos;;
import { getLiveEntry } from &apos;astro:content&apos;;
const { entry, error } = await getLiveEntry(&apos;genres&apos;, Astro.params.id);

if (error) {
  return Astro.rewrite(&apos;/404&apos;);
}

const title = Astro.url.searchParams.get(&amp;quot;l&amp;quot;);
---

&amp;lt;BaseLayout pageTitle={title}&amp;gt;

    &amp;lt;div class=&amp;quot;movieWrapper&amp;quot;&amp;gt;
        { entry.data.map((movie:any) =&amp;gt; 
            &amp;lt;article class=&amp;quot;movie&amp;quot;&amp;gt;
                &amp;lt;h3&amp;gt;{movie.title}&amp;lt;/h3&amp;gt;
                &amp;lt;p&amp;gt;
                    {movie.overview}
                &amp;lt;/p&amp;gt;
                &amp;lt;p&amp;gt;
                    Releases {movie.release_date}
                &amp;lt;/p&amp;gt;
                { movie.poster_path ?
                &amp;lt;p&amp;gt;&amp;lt;img src={&amp;quot;https://image.tmdb.org/t/p/w300/&amp;quot;+movie.poster_path}&amp;gt;&amp;lt;/p&amp;gt; : &apos;&apos;
                }
            &amp;lt;/article&amp;gt;
        )}
    &amp;lt;/div&amp;gt;

&amp;lt;/BaseLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And... that&apos;s it. I fired it up, ran it, and it worked perfectly. After adding the Netlify adapter, I pushed it up here: &lt;a href=&quot;https://tmdb-movie-browser-v6.netlify.app/&quot;&gt;https://tmdb-movie-browser-v6.netlify.app/&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;but-wait...&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#but-wait...&quot;&gt;But wait...&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;This isn&apos;t the most complex example of course, and I was ready to write up the blog post when I noticed something interesting in the V6 post - &lt;a href=&quot;https://astro.build/blog/astro-6/#experimental-route-caching&quot;&gt;route caching&lt;/a&gt;. This is still marked as experimental so I&apos;m not sure I&apos;d use it in a production app, but it looked super easy so I thought I&apos;d give it a try.&lt;/p&gt;
&lt;p&gt;In both pages, I added this to my front matter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;Astro.cache.set({
  maxAge: 120, // Cache for 2 minutes
  swr: 60, // Serve stale for 1 minute while revalidating
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And modified my &lt;code&gt;astro.config.mjs&lt;/code&gt; to load it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// @ts-check
import { defineConfig } from &apos;astro/config&apos;;
import { memoryCache } from &apos;astro/config&apos;;

import netlify from &apos;@astrojs/netlify&apos;;

// https://astro.build/config
export default defineConfig({
    adapter: netlify(),
    experimental: {
        cache: { provider: memoryCache() },
    },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And... it did nothing! Complete failure! Astro sucks!&lt;/p&gt;
&lt;p&gt;After taking a minute to chill out, I looked closer at the &lt;a href=&quot;https://docs.astro.build/en/reference/experimental-flags/route-caching/&quot;&gt;docs&lt;/a&gt; for this feature and noticed this important line (emphasis mine):&lt;/p&gt;
&lt;blockquote&gt;
Use `cache.enabled` to check whether a cache provider is configured and active.
This returns false when no provider is configured, or in &lt;u style=&quot;color:red&quot;&gt;development mode:&lt;/u&gt;
&lt;/blockquote&gt;
&lt;p&gt;I killed my locally running Astro app and did this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run build
npm run preview
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That was the first time I&apos;d done that with an Astro app and it worked perfectly - and - the caching worked perfectly as well. Literally a line of code to each page and the work was done!&lt;/p&gt;
&lt;h2 id=&quot;show-me-the-code-(and-more-stuff)&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#show-me-the-code-(and-more-stuff)&quot;&gt;Show Me the Code (and More Stuff)&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;If you want to see everything, the repo is here: &lt;a href=&quot;https://github.com/cfjedimaster/astro-tests/tree/main/tmdb-movie-browser-v6&quot;&gt;https://github.com/cfjedimaster/astro-tests/tree/main/tmdb-movie-browser-v6&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;As a quick note - a &amp;quot;live&amp;quot; view of movie genres is &lt;strong&gt;not&lt;/strong&gt; the most sensible example of this feature. Most likely the movie genre data itself changes incredibly rarely. But this goes back to what I said above - I really dig that Astro would make it easy to have build time data (genres) and live data (moves in that genre) with minimal effort on my part.&lt;/p&gt;
&lt;p&gt;Let me know what you think, and if you are already an Astro user, have you moved to V6 yet?&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@wansan_99?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Wan San Yip&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/people-sitting-on-chair-inside-room-ID1yWa1Wpx0?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="astro" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/03/10/building-my-own-social-network-poster-in-astro</id>
                <title>Building My Own Social Network Poster in Astro</title>
                <updated>2026-03-10T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/03/10/building-my-own-social-network-poster-in-astro" rel="alternate" type="text/html" title="Building My Own Social Network Poster in Astro"/>
                <content type="html">
				
                        &lt;p&gt;Today is a big day for &lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt;, not only do you get Astro v6 (it just released a few hours ago!), you also get one of my demos! Ok, one of these is more important than the other, but, I&apos;m really excited about v6 and hope to have a demo of the new features to share soon. With that being said, I&apos;m also sharing a demo I started work on a few weeks ago and finally wrapped up this past weekend - Social Beast.&lt;/p&gt;
&lt;h2 id=&quot;what-is-social-beast&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what-is-social-beast&quot;&gt;What is Social Beast&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Social Beast is a web app meant to be run locally (although I have some thoughts on that restriction and will share at the end) that handles posting to multiple social networks at once. Right now &amp;quot;multiple&amp;quot; is two:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Mastodon&lt;/li&gt;
&lt;li&gt;Bluesky&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It doesn&apos;t support Twitter because Twitter is a dumpster, on fire, inside another burning dumpster. It was initially going to support Threads as well, but as I wanted to skip oAuth (more on that too), I decided against it.&lt;/p&gt;
&lt;p&gt;And that&apos;s it - literally. It doesn&apos;t show you latest posts and stats, it&apos;s just meant to give me a quick way to post to both networks at once. There &lt;em&gt;are&lt;/em&gt; tools for this of course, many of which cost money. There&apos;s also &lt;a href=&quot;https://openvibe.social/&quot;&gt;openvibe&lt;/a&gt;, which has a good mobile app, but I wasn&apos;t happy with the web/desktop experience.&lt;/p&gt;
&lt;p&gt;After setting up your authentication, you run the app, open it in your browser, and you&apos;re ready to go:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/03/sb1.jpg&quot; loading=&quot;lazy&quot; alt=&quot;App view showing the form&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;You simply enter your text, optionally include an image (with required alt text), and then hit post:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/03/sb2.jpg&quot; loading=&quot;lazy&quot; alt=&quot;App view showing a filled out form&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;The little activity window on the right updates when done:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/03/sb3.jpg&quot; loading=&quot;lazy&quot; alt=&quot;App activity log&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;You can see the result below. I haven&apos;t used the default embedding experience for Bluesky and Mastodon in a while, so here goes nothing:&lt;/p&gt;
&lt;h3 id=&quot;bluesky&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#bluesky&quot;&gt;Bluesky&lt;/a&gt;&lt;/h3&gt;
&lt;blockquote class=&quot;bluesky-embed&quot; data-bluesky-uri=&quot;at://did:plc:4tan3ugu55i2u3hmtblu7wf5/app.bsky.feed.post/3mgpk65ei7d2u&quot; data-bluesky-cid=&quot;bafyreiffl5jkmm4sjnbagu75lyp7dtg4cc4do2xolc6tbthqook3iunjwe&quot; data-bluesky-embed-color-mode=&quot;system&quot;&gt;&lt;p lang=&quot;&quot;&gt;Good morning good people - just a quick test from my Astro app that posts to Mastodon and Bluesky at the same time.&lt;br&gt;&lt;br&gt;&lt;a href=&quot;https://bsky.app/profile/did:plc:4tan3ugu55i2u3hmtblu7wf5/post/3mgpk65ei7d2u?ref_src=embed&quot;&gt;[image or embed]&lt;/a&gt;&lt;/p&gt;&amp;mdash; Raymond Camden (&lt;a href=&quot;https://bsky.app/profile/did:plc:4tan3ugu55i2u3hmtblu7wf5?ref_src=embed&quot;&gt;@raymondcamden.com&lt;/a&gt;) &lt;a href=&quot;https://bsky.app/profile/did:plc:4tan3ugu55i2u3hmtblu7wf5/post/3mgpk65ei7d2u?ref_src=embed&quot;&gt;March 10, 2026 at 9:06 AM&lt;/a&gt;&lt;/blockquote&gt;&lt;script async src=&quot;https://embed.bsky.app/static/embed.js&quot; charset=&quot;utf-8&quot;&gt;&lt;/script&gt;
&lt;h3 id=&quot;mastodon&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#mastodon&quot;&gt;Mastodon&lt;/a&gt;&lt;/h3&gt;
&lt;blockquote class=&quot;mastodon-embed&quot; data-embed-url=&quot;https://mastodon.social/@raymondcamden/116205264348332986/embed&quot; style=&quot;background: #FCF8FF; border-radius: 8px; border: 1px solid #C9C4DA; margin: 0; max-width: 540px; min-width: 270px; overflow: hidden; padding: 0;&quot;&gt; &lt;a href=&quot;https://mastodon.social/@raymondcamden/116205264348332986&quot; target=&quot;_blank&quot; style=&quot;align-items: center; color: #1C1A25; display: flex; flex-direction: column; font-family: system-ui, -apple-system, BlinkMacSystemFont, &apos;Segoe UI&apos;, Oxygen, Ubuntu, Cantarell, &apos;Fira Sans&apos;, &apos;Droid Sans&apos;, &apos;Helvetica Neue&apos;, Roboto, sans-serif; font-size: 14px; justify-content: center; letter-spacing: 0.25px; line-height: 20px; padding: 24px; text-decoration: none;&quot;&gt; &lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; xmlns:xlink=&quot;http://www.w3.org/1999/xlink&quot; width=&quot;32&quot; height=&quot;32&quot; viewBox=&quot;0 0 79 75&quot;&gt;&lt;path d=&quot;M63 45.3v-20c0-4.1-1-7.3-3.2-9.7-2.1-2.4-5-3.7-8.5-3.7-4.1 0-7.2 1.6-9.3 4.7l-2 3.3-2-3.3c-2-3.1-5.1-4.7-9.2-4.7-3.5 0-6.4 1.3-8.6 3.7-2.1 2.4-3.1 5.6-3.1 9.7v20h8V25.9c0-4.1 1.7-6.2 5.2-6.2 3.8 0 5.8 2.5 5.8 7.4V37.7H44V27.1c0-4.9 1.9-7.4 5.8-7.4 3.5 0 5.2 2.1 5.2 6.2V45.3h8ZM74.7 16.6c.6 6 .1 15.7.1 17.3 0 .5-.1 4.8-.1 5.3-.7 11.5-8 16-15.6 17.5-.1 0-.2 0-.3 0-4.9 1-10 1.2-14.9 1.4-1.2 0-2.4 0-3.6 0-4.8 0-9.7-.6-14.4-1.7-.1 0-.1 0-.1 0s-.1 0-.1 0 0 .1 0 .1 0 0 0 0c.1 1.6.4 3.1 1 4.5.6 1.7 2.9 5.7 11.4 5.7 5 0 9.9-.6 14.8-1.7 0 0 0 0 0 0 .1 0 .1 0 .1 0 0 .1 0 .1 0 .1.1 0 .1 0 .1.1v5.6s0 .1-.1.1c0 0 0 0 0 .1-1.6 1.1-3.7 1.7-5.6 2.3-.8.3-1.6.5-2.4.7-7.5 1.7-15.4 1.3-22.7-1.2-6.8-2.4-13.8-8.2-15.5-15.2-.9-3.8-1.6-7.6-1.9-11.5-.6-5.8-.6-11.7-.8-17.5C3.9 24.5 4 20 4.9 16 6.7 7.9 14.1 2.2 22.3 1c1.4-.2 4.1-1 16.5-1h.1C51.4 0 56.7.8 58.1 1c8.4 1.2 15.5 7.5 16.6 15.6Z&quot; fill=&quot;currentColor&quot;/&gt;&lt;/svg&gt; &lt;div style=&quot;color: #787588; margin-top: 16px;&quot;&gt;Post by @raymondcamden@mastodon.social&lt;/div&gt; &lt;div style=&quot;font-weight: 500;&quot;&gt;View on Mastodon&lt;/div&gt; &lt;/a&gt; &lt;/blockquote&gt; &lt;script data-allowed-prefixes=&quot;https://mastodon.social/&quot; async src=&quot;https://mastodon.social/embed.js&quot;&gt;&lt;/script&gt;
&lt;h2 id=&quot;show-me-the-code!&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#show-me-the-code!&quot;&gt;Show Me The Code!&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, as always, you can find this demo (and others) up on my &lt;code&gt;astro-tests&lt;/code&gt; repo. Here is the link to this one: &lt;a href=&quot;https://github.com/cfjedimaster/astro-tests/tree/main/social-beast&quot;&gt;https://github.com/cfjedimaster/astro-tests/tree/main/social-beast&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The application is a single page with a few routes for API calls. I made use of &lt;a href=&quot;https://oat.ink/&quot;&gt;Oat&lt;/a&gt; for the design. Here&apos;s the main HTML page, containing the form and two panels on the right:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;---
import BaseLayout from &apos;../layouts/BaseLayout.astro&apos;;
---

&amp;lt;BaseLayout pageTitle=&amp;quot;Social Beast&amp;quot;&amp;gt;
	&amp;lt;h1&amp;gt;Welcome to Social Beast&amp;lt;/h1&amp;gt;

	&amp;lt;div class=&amp;quot;row&amp;quot;&amp;gt;
		&amp;lt;div class=&amp;quot;col-8&amp;quot;&amp;gt;

			&amp;lt;p&amp;gt;
			&amp;lt;label for=&amp;quot;postContent&amp;quot;&amp;gt;What&apos;s on your mind?&amp;lt;/label&amp;gt;
			&amp;lt;textarea id=&amp;quot;postContent&amp;quot;&amp;gt;&amp;lt;/textarea&amp;gt;
			&amp;lt;/p&amp;gt;

			&amp;lt;div class=&amp;quot;row&amp;quot;&amp;gt;
				&amp;lt;div class=&amp;quot;col-4&amp;quot;&amp;gt;
					&amp;lt;label for=&amp;quot;postImage&amp;quot;&amp;gt;Add an image (optional)&amp;lt;/label&amp;gt;
					&amp;lt;input type=&amp;quot;file&amp;quot; id=&amp;quot;postImage&amp;quot; accept=&amp;quot;image/*&amp;quot;&amp;gt;
				&amp;lt;/div&amp;gt;
				&amp;lt;div class=&amp;quot;col-4&amp;quot;&amp;gt;
					&amp;lt;label for=&amp;quot;altText&amp;quot;&amp;gt;Alt text for image (&amp;lt;strong&amp;gt;required&amp;lt;/strong&amp;gt;)&amp;lt;/label&amp;gt;
					&amp;lt;input type=&amp;quot;text&amp;quot; id=&amp;quot;altText&amp;quot; placeholder=&amp;quot;Describe the image for accessibility&amp;quot;&amp;gt;
				&amp;lt;/div&amp;gt;
				&amp;lt;div class=&amp;quot;col-4&amp;quot;&amp;gt;
					&amp;lt;img id=&amp;quot;imagePreview&amp;quot; src=&amp;quot;&amp;quot; alt=&amp;quot;Image preview&amp;quot; style=&amp;quot;max-width: 100%; display: none;&amp;quot;&amp;gt;
				&amp;lt;/div&amp;gt;
			&amp;lt;/div&amp;gt;

			&amp;lt;p&amp;gt;
			&amp;lt;button id=&amp;quot;postButton&amp;quot; class=&amp;quot;mt-6&amp;quot;&amp;gt;Post&amp;lt;/button&amp;gt;
			&amp;lt;/p&amp;gt;
		&amp;lt;/div&amp;gt;
		&amp;lt;div class=&amp;quot;col-4&amp;quot;&amp;gt;

			&amp;lt;article class=&amp;quot;card&amp;quot;&amp;gt;
			&amp;lt;header&amp;gt;
				&amp;lt;h3&amp;gt;Connection Status&amp;lt;/h3&amp;gt;
			&amp;lt;/header&amp;gt;
			&amp;lt;div class=&amp;quot;row mb-6 items-center&amp;quot;&amp;gt;
				&amp;lt;div class=&amp;quot;col-6&amp;quot;&amp;gt;
					Mastodon
				&amp;lt;/div&amp;gt;
				&amp;lt;div class=&amp;quot;col-6&amp;quot;&amp;gt;
					&amp;lt;span class=&amp;quot;badge secondary&amp;quot; id=&amp;quot;mastodonStatus&amp;quot;&amp;gt;Unknown&amp;lt;/span&amp;gt;
				&amp;lt;/div&amp;gt;
			&amp;lt;/div&amp;gt;
			&amp;lt;div class=&amp;quot;row mb-6 items-center&amp;quot;&amp;gt;
				&amp;lt;div class=&amp;quot;col-6&amp;quot;&amp;gt;
					Bluesky
				&amp;lt;/div&amp;gt;
				&amp;lt;div class=&amp;quot;col-6&amp;quot;&amp;gt;
					&amp;lt;span class=&amp;quot;badge secondary&amp;quot; id=&amp;quot;blueskyStatus&amp;quot;&amp;gt;Unknown&amp;lt;/span&amp;gt;
				&amp;lt;/div&amp;gt;
			&amp;lt;/div&amp;gt;
			&amp;lt;/article&amp;gt;

			&amp;lt;article class=&amp;quot;card mt-6&amp;quot;&amp;gt;
			&amp;lt;header&amp;gt;
				&amp;lt;h3&amp;gt;Activity&amp;lt;/h3&amp;gt;
				&amp;lt;div id=&amp;quot;activityFeed&amp;quot;&amp;gt;
					&amp;lt;p&amp;gt;No activity yet.&amp;lt;/p&amp;gt;
			&amp;lt;/header&amp;gt;
			&amp;lt;/article&amp;gt;
		&amp;lt;/div&amp;gt;
	&amp;lt;/div&amp;gt;
&amp;lt;/BaseLayout&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most of the work is done in JavaScript. Let&apos;s break that down.&lt;/p&gt;
&lt;p&gt;First, on document load I&apos;m creating a bunch of variables for DOM manipulation and such, and I fire off a few status checks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let $mastodonStatus;
let $blueskyStatus;
let $postButton, $postContent, $postImage, $altText, $imagePreview;
let $activityFeed;

let MASTODON = false;
let BLUESKY = false;

document.addEventListener(&apos;DOMContentLoaded&apos;, function() {

    $mastodonStatus = document.querySelector(&apos;#mastodonStatus&apos;);
    $blueskyStatus = document.querySelector(&apos;#blueskyStatus&apos;);
    $postButton = document.querySelector(&apos;#postButton&apos;);
    $postContent = document.querySelector(&apos;#postContent&apos;);
    $postImage = document.querySelector(&apos;#postImage&apos;);
    $altText = document.querySelector(&apos;#altText&apos;);
    $imagePreview = document.querySelector(&apos;#imagePreview&apos;);
    $activityFeed = document.querySelector(&apos;#activityFeed&apos;);

    // Begin by checking the status of the 2 networks
    checkMastodonStatus();
    checkBlueskyStatus();

    $postButton.addEventListener(&apos;click&apos;, handlePost);
    $postImage.addEventListener(&apos;change&apos;, doPreview);

});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the two status methods. These two functions honestly could have been one, but here they are:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function checkMastodonStatus() {
    try {
        const response = await fetch(&apos;/api/mastodon/check.json&apos;);
        const data = await response.json();
        if (data.ready) {
            $mastodonStatus.textContent = &apos;Connected&apos;;
            $mastodonStatus.classList.remove(&apos;secondary&apos;);
            $mastodonStatus.classList.add(&apos;success&apos;);
            MASTODON = true;
        } else {
            $mastodonStatus.textContent = &apos;Not Connected&apos;;
            $mastodonStatus.classList.remove(&apos;secondary&apos;);
            $mastodonStatus.classList.add(&apos;danger&apos;);
            $mastodonStatus.title = data.error || &apos;Unknown error&apos;;
            console.error(data.error);
        }
    } catch (error) {
        console.error(&apos;Error checking Mastodon status:&apos;, error);
        $mastodonStatus.textContent = &apos;Error&apos;;
        $mastodonStatus.classList.remove(&apos;secondary&apos;);
        $mastodonStatus.classList.add(&apos;danger&apos;);
    }
}

async function checkBlueskyStatus() {
    try {
        const response = await fetch(&apos;/api/bluesky/check.json&apos;);
        const data = await response.json();
        if (data.ready) {
            $blueskyStatus.textContent = &apos;Connected&apos;;
            $blueskyStatus.classList.remove(&apos;secondary&apos;);
            $blueskyStatus.classList.add(&apos;success&apos;);
            BLUESKY = true;
        } else {
            $blueskyStatus.textContent = &apos;Not Connected&apos;;
            $blueskyStatus.classList.remove(&apos;secondary&apos;);
            $blueskyStatus.classList.add(&apos;danger&apos;);
            $blueskyStatus.title = data.error || &apos;Unknown error&apos;;
            console.error(data.error);
        }
    } catch (error) {
        console.error(&apos;Error checking Bluesky status:&apos;, error);
        $blueskyStatus.textContent = &apos;Error&apos;;
        $blueskyStatus.classList.remove(&apos;secondary&apos;);
        $blueskyStatus.classList.add(&apos;danger&apos;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both call an endpoint and based on the result, update the UI. You &lt;em&gt;can&lt;/em&gt; run the app with only one network available.&lt;/p&gt;
&lt;p&gt;Now, let&apos;s leave the front end and demonstrate how the status checks are done. Both Mastodon and Bluesky support are provided via environment variables. Here&apos;s my &lt;code&gt;.env&lt;/code&gt; file as an example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MASTODON_TOKEN=mytokenbringsalltheboystotheyard
MASTODON_SERVER=https://mastodon.social

BLUESKY_USERNAME=raymondcamden.com
BLUESKY_PASSWORD=damnrightitsbetterthanyours
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each of my social networks is stored in the &lt;code&gt;api&lt;/code&gt; folder of my app. Mastdon&apos;s status route looks like so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export const prerender = false;

const MASTODON_TOKEN = process.env.MASTODON_TOKEN;
const MASTODON_SERVER = process.env.MASTODON_SERVER;

export async function GET({ params, request }) {

  let response = {
    ready: false
  }

  // check for env values 
  if(!MASTODON_TOKEN || !MASTODON_SERVER) {
    response.error = &apos;Missing env values for MASTODON_TOKEN or MASTODON_SERVER&apos;;
  } else {
    // try to fetch account info using the token and server
    await fetch(`${MASTODON_SERVER}/api/v1/accounts/verify_credentials`, {
      headers: {
        &apos;Authorization&apos;: `Bearer ${MASTODON_TOKEN}`
      }
    })
    .then(res =&amp;gt; {
      if(res.ok) {
        response.ready = true;
      } else {
        response.error = `Mastodon API error: ${res.status} ${res.statusText}`;
      }
    })
    .catch(error =&amp;gt; {
      response.error = `Error connecting to Mastodon API: ${error.message}`;
    })
  }

  return new Response(
    JSON.stringify(response),
  );

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mastodon has a handy &lt;code&gt;verify_credentials&lt;/code&gt; API so I simply use that to see if the provided auth is correct.&lt;/p&gt;
&lt;p&gt;For Bluesky, I did it a bit differently. You exchange your auth for an auth token, so I built it out in two files. First, &lt;code&gt;login.js&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export async function loginToBluesky() {

    const BLUESKY_USERNAME = process.env.BLUESKY_USERNAME;
    const BLUESKY_PASSWORD = process.env.BLUESKY_PASSWORD;

    if(!BLUESKY_USERNAME || !BLUESKY_PASSWORD) {   
        console.error(&apos;Missing env values for BLUESKY_USERNAME or BLUESKY_PASSWORD&apos;);
        return null;
    }

    let body = {
        identifier: BLUESKY_USERNAME,
        password: BLUESKY_PASSWORD
    };

    try {
        let response = await fetch(&apos;https://bsky.social/xrpc/com.atproto.server.createSession&apos;, {
            method: &apos;POST&apos;,
            headers: {
                &apos;Content-Type&apos;: &apos;application/json&apos;
            },
            body: JSON.stringify(body)
        });

        if(response.ok) {
            let data = await response.json();
            //console.log(&apos;Bluesky session data:&apos;, data);
            return { auth: data };
        } else {
            console.error(`Bluesky API error: ${response.status} ${response.statusText}`);
            return { error : `Bluesky API error: ${response.status} ${response.statusText}` };
        }
    } catch (error) {
        console.error(`Error connecting to Bluesky API: ${error.message}`);
        return { error: `Error connecting to Bluesky API: ${error.message}` };
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which makes &lt;code&gt;check.json.js&lt;/code&gt; pretty short:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { loginToBluesky } from &amp;quot;./logon&amp;quot;;

export const prerender = false;

export async function GET({ params, request }) {

  let response = {
    ready: false
  }

  let authCheck = await loginToBluesky();
  if(authCheck &amp;amp;&amp;amp; authCheck.auth) {
    response.ready = true;
  } else if(authCheck &amp;amp;&amp;amp; authCheck.error) {
    response.error = authCheck.error;
  }
  
  return new Response(
    JSON.stringify(response),
  );

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Alright, so back to the front end, the post logic begins with a bit of validation, and then passing off the calls to helper methods:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function handlePost() {
    let post = {};

    // First, a sanity check
    if(!MASTODON &amp;amp;&amp;amp; !BLUESKY &amp;amp;&amp;amp; !THREADS) {
        ot.toast(&apos;No social networks are configured! Please set up at least one network to post.&apos;, &apos;Action Stopped&apos;, {
            variant: &apos;danger&apos;,
            duration: 6000
        });

        return;
    }

    const content = $postContent.value.trim();
    if(!content) {
        ot.toast(&apos;No content. Type something!&apos;, &apos;Action Stopped&apos;, {
            variant: &apos;danger&apos;,
            duration: 3000
        });

        return;
    }

    post.content = content;

    let caption = $altText.value.trim();   
    if($postImage.files.length &amp;gt; 0) {
        if(!caption) {
            ot.toast(&apos;Image requires alt text. Please add alt text for the image and try again.&apos;, &apos;Action Stopped&apos;, {
                variant: &apos;danger&apos;,
                duration: 3000
            });
            return;
        }

        post.image = await fileToBase64($postImage.files[0]);
        post.altText = caption;
    }

    $postButton.setAttribute(&apos;disabled&apos;, &apos;disabled&apos;);
    // Call all 3 networks, and wait for the results
    $activityFeed.innerHTML = &apos;Posting to enabled networks...&apos;;

    let results = [];
    
    if(MASTODON) {
        results.push(postToMastodon(post));
    }

    if(BLUESKY) {
        results.push(postToBluesky(post));
    }

    let settledResults = await Promise.allSettled(results);
    console.log(&apos;Settled results:&apos;, settledResults);
    let resultHTML = &apos;&apos;;
    for(let result of settledResults) {
        if(result.status === &apos;fulfilled&apos;) {
            let data = result.value;
            if(data.ok) {
                resultHTML += `Successfully posted to ${data.network}!&amp;lt;br&amp;gt;`;
            } else {
                resultHTML += `Failed to post to ${data.network}: ${data.error}&amp;lt;br&amp;gt;`;
            }
        } else {
            resultHTML += `Error posting to a network: ${result.reason}&amp;lt;br&amp;gt;`;
        }
    }

    $activityFeed.innerHTML = resultHTML;

    // cleanup
    $postContent.value = &apos;&apos;;
    $postImage.value = &apos;&apos;;
    $altText.value = &apos;&apos;;
    $imagePreview.src = &apos;&apos;;
    $imagePreview.style.display = &apos;none&apos;;
    $postButton.removeAttribute(&apos;disabled&apos;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I&apos;ll skip showing you &lt;code&gt;postToMastodon&lt;/code&gt; and &lt;code&gt;postToBluesky&lt;/code&gt; as they simply call API routes on the back end and gather the results. Mastodon posting is pretty simple:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export const prerender = false;

const MASTODON_TOKEN = process.env.MASTODON_TOKEN;
const MASTODON_SERVER = process.env.MASTODON_SERVER;

export async function POST({ params, request }) {

    const body = await request.json();

    let response = {
        ok: false
    }

    // check for env values (this shouldn&apos;t ever run as check returns false, but just in case
    if(!MASTODON_TOKEN || !MASTODON_SERVER) {
        response.error = &apos;Missing env values for MASTODON_TOKEN or MASTODON_SERVER&apos;;
    } else if(!body.post.content) {
        response.error = &apos;Missing content in request body&apos;;
    } else {

        let postData = new FormData();
        postData.append(&apos;status&apos;, body.post.content);

        // check for image
        if(body.post.image) {
            let mediaPost = new FormData();
            mediaPost.append(&apos;file&apos;, body.post.image);
            mediaPost.append(&apos;description&apos;, body.post.altText || &apos;&apos;);

            let mediaResponse = await fetch(`${MASTODON_SERVER}/api/v2/media`, {
                method: &apos;POST&apos;,
                headers: { 
                    &apos;Authorization&apos;: `Bearer ${MASTODON_TOKEN}`
                },
                body: mediaPost
            });

            let mediaResult = await mediaResponse.json();

            postData.append(&apos;media_ids[]&apos;, mediaResult.id);
        }


        await fetch(`${MASTODON_SERVER}/api/v1/statuses`, {
            method: &apos;POST&apos;,
            body: postData,
            headers: {
                &apos;Authorization&apos;: `Bearer ${MASTODON_TOKEN}`
            }
        })
        .then(res =&amp;gt; {
            if(res.ok) {
                response.ok = true;
            } else {
                response.error = `Mastodon API error: ${res.status} ${res.statusText}`;
            }
        })
        .catch(error =&amp;gt; {
            response.error = `Error connecting to Mastodon API: ${error.message}`;
        })
    }

    return new Response(
        JSON.stringify(response),
    );

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And here&apos;s Bluesky:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export const prerender = false;

import { loginToBluesky } from &amp;quot;./logon&amp;quot;;

const BLUESKY_USERNAME = process.env.BLUESKY_USERNAME;

export async function POST({ params, request }) {

    const body = await request.json();

    let response = {
        ok: false
    }

    let authCheck = await loginToBluesky();
    if(authCheck &amp;amp;&amp;amp; authCheck.error) {
        response.error = authCheck.error;

    } else {
        
        let postBody = {
            repo:BLUESKY_USERNAME, 
            collection:&amp;quot;app.bsky.feed.post&amp;quot;, 
            record: {
                text:body.post.content,
                createdAt: new Date().toISOString()

            }
        };

        // check for image
        if(body.post.image) {

            body.post.image = body.post.image.replace(/^data:image\/\w+;base64,/, &amp;quot;&amp;quot;);
            let imageBuffer = Buffer.from(body.post.image, &apos;base64&apos;);

            let mediaResponse = await fetch(&apos;https://bsky.social/xrpc/com.atproto.repo.uploadBlob&apos;, {
                method: &apos;POST&apos;,
                headers: { 
                    &apos;Authorization&apos;: `Bearer ${authCheck.auth.accessJwt}`,
                    &apos;Content-Type&apos;: &apos;image/jpeg&apos;
                },
                body: imageBuffer
            });

            let mediaResult = await mediaResponse.json();
            //console.log(&apos;Media upload result:&apos;, mediaResult);

            // modify postBody to include image 
            postBody.record.embed = {
                &amp;quot;$type&amp;quot;: &amp;quot;app.bsky.embed.images&amp;quot;,
                images: [
                {
                    alt:body.post.altText || &apos;&apos;,
                    image: mediaResult.blob
                }
                ]
            }

        }

        await fetch(&apos;https://bsky.social/xrpc/com.atproto.repo.createRecord&apos;, {
            method: &apos;POST&apos;,
            headers: {
                &apos;Content-Type&apos;: &apos;application/json&apos;, 
                &apos;Authorization&apos;: `Bearer ${authCheck.auth.accessJwt}`
             },
            body: JSON.stringify(postBody)
        })
        .then(res =&amp;gt; {
            if(res.ok) {
                response.ok = true;
            } else {
                response.error = `Bluesky API error: ${res.status} ${res.statusText}`;
            }
        })
        .catch(error =&amp;gt; {
            response.error = `Error connecting to Bluesky API: ${error.message}`;
        })

    }

    return new Response(
        JSON.stringify(response),
    );

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that each post to Bluesky runs the login method and I &lt;em&gt;could&lt;/em&gt; cache the auth token returned, but I figured even a person posting a lot won&apos;t necessarily get a lot of benefit from that. That being said - there&apos;s room for improvement...&lt;/p&gt;
&lt;h2 id=&quot;what-else%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what-else%3F&quot;&gt;What Else?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, so, this is pretty simple, as I wanted it to be, but there&apos;s a few things I&apos;d consider good changes.&lt;/p&gt;
&lt;p&gt;I mentioned that the idea here was to run this locally with hard coded auth in your environment. I &lt;em&gt;could&lt;/em&gt; prompt the user to paste in the values and cache it in LocalStorage, but that felt iffy to me.&lt;/p&gt;
&lt;p&gt;I also mentioned I skipped Threads as I didn&apos;t want to do oAuth. You can absolutely do oAuth in Astro, even I&apos;ve done it, and I&apos;d be willing to take in a PR from someone who wants to add that, but it didn&apos;t feel worth the effort to me.&lt;/p&gt;
&lt;p&gt;Finally, a shoutout to &lt;a href=&quot;https://bsky.app/profile/bobmonsour.com&quot;&gt;Bob Monsour&lt;/a&gt; for the inspiration. You can find his version of this idea (which does a lot more), here: &lt;a href=&quot;https://github.com/bobmonsour/social-posting&quot;&gt;https://github.com/bobmonsour/social-posting&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@fosterious?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Sean Foster&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/brown-lion-in-close-up-photography-UyrslM2tyKc?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="astro" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/03/08/links-for-you-3826</id>
                <title>Links For You (3/8/26)</title>
                <updated>2026-03-08T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/03/08/links-for-you-3826" rel="alternate" type="text/html" title="Links For You (3/8/26)"/>
                <content type="html">
				
                        &lt;p&gt;Greetings, programs. I&apos;m taking a break from Assassin&apos;s Creed Shadows (and being lazy in general) and thought I&apos;d write up my links post. Yesterday was the 15th birthday of one of my kids and later today we get to celebrate with her friends. Outside of that and laundry, I&apos;ve got a large amount of nothing to get accomplished today and I absolutely love it. Let&apos;s get to the links. As always, I hope you find these worth your time!&lt;/p&gt;
&lt;h2 id=&quot;eleventy&apos;s-future&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#eleventy&apos;s-future&quot;&gt;Eleventy&apos;s Future&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;As you know (or hopefully know), my blog here is built on &lt;a href=&quot;https://www.11ty.dev/&quot;&gt;Eleventy&lt;/a&gt;, a Node.js static site generator. I&apos;ve been using it, and &lt;a href=&quot;https://www.raymondcamden.com/tags/eleventy&quot;&gt;blogging about it&lt;/a&gt; for almost six years. This past week, Zach announced that Eleventy is becoming &amp;quot;Build Awesome&amp;quot; - you can read the announcement post here: &lt;a href=&quot;https://www.11ty.dev/blog/build-awesome/&quot;&gt;Eleventy is now Build Awesome&lt;/a&gt;. For folks who may not know, this is part of the same group that owns Font Awesome and &lt;a href=&quot;https://webawesome.com/&quot;&gt;Web Awesome&lt;/a&gt;, the new name for Shoelace.&lt;/p&gt;
&lt;p&gt;As you can imagine, this has caused quite a bit of ... concern amongst the Eleventy community. As an example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://brennan.day/the-end-of-eleventy/&quot;&gt;The End of Eleventy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://hamatti.org/posts/au-revoir-eleventy/&quot;&gt;Au revoir, Eleventy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://davidrhoden.com/notes/eleventy-is-changing-its-name-among-other-things-and-i-dont-love-it/&quot;&gt;Eleventy is changing its name, among other things, and I don&apos;t love it&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Also, and to be clear, this is not related, Sia announced the end of the &lt;a href=&quot;https://11tymeetup.dev/&quot;&gt;Eleventy Meetup&lt;/a&gt; after five years and thirty episodes.&lt;/p&gt;
&lt;p&gt;I&apos;m not stressing over this now. I don&apos;t like the name, but honestly that doesn&apos;t bother me, I&apos;ll just keep saying Eleventy. I can appreciate the goal here - provide more while also earning money - but I do worry about the community, usage, and so forth. That being said, Zach&apos;s been a great caretaker and devoted a hell of a lot of energy to something most people don&apos;t pay a dime for. All I can do now is hope for the best.&lt;/p&gt;
&lt;h2 id=&quot;swatchify&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#swatchify&quot;&gt;Swatchify&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A few days ago, I &lt;a href=&quot;https://www.raymondcamden.com/2026/03/04/dyanimically-adjusting-image-text-for-contrast&quot;&gt;blogged&lt;/a&gt; about the latest update to Color Thief, a JavaScript library for getting prominent colors in images. There&apos;s another option as well, the nicely named &lt;a href=&quot;https://james-see.github.io/swatchify/&quot;&gt;Swatchify&lt;/a&gt;. This is also a JavaScript library, but can also be used as a API service as well. (To be clear, if you host it.) There&apos;s also a Go library and CLI.&lt;/p&gt;
&lt;p&gt;Oh, and this has nothing to do with the very cool watch company...&lt;/p&gt;
&lt;lite-youtube videoid=&quot;z-uzlR52HOw&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/z-uzlR52HOw/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=z-uzlR52HOw&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;
&lt;h2 id=&quot;codepen-2.0&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#codepen-2.0&quot;&gt;CodePen 2.0&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I don&apos;t usually cover &amp;quot;product launches&amp;quot; (although I guess I just did re: Build Awesome), but one of my favorite online tools, &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt; just released (in beta!) a 2.0 version. You can learn more here: &lt;a href=&quot;https://codepen.io/beta&quot;&gt;CodePen 2.0 Beta&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This is a &lt;em&gt;big&lt;/em&gt; change, and I &lt;em&gt;strongly&lt;/em&gt; encourage you to pay attention to the onboarding notes if you give it a try. There is a &lt;em&gt;lot&lt;/em&gt; of really good stuff here, including a proper file system, build processes, and more, but you will want to pay attention as the editor walks you through the changes.&lt;/p&gt;
&lt;p&gt;Cassidy has a great demo in the video below:&lt;/p&gt;
&lt;lite-youtube videoid=&quot;0R4l8nlmCAQ&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/0R4l8nlmCAQ/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=0R4l8nlmCAQ&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;
&lt;p&gt;If you watched it, you&apos;ll note the gets tripped up by one of the main changes - your CSS and JavaScript aren&apos;t automatically included anymore. They can be! But as she discovered, you need to do set things up right if you wan the &amp;quot;auto&amp;quot; behavior.&lt;/p&gt;
&lt;p&gt;I&apos;ve only played with it a bit, but I &lt;strong&gt;really&lt;/strong&gt; like what I see so far.&lt;/p&gt;
&lt;h2 id=&quot;just-for-fun&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#just-for-fun&quot;&gt;Just For Fun&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I&apos;ve shared videos from Matt One before - he creates incredible mashups and remixes. I just wish he&apos;d skip the &lt;em&gt;incredibly&lt;/em&gt; cringe worthy AI videos. That being said, I subscribe to him for his tunes, not his videos. The track below is a remix of The Cure&apos;s &amp;quot;Letter To Elise&amp;quot;, and it&apos;s a &lt;em&gt;dramatic&lt;/em&gt; mix of the original, really changing the tone/mood in an interesting way:&lt;/p&gt;
&lt;lite-youtube videoid=&quot;GkkXxDUE5Bc&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/GkkXxDUE5Bc/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=GkkXxDUE5Bc&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;

                        
                
				</content>

                
                <category term="links4you" />
                
                
                <category term="misc" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
</feed>