<?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-27T14:48:09+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/26/links-for-you-42626</id>
                <title>Links For You (4/26/26)</title>
                <updated>2026-04-26T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/26/links-for-you-42626" rel="alternate" type="text/html" title="Links For You (4/26/26)"/>
                <content type="html">
				
                        &lt;p&gt;I was supposed to post this last week (I try to keep to a schedule of every two weeks), but I didn&apos;t get around to it because... nope, that&apos;s it. That&apos;s the reason. Because. And that&apos;s good enough, amiright!?!? The heat is slowly cranking up here in Louisiana and I&apos;m dreading the full on summer, but things do slow down a bit when the kids aren&apos;t in school and that&apos;s something I greatly appreciate. Before getting into this weeks links, I was reminded a few weeks back that my wife actually reads my posts so... hi baby, I love you.&lt;/p&gt;
&lt;h2 id=&quot;super-useful-web-components-ftw---%3Cform-saver%3E&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#super-useful-web-components-ftw---%3Cform-saver%3E&quot;&gt;Super useful web components FTW - &amp;lt;form-saver&amp;gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;First up is a really simple and really useful web component, &lt;a href=&quot;https://www.aaron-gustafson.com/notebook/never-lose-form-progress-again/&quot;&gt;form-saver&lt;/a&gt;. You can wrap any form with the component and instantly get client-side storage of form contents until the form is submitted. This works for all types of form fields except file fields of course. (I assume folks know this but you can&apos;t use JavaScript to set the value of a file field for security reasons.)&lt;/p&gt;
&lt;p&gt;Here&apos;s a simple usage example from the docs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;form-saver&amp;gt;
  &amp;lt;form action=&amp;quot;/contact&amp;quot; method=&amp;quot;post&amp;quot;&amp;gt;
    &amp;lt;label&amp;gt;
      Name
      &amp;lt;input name=&amp;quot;name&amp;quot; autocomplete=&amp;quot;name&amp;quot; /&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;label&amp;gt;
      Email
      &amp;lt;input name=&amp;quot;email&amp;quot; type=&amp;quot;email&amp;quot; autocomplete=&amp;quot;email&amp;quot; /&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;button type=&amp;quot;submit&amp;quot;&amp;gt;Send&amp;lt;/button&amp;gt;
  &amp;lt;/form&amp;gt;
&amp;lt;/form-saver&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Isn&apos;t that sweet? Thanks go to Aaron Gustafson.&lt;/p&gt;
&lt;p&gt;As a reminder (and I usually try to avoid linking to my own stuff in these posts, but it&apos;s definitely related), if you like this you may like my &lt;a href=&quot;https://www.npmjs.com/package/@raymondcamden/table-sorter&quot;&gt;table-sorter&lt;/a&gt; web component as well.&lt;/p&gt;
&lt;h2 id=&quot;share-the-python-love&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#share-the-python-love&quot;&gt;Share the Python Love&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Next up is a superb guide at &lt;a href=&quot;https://stephenlf.dev/blog/python-library-in-2026/&quot;&gt;packing Python code&lt;/a&gt; for distribution. I&apos;ve written a lot of Python code, but have only created a distribution once or twice, and this guide literally walks you from the first line of code to publication.&lt;/p&gt;
&lt;p&gt;Thanks to Stephen Funk for writing this up!&lt;/p&gt;
&lt;h2 id=&quot;the-last-quiet-thing&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-last-quiet-thing&quot;&gt;The Last Quiet Thing&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Finally, this essay, &lt;a href=&quot;https://www.terrygodier.com/the-last-quiet-thing&quot;&gt;&amp;quot;The Last Quiet Thing&amp;quot;&lt;/a&gt;, is a thought provoking deep look at how much of our lives are being stolen from devices that constantly, &lt;em&gt;endlessly&lt;/em&gt; need our attention. Not only is it incredibly well written, it&apos;s also really well designed as well.&lt;/p&gt;
&lt;p&gt;This was written by Terry Godier and thanks go to Salma Alam-Naylor for sharing it on her &lt;a href=&quot;https://buttondown.com/weirdwidewebhole/archive/&quot;&gt;newsletter&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;I love building silly things on the web (see &lt;a href=&quot;https://www.raymondcamden.com/2026/04/25/another-game-my-little-mortal-combat&quot;&gt;yesterday&apos;s post&lt;/a&gt; as an example), and this little toy from Wes Bos is just that. &lt;a href=&quot;https://tab.wesbos.com/&quot;&gt;Tab Snitch&lt;/a&gt; does one thing - set a custom title for the web page - but does so with silly and quite embarrassing titles. Although I have to be honest - a few of the fake titles listed there are one&apos;s I&apos;ve probably legitimately had on my screen at some point in time. You can guess which.&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/25/another-game-my-little-mortal-combat</id>
                <title>Another Game: My Little Mortal Combat</title>
                <updated>2026-04-25T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/25/another-game-my-little-mortal-combat" rel="alternate" type="text/html" title="Another Game: My Little Mortal Combat"/>
                <content type="html">
				
                        &lt;p&gt;Hello awesome readers! I&apos;m happy to announce my latest web game, &lt;a href=&quot;https://cfjedimaster.github.io/webdemos/my_little_mortal_combat/&quot;&gt;My Little Mortal Combat&lt;/a&gt;, a mashup of two epic franchises, My Little Pony and Mortal Kombat. This began as an idea, just the name, that I recorded in Microsoft To Do in September of 2019. Yes, almost seven years ago. It sat there, at the bottom of my &apos;idea&apos; list, until about a month ago when in the shower (not joking), it popped up in my head along with the basic mechanics of how the game would play.&lt;/p&gt;
&lt;p&gt;Right now the game is just missing one feature (I&apos;d rather not talk about until I figure out how I&apos;m going to do it) but definitely needs some balancing work. I enjoy playing games without knowing the details of how things work, so if&apos;s that you too, head over to the game now and good luck!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://cfjedimaster.github.io/webdemos/my_little_mortal_combat/&quot;&gt;https://cfjedimaster.github.io/webdemos/my_little_mortal_combat/&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;how-i-built-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#how-i-built-it&quot;&gt;How I Built It&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;As a web app, I kept it pretty simple. Just HTML, CSS, JavaScript, and Alpine.js. I used AI (Cursor&apos;s IDE specifically) to create the UI for the three phases of the game - into, main display, and combat. I also used AI to generate some of the strings used in the game. Opponents have random &amp;quot;evil&amp;quot;-ish titles and I wrote some and then asked AI to generate some more. Some examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Life Eater&lt;/li&gt;
&lt;li&gt;Hoof Smasher&lt;/li&gt;
&lt;li&gt;the Blood Soaked&lt;/li&gt;
&lt;li&gt;the Blood Drinker&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Opponents also have random annoying facts. Again, I wrote some, had AI generate some more.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;doesn&apos;t return library books on time.&lt;/li&gt;
&lt;li&gt;likes to ruin the end of movies.&lt;/li&gt;
&lt;li&gt;has been known to sneeze at the buffet.&lt;/li&gt;
&lt;li&gt;steals candy from babies and then throws the candy in the trash—in front of them!&lt;/li&gt;
&lt;li&gt;only speaks in passive-aggressive voice.&lt;/li&gt;
&lt;li&gt;will point out your least favorite body parts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The actual names of the opponents come directly from a My Little Pony API I found that was open source.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example of a randomly generated opponent:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/pony1.png&quot; loading=&quot;lazy&quot; alt=&quot;Pony!&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Combat is basic &amp;quot;Rock Paper Scissors&amp;quot; style where you have 3 choices (Attack, Defend, Vogue) and the result is based on what your opponent does.&lt;/p&gt;
&lt;p&gt;Your character, and the opponents, have numerical value for Attack, Defend, and Vogue. When you win a round in a fight, the damage you do is based on that skill. Your total HP is based on level.&lt;/p&gt;
&lt;p&gt;As you can play, if you win or lose, you get gold and XP. Obviously you get a lot more when you win. You can use the gold to train skills. Your XP turns into your level which improves your HP.&lt;/p&gt;
&lt;p&gt;As I said, I definitely think the numbers need tweaking probably, so let me know. You can check out all the code here: &lt;a href=&quot;https://github.com/cfjedimaster/webdemos/tree/master/my_little_mortal_combat&quot;&gt;https://github.com/cfjedimaster/webdemos/tree/master/my_little_mortal_combat&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Don&apos;t forget, I&apos;ve got this game and my others listed over on my my &lt;a href=&quot;https://www.raymondcamden.com/stuff&quot;&gt;Stuff&lt;/a&gt; page. Enjoy!&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@farvardin?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Felis Amafeles&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/five-small-cartoon-ponies-sitting-in-a-row-MI-KCy_foeU?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="development" />
                
                <category term="games" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/04/20/building-a-simple-markdown-pwa-app</id>
                <title>Building a Simple Markdown PWA App</title>
                <updated>2026-04-20T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/20/building-a-simple-markdown-pwa-app" rel="alternate" type="text/html" title="Building a Simple Markdown PWA App"/>
                <content type="html">
				
                        &lt;p&gt;While I didn&apos;t share it on the blog, last week I tasked Claude with using Electron to build a Markdown viewer app. It was part test (how well can Claude work with Electron) and part real need - I work with Markdown files all the time but didn&apos;t have a simple &amp;quot;view focused&amp;quot; application for it. I was sure there open source or paid app options out there, but I wanted my own. Claude did a pretty good job (you can see the source &lt;a href=&quot;https://github.com/cfjedimaster/webdemos/tree/master/mdviewer&quot;&gt;here&lt;/a&gt;) but one thing stood out to me - the size of the bundled app.&lt;/p&gt;
&lt;p&gt;I created both a Mac and Windows distribution and both were around 90 megs. That&apos;s not huge of course, but still felt like a lot for what could - in theory - just be a web app. But there was one crucial feature I wasn&apos;t sure I could replicate - double clicking on a MD file to have it open my app. Turns out - you certainly &lt;em&gt;can&lt;/em&gt; do it that.&lt;/p&gt;
&lt;p&gt;If you don&apos;t care how I built it, you can go to the app right now and install it: &lt;a href=&quot;https://mdviewerpwa.netlify.app/&quot;&gt;https://mdviewerpwa.netlify.app/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Alright, let&apos;s break it down.&lt;/p&gt;
&lt;h2 id=&quot;the-ui&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-ui&quot;&gt;The UI&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;When I had Claude design the application for me, it went with an incredibly simple UI. I felt no reason to add to that so when I began the web app, I copied over the generated HTML/CSS from the Electron app into my new folder. Here&apos;s an example of how it looks with no file selected:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/pwa1.png&quot; loading=&quot;lazy&quot; alt=&quot;App with nothing loaded&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;And here&apos;s how it looks after a Markdown file is opened:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/pwa2.png&quot; loading=&quot;lazy&quot; alt=&quot;App with MD loaded&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Now let&apos;s look at the code a bit.&lt;/p&gt;
&lt;h2 id=&quot;markdown-support&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#markdown-support&quot;&gt;Markdown Support&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;This normally would be the boring part. Just drop in &lt;a href=&quot;https://www.npmjs.com/package/marked&quot;&gt;marked&lt;/a&gt; and be done with it. But so many Markdown files I use have frontmatter I wanted to do something special for it. My fix was incredibly simple. If a file begins with three dashes and has another three dashes, replace them with backticks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const renderMarkdown = content =&amp;gt; {
    rawContent = content;
    /*
    Special tweak for frontmatter. If our content starts with &apos;---&apos; and 
    contains &apos;---&apos; again, we assume it&apos;s frontmatter and wrap it in and
    swap the --- to ```.
    */
    contentToRender = content.trim();
    // also, making a copy so we can keep the View Source version working
    if (contentToRender.startsWith(&apos;---&apos;)) {
        console.log(&apos;detected frontmatter, applying special formatting&apos;);
        const parts = content.split(&apos;---&apos;);
        if (parts.length &amp;gt;= 3) {
            const frontmatter = parts[1];
            const rest = parts.slice(2).join(&apos;---&apos;);
            contentToRender = `\`\`\`yaml${frontmatter}\`\`\`\n\n${rest}`;
            console.log(contentToRender);
        }
    }
    renderedEl.innerHTML = marked.parse(contentToRender);
    sourceEl.textContent = content;

    emptyState.style.display = &apos;none&apos;;
    renderedEl.style.display = &apos;block&apos;;
    sourceEl.style.display = &apos;none&apos;;
    toggleBtn.style.display = &apos;inline-block&apos;;
    showingSource = false;
    toggleBtn.textContent = &apos;View Source&apos;;

    document.title = `MD Viewer — ${fileNameEl.textContent}`;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Honestly most of that code is UI crap, but you can see the frontmatter support on top. I think it came out &lt;em&gt;perfect&lt;/em&gt; - it stands out and I think most folks will recognize it for what it represents, but in theory I could possibly add a small graphical label or something to the block.&lt;/p&gt;
&lt;p&gt;So again, there&apos;s UI handling code in here that&apos;s not that interesting, so let me turn to the real cool part. Yes, Virginia, a PWA can absolutely associate itself with files. I added a manifest.json and basic service worker. For bot of these I relied on Claude and it &lt;em&gt;mostly&lt;/em&gt; did a good job, I had to tweak a few things.&lt;/p&gt;
&lt;p&gt;After the basics worked, I did some Googling and came across this excellent MDN resource: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Associate_files_with_your_PWA&quot;&gt;Associate files with your PWA&lt;/a&gt;. Adding file support took two steps.&lt;/p&gt;
&lt;p&gt;First, I added the following to my manifest:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;&amp;quot;file_handlers&amp;quot;: [
{
    &amp;quot;action&amp;quot;: &amp;quot;/&amp;quot;, 
    &amp;quot;accept&amp;quot;: {
    &amp;quot;text/markdown&amp;quot;: [&amp;quot;.md&amp;quot;, &amp;quot;.markdown&amp;quot;]
    }
}
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;action&lt;/code&gt; step there tells my app what URL to go to when being opened via a file. As my app has one page/view, I just used &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The next step was to look for this in JavaScript. My application does this when starting up:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;if(&amp;quot;launchQueue&amp;quot; in window) {
    console.log(&apos;Launch Queue API is supported, setting up consumer&apos;);
    window.launchQueue.setConsumer(launchParams =&amp;gt; {
        if (!launchParams.files.length) {
            return;
        }
        const fileHandle = launchParams.files[0];
        console.log(&apos;File launched:&apos;, fileHandle);
        fileHandle.getFile().then(file =&amp;gt; {
            const reader = new FileReader();
            reader.onload = e =&amp;gt; {
                const content = e.target.result;
                fileNameEl.textContent = file.name;
                renderMarkdown(content);
            };
            reader.readAsText(file);
        }).catch(error =&amp;gt; {
            console.error(&apos;Error reading file:&apos;, error);
        });
    });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Basically, if I can use &lt;code&gt;launchQueue&lt;/code&gt;, it will consist of a list of files, each of which is a file handle. I&apos;ve used File objects in JavaScript before, but not file handles, but you can quickly go to a real file object using &lt;code&gt;getFile()&lt;/code&gt;. Once you have that, the regular &lt;code&gt;FileReader&lt;/code&gt; approach works to get the contents and render it.&lt;/p&gt;
&lt;p&gt;I deployed the app to Netlify, opened it in my browser, and clicked the install icon.&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/pwa3.png&quot; loading=&quot;lazy&quot; alt=&quot;Install dialog&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;After I confirmed I had the application, I right clicked on a MD file, used open with, navigated to my PWA, and selected it:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/pwa4.png&quot; loading=&quot;lazy&quot; alt=&quot;Open file prompt&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;I told it to remember my choice and that was literally it. So now I&apos;ve got a web-based app I can use locally, heck even offline, to render my Markdown files in a nice reading experience. (Well, nice to me anyway. ;) Oh, and the size is about 400k, of which most is one of the icons. Significantly smaller than the Electron app. (But to be fair, Electron was overkill for what I was doing.)&lt;/p&gt;
&lt;p&gt;Once again, the link to the site is here, &lt;a href=&quot;https://mdviewerpwa.netlify.app/&quot;&gt;https://mdviewerpwa.netlify.app/&lt;/a&gt;, and you can find all the code here: &lt;a href=&quot;https://github.com/cfjedimaster/webdemos/tree/master/mdviewerpwa&quot;&gt;https://github.com/cfjedimaster/webdemos/tree/master/mdviewerpwa&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@anniespratt?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Annie Spratt&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/shelf-with-art-supplies-books-and-decorations-ModHj41WZhg?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="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/04/17/summarizing-docs-with-built-in-ai</id>
                <title>Summarizing Docs with Built-in AI</title>
                <updated>2026-04-17T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/17/summarizing-docs-with-built-in-ai" rel="alternate" type="text/html" title="Summarizing Docs with Built-in AI"/>
                <content type="html">
				
                        &lt;p&gt;Back in January of this year, I blogged about on-device summarization of PDFs: &lt;a href=&quot;https://www.raymondcamden.com/2026/01/28/summarizing-pdfs-with-on-device-ai&quot;&gt;Summarizing PDFs with On-Device AI
&lt;/a&gt;. In that post, I made use of Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/summarizer-api&quot;&gt;Summary API&lt;/a&gt; and &lt;a href=&quot;https://mozilla.github.io/pdf.js/&quot;&gt;PDF.js&lt;/a&gt; to create summaries of PDFs completely within the browser. I thought I&apos;d take a look at extending that demo into more document types, specifically Office. And even more specifically - Word, Excel, and PowerPoint. Here&apos;s what I came up with.&lt;/p&gt;
&lt;h2 id=&quot;officeparser-ftw&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#officeparser-ftw&quot;&gt;officeParser FTW&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;So here comes the fun part. Last weekend I had this demo completely done using a few different libraries. Then - earlier this week one of the developer newsletters I subscribe to shared &lt;a href=&quot;https://officeparser.harshankur.com/&quot;&gt;officeParser&lt;/a&gt;. This nifty library handles Office, PDF, even Open Office formats. It also includes the metadata for files which is handy as heck. I forked my initial demo and removed all the extra libraries, leaving only officeParser.&lt;/p&gt;
&lt;p&gt;The library can return incredibly detailed information about the structure of the your doc as well as a plain text view. What I found in my testing is that the plain text view didn&apos;t seem like it would work well in my demo. For example, an XLS file was kinda glommed all together. I reached out to the developer and he is planning on a &lt;code&gt;toMarkdown&lt;/code&gt; feature that will make this easier, but for now what I did was get the complex data and write my own custom code to &apos;shape&apos; it well for AI.&lt;/p&gt;
&lt;p&gt;Generally speaking the first part was stupid easy - and I got this from the docs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const getAST = async (file, config) =&amp;gt; (await OfficeParser.parseOffice(file, config));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now let&apos;s dig into the code a bit.&lt;/p&gt;
&lt;h2 id=&quot;working-with-docs&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#working-with-docs&quot;&gt;Working with Docs&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I&apos;m going to skip over the DOM manipulation aspects as that&apos;s not terribly interesting. My code basically has a file input field and when you select a file, a process is fired off to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;get the text, again, with formatting to hopefully make it better for AI&lt;/li&gt;
&lt;li&gt;pass it to the Summary API for... summarization. :)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Let&apos;s focus on the &amp;quot;get text&amp;quot; aspect. My file input handler has this logic:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;if(file.name.toLowerCase().endsWith(&apos;.doc&apos;) || file.name.toLowerCase().endsWith(&apos;.docx&apos;)) {
    summary = await processDoc(file);
} else if(file.name.toLowerCase().endsWith(&apos;.pdf&apos;)) {
    summary = await processPDF(file);
} else if(file.name.toLowerCase().endsWith(&apos;.ppt&apos;) || file.name.toLowerCase().endsWith(&apos;.pptx&apos;)) {
    summary = await processPPT(file);
    // i add a flag to powerpoint so my summary func knows it has to deal with the text
    summary.powerpoint = true;
} else if(file.name.toLowerCase().endsWith(&apos;.xls&apos;) || file.name.toLowerCase().endsWith(&apos;.xlsx&apos;)) {
    summary = await processXLS(file);
    // i add a flag to powerpoint so my summary func knows it has to deal with the text
    summary.excel = true;
} else {
    // in theory we can&apos;t get here, so just a return is fine
    return;
}

doSummary(summary);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My expectation is that the &lt;code&gt;summary&lt;/code&gt; will be an object with two fields: &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;title&lt;/code&gt;. I also use a flag for PowerPoint and Excel to help direct the Summary API to more properly handle the text.&lt;/p&gt;
&lt;p&gt;Now let&apos;s break down these functions. Doc and PDF are the easiest:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function processDoc(f) {
	let arrayBuffer = await f.arrayBuffer();
	let data = await getAST(arrayBuffer, {});
	
	return {
		text: data.toText(), 
		title:data.metadata?.title ?? &apos;No Title&apos;
	}
	
}

async function processPDF(f) {
	const arrayBuffer = await f.arrayBuffer();
	let data = await getAST(arrayBuffer, {});
	console.log(data, data.toText());

	return {
		text: data.toText(), 
		title:data.metadata?.title ?? &apos;No Title&apos;
	}

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For Excel, as I mentioned earlier I got the raw data and examined it, and then wrote some utility bits to turn my sheets into (roughly) CSV form:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function processXLS(f) {
	let arrayBuffer = await f.arrayBuffer();
	let data = await getAST(arrayBuffer, {});

	const getCSV = s =&amp;gt; {
		let result = &apos;&apos;;
		let rows = s.children.filter(c =&amp;gt; c.type === &apos;row&apos;);
		console.log(rows[0]);
		rows.forEach(r =&amp;gt; {
			let data = [];
			let cells = r.children.filter(c =&amp;gt; c.type === &apos;cell&apos;);
			cells.forEach(c =&amp;gt; data.push(c.text));
			result += data.join(&apos;, &apos;) + &apos;\n&apos;;

		});
		return result;
	};
	
	let result = {
		text:&apos;&apos;,
		title:data.metadata?.title ?? &apos;No Title&apos;
	}

	let sheets = data.content.filter(c =&amp;gt; c.type === &apos;sheet&apos;);
	sheets.forEach(s =&amp;gt; {
		result.text += getCSV(s);
		result.text += &apos;\n\n&apos;;
	});
	
	return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PowerPoint was a bit more complex as I had to create a separation between slides, and get deeply nested text nodes. I ignored anything that wasn&apos;t text.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function processPPT(f) {
	let arrayBuffer = await f.arrayBuffer();
	let data = await getAST(arrayBuffer, {});
	let result = {
		text:&apos;&apos;,
		title:data.metadata?.title ?? &apos;No Title&apos;
	}
	
	const getText = c =&amp;gt; {
		let result = &apos;&apos;;

		c.forEach(kid =&amp;gt; {
			if(kid.text) {
				result += kid.text;
				if(kid.type === &apos;paragraph&apos;) result += &apos;\n&apos;;
				else result += &apos; &apos;;
			}
			if(kid.children) {
				kid.children.forEach(gk =&amp;gt; {
					if(gk.text) {
						result += gk.text;
						if(gk.type === &apos;paragraph&apos;) result += &apos;\n&apos;;
						else result += &apos; &apos;;
					}
				});
			}
		});
		return result;
	}

	let content = data.content.filter(c =&amp;gt; c.type === &apos;slide&apos;);
	result.text = content.reduce((prev, cur) =&amp;gt; {
		return prev += getText(cur.children) + &apos;\n-------------\n&apos;;
	}, &apos;&apos;);

	return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing special here is that sometimes items on the same line were two nodes, hence me only adding newlines after paragraphs.&lt;/p&gt;
&lt;h2 id=&quot;the-fancy-ai&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-fancy-ai&quot;&gt;The Fancy AI&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The code to do summarization is something I&apos;ve shown before, the only thing I did unique here was try to warn the system about my PowerPoint and Excel data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function doSummary(summaryOb) {
	console.log(summaryOb);
	$output.innerHTML = &amp;quot;&amp;lt;i&amp;gt;File text extracted, working on the summary.&amp;quot;;
	let sharedContext = null;
	
	if(summaryOb.powerpoint) {
		sharedContext = &apos;This is extracted text from a Powerpoint file. Slides are separated by ----&apos;;
	} else if(summaryOb.excel) {
		sharedContext = &apos;This is extracted text from a Excel file in CSV format.&apos;
	}
	
	let summarizer = await window.Summarizer.create({
		type:&apos;tldr&apos;,
		length:&apos;long&apos;,
		sharedContext, 
		monitor(m) {
            m.addEventListener(&amp;quot;downloadprogress&amp;quot;, e =&amp;gt; {
                /*
                why this? the download event _always_ runs at
                least once, so this prevents the msg showing up
                when its already done. I&apos;ve seen it report 0 and 1
                in this case, so we skip both
                */
                if(e.loaded === 0 || e.loaded === 1) return;
                $output.innerHTML = `Downloading the Summary model, currently at ${Math.floor(e.loaded * 100)}%`;
            });
		}
	});


	try {
		let summary = await summarizer.summarize(summaryOb.text);
		$output.innerHTML = `&amp;lt;h3&amp;gt;Summary for ${summaryOb.title}&amp;lt;/h3&amp;gt;${marked.parse(summary)}`;
	} catch(e) {
		if(e.name === &apos;QuotaExceededError&apos;) {
			$output.innerHTML = &apos;Unfortunately this document was too large!&apos;;
		} else {
			$output.innerHTML = `Some other error was thrown: ${e}`;
		}
		console.log(e);
	}
	
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All in all, it worked well. It did feel like I filled the context window with Excel pretty quickly. My initial text file had 1000 rows and that threw a quota error. I had to get it down to 200 to properly parse.&lt;/p&gt;
&lt;p&gt;If you are on the latest Chrome, in theory, this will work for you, but as always, let me know!&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;600&quot; data-pen-title=&quot;Chrome AI, Doc Summaries (V2)&quot; data-preview=&quot;true&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;MYjxbrv&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height:600px; 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/cfjedimaster/pen/MYjxbrv&quot;&gt;
  Chrome AI, Doc Summaries (V2)&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="generative ai" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/04/11/testing-ocr-with-chrome-built-in-ai</id>
                <title>Testing OCR with Chrome Built-in AI</title>
                <updated>2026-04-11T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/11/testing-ocr-with-chrome-built-in-ai" rel="alternate" type="text/html" title="Testing OCR with Chrome Built-in AI"/>
                <content type="html">
				
                        &lt;p&gt;Sorry for the lack of posting this month. I&apos;m on the way back home from speaking at CodeStock so I&apos;ve been on the road a bit, and work has been incredibly busy (which is good!) so my usual blog cadence has slipped a bit. Luckily I had a great question in my session on &lt;a href=&quot;https://developer.chrome.com/docs/ai/built-in&quot;&gt;Chrome&apos;s Built-in AI&lt;/a&gt; which led to a bit of investigating last night. The question involved how well Chrome&apos;s AI could do OCR on an image. I had a demo in my presentation showing using AI to describe an image and another to generate a list of tags, but not one specifically for OCR. Here&apos;s what I found.&lt;/p&gt;
&lt;p&gt;Oh, before I get into the code - remember that as of the time I&apos;m writing this, the Prompt API in Chrome is still behind a flag. Check the &lt;a href=&quot;https://developer.chrome.com/docs/ai/prompt-api&quot;&gt;docs&lt;/a&gt; for what you need to enable in order to run my tests yourself.&lt;/p&gt;
&lt;h2 id=&quot;the-first-test&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-first-test&quot;&gt;The First Test&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;My initial plan was two-fold:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ask the built-in AI to find and return text from the image.&lt;/li&gt;
&lt;li&gt;Ask the built-in AI to return bounding boxes for text found in the AI.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To be clear, none of this is new in any way. People tend to forget we had AI-driven APIs for years before the GenAI explosion came about. Microsoft, Amazon, and more had basic APIs that covered exactly what I listed above.&lt;/p&gt;
&lt;p&gt;But being able to do it on device could be really compelling - especially in a case where a user may be offline.&lt;/p&gt;
&lt;p&gt;I started with a fork of my basic &amp;quot;what is this image&amp;quot; code: &lt;a href=&quot;https://codepen.io/cfjedimaster/pen/bNEMbrX&quot;&gt;https://codepen.io/cfjedimaster/pen/bNEMbrX&lt;/a&gt;. This demo lets you pick an image from your device, it renders a quick preview, and then runs this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function analyze() {
	$result.innerHTML = &apos;&apos;;
	if(!$imgFile.value) return;
	console.log(`Going to analyze ${$imgFile.value}`);

	if(!session) {
		console.log(&apos;creating the session&apos;);
		session = await LanguageModel.create({
		  expectedInputs:[{type: &apos;image&apos;}],
			monitor(m) {
        m.addEventListener(&amp;quot;downloadprogress&amp;quot;, e =&amp;gt; {
        	console.log(`Downloaded ${e.loaded * 100}%`);
	        /*
          why this? the download event _always_ runs at
          least once, so this prevents the msg showing up
          when its already done. I&apos;ve seen it report 0 and 1
          in this case, so we skip both
          */
          if(e.loaded === 0 || e.loaded === 1) return;
          $result.innerHTML = `Downloading, currently at ${Math.floor(e.loaded * 100)}%`;
        });
    	}	
		});
	}
	$result.innerHTML = &apos;Working on analyzing picture.&apos;;
	console.log(session);
	
	let result = await session.prompt([
		{
			role:&apos;user&apos;,
			content: [
				{ type:&apos;text&apos;, value:&apos;Create a summary description of the image.&apos;},
		  	{ type: &amp;quot;image&amp;quot;, value: $imgFile.files[0]}
			]
		}
	]);

	console.log(result);
	$result.innerHTML = result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This code block handles creating the session once and then passing the user&apos;s selected image to the model and return a description.&lt;/p&gt;
&lt;p&gt;My new version made a few changes. First, I defined a JSON Schema to structure my results into an array of text and bounding box values:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const schema = {
	type:&amp;quot;array&amp;quot;, 
	items: {
		type:&amp;quot;object&amp;quot;,
		properties: {
			text: { type: &amp;quot;string&amp;quot;, description: &amp;quot;Text found in picture&amp;quot; }, 
			topLeft: {
				type:&amp;quot;object&amp;quot;, 
				properties: {
					x: {
						type:&amp;quot;number&amp;quot;, description: &amp;quot;Top left horizonatal position of the bounding box for the found text.&amp;quot; 
					},
					y: {
						type:&amp;quot;number&amp;quot;, description: &amp;quot;Top left vertical position of the bounding box for the found text.&amp;quot; 
					},
				
				}
			},
			bottomRight: {
				type:&amp;quot;object&amp;quot;, 
				properties: {
					x: {
						type:&amp;quot;number&amp;quot;, description: &amp;quot;Bottom right horizonatal position of the bounding box for the found text.&amp;quot; 
					},
					y: {
						type:&amp;quot;number&amp;quot;, description: &amp;quot;Bottom right vertical position of the bounding box for the found text.&amp;quot; 
					},
				
				}
			},
			
		}
	}
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, I modified how I created the session, using a system instruction to help guide the results:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;session = await LanguageModel.create({
        expectedInputs:[{type: &apos;image&apos;}],
        initialInputs: [
            { 
                role: &apos;system&apos;, 
                content: 
                    &apos;Your job is to extract and find text in an image. Return each block of text along with a X,Y position for the top left and bottom right block that covers the image.&apos; 
            }
        ],
        monitor(m) {
            m.addEventListener(&amp;quot;downloadprogress&amp;quot;, e =&amp;gt; {
                /*
                why this? the download event _always_ runs at
                least once, so this prevents the msg showing up
                when its already done. I&apos;ve seen it report 0 and 1
                in this case, so we skip both
                */
                if(e.loaded === 0 || e.loaded === 1) return;
                $result.innerHTML = `Downloading, currently at ${Math.floor(e.loaded * 100)}%`;
        });
    }	
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And when I pass my image to the model, I include the schema:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let result = await session.prompt([
    {
        role:&apos;user&apos;,
        content: [
            { type: &amp;quot;image&amp;quot;, value: $imgFile.files[0]}
        ]
    }
], { responseConstraint: schema });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At this point, I could get a quick result. Given this image:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/devtn.jpg&quot; loading=&quot;lazy&quot; alt=&quot;MCP as Your CMS Helper&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;I got this result:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;[
  {
    &amp;quot;text&amp;quot;: &amp;quot;MCP as Your CMS Helper&amp;quot;,
    &amp;quot;topLeft&amp;quot;: {
      &amp;quot;x&amp;quot;: 150,
      &amp;quot;y&amp;quot;: 100
    },
    &amp;quot;bottomRight&amp;quot;: {
      &amp;quot;x&amp;quot;: 340,
      &amp;quot;y&amp;quot;: 100
    }
  },
  {
    &amp;quot;text&amp;quot;: &amp;quot;Webflow Developers&amp;quot;,
    &amp;quot;topLeft&amp;quot;: {
      &amp;quot;x&amp;quot;: 100,
      &amp;quot;y&amp;quot;: 300
    },
    &amp;quot;bottomRight&amp;quot;: {
      &amp;quot;x&amp;quot;: 210,
      &amp;quot;y&amp;quot;: 300
    }
  },
  {
    &amp;quot;text&amp;quot;: &amp;quot;&amp;lt;&amp;gt;&amp;quot;,
    &amp;quot;topLeft&amp;quot;: {
      &amp;quot;x&amp;quot;: 500,
      &amp;quot;y&amp;quot;: 150
    },
    &amp;quot;bottomRight&amp;quot;: {
      &amp;quot;x&amp;quot;: 600,
      &amp;quot;y&amp;quot;: 250
    }
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The text values were perfect. I mean heck, it picked up the &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt; bit which is technically text. However, the bounding boxes were off. Now, I&apos;m not convinced this isn&apos;t my fault. I checked, and double checked, the way I worded the schema, and as far as I know I&apos;m asking the right question, but the boxes never seemed to actually match the area of the text.&lt;/p&gt;
&lt;p&gt;I decided to give up on the idea of requesting the boxes but may come back to it later. (Especially if an eagle-eyed reader finds a dumb mistake I made.)&lt;/p&gt;
&lt;p&gt;You can see this full demo below:&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;500&quot; data-pen-title=&quot;Testing Chrome OCR Capabilities (2)&quot; data-preview=&quot;true&quot; data-default-tab=&quot;js&quot; data-slug-hash=&quot;wBzYEbJ&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/cfjedimaster/pen/wBzYEbJ&quot;&gt;
  Testing Chrome OCR Capabilities (2)&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;h2 id=&quot;a-quick-aside&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#a-quick-aside&quot;&gt;A Quick Aside&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;In the demo above, if you can actually run it, I added a quick utility to draw the bounding boxes on the image. This utility was created by Claude Code and it actually works &lt;em&gt;really&lt;/em&gt; well. You begin by wrapping the image from the DOM:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const img = document.querySelector(&apos;#my-image&apos;);
const annotator = makeImageAnnotator(img);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then just draw your rectangles:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// Draw a box: upper-left (50, 30), lower-right (200, 150)
annotator.drawBox(50, 30, 200, 150);

// Optional style overrides
annotator.drawBox(10, 10, 80, 80, { color: &apos;red&apos;, lineWidth: 3 });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There&apos;s even a reset:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// Clear all boxes
annotator.clearBoxes();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I built a CodePen just for this little utility and as there&apos;s no use of Chrome AI in here, it should work fine for everyone:&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;500&quot; data-pen-title=&quot;Annotator Test&quot; data-preview=&quot;true&quot; data-version=&quot;2&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;KwgGjMj&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/019d7c9d-8344-770f-8c07-15adb9b196e1&quot;&gt;
  Annotator Test&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;One note - if you make use of this - notice I check to ensure the image is done loading before I annotate. That&apos;s required for this utility.&lt;/p&gt;
&lt;h2 id=&quot;the-second-test&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-second-test&quot;&gt;The Second Test&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;So - giving up on the idea of creating bounding boxes, I pivoted to a new version that focused on &lt;em&gt;just&lt;/em&gt; the text. First, my schema got a lot simpler:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const schema = {
	type:&amp;quot;array&amp;quot;, 
	items: {
		type:&amp;quot;string&amp;quot;,
		description:&amp;quot;The extracted text.&amp;quot;
	}
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then I simplified my system instruction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;session = await LanguageModel.create({
    expectedInputs:[{type: &apos;image&apos;}],
    initialInputs: [
        { 
            role: &apos;system&apos;, 
            content: 
                &apos;Your job is to extract and find text in an image. An image may have multiple blocks of text. Return each block of text.&apos; 
        }
    ],
    monitor(m) {
        m.addEventListener(&amp;quot;downloadprogress&amp;quot;, e =&amp;gt; {
	        /*
          why this? the download event _always_ runs at
          least once, so this prevents the msg showing up
          when its already done. I&apos;ve seen it report 0 and 1
          in this case, so we skip both
          */
          if(e.loaded === 0 || e.loaded === 1) return;
          $result.innerHTML = `Downloading, currently at ${Math.floor(e.loaded * 100)}%`;
        });
    }	
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then I ran some tests. First, a super simple stop sign:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/stop.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Stop sign&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Which returned... &amp;quot;STOP&amp;quot;. Perfect. Stupid simple, but perfect.&lt;/p&gt;
&lt;p&gt;Next is a sign filled with more signs:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/restsign.png&quot; loading=&quot;lazy&quot; alt=&quot;Highway sign with restaurants&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;The result was pretty impressive (line breaks you see below were from me):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FOOD - EXIT 14, Burger King, Olive Garden ITALIAN RESTAURANT, 
Izzy&apos;s CLASSIC BUFFET, McMenamins SUNNYSIDE PUB, McDonald&apos;s, 
Wendy&apos;s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, I tested that first image (the MCP as Your CMS Helper one above) and oddly... the results weren&apos;t quite as good. Sometimes I only got &amp;quot;MCP as Your CMS Helper&amp;quot;.  That&apos;s the primary text so I guess that&apos;s good, but I was surprised it missed the other blocks. And yet other times I&apos;d get a bit more, like just now when I tested I got &amp;quot;Webflow Developers&amp;quot;. Just seems odd that this version of the script sometimes returns a bit less than the one where I tried to annotate. Maybe it would make sense to keep the same schema and simply &lt;em&gt;ignore&lt;/em&gt; the bounding box values, but that feels wrong.&lt;/p&gt;
&lt;p&gt;You can see the full code of this below:&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;500&quot; data-pen-title=&quot;Testing Chrome OCR Capabilities (3)&quot; data-preview=&quot;true&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;dPpggYo&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/cfjedimaster/pen/dPpggYo&quot;&gt;
  Testing Chrome OCR Capabilities (3)&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;h2 id=&quot;the-final-boss&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-final-boss&quot;&gt;The Final Boss&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;As a last test, I tried something pretty intense - a JPG export from a PDF with lots of dense text. You can see it here, and honestly, it&apos;s a bit too small for even me to read:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/rules.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Page from DND Manual&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;The results were not as good:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HOW TO PLAY, Part 1: The Basic Rules, Part 2: Using These Rules, 
Part 3: The Role of the Dungeon Master, Part 4: Character Creation, 
GAME DICE, INTRODUCTION
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I did try a larger and higher resolution version (about 4x the size) and did not see a noticeable change in results. I don&apos;t think we will be using this API to OCR books anytime soon, but it did seem to do a reasonable job with signs and such. I&apos;d call that a fair bargain for something running on the device without needing a remote API.&lt;/p&gt;
&lt;p&gt;As always, let me know what you think. Also, I should note that &lt;a href=&quot;https://huggingface.co/docs/transformers.js/en/index&quot;&gt;Transformers.js&lt;/a&gt; can also support this. I haven&apos;t tested it myself, but here&apos;s a &lt;a href=&quot;https://discuss.huggingface.co/t/texo-an-in-browser-latex-ocr-model-built-on-transformers-and-transformers-js/169634&quot;&gt;post from their community&lt;/a&gt; showing an example. You can run their demo here: &lt;a href=&quot;https://texocr.netlify.app/&quot;&gt;https://texocr.netlify.app/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@mosdesign?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;mos design&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/a-picture-of-a-cat-on-a-billboard-in-a-city-SDJLR8UH44U?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/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>
        
</feed>