<?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-10-12T21:04:45+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/10/12/adding-google-auth-to-tinyjs</id>
                <title>Adding Google Auth to TinyJS</title>
                <updated>2026-10-12T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/10/12/adding-google-auth-to-tinyjs" rel="alternate" type="text/html" title="Adding Google Auth to TinyJS"/>
                <content type="html">
				
                        &lt;p&gt;A few days ago I &lt;a href=&quot;https://www.raymondcamden.com/2026/10/08/tiny-apps-with-tinyjs&quot;&gt;posted&lt;/a&gt; about my experience playing around with &lt;a href=&quot;https://tinyjs.app/&quot;&gt;TinyJS&lt;/a&gt;. This is a &lt;em&gt;very&lt;/em&gt; light &amp;quot;web to desktop app&amp;quot; platform for - well - building desktop apps. Its simplicity and similarity to PhoneGap (at least for the desktop) has got me interested in seeing what I can build with the project. One of my ideas would be improved with a login system so I naturally looked into adding a &amp;quot;Login with Google&amp;quot; feature. While this was &lt;em&gt;mostly&lt;/em&gt; straightforward, I ran into some oddities and figured I&apos;d share it here.&lt;/p&gt;
&lt;p&gt;I went into this exploration with a basic idea of how I&apos;d approach it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create an app in the Google Cloud console to represent my app, getting the client id and secret.&lt;/li&gt;
&lt;li&gt;Generate a link using the standard OAuth flow&lt;/li&gt;
&lt;li&gt;For my redirect URL, use a custom URI scheme (like &lt;code&gt;mycustomapp://&lt;/code&gt;) so that on redirect from Google, it would open the app automatically&lt;/li&gt;
&lt;li&gt;The response would include the code I&apos;d exchange for an access token (and refresh token), at which point I could get the user&apos;s profile and have their name and email address. I&apos;m only thinking of that as my mental model for this little POC is &lt;em&gt;just&lt;/em&gt; login, not using other Google services.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Right away I hit a couple of snags.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Google&apos;s OAuth flow specifically blocks the use of a custom URI scheme. Instead, you need to hit a locally running web server which would handle getting and verifying the code. I&apos;ve actually seen this before, you end up with a new tab in your browser that says you can close the tab, but I had never implemented it before.&lt;/li&gt;
&lt;li&gt;The next bit isn&apos;t a snag per se, but in my research, I found that in most places, your desktop app can include just the client id, but for Google, you must include the secret as well. Obviously that makes me nervous as heck, but again, this is apparently a well known thing for desktop apps that authenticate with Google. While I don&apos;t feel 100% certain about this, I asked multiple agents, Googled, and so forth, and they all seemed to be in agreement. Absolutely correct me in a comment below if I&apos;m wrong.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Alright, so with all of that in mind - I want to once again thank the author of the program for pointing me to the &lt;a href=&quot;https://github.com/tarwin/tinyjsapp-examples/tree/main/platter&quot;&gt;platter demo&lt;/a&gt; which implements the local web server mentioned above. I was able to point my coding agent to that when working through the demo.&lt;/p&gt;
&lt;p&gt;I have &lt;em&gt;not&lt;/em&gt; put my code into a GitHub repo, but will include everything below. As this was just a POC I didn&apos;t feel like it was necessary, but if you want a zip, just ask. Nicely. Or not. I&apos;ll still send it.&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;Ok, let me start with the simplest part, the HTML. I&apos;m not using Alpine for this, or any real CSS. It&apos;s just this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html&amp;gt;
&amp;lt;head&amp;gt;
&amp;lt;meta charset=&amp;quot;utf-8&amp;quot;&amp;gt;
&amp;lt;title&amp;gt;__NAME__&amp;lt;/title&amp;gt;
&amp;lt;link rel=&amp;quot;stylesheet&amp;quot; href=&amp;quot;style.css&amp;quot;&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;

&amp;lt;main&amp;gt;
  &amp;lt;h1&amp;gt;Google sign-in Test&amp;lt;/h1&amp;gt;
  &amp;lt;a id=&amp;quot;login-link&amp;quot; href=&amp;quot;#&amp;quot;&amp;gt;Login with Google&amp;lt;/a&amp;gt;
  &amp;lt;section id=&amp;quot;profile&amp;quot; aria-live=&amp;quot;polite&amp;quot; hidden&amp;gt;
    &amp;lt;h2&amp;gt;Signed in&amp;lt;/h2&amp;gt;
    &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Name:&amp;lt;/strong&amp;gt; &amp;lt;span id=&amp;quot;profile-name&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/p&amp;gt;
    &amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Email:&amp;lt;/strong&amp;gt; &amp;lt;span id=&amp;quot;profile-email&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/p&amp;gt;
  &amp;lt;/section&amp;gt;
  &amp;lt;p id=&amp;quot;login-error&amp;quot; role=&amp;quot;alert&amp;quot; hidden&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;/main&amp;gt;

&amp;lt;script src=&amp;quot;app.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I&apos;ve got a login link (blank, that gets filled in later), a section that&apos;s displayed when you are logged in, and a block for errors. The login link should be hidden as well (I&apos;ll explain why later), but again I was trying to keep this simple.&lt;/p&gt;
&lt;p&gt;Now let&apos;s look at the JavaScript, and this is a bit of a mess. With a proper framework like Alpine it would be better organized, but it gets the job done.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const CLIENT_ID = &amp;quot;safe but why not be sure&amp;quot;;

const REDIRECT_URI = &amp;quot;http://127.0.0.1:8765/callback&amp;quot;;
const TOKENS_KEY = &amp;quot;google-oauth-tokens&amp;quot;;

async function makeLoginUrl() {
  const b64url = (buf) =&amp;gt;
    btoa(String.fromCharCode(...new Uint8Array(buf)))
      .replace(/\+/g, &amp;quot;-&amp;quot;).replace(/\//g, &amp;quot;_&amp;quot;).replace(/=+$/, &amp;quot;&amp;quot;);

  const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
  const challenge = b64url(
    await crypto.subtle.digest(&amp;quot;SHA-256&amp;quot;, new TextEncoder().encode(verifier))
  );
  const state = b64url(crypto.getRandomValues(new Uint8Array(16)));

  // keep these for the token exchange step
  sessionStorage.setItem(&amp;quot;pkce_verifier&amp;quot;, verifier);
  sessionStorage.setItem(&amp;quot;oauth_state&amp;quot;, state);

  const params = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    response_type: &amp;quot;code&amp;quot;,
    scope: &amp;quot;openid email profile&amp;quot;,
    access_type: &amp;quot;offline&amp;quot;,
    prompt: &amp;quot;consent&amp;quot;,
    code_challenge: challenge,
    code_challenge_method: &amp;quot;S256&amp;quot;,
    state,
  });

  return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}

document.addEventListener(&amp;quot;DOMContentLoaded&amp;quot;, async () =&amp;gt; {
  const loginLink = document.querySelector(&amp;quot;#login-link&amp;quot;);

  try {
    const savedTokens = await tiny.app.secrets.get(TOKENS_KEY);
    if (savedTokens) {
      try {
        const session = await tiny.api.call(&amp;quot;restoreSession&amp;quot;, {
          tokens: JSON.parse(savedTokens),
          clientId: CLIENT_ID,
        });
        await tiny.app.secrets.set(TOKENS_KEY, JSON.stringify(session.tokens));
        showProfile(session.profile);
        return;
      } catch {
        await tiny.app.secrets.delete(TOKENS_KEY);
      }
    }
    await prepareLogin(loginLink);
  } catch (error) {
    showError(`Google sign-in failed: ${error.message}`);
  }
});

async function prepareLogin(loginLink) {
  const url = await makeLoginUrl();
  const state = sessionStorage.getItem(&amp;quot;oauth_state&amp;quot;);
  const codeVerifier = sessionStorage.getItem(&amp;quot;pkce_verifier&amp;quot;);
  loginLink.href = url;
  loginLink.target = &amp;quot;_blank&amp;quot;;
  loginLink.rel = &amp;quot;noopener&amp;quot;;

  loginLink.onclick = async () =&amp;gt; {
    try {
      const session = await tiny.api.call(&amp;quot;waitForCode&amp;quot;, {
        port: 8765,
        state,
        codeVerifier,
        clientId: CLIENT_ID,
      });
      await tiny.app.secrets.set(TOKENS_KEY, JSON.stringify(session.tokens));
      showProfile(session.profile);
    } catch (error) {
      showError(`Google sign-in failed: ${error.message}`);
      await prepareLogin(loginLink);
    }
  };
}

function showProfile(profile) {
  document.querySelector(&amp;quot;#profile-name&amp;quot;).textContent = profile.name || &amp;quot;Not provided&amp;quot;;
  document.querySelector(&amp;quot;#profile-email&amp;quot;).textContent = profile.email || &amp;quot;Not provided&amp;quot;;
  document.querySelector(&amp;quot;#profile&amp;quot;).hidden = false;
  document.querySelector(&amp;quot;#login-link&amp;quot;).hidden = true;
  document.querySelector(&amp;quot;#login-error&amp;quot;).hidden = true;
}

function showError(message) {
  const errorOutput = document.querySelector(&amp;quot;#login-error&amp;quot;);
  errorOutput.textContent = message;
  errorOutput.hidden = false;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I start off with a utility function to generate my login link. Note that redirect URI - it&apos;s going to be hosted by my app via the backend script and makes an assumption on a port that&apos;s free. A better version would try a set of ports to see which can be used.&lt;/p&gt;
&lt;p&gt;In my &lt;code&gt;DOMContentLoaded&lt;/code&gt; block I&apos;ve got multiple things going on.&lt;/p&gt;
&lt;p&gt;First off - I look at a secure storage system for an existing access token. This is documented in the &lt;a href=&quot;https://tinyjs.app/docs.html#secrets&quot;&gt;secrets and auth&lt;/a&gt; section of TinyJS&apos;s docs. If the values exist, a call to the backend is made to try to verify the values and treat the user as already logged in.&lt;/p&gt;
&lt;p&gt;If this fails, or the values weren&apos;t there, we:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Get the login URL and update the DOM&lt;/li&gt;
&lt;li&gt;Tell the backend to fire up the HTTP server&lt;/li&gt;
&lt;li&gt;And then wait for a success.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The final two functions just display the profile or an error. Now let&apos;s look at the backend:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// Yes, I said it was safe, still, why share it here?
const GOOGLE_CLIENT_SECRET = &amp;quot;my secret brings the boys to the yard&amp;quot;;

async function exchangeCodeForToken({ code, codeVerifier, clientId, port }) {
  const response = await fetch(&amp;quot;https://oauth2.googleapis.com/token&amp;quot;, {
    method: &amp;quot;POST&amp;quot;,
    headers: {
      &amp;quot;content-type&amp;quot;: &amp;quot;application/x-www-form-urlencoded&amp;quot;,
    },
    body: new URLSearchParams({
      code,
      client_id: clientId,
      client_secret: GOOGLE_CLIENT_SECRET,
      redirect_uri: `http://127.0.0.1:${port}/callback`,
      grant_type: &amp;quot;authorization_code&amp;quot;,
      code_verifier: codeVerifier,
    }),
  });
  const tokens = await response.json();
  if (!response.ok || tokens.error) {
    throw new Error(tokens.error_description || tokens.error || `Token request failed (${response.status}).`);
  }
  return tokens;
}

async function refreshAccessToken(refreshToken, clientId) {
  const response = await fetch(&amp;quot;https://oauth2.googleapis.com/token&amp;quot;, {
    method: &amp;quot;POST&amp;quot;,
    headers: {
      &amp;quot;content-type&amp;quot;: &amp;quot;application/x-www-form-urlencoded&amp;quot;,
    },
    body: new URLSearchParams({
      client_id: clientId,
      client_secret: GOOGLE_CLIENT_SECRET,
      refresh_token: refreshToken,
      grant_type: &amp;quot;refresh_token&amp;quot;,
    }),
  });
  const tokens = await response.json();
  if (!response.ok || tokens.error) {
    throw new Error(tokens.error_description || tokens.error || `Token refresh failed (${response.status}).`);
  }
  return tokens;
}

function expiresAt(tokens) {
  const expiresIn = Number(tokens.expires_in);
  if (!tokens.access_token || !Number.isFinite(expiresIn) || expiresIn &amp;lt;= 0) {
    throw new Error(&amp;quot;Google returned an invalid access token response.&amp;quot;);
  }
  return Date.now() + expiresIn * 1000;
}

async function getUserProfile(accessToken) {
  const response = await fetch(&amp;quot;https://openidconnect.googleapis.com/v1/userinfo&amp;quot;, {
    headers: {
      authorization: `Bearer ${accessToken}`,
    },
  });
  const profile = await response.json();
  if (!response.ok) {
    throw new Error(profile.error_description || profile.error || `Profile request failed (${response.status}).`);
  }
  return {
    name: profile.name || &amp;quot;&amp;quot;,
    email: profile.email || &amp;quot;&amp;quot;,
  };
}

async function restoreSession(tokens, clientId) {
  if (!tokens?.access_token || !tokens?.refresh_token || !tokens?.expires_at) {
    throw new Error(&amp;quot;Saved sign-in is incomplete.&amp;quot;);
  }

  let refreshedTokens = {};
  if (tokens.expires_at &amp;lt;= Date.now() + 60000) {
    refreshedTokens = await refreshAccessToken(tokens.refresh_token, clientId);
  }

  const updatedTokens = {
    access_token: refreshedTokens.access_token || tokens.access_token,
    refresh_token: refreshedTokens.refresh_token || tokens.refresh_token,
    expires_at: refreshedTokens.access_token
      ? expiresAt(refreshedTokens)
      : tokens.expires_at,
  };
  const profile = await getUserProfile(updatedTokens.access_token);
  return { profile, tokens: updatedTokens };
}

async function waitForCode(port, expectedState, codeVerifier, clientId) {
  return new Promise((resolve, reject) =&amp;gt; {
    let server;
    let timer;
    let settled = false;
    let processing = false;

    const stopServer = () =&amp;gt; {
      if (timer) clearTimeout(timer);
      if (!server) return;
      const activeServer = server;
      server = null;
      try {
        activeServer.close();
      } catch (error) {
        console.error(&amp;quot;Failed to close OAuth callback server:&amp;quot;, error);
      }
    };

    const finish = (error, code, closeImmediately = false) =&amp;gt; {
      if (settled) return;
      settled = true;
      if (timer) clearTimeout(timer);
      if (closeImmediately) stopServer();
      else setTimeout(stopServer, 1000);
      if (error) reject(error);
      else resolve(code);
    };

    const escapeHtml = (value) =&amp;gt; String(value)
      .replace(/&amp;amp;/g, &amp;quot;&amp;amp;amp;&amp;quot;)
      .replace(/&amp;lt;/g, &amp;quot;&amp;amp;lt;&amp;quot;)
      .replace(/&amp;gt;/g, &amp;quot;&amp;amp;gt;&amp;quot;)
      .replace(/&amp;quot;/g, &amp;quot;&amp;amp;quot;&amp;quot;)
      .replace(/&apos;/g, &amp;quot;&amp;amp;#39;&amp;quot;);
    const htmlResponse = (message, status = 200) =&amp;gt; new Response(
      `&amp;lt;html&amp;gt;&amp;lt;body&amp;gt;&amp;lt;h3&amp;gt;${escapeHtml(message)}&amp;lt;/h3&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;`,
      {
        status,
        headers: {
          &amp;quot;content-type&amp;quot;: &amp;quot;text/html; charset=utf-8&amp;quot;,
          &amp;quot;content-security-policy&amp;quot;: &amp;quot;default-src &apos;none&apos;; style-src &apos;unsafe-inline&apos;&amp;quot;,
        },
      }
    );

    server = tjs.serve({
      port,
      fetch: async (request) =&amp;gt; {
        let url;
        try {
          url = new URL(request.url);
        } catch {
          return htmlResponse(&amp;quot;Invalid callback request.&amp;quot;, 400);
        }

        if (url.pathname !== &amp;quot;/callback&amp;quot;) return htmlResponse(&amp;quot;Not found.&amp;quot;, 404);
        if (settled) return htmlResponse(&amp;quot;This login request has already been processed.&amp;quot;, 410);
        if (processing) return htmlResponse(&amp;quot;This login request is being processed.&amp;quot;, 409);
        if (url.searchParams.get(&amp;quot;state&amp;quot;) !== expectedState) {
          return htmlResponse(&amp;quot;State mismatch. Return to the app and try again.&amp;quot;, 400);
        }

        const oauthError = url.searchParams.get(&amp;quot;error&amp;quot;);
        if (oauthError) {
          finish(new Error(oauthError));
          return htmlResponse(&amp;quot;Login failed: &amp;quot; + oauthError);
        }

        const code = url.searchParams.get(&amp;quot;code&amp;quot;);
        if (!code) {
          finish(new Error(&amp;quot;OAuth callback did not include an authorization code.&amp;quot;));
          return htmlResponse(&amp;quot;Login failed: authorization code was missing.&amp;quot;, 400);
        }

        processing = true;
        try {
          const tokens = await exchangeCodeForToken({
            code,
            codeVerifier,
            clientId,
            port,
          });
          const profile = await getUserProfile(tokens.access_token);
          finish(null, {
            profile,
            tokens: {
              access_token: tokens.access_token,
              refresh_token: tokens.refresh_token,
              expires_at: expiresAt(tokens),
            },
          });
          return htmlResponse(&amp;quot;Login complete. You can close this tab.&amp;quot;);
        } catch (error) {
          finish(error);
          return htmlResponse(&amp;quot;Token exchange failed: &amp;quot; + error.message, 502);
        }
      },
    });

    timer = setTimeout(() =&amp;gt; {
      finish(new Error(&amp;quot;Timed out waiting for the OAuth callback.&amp;quot;), null, true);
    }, 120000);
  });
}

export const api = {
  restoreSession: ({ tokens, clientId }) =&amp;gt; {
    if (!clientId) throw new Error(&amp;quot;Google client ID is required.&amp;quot;);
    return restoreSession(tokens, clientId);
  },
  waitForCode: ({ port, state, codeVerifier, clientId }) =&amp;gt; {
    if (!codeVerifier || !clientId) {
      throw new Error(&amp;quot;OAuth code verifier and client ID are required.&amp;quot;);
    }
    return waitForCode(port, state, codeVerifier, clientId);
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This one&apos;s a bit of a beast, but some are fairly standard. Both &lt;code&gt;exchangeCodeForToken&lt;/code&gt; and &lt;code&gt;refreshAccessToken&lt;/code&gt; are standard OAuth stuff. Ditto for &lt;code&gt;getUserProfile&lt;/code&gt;. The important bit is &lt;code&gt;waitForCode&lt;/code&gt;. This is where the small web server is kicked off that attempts to handle the redirect back from Google. I said up front I used AI to build most of this and I have to be honest, I&apos;m glad I did as this part would have taken me quite some time.&lt;/p&gt;
&lt;p&gt;All in all - it works great. The only real issue is a nit-picky one - when the app loads you see the login link for a second before the stored auth is verified. A bit of UX magic there would handle that.&lt;/p&gt;
&lt;p&gt;There&apos;s an important part left out of this that I&apos;ll need to tackle next - which is how I would take the verified identity here and pass it to a remote service in a secure way such that the remote service can also verify it (&amp;quot;Yes, this was Raymond Camden&amp;quot;) before performing an action.&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/10/08/tiny-apps-with-tinyjs</id>
                <title>Tiny Apps with TinyJS</title>
                <updated>2026-10-08T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/10/08/tiny-apps-with-tinyjs" rel="alternate" type="text/html" title="Tiny Apps with TinyJS"/>
                <content type="html">
				
                        &lt;p&gt;About a year ago, I was still running my live stream (&lt;a href=&quot;https://www.youtube.com/watch?v=BzRUsvAR4oQ&amp;amp;list=PL_z-rqJYNijqhtPcEbwacp34TiFfb8eyO&quot;&gt;Code Break&lt;/a&gt;) and my final three streams all covered one topic - building desktop applications with non-native code. I covered &lt;a href=&quot;https://v2.tauri.app/&quot;&gt;Tauri&lt;/a&gt; (HTML on the front, Rust on the back) and &lt;a href=&quot;https://flet.dev/&quot;&gt;Flet&lt;/a&gt; (all Python). Recently I came across another option, &lt;a href=&quot;https://tinyjs.app/&quot;&gt;tinyjs&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;TinyJS (technically the name is &amp;quot;tinyjs&amp;quot; but I can&apos;t make myself use the lowercase in my writing like that ;) is a lot like PhoneGap/Apache Cordova. Your application is the system web view shipped along with the ability to make system calls via a bridge to &lt;a href=&quot;https://txikijs.org/&quot;&gt;txiki.js&lt;/a&gt;. This bridge gives you full access to the file system and more, like any other native app.&lt;/p&gt;
&lt;p&gt;Unlike PhoneGap, this is &lt;em&gt;just&lt;/em&gt; for desktop apps, which is fine, but I think the biggest selling point is the size of the bundled app - typically around 6 megs. That&apos;s tiny versus something like Electron.&lt;/p&gt;
&lt;p&gt;It&apos;s got hooks into everything a desktop app could need, like native dialogs and OS menus and such. It can write securely to the OS&apos;s credential store, use SQLite, and even handle application updating.&lt;/p&gt;
&lt;p&gt;I&apos;ve just begun playing with it recently and I&apos;ve got a few ideas for apps I want to build, but I thought I&apos;d share a quick demo. This one&apos;s a bit ugly, and to be clear, that&apos;s on me. As it&apos;s a web view, you&apos;ve got full control over how the end result looks. I was just going for something quick and dirty in my test.&lt;/p&gt;
&lt;h2 id=&quot;first-steps&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#first-steps&quot;&gt;First Steps&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;After doing a quick &lt;a href=&quot;https://tinyjs.app/docs.html#install&quot;&gt;install&lt;/a&gt;, you can scaffold at the command line with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tinyjs new appname
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By default this scaffolds out a vanilla application (no React default, woot!) with a few files and folders - the important ones (at least initially while testing and learning) are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;frontend - the UI/UX layer (HTML, CSS, JS)&lt;/li&gt;
&lt;li&gt;main.js - a &amp;quot;backend&amp;quot; file that lets you build services you expose to your front end code. This is where you do &amp;quot;system&amp;quot; stuff your frontend JavaScript can&apos;t do.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As an example, your backend code could use this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const api: Record&amp;lt;string, TinyApiHandler&amp;gt; = {
  hello: async ({ name }: { name: string }) =&amp;gt; &apos;hi &apos; + name + &apos; — from the backend&apos;,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which would then be available in the frontend as &lt;code&gt;await tiny.api.call(&apos;hello&apos;, {...})&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Running the app is done via &lt;code&gt;tinyjs dev&lt;/code&gt; which will load the app and enable HMR. In my testing, reloading was &lt;em&gt;incredibly&lt;/em&gt; quick, but I didn&apos;t build anything large (yet).&lt;/p&gt;
&lt;h2 id=&quot;working-with-alpine&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#working-with-alpine&quot;&gt;Working with Alpine&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The CLI has got a number of different scaffolds you can use, including Alpine. (And I should say - huge thanks to &lt;a href=&quot;https://tarwin.art/&quot;&gt;tarwin&lt;/a&gt;, the creator - who answered a lot of my questions via email and added stuff I asked for left and right!) I scaffolded one like that and one thing I&apos;ll note - the scaffold for different frameworks will differ a bit from the simple app you get by default. Each project has a &lt;code&gt;tinyjs.json&lt;/code&gt; file which has metadata for the project, including where your backend file is and how the frontend works.&lt;/p&gt;
&lt;p&gt;For my simple test, I wanted to check a few things. First, is CORS still enforced when using &lt;code&gt;fetch&lt;/code&gt;? The short answer is yes, it is. The longer answer is that doesn&apos;t matter as you can use &lt;code&gt;tiny.fetch&lt;/code&gt; instead. I thought it would be fun then to build a simple &amp;quot;RSS to HTML&amp;quot; application where I could enter an RSS feed and render the items.&lt;/p&gt;
&lt;p&gt;I copied the browser distributable for the &lt;a href=&quot;https://www.npmjs.com/package/rss-parser&quot;&gt;&lt;code&gt;rss-parser&lt;/code&gt;&lt;/a&gt; project and simply included it in my HTML via a script tag. Here&apos;s the entirety of that file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot; /&amp;gt;
    &amp;lt;link rel=&amp;quot;icon&amp;quot; type=&amp;quot;image/svg+xml&amp;quot; href=&amp;quot;/favicon.svg&amp;quot; /&amp;gt;
    &amp;lt;meta name=&amp;quot;viewport&amp;quot; content=&amp;quot;width=device-width, initial-scale=1.0&amp;quot; /&amp;gt;
    &amp;lt;title&amp;gt;RSS Parser&amp;lt;/title&amp;gt;
  &amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;
    &amp;lt;section id=&amp;quot;center&amp;quot; x-data=&amp;quot;hello&amp;quot;&amp;gt;
      &amp;lt;h1&amp;gt;RSS Parser&amp;lt;/h1&amp;gt;
      &amp;lt;input x-model=&amp;quot;url&amp;quot; placeholder=&amp;quot;RSS feed URL&amp;quot; /&amp;gt; &amp;lt;button @click=&amp;quot;parse&amp;quot;&amp;gt;Parse&amp;lt;/button&amp;gt;
      &amp;lt;template x-if=&amp;quot;items.length &amp;gt; 0&amp;quot;&amp;gt;
        &amp;lt;div&amp;gt;
        &amp;lt;h2&amp;gt;Items&amp;lt;/h2&amp;gt;
        &amp;lt;ul&amp;gt;
          &amp;lt;template x-for=&amp;quot;item in items&amp;quot; :key=&amp;quot;item.link&amp;quot;&amp;gt;
            &amp;lt;li&amp;gt;
              &amp;lt;a :href=&amp;quot;item.link&amp;quot; target=&amp;quot;_blank&amp;quot; x-text=&amp;quot;item.title&amp;quot;&amp;gt;&amp;lt;/a&amp;gt;
            &amp;lt;/li&amp;gt;
          &amp;lt;/template&amp;gt;
        &amp;lt;/ul&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/template&amp;gt;
    &amp;lt;/section&amp;gt;
    &amp;lt;script src=&amp;quot;/src/rss-parser.min.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
    &amp;lt;script type=&amp;quot;module&amp;quot; src=&amp;quot;/src/main.ts&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s all regular Alpine.js stuff, nothing fancy there. Now for the JavaScript:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import &apos;./style.css&apos;;
import &apos;simpledotcss/simple.min.css&apos;;

import Alpine from &apos;alpinejs&apos;;

Alpine.data(&apos;hello&apos;, () =&amp;gt; ({
  name: &apos;world&apos;,
  url: &apos;https://www.raymondcamden.com/feed.xml&apos;,
  items: [],
  async init() {
  },
  async parse() {
    const parser = new RSSParser();
    let req = await tiny.fetch(this.url);
    let res = await req.text();
    let feed = await parser.parseString(res);
    this.items = feed.items;
  },
  async ask() {
    // Every function in the backend&apos;s api object is callable from the page.
    this.reply = await tiny.api.call(&apos;hello&apos;, { name: this.name })
  },
}))

Alpine.start()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few things to note here - I&apos;ve got some junk in the file still, like the &lt;code&gt;ask&lt;/code&gt; function which was a leftover from the scaffold. The important bit is the &lt;code&gt;parse&lt;/code&gt; function. The RSSParser object has the ability to parse a URL, but it uses fetch and is blocked by CORS. So instead I get the XML via &lt;code&gt;tiny.fetch&lt;/code&gt; and pass it to &lt;code&gt;parseString&lt;/code&gt;. And that&apos;s literally it. Here&apos;s a screenshot of the application running:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/10/tj1.png&quot; loading=&quot;lazy&quot; alt=&quot;App running&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;h2 id=&quot;more-info&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#more-info&quot;&gt;More Info&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://tinyjs.app/docs.html&quot;&gt;docs&lt;/a&gt; obviously are a great place to start, and if you&apos;re curious about more of how it actually works, there&apos;s a good &lt;a href=&quot;https://tinyjs.app/docs.html#howitworks&quot;&gt;explanation&lt;/a&gt; of that as well.&lt;/p&gt;
&lt;p&gt;My next demo is going to be testing an OAuth login flow with the application. The project supports registering custom URL schemes which is typically how that&apos;s handled so it &lt;em&gt;should&lt;/em&gt; be possible, but I want to test it out myself of course. :)&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@goumbik?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Lukas Blazek&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/coffee-latte-near-white-wireless-keyboard-and-apple-earpods-on-the-table-photography-GnvurwJsKaY?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/10/06/adding-my-latest-codepens-to-my-blog</id>
                <title>Adding My Latest CodePens to My Blog</title>
                <updated>2026-10-06T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/10/06/adding-my-latest-codepens-to-my-blog" rel="alternate" type="text/html" title="Adding My Latest CodePens to My Blog"/>
                <content type="html">
				
                        &lt;p&gt;I&apos;ve been a fan of CodePen for many, many years, and it&apos;s one of the few services I spend money on to help support. A few months back, they released an entirely new version that dramatically improved the service (CodePen 2.0) and it just made me more happy I was a supporter. Just a few days ago they &lt;a href=&quot;https://codepen.io/changelog/2026-10-02&quot;&gt;announced more changes&lt;/a&gt; including a full &lt;a href=&quot;https://blog.codepen.io/docs/api/api-v2/&quot;&gt;API&lt;/a&gt;. I thought it would be fun to add a display of my recent pens. Yes, I thought that would be fun, because that&apos;s the kind of nerd I am.&lt;/p&gt;
&lt;h2 id=&quot;adding-dynamic-content-to-a-ssg&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#adding-dynamic-content-to-a-ssg&quot;&gt;Adding Dynamic Content to a SSG&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;It has been a &lt;em&gt;very&lt;/em&gt; long time since I blogged about anything related to static site generators. There&apos;s no particular reason for that, but I figure this is a good time to bring up the topic again, specifically about how static sites can include dynamic content. There&apos;s two main paths to this.&lt;/p&gt;
&lt;p&gt;The first is getting content at build time. For Eleventy, this is done with data files that are executed at build time and can return ad hoc data for use in your templates. As an example of that, the list of books I&apos;m reading on my &lt;a href=&quot;https://www.raymondcamden.com/now&quot;&gt;Now&lt;/a&gt; page are gathered via a GraphQL query:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const HARDCOVER_BOOKS = process.env.HARDCOVER_BOOKS;

export default async function() {

    if(process.env.SKIP_REMOTE_DATA) return [];
    if(!HARDCOVER_BOOKS) return [];
    let req;

    let body = `
    {
    user_books(
        where: {user_id: {_eq: 65213}, status_id: {_eq: 2}}
    ) {
        book {
            title
            image {
                url
            }
            contributions {
                author {
                    name
                }
            }
        }
    }
    }
    `.trim();

    try {
        req = await fetch(&apos;https://api.hardcover.app/v1/graphql&apos;, {
            method:&apos;POST&apos;, 
            headers: {
                &apos;authorization&apos;:HARDCOVER_BOOKS,
                &apos;Content-Type&apos;:&apos;application/json&apos;
            },
            body:JSON.stringify({query:body})
        });
    } catch (e) {
        console.log(&apos;Hardcover API error&apos;, e);
        return [];
    }

    let data = (await req.json()).data.user_books.map(ob =&amp;gt; ob.book);
    /* normalize authors */
    data = data.map(b =&amp;gt; {
    b.authors = b.contributions.reduce((list,c) =&amp;gt; {
        if(c.author) list.push(c.author.name);
        return list;
        },[]);
        return b;
    });

    return data;

};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The list of books returned there can then be rendered in my page template:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div class=&amp;quot;films&amp;quot;&amp;gt;
{% for book in hardcover_books  %}
  &amp;lt;div class=&amp;quot;film&amp;quot;&amp;gt;
  {% if book.image != null %}
  &amp;lt;img src=&amp;quot;https://res.cloudinary.com/raymondcamden/image/fetch/c_fit,w_216/{{book.image.url}}&amp;quot; alt=&amp;quot;Cover of {{ book.title }}&amp;quot;&amp;gt;
  {% else  %}
  &amp;lt;img src=&amp;quot;https://res.cloudinary.com/raymondcamden/image/fetch/c_fit,w_216/https://static.raymondcamden.com/images/no_cover_available.jpg&amp;quot; alt=&amp;quot;No Cover Available&amp;quot;&amp;gt;
  {% endif %}
  &amp;quot;{{ book.title  }}&amp;quot; by {{ book.authors | join: &apos;, &apos; }}
  &amp;lt;/div&amp;gt;
{% endfor %}
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you can tell by the class names, I use the same logic for the films I&apos;ve watched. In both cases, the data doesn&apos;t change terribly often so I&apos;m ok with it being a bit out of date. I also publish &lt;em&gt;something&lt;/em&gt; to the blog every week so it&apos;s never too far behind.&lt;/p&gt;
&lt;p&gt;That&apos;s option 1. Option 2 is to use JavaScript and an API. That ensures the content is always 100% up to date but it has other trade offs. Some APIs don&apos;t have CORS support and APIs requiring keys that can&apos;t be in client-side code. In that case, I simply use a serverless function which Netlify makes pretty simple.&lt;/p&gt;
&lt;h2 id=&quot;getting-my-pens&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#getting-my-pens&quot;&gt;Getting my Pens&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, so given that I&apos;ve decided to fetch my CodePens via JavaScript on the front-end via a serverless function, I first created a key in CodePen&apos;s settings, added to my Netlify configuration, and then wrote a quick and dirty function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { Context } from &apos;@netlify/functions&apos;

export default async (request: Request, context: Context) =&amp;gt; {
  try {

    const CP_KEY = process.env.CODEPEN_KEY;

    const req = await fetch(&apos;https://api.codepen.io/v2/pens?sort=updated&amp;amp;direction=desc&amp;amp;access=Public&amp;amp;template=false&amp;amp;per_page=10&apos;, {
      method: &apos;GET&apos;,
      headers: {
        &apos;Authorization&apos;: `Bearer ${CP_KEY}`,
        &apos;Content-Type&apos;: &apos;application/json&apos;
      }});
    
    let data = await req.json();

    // now lets do some mapping 
    data = data.map((pen: any) =&amp;gt; {
      return {
        title: pen.title,
        url: pen.urls.editor,
        created_at: pen.created_at,
        updated_at: pen.updated_at,
        screenshot: pen.screenshot_urls.small
      }
    });

    return new Response(JSON.stringify(data), {
      headers: {
        &apos;Content-Type&apos;: &apos;application/json&apos;
      }
    })
  } catch (error) {
    return new Response(error.toString(), {
      status: 500,
    })
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I&apos;m filtering by public pens and pens that aren&apos;t templates, sorted by last updated, and capping the list to 10. Then I map the data to a &lt;em&gt;much&lt;/em&gt; smaller set of information as I only need part of what&apos;s returned in my front end.&lt;/p&gt;
&lt;p&gt;Then all I needed to do was add it to my &lt;a href=&quot;/stuff&quot;&gt;stuff&lt;/a&gt; page:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let $pens = document.querySelector(&apos;#pens&apos;);

let pens = await fetch(&apos;/.netlify/functions/get-codepens&apos;).then(r =&amp;gt; r.json());

pens.forEach(p =&amp;gt; {
	let pen = document.createElement(&apos;a&apos;);
	pen.className = &apos;penBox&apos;;
	pen.href = p.url;
	pen.target = &apos;_blank&apos;;
	pen.rel = &apos;noopener noreferrer&apos;;

	let screenshot = document.createElement(&apos;img&apos;);
	screenshot.src = p.screenshot;
	screenshot.alt = &apos;&apos;;
	screenshot.loading = &apos;lazy&apos;;

	let title = document.createElement(&apos;h3&apos;);
	title.textContent = p.title;

	pen.append(screenshot, title);
	$pens.append(pen);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sweet. Of course, I think I&apos;m the only person who visits either my &lt;a href=&quot;/stuff&quot;&gt;stuff&lt;/a&gt; or &lt;a href=&quot;/now&quot;&gt;now&lt;/a&gt; pages, but they make me happy!&lt;/p&gt;

                        
                
				</content>

                
                <category term="eleventy" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/10/02/a-not-so-simple-rss-app-in-typescript</id>
                <title>A Not-So-Simple RSS App in TypeScript</title>
                <updated>2026-10-02T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/10/02/a-not-so-simple-rss-app-in-typescript" rel="alternate" type="text/html" title="A Not-So-Simple RSS App in TypeScript"/>
                <content type="html">
				
                        &lt;p&gt;Ok, I lie, this demo is also pretty simple, but I wanted to break the pattern of the last few posts and at least pretend this example is a bit more complex. As I try to find excuses to write more TypeScript, I thought a great example of that would be a simple RSS aggregator. You hit the site, tell it the feeds you care about, and it renders a date sorted list of entries.&lt;/p&gt;
&lt;p&gt;Because RSS typically can&apos;t be parsed in client-side JavaScript (don&apos;t know why? ask me below!) so I&apos;d have to make use of a serverless function to proxy the calls (and at the same time, handle turning the XML into JSON).&lt;/p&gt;
&lt;p&gt;Now, to be fair, I had built nearly the same thing back in February: &lt;a href=&quot;https://www.raymondcamden.com/2026/02/02/building-an-rss-aggregator-with-astro&quot;&gt;Building an RSS Aggregator with Astro
&lt;/a&gt;. This let me crib a bit of the work already done.&lt;/p&gt;
&lt;p&gt;For this one, my stack was:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Vite for the build process&lt;/li&gt;
&lt;li&gt;Alpine on the front end&lt;/li&gt;
&lt;li&gt;TypeScript, as much as possible&lt;/li&gt;
&lt;li&gt;Hosting on Netlify to make the serverless function easier to do&lt;/li&gt;
&lt;li&gt;WebAwesome, which I&apos;ve used in the previous ones, for the UI&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can test this yourself here: &lt;a href=&quot;https://ts-rss-vite.netlify.app/&quot;&gt;https://ts-rss-vite.netlify.app/&lt;/a&gt;. The full source is here: &lt;a href=&quot;https://github.com/cfjedimaster/typescript-stuff/tree/main/rss-reader-1&quot;&gt;https://github.com/cfjedimaster/typescript-stuff/tree/main/rss-reader-1&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I figure yall have seen a few of these posts already, so let me focus on sharing the interesting stuff.&lt;/p&gt;
&lt;h2 id=&quot;run-typescript-checking&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#run-typescript-checking&quot;&gt;Run TypeScript Checking&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I mentioned in my &lt;a href=&quot;https://www.raymondcamden.com/2026/09/26/a-simple-weather-app-in-typescript&quot;&gt;last post&lt;/a&gt; that when Vite would reload, it wasn&apos;t running the TypeScript checker. It only did this when a build was run. Enabling this checking while in dev mode took two steps.&lt;/p&gt;
&lt;p&gt;First, simply add &lt;code&gt;&amp;quot;strict&amp;quot;: true,&lt;/code&gt; to &lt;code&gt;tsconfig.json&lt;/code&gt;. Funny enough, in years past I can remember removing this when I was trying to get something working in a TypeScript app and I was too lazy to learn or figure it out.&lt;/p&gt;
&lt;p&gt;Second, I had to add &lt;a href=&quot;https://www.npmjs.com/package/vite-plugin-checker&quot;&gt;vite-plugin-checker&lt;/a&gt; to the app and modify &lt;code&gt;vite.config.ts&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { defineConfig } from &apos;vite&apos;;
import checker from &apos;vite-plugin-checker&apos;;

export default defineConfig({
  plugins: [checker({ typescript: true })],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This file doesn&apos;t exist by default so it was a net new file for the app. As soon as I did that, if I screwed something up on purpose (ahem, I only make mistakes on purpose), I saw errors in my terminal when Vite was running.&lt;/p&gt;
&lt;h2 id=&quot;the-serverless-function&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-serverless-function&quot;&gt;The Serverless Function&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;For the backend, I used the &lt;code&gt;ntl&lt;/code&gt; CLI (this is Netlify&apos;s CLI tool) to scaffold a function, which helpfully includes a TypeScript option. For the most part, the code here is a simpler version of what I had done for the Astro site, specifically I didn&apos;t bother with BLOB caching. Anyway, here&apos;s that function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import Parser from &apos;rss-parser&apos;;
import type { Context } from &apos;@netlify/functions&apos;;

type FeedItem = {
  title: string;
  link: string;
  pubDate: string;
  content: string;
  feedTitle: string;
};

export default async (request: Request, context: Context) =&amp;gt; {
  if (request.method !== &apos;POST&apos;) {
    return new Response(&apos;Method not allowed&apos;, {
      status: 405,
      headers: { Allow: &apos;POST&apos; },
    })
  }

  try {
    const body: unknown = await request.json()
    const parser = new Parser();

    if (
      typeof body !== &apos;object&apos; ||
      body === null ||
      !(&apos;feeds&apos; in body) ||
      !Array.isArray(body.feeds)
    ) {
      return new Response(&apos;Request body must include a feeds array&apos;, {
        status: 400,
      })
    }
    console.log(&apos;Parsing feeds:&apos;, body.feeds);

    let reqs: Promise&amp;lt;any&amp;gt;[] = [];
    let items: FeedItem[] = [];

    for(const feed of body.feeds) {
      reqs.push(parser.parseURL(feed.url));
    }

    const results = await Promise.allSettled(reqs);
    for (const result of results) {
        if (result.status === &apos;fulfilled&apos;) {
            const feed = result.value;
            console.log(`Fetched feed: ${feed.title} with ${feed.items.length} items.`);
            let newItems:any[] = [];
            feed.items.forEach(item =&amp;gt; {
                /*
                will use content as a grab all for different fields
                for example, netlify had summary, not content
                */
                let content = item.contentSnippet || item.summary || item.content || &apos;&apos;;
                newItems.push({
                    title: item.title,
                    link: item.link,
                    content: content,
                    pubDate: item.pubDate,
                    feedTitle: feed.title
                });
            });

            items.push(...newItems);

        } else {
            console.error(&apos;Error fetching/parsing feed:&apos;, result.reason);
        }
    }

    // now sort items by pubDate descending
    items.sort((a, b) =&amp;gt; new Date(b.pubDate) - new Date(a.pubDate));

    return new Response(JSON.stringify(items), {
        status: 200,
        headers: {
        &amp;quot;Content-Type&amp;quot;: &amp;quot;application/json&amp;quot;,
        },
    });

  } catch(e) {
    console.log(e);
    return new Response(&apos;Request body must be valid JSON&apos;, {
      status: 400,
    })
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is all pretty simple but I liked having the ability to create a custom type for my results.&lt;/p&gt;
&lt;p&gt;I did run into an error after I created the function - and it&apos;s one I&apos;ve seen twice now. Something in what the Netlify CLI does breaks some dependencies and my app fails to run at all. I passed the error to Claude and what it said was:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;The most likely cause is that adding the Netlify function pulled in an older 
typescript that&apos;s now being resolved instead of your project&apos;s version. This 
usually happens when a netlify/functions package has its own package.json, 
or when netlify-cli or the functions bundler is running an older tsc from a 
different node_modules.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Its suggested fix, &lt;code&gt;npm i -D typescript@latest&lt;/code&gt;, was enough to correct it. I definitely won&apos;t forget this when it happens again.&lt;/p&gt;
&lt;h2 id=&quot;what&apos;s-next%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what&apos;s-next%3F&quot;&gt;What&apos;s Next?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;This has been so fun, at least for me. I&apos;ve got my next two ideas ready! (Well, in my head at least. ;)&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="typescript" />
                
                <category term="alpinejs" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/09/26/a-simple-weather-app-in-typescript</id>
                <title>A Simple Weather App in TypeScript</title>
                <updated>2026-09-26T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/09/26/a-simple-weather-app-in-typescript" rel="alternate" type="text/html" title="A Simple Weather App in TypeScript"/>
                <content type="html">
				
                        &lt;p&gt;Happy Sunday, my fellow nerds. If you&apos;ve been keeping up with my posts (all 4 of you - thank you!) then you know I&apos;ve been experimenting with TypeScript (and Vite) recently. This past week I decided to try building a real &amp;quot;app&amp;quot;, and of course by app I mean something incredibly small, but a bit &amp;quot;real world&amp;quot;-ish to kind of get a feel for what the development process felt like.&lt;/p&gt;
&lt;p&gt;I decided on a fairly simple weather application. The application would prompt you for a location. That location gets geocoded to longitude and latitude values. With those values, I&apos;d grab a simple weather forecast. After you add one, you can add another, and delete as well. Finally, all the values are stored in local storage so that on reload you get the same values loaded immediately.&lt;/p&gt;
&lt;p&gt;If you actually want to see this - you can head over to &lt;a href=&quot;https://weather-app-ts-vite-alpine.netlify.app/&quot;&gt;https://weather-app-ts-vite-alpine.netlify.app/&lt;/a&gt;. The complete source may be found here: &lt;a href=&quot;https://github.com/cfjedimaster/typescript-stuff/tree/main/weather-demo&quot;&gt;https://github.com/cfjedimaster/typescript-stuff/tree/main/weather-demo&lt;/a&gt;. Now let me dig into what I discovered while building it.&lt;/p&gt;
&lt;h2 id=&quot;the-one-thing&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-one-thing&quot;&gt;The One Thing&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Let me start off with what I think is the biggest thing that clicked with me. While building out the various parts and using (at least some) of the TypeScript features, I immediately ran into probably the biggest win - immediate feedback in my editor when I had screwed things up. And yea, I know that&apos;s something TypeScript provided since day one, but it&apos;s also a bit different to kind of see it in action while building the app. Especially since I had two or three times when I had to refactor how I was doing things. Having the types in place (specifically for my remote API calls) helped keep things in order across the different moving parts.&lt;/p&gt;
&lt;p&gt;I also found myself being forced to think more about how those parts interacted. So for example, a wrapper to my geocoding service - I instinctively knew what I had wanted - but having it spelled out in code actually made me see possible issues - modify my approach - and so forth - all earlier than I would have in my usual development process.&lt;/p&gt;
&lt;p&gt;Again - this wasn&apos;t necessarily a surprise to me. This all fell in line with what I knew about TypeScript and what I had expected. But never having built an app from scratch with TypeScript before it still felt pretty cool to actually see all this play out.&lt;/p&gt;
&lt;h2 id=&quot;the-architecture&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-architecture&quot;&gt;The Architecture&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;It feels a bit silly to talk about the architecture of such a small little app, but this is how I built out the parts.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Once again, I used Vite.&lt;/li&gt;
&lt;li&gt;On top of that, I used Alpine.js. In case you missed it, my &lt;a href=&quot;https://www.raymondcamden.com/2026/09/22/a-simple-alpinejs-template-with-vite-and-typescript&quot;&gt;last blog post&lt;/a&gt; discussed how to do this.&lt;/li&gt;
&lt;li&gt;The main page handles displaying the &apos;weather cards&apos; along with a simple form to add new locations.&lt;/li&gt;
&lt;li&gt;When a location is entered, I use the excellent &lt;a href=&quot;https://www.geocod.io/&quot;&gt;Geocodio&lt;/a&gt; API to translate it into longitude and latitude.&lt;/li&gt;
&lt;li&gt;With that information, I use the &lt;a href=&quot;https://pirateweather.net/&quot;&gt;Pirate Weather&lt;/a&gt; API to get the forecast.&lt;/li&gt;
&lt;/ul&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;As mentioned above, this was a Vite and Alpine app, but I also decided to try adding a UI library in as well. I picked &lt;a href=&quot;https://webawesome.com/&quot;&gt;Web Awesome&lt;/a&gt; primarily for the Card element. I&apos;ve used this library in the past (when it was called Shoelace) and I really like it, especially as it uses web components. They also make it easy to only import what you need.&lt;/p&gt;
&lt;p&gt;In my &lt;code&gt;main.ts&lt;/code&gt;, this came down to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import &apos;@awesome.me/webawesome/dist/styles/webawesome.css&apos;;
import &apos;@awesome.me/webawesome/dist/components/card/card.js&apos;;
import &apos;@awesome.me/webawesome/dist/components/input/input.js&apos;;
import &apos;@awesome.me/webawesome/dist/components/button/button.js&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this in place, it was a simple matter to use the components. As an example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div class=&amp;quot;wa-flank:end wa-gap-xs&amp;quot;&amp;gt;
&amp;lt;wa-input placeholder=&amp;quot;New Location (America, Canada, Mexico, UK only)&amp;quot; x-model=&amp;quot;newLocation&amp;quot;&amp;gt;&amp;lt;/wa-input&amp;gt;
&amp;lt;wa-button variant=&amp;quot;brand&amp;quot; @click=&amp;quot;addLocation(newLocation)&amp;quot;&amp;gt;Add&amp;lt;/wa-button&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make note of the classes on the &lt;code&gt;div&lt;/code&gt; tag there. That comes from Web Awesome&apos;s various utility classes and this was a place where I leaned on my AI tool to help.&lt;/p&gt;
&lt;p&gt;Here&apos;s the entirety of the main HTML page which shows you both Web Awesome in play as well as my Alpine directives:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang=&amp;quot;en&amp;quot; class=&amp;quot;wa-dark&amp;quot;&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot; /&amp;gt;
    &amp;lt;link rel=&amp;quot;icon&amp;quot; type=&amp;quot;image/svg+xml&amp;quot; href=&amp;quot;/favicon.svg&amp;quot; /&amp;gt;
    &amp;lt;meta name=&amp;quot;viewport&amp;quot; content=&amp;quot;width=device-width, initial-scale=1.0&amp;quot; /&amp;gt;
    &amp;lt;title&amp;gt;Weather Demo&amp;lt;/title&amp;gt;
  &amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;
    &amp;lt;div x-data=&amp;quot;app&amp;quot; class=&amp;quot;container wa-stack&amp;quot;&amp;gt;

      &amp;lt;header&amp;gt;
        &amp;lt;h1&amp;gt;Weather Demo&amp;lt;/h1&amp;gt;
      &amp;lt;/header&amp;gt;

      &amp;lt;template x-if=&amp;quot;locations.length === 0&amp;quot;&amp;gt; 
        &amp;lt;p&amp;gt;No locations added yet. Add a location to get started.&amp;lt;/p&amp;gt;
      &amp;lt;/template&amp;gt;
      &amp;lt;div class=&amp;quot;wa-grid wa-gap-1&amp;quot; style=&amp;quot;--min-column-size: 30ch;&amp;quot;&amp;gt;
      &amp;lt;template x-for=&amp;quot;(location,index) in locations&amp;quot; :key=&amp;quot;location.name&amp;quot;&amp;gt;
        &amp;lt;wa-card class=&amp;quot;card-basic&amp;quot; &amp;gt;
          &amp;lt;h3 slot=&amp;quot;header&amp;quot; x-text=&amp;quot;location.name&amp;quot;&amp;gt;&amp;lt;/h3&amp;gt;
          &amp;lt;wa-button appearance=&amp;quot;plain&amp;quot; @click=&amp;quot;removeLocation(index)&amp;quot; slot=&amp;quot;header-actions&amp;quot;&amp;gt;
            &amp;lt;wa-icon name=&amp;quot;trash&amp;quot; variant=&amp;quot;solid&amp;quot; label=&amp;quot;Delete&amp;quot;&amp;gt;&amp;lt;/wa-icon&amp;gt;
          &amp;lt;/wa-button&amp;gt;
          &amp;lt;template x-if=&amp;quot;location.weather&amp;quot;&amp;gt;
            &amp;lt;p x-html=&amp;quot;`Temperature: ${location.weather.temperature}°F&amp;lt;br/&amp;gt;Condition: ${location.weather.summary}&amp;lt;br&amp;gt;Low: ${location.weather.low}°F&amp;lt;br/&amp;gt;High: ${location.weather.high}°F`&amp;quot;&amp;gt;&amp;lt;/p&amp;gt;
          &amp;lt;/template&amp;gt;
          &amp;lt;template x-if=&amp;quot;!location.weather&amp;quot;&amp;gt;
            &amp;lt;p&amp;gt;Loading weather data...&amp;lt;/p&amp;gt;
          &amp;lt;/template&amp;gt;
        &amp;lt;/wa-card&amp;gt;
      &amp;lt;/template&amp;gt;
        &amp;lt;/div&amp;gt;

      &amp;lt;div class=&amp;quot;wa-flank:end wa-gap-xs&amp;quot;&amp;gt;
      &amp;lt;wa-input placeholder=&amp;quot;New Location (America, Canada, Mexico, UK only)&amp;quot; x-model=&amp;quot;newLocation&amp;quot;&amp;gt;&amp;lt;/wa-input&amp;gt;
      &amp;lt;wa-button variant=&amp;quot;brand&amp;quot; @click=&amp;quot;addLocation(newLocation)&amp;quot;&amp;gt;Add&amp;lt;/wa-button&amp;gt;
      &amp;lt;/div&amp;gt;

    &amp;lt;/div&amp;gt;
    &amp;lt;script type=&amp;quot;module&amp;quot; src=&amp;quot;/src/main.ts&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;the-three-%22services%22&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-three-%22services%22&quot;&gt;The Three &amp;quot;Services&amp;quot;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I&apos;m using &amp;quot;services&amp;quot; in quotes there as it feels a bit overly dramatic to name it as such, but I broke out three parts into their own files - one for geocoding, one for the weather, and one for storage. Let&apos;s start with storage first.&lt;/p&gt;
&lt;p&gt;This file wraps the calls to LocalStorage and in theory - I could easily switch out to IndexedDB in the future - but I&apos;d have to mark the functions &lt;code&gt;async&lt;/code&gt; of course:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import type { SavedLocation } from &apos;./types&apos;;

// This is where it&apos;s stored in localStorage. 
const KEY = &apos;weather-locations&apos;;

// Type guard: checks at runtime that an unknown value really is a SavedLocation.
// localStorage can contain anything (old versions of your app, manual edits),
// so we validate instead of trusting it.
// Ray, in case you forget, the value is means that if the function returns true, 
// it&apos;s ok for TS to consider the value as of type SavedLocation
function isSavedLocation(value: unknown): value is SavedLocation {
  if (typeof value !== &apos;object&apos; || value === null) return false;
  const v = value as Record&amp;lt;string, unknown&amp;gt;;
  return (
    typeof v.name === &apos;string&apos; &amp;amp;&amp;amp;
    typeof v.longitude === &apos;number&apos; &amp;amp;&amp;amp;
    typeof v.latitude === &apos;number&apos;
  );
}

export function getLocations(): SavedLocation[] {
  const locations = localStorage.getItem(KEY);
  if(!locations) return [];

  const parsed = JSON.parse(locations);
  return Array.isArray(parsed) ? parsed.filter(isSavedLocation) : [];
}

export function addLocation(location: SavedLocation): void {
  const locations = getLocations();
  locations.push(location);
  localStorage.setItem(KEY, JSON.stringify(locations));
}

export function removeLocation(index: number): void {
  const locations = getLocations();
  locations.splice(index, 1);
  localStorage.setItem(KEY, JSON.stringify(locations));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first thing you see on top is importing a type that defines what a &apos;stored location&apos; is in terms of my application. From that file, here is the definition:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export type SavedLocation = {
  name: string;
  longitude: number;
  latitude: number, 
  weather?: WeatherData;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This should mostly make sense - the name comes from the user, the longitude and latitude from the geocoding service.&lt;/p&gt;
&lt;p&gt;The weather part actually comes &lt;em&gt;after&lt;/em&gt; the app loads in the existing values from local storage and hits the weather API. There&apos;s a part of me that looks at that and thinks maybe I should have a type for the &amp;quot;stored&amp;quot; value and one for the &amp;quot;live&amp;quot; value. I also think I could possibly cache the weather so that on reload it&apos;s quicker, but weather data is the kind of thing that gets stale pretty quickly.&lt;/p&gt;
&lt;p&gt;Here&apos;s that file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import type { WeatherData } from &apos;./types&apos;;

const KEY = import.meta.env.VITE_PIRATE_KEY as string;

export async function getWeather(lat: number, lng: number): Promise&amp;lt;WeatherData&amp;gt; {
    const req = await fetch(`https://api.pirateweather.net/forecast/${KEY}/${lat},${lng}?units=us&amp;amp;exclude=minutely,hourly,alerts,flags`);
    const res = await req.json();

    return {
        summary: res.currently.summary,
        temperature: res.currently.temperature,
        low: res.daily.data[0].temperatureLow,
        high: res.daily.data[0].temperatureHigh
    };

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And here&apos;s the type:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export type WeatherData = {
  summary: string;
  temperature: number;
  low: number;
  high: number;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, here&apos;s the geocoding wrapper:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import type { GeoCodedLocation } from &apos;./types&apos;;

// Only the fields we use from Geocodio&apos;s &amp;quot;simple&amp;quot; format
type GeocodioSimpleResponse = {
  lat?: number;
  lng?: number;
};

const KEY = import.meta.env.VITE_GEOCODIO_KEY as string;

export async function geoCode(location: string): Promise&amp;lt;GeoCodedLocation&amp;gt; {
    const req = await fetch(`https://api.geocod.io/v1.7/geocode?q=${encodeURIComponent(location)}&amp;amp;format=simple&amp;amp;api_key=${KEY}`);
    const res = (await req.json()) as GeocodioSimpleResponse;

    if(!res || !res.lat || !res.lng) {
        throw new Error(&apos;No results&apos;);
    }

    return { lat: res.lat, lng: res.lng };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&apos;ll notice I do the HTTP call slightly different here. I&apos;ve got an inline type here that defines what is being used from Geocodio. I&apos;ll be honest and say I want to think about these two approaches and figure out when I should use each. I mean, when you look at &lt;code&gt;GeoCodedLocation&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export type GeoCodedLocation = {
  lat: number;
  lng: number;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I honestly don&apos;t get the point of the inline type as well. I was having AI help me a bit, so it&apos;s on me to nail down if this makes sense in the context of the file. I&apos;ll be returning to that soon.&lt;/p&gt;
&lt;h2 id=&quot;the-core-app&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-core-app&quot;&gt;The Core App&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Now let&apos;s put it together with my core file - which is mainly Alpine.js specific:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import Alpine from &apos;alpinejs&apos;;

import &apos;@awesome.me/webawesome/dist/styles/webawesome.css&apos;;
import &apos;@awesome.me/webawesome/dist/components/card/card.js&apos;;
import &apos;@awesome.me/webawesome/dist/components/input/input.js&apos;;
import &apos;@awesome.me/webawesome/dist/components/button/button.js&apos;;
import &apos;./style.css&apos;;

import type { SavedLocation } from &apos;./types&apos;;
import { getLocations, addLocation, removeLocation } from &apos;./storage&apos;;
import { getWeather } from &apos;./weather&apos;;
import { geoCode } from &apos;./geocode&apos;;

Alpine.data(&apos;app&apos;, () =&amp;gt; ({
  locations: [] as SavedLocation[],
  newLocation: &apos;&apos;,
  init() {
    this.locations = getLocations();
    if(this.locations.length &amp;gt; 0) {
      this.hydrateWeather();
    }
  },
  async addLocation(location: string) {
    if(!location) return;
    const geo = await geoCode(location);
    const newLoc: SavedLocation = { name: location, longitude: geo.lng, latitude: geo.lat };
    this.locations.push(newLoc);
    // just noticed my method is addLocation as is the imported one. works but - eww. 
    addLocation(newLoc);
    this.newLocation = &apos;&apos;;
    // in theory it is wasteful to hydrate ALL of them, but it&apos;s a super quick call
    this.hydrateWeather();
  }, 
  removeLocation(index: number) {
    this.locations.splice(index, 1);
    // same issue with naming - advice?
    removeLocation(index);
  },
  async hydrateWeather() {
    for(const loc of this.locations) {
      const weather = await getWeather(loc.latitude, loc.longitude);
      loc.weather = weather;
    }
  }
}));

Alpine.start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you actually took the time to read all that, you can see I wrote a few questions to myself. By accident, I ended up having Alpine methods with the same name as methods in my services, and that &lt;em&gt;really&lt;/em&gt; bugs me... but it also works... so... yeah. It&apos;s kinda clear that &lt;code&gt;this.something&lt;/code&gt; is Alpine and &lt;code&gt;something&lt;/code&gt; is being imported in, but I still don&apos;t care for that. How would you rename things? (And I&apos;d assume the rename would be on the Alpine side.)&lt;/p&gt;
&lt;p&gt;I also notice a few lines where I didn&apos;t define my type. A quick search shows that by adding &lt;code&gt;strict:true&lt;/code&gt; to my &lt;code&gt;tsconfig.json&lt;/code&gt; would fix that. I know in the past I&apos;ve seen that in projects and it&apos;s annoyed the heck out of me, so I get why Vite&apos;s scaffold doesn&apos;t include it, but for my next project, I&apos;m going to turn that on so I can be a bit more precise in my learning with TypeScript.&lt;/p&gt;
&lt;h2 id=&quot;your-turn!&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#your-turn!&quot;&gt;Your Turn!&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, I know blogging is dead (again) and no humans read these posts again, but if you are reading this, and know TypeScript well, I&apos;d &lt;strong&gt;love&lt;/strong&gt; any and all feedback! Give me a comment below and help me keep learning this.&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@patrickian4?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Patrick Fore&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/black-corona-typewriter-on-brown-wood-planks-0gkw_9fy0eQ?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="typescript" />
                
                <category term="alpinejs" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/09/22/a-simple-alpinejs-template-with-vite-and-typescript</id>
                <title>A Simple Alpine.js Template with Vite and TypeScript</title>
                <updated>2026-09-22T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/09/22/a-simple-alpinejs-template-with-vite-and-typescript" rel="alternate" type="text/html" title="A Simple Alpine.js Template with Vite and TypeScript"/>
                <content type="html">
				
                        &lt;p&gt;This will be a quick one - but as I think more about TypeScript and how I&apos;d like to learn (and play, and build silly demos), I naturally thought it may make sense to look at how I&apos;d use &lt;a href=&quot;https://alpinejs.dev/&quot;&gt;Alpine.js&lt;/a&gt; with that stack. Alpine&apos;s been my go to library for web apps that reach the level of complexity where I&apos;d like some help with DOM manipulation and such. I don&apos;t &lt;em&gt;always&lt;/em&gt; use it, because (imho) the default stack should be as vanilla as possible (obviously I&apos;m going a bit off ranch with these explorations into TypeScript and Vite) but Alpine is lightweight and simple and just a great little library in general. What follows isn&apos;t necessarily a &amp;quot;template&amp;quot; as it&apos;s got a bit of template code with it, but I thought it would be helpful to share. As always, let me know what you think!&lt;/p&gt;
&lt;h2 id=&quot;step-one---the-scaffold&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#step-one---the-scaffold&quot;&gt;Step One - the Scaffold&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;In my &lt;a href=&quot;https://www.raymondcamden.com/2026/09/18/its-only-a-decade-late-ill-learn-typescript&quot;&gt;first post&lt;/a&gt; a few days ago, I mentioned that the Vite scaffold support lets you create a vanilla application with TypeScript. I used that for my code and removed as much of the demo code as I could. I do wish Vite&apos;s template was a bit more minimal.&lt;/p&gt;
&lt;h2 id=&quot;step-two---adding-alpine&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#step-two---adding-alpine&quot;&gt;Step Two - Adding Alpine&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Usually I make use of the Alpine CDN in my demos, for this one, I instead installed it as a dependency. I also installed the Alpine types dependency. Here&apos;s my package.json:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;name&amp;quot;: &amp;quot;alpine-test-1&amp;quot;,
  &amp;quot;private&amp;quot;: true,
  &amp;quot;version&amp;quot;: &amp;quot;0.0.0&amp;quot;,
  &amp;quot;type&amp;quot;: &amp;quot;module&amp;quot;,
  &amp;quot;scripts&amp;quot;: {
    &amp;quot;dev&amp;quot;: &amp;quot;vite&amp;quot;,
    &amp;quot;build&amp;quot;: &amp;quot;tsc &amp;amp;&amp;amp; vite build&amp;quot;,
    &amp;quot;preview&amp;quot;: &amp;quot;vite preview&amp;quot;
  },
  &amp;quot;devDependencies&amp;quot;: {
    &amp;quot;@types/alpinejs&amp;quot;: &amp;quot;^3.13.11&amp;quot;,
    &amp;quot;typescript&amp;quot;: &amp;quot;~6.0.2&amp;quot;,
    &amp;quot;vite&amp;quot;: &amp;quot;^8.3.0&amp;quot;
  },
  &amp;quot;dependencies&amp;quot;: {
    &amp;quot;alpinejs&amp;quot;: &amp;quot;^3.17.4&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;step-three---the-html&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#step-three---the-html&quot;&gt;Step Three - the HTML&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, so this part is really simple. Alpine &amp;quot;ties&amp;quot; to your DOM usually via an &lt;code&gt;x-data&lt;/code&gt; attribute. That means this is the bare minimum:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div x-data=&amp;quot;app&amp;quot;&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But of course you&apos;ll have more Alpine directives and more HTML. Also, &amp;quot;app&amp;quot; is not required, but is the name I pretty much use all the time. For my template/demo, I output a couple of variables and included a few click directives just to test stuff out. Oh, I also added &lt;a href=&quot;https://simplecss.org/&quot;&gt;Simple.css&lt;/a&gt; to just to make it look prettier. That&apos;s absolutely not necessary. The Vite demo actually imports CSS in their main TypeScript file (you can see that on Stackblitz &lt;a href=&quot;https://stackblitz.com/edit/vitejs-vite-hpgbiipd?file=src%2Fmain.ts&amp;amp;terminal=dev&quot;&gt;here&lt;/a&gt;) and I&apos;m not sure how I feel about that. I know it minimizes the code, but it feels really weird to me. Anyway, here&apos;s my HTML:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot; /&amp;gt;
    &amp;lt;link rel=&amp;quot;icon&amp;quot; type=&amp;quot;image/svg+xml&amp;quot; href=&amp;quot;/favicon.svg&amp;quot; /&amp;gt;
    &amp;lt;meta name=&amp;quot;viewport&amp;quot; content=&amp;quot;width=device-width, initial-scale=1.0&amp;quot; /&amp;gt;
    &amp;lt;link rel=&amp;quot;stylesheet&amp;quot; href=&amp;quot;https://cdn.simplecss.org/simple.min.css&amp;quot;&amp;gt;
    &amp;lt;title&amp;gt;alpine-test-1&amp;lt;/title&amp;gt;
  &amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;
    &amp;lt;div x-data=&amp;quot;app&amp;quot;&amp;gt;
      &amp;lt;p&amp;gt;
      &amp;lt;span x-text=&amp;quot;message&amp;quot;&amp;gt;&amp;lt;/span&amp;gt;
      &amp;lt;/p&amp;gt;
      &amp;lt;p&amp;gt;
      &amp;lt;button @click=&amp;quot;meow()&amp;quot;&amp;gt;Meow (default)&amp;lt;/button&amp;gt;
      &amp;lt;button @click=&amp;quot;meow(&apos;Purr...&apos;)&amp;quot;&amp;gt;Meow (custom)&amp;lt;/button&amp;gt;
      &amp;lt;/p&amp;gt;
      &amp;lt;ul&amp;gt;
        &amp;lt;template x-for=&amp;quot;(cat, index) in cats&amp;quot; :key=&amp;quot;index&amp;quot;&amp;gt;
          &amp;lt;li x-text=&amp;quot;cat.name&amp;quot;&amp;gt;&amp;lt;/li&amp;gt;
        &amp;lt;/template&amp;gt;
      &amp;lt;/ul&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;script type=&amp;quot;module&amp;quot; src=&amp;quot;/src/main.ts&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;step-four---enter-the-typescript&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#step-four---enter-the-typescript&quot;&gt;Step Four - Enter the TypeScript&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, for the final bit - my TypeScript code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Alpine from &apos;alpinejs&apos;;

type Cat = {
  name: string;
  breed: string;
  gender: &apos;male&apos; | &apos;female&apos;;
};

Alpine.data(&apos;app&apos;, () =&amp;gt; ({
  message:&apos;Hello from Alpine!&apos;,
  cats: [] as Cat[],
  init() {
    this.cats.push({ name: &apos;Whiskers&apos;, breed: &apos;Siamese&apos;, gender: &apos;male&apos; });
    this.cats.push({ name: &apos;Fluffy&apos;, breed: &apos;Persian&apos;, gender: &apos;female&apos; });
  },
  meow(message:string = &apos;Meow!&apos;) {
    alert(message);
  }
}));

Alpine.start();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let me point out the important bits. Unlike my usual Alpine demos, I import Alpine here from the local install. I also need to fire &lt;code&gt;Alpine.start()&lt;/code&gt; manually. But outside of that, it&apos;s pretty vanilla Alpine - define the app and include relevant variables and methods, which in this case is pretty small.&lt;/p&gt;
&lt;p&gt;Now obviously I&apos;m trying to learn TypeScript as well, but I kept it pretty short here. I&apos;ve defined a type for &lt;code&gt;Cat&lt;/code&gt; and when I worked with the data, my editor (Visual Studio Code) provided support as I&apos;d expect - it knew the right parts of a cat and correctly flagged an error if I tried to include something that wasn&apos;t defined in the type.&lt;/p&gt;
&lt;p&gt;As a reminder, this is all done in the editor - it wouldn&apos;t be a &amp;quot;real&amp;quot; error in production - but the idea here - and the benefit of TypeScript - is that I&apos;d (hopefully!) catch it much earlier.&lt;/p&gt;
&lt;p&gt;Outside of that, you can also see where I define &lt;code&gt;cats&lt;/code&gt; as an array of &lt;code&gt;Cat&lt;/code&gt; and specify that the &lt;code&gt;message&lt;/code&gt; argument to &lt;code&gt;meow&lt;/code&gt; is string.&lt;/p&gt;
&lt;p&gt;Again - this is pretty minimal TypeScript usage, but I dig it, and I can really see how in some of my larger Alpine demos in the past, the additional safety/checking/etc would have been real helpful I think.&lt;/p&gt;
&lt;h2 id=&quot;there-is-no-step-five&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#there-is-no-step-five&quot;&gt;There Is No Step Five&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;If you want the code to try it yourself, you can copy it from here: &lt;a href=&quot;https://github.com/cfjedimaster/typescript-stuff/tree/main/alpine-test-1&quot;&gt;https://github.com/cfjedimaster/typescript-stuff/tree/main/alpine-test-1&lt;/a&gt;. I know this was pretty short, but I&apos;d still love any feedback or advice, so hit up the comments below!&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="typescript" />
                
                <category term="alpinejs" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/09/20/links-for-you-92026</id>
                <title>Links For You (9/20/26)</title>
                <updated>2026-09-20T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/09/20/links-for-you-92026" rel="alternate" type="text/html" title="Links For You (9/20/26)"/>
                <content type="html">
				
                        &lt;p&gt;Happy &amp;quot;Football is Back&amp;quot; season to those of you who celebrate. I&apos;m currently watching my Saints take on the Ravens (who are also a favorite of mine) and hoping for our first win. The last few weeks have been incredibly busy (I feel like I always say that, sorry) with a trip for work that then morphed into a short vacation with my wife and I which included my first Blizzcon (which is freaking cool as hell) and our first visit to Disneyland. (We&apos;ve been to Disney World a few times.) I think we both agreed that Disney World is cooler, but the crowds and - most importantly - the weather - was much better in California.&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/09/st.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Stormtroopers&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;h2 id=&quot;rss-lookup&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#rss-lookup&quot;&gt;RSS Lookup&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;First up for the links today is &lt;a href=&quot;https://www.rsslookup.com/&quot;&gt;RSS Lookup&lt;/a&gt;, a simple web site that lets you paste in a URL and see if it has a RSS feed. Not every site does a good job of letting you know about their RSS options so this can make it easier. Most likely I think the bookmarklet will be more useful to folks.&lt;/p&gt;
&lt;p&gt;If your curious, here&apos;s the meta tag used to signify a site&apos;s feed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;link rel=&amp;quot;alternate&amp;quot; type=&amp;quot;application/rss+xml&amp;quot; 
title=&amp;quot;Your Site Title - RSS Feed&amp;quot; href=&amp;quot;https://example.com&amp;quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;interfaces%2C-a-collection-by-ron-domingue&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#interfaces%2C-a-collection-by-ron-domingue&quot;&gt;Interfaces, a collection by Ron Domingue&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ron is someone I&apos;ve known for a while, a local down here in Louisiana, who has done some &lt;em&gt;incredibly&lt;/em&gt; stunning visual works. His &lt;a href=&quot;https://interfaces.rondomingue.com/&quot;&gt;Interfaces&lt;/a&gt; is a collection of various UI interfaces for different types of data. I&apos;d be hard pressed to pick a favorite, but &lt;a href=&quot;https://interfaces.rondomingue.com/landfall.html&quot;&gt;Landfall&lt;/a&gt; is a stunning visualization of hurricane tracks and their landfall.&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/09/landfall.png&quot; loading=&quot;lazy&quot; alt=&quot;Landfall&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;h2 id=&quot;remember-imagemaps%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#remember-imagemaps%3F&quot;&gt;Remember Imagemaps?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Imagemaps are one of those old web techs that used to be everywhere, and then disappeared. In case you don&apos;t remember, the idea was that one image on a web page, when clicked, could have multiple different destinations based on where you clicked. Typically this was defined in HTML with coordinates mapping to URLs. But you could also handle the location on the server side as well. In that form, the coordinates are included in the URL and your server side tech has to handle figuring out what goes where.&lt;/p&gt;
&lt;p&gt;This post, &lt;a href=&quot;https://danq.me/2026/07/10/rescuing-7234-gifs/?utm_source=tldrdev&quot;&gt;&amp;quot;Today I Rescued 7,234 Old GIFs&amp;quot;&lt;/a&gt;, by Dan Q explores how he attempted (and succeeded) in scraping a bunch of Gif icons for an old directory.&lt;/p&gt;
&lt;p&gt;Not gonna lie - kinda miss the old web. :)&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, usually I share cool music videos here, but today I want to share something different. My father-in-law shared this short with us and while it&apos;s marked as comedy, it&apos;s a pretty deep 30-ish minute short staring Jane Kaczmarek. It&apos;s definitely worth your time!&lt;/p&gt;
&lt;lite-youtube videoid=&quot;Ec6yq3isEjg&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/Ec6yq3isEjg/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=Ec6yq3isEjg&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/09/18/its-only-a-decade-late-ill-learn-typescript</id>
                <title>It&apos;s only a decade late, I&apos;ll learn TypeScript!</title>
                <updated>2026-09-18T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/09/18/its-only-a-decade-late-ill-learn-typescript" rel="alternate" type="text/html" title="It&apos;s only a decade late, I&apos;ll learn TypeScript!"/>
                <content type="html">
				
                        &lt;p&gt;I&apos;ve been a bit worried lately. Of course, my friends will tell you that&apos;s my norm. But more specifically, I&apos;ve been worried about my coding skills. As I use AI to generate more and more code, and it does that job well, I kinda miss actually &lt;em&gt;writing&lt;/em&gt; code. I&apos;ve also had a bit of writer&apos;s block in terms of &lt;em&gt;topics&lt;/em&gt; to cover, and especially in the area of the web platform. A few days ago it occurred to me - I&apos;ve never &lt;em&gt;really&lt;/em&gt; dug into &lt;a href=&quot;https://www.typescriptlang.org/&quot;&gt;TypeScript&lt;/a&gt;. Sure, I&apos;ve used it, many times, in the fourteen or so years it&apos;s been out, but many times I kinda guess at what I&apos;m doing and it was never my default for projects. Partly that&apos;s due to my philosophy of avoiding a build step in my projects and just KISS in general.&lt;/p&gt;
&lt;p&gt;But as I said - the inspiration hit me this past week and I thought I&apos;d do some digging. Here&apos;s a few initial thoughts and experiments. Oh, and I still used AI a bit and I&apos;ll explain exactly how in the post below.&lt;/p&gt;
&lt;h2 id=&quot;typescript-via-vite&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#typescript-via-vite&quot;&gt;TypeScript via Vite&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Working with TypeScript can be as simple as &lt;code&gt;npm i -g typescript&lt;/code&gt; and then &lt;code&gt;tsc somefile.ts&lt;/code&gt;. I decided to use &lt;a href=&quot;https://vite.dev/&quot;&gt;Vite&lt;/a&gt; instead as it&apos;s focused on web projects and most of my development involves web projects. I assume most of my readers know of Vite, but if you somehow have managed to avoid hearing about it or using it yet, it is a &lt;em&gt;lightning&lt;/em&gt; quick build tool for web projects. By &amp;quot;lightning quick&amp;quot; I mean that I still remember the first time I used it thinking it had failed to actually do anything - that&apos;s how quick it works.&lt;/p&gt;
&lt;p&gt;I also appreciated the fact that Vite&apos;s CLI supports a &lt;a href=&quot;https://stackblitz.com/edit/vitejs-vite-rnustopr?file=index.html&amp;amp;terminal=dev&quot;&gt;&lt;code&gt;vanilla-ts&lt;/code&gt;&lt;/a&gt; scaffold that is really lightweight. On the other hand, I&apos;m not necessarily a fan of what it actually shows as it feels a bit like bad practice, specifically an index file with just one div:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset=&amp;quot;UTF-8&amp;quot; /&amp;gt;
    &amp;lt;link rel=&amp;quot;icon&amp;quot; type=&amp;quot;image/svg+xml&amp;quot; href=&amp;quot;/favicon.svg&amp;quot; /&amp;gt;
    &amp;lt;meta name=&amp;quot;viewport&amp;quot; content=&amp;quot;width=device-width, initial-scale=1.0&amp;quot; /&amp;gt;
    &amp;lt;title&amp;gt;Vite + TS&amp;lt;/title&amp;gt;
  &amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;
    &amp;lt;div id=&amp;quot;app&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;script type=&amp;quot;module&amp;quot; src=&amp;quot;/src/main.ts&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then injects a bunch of HTML in main.ts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import &apos;./style.css&apos;
import heroImg from &apos;./assets/hero.png&apos;
import typescriptLogo from &apos;./assets/typescript.svg&apos;
import viteLogo from &apos;./assets/vite.svg&apos;
import { setupCounter } from &apos;./counter.ts&apos;

document.querySelector&amp;lt;HTMLDivElement&amp;gt;(&apos;#app&apos;)!.innerHTML = `
&amp;lt;section id=&amp;quot;center&amp;quot;&amp;gt;
  &amp;lt;div class=&amp;quot;hero&amp;quot;&amp;gt;
    &amp;lt;img src=&amp;quot;${heroImg}&amp;quot; class=&amp;quot;base&amp;quot; width=&amp;quot;170&amp;quot; height=&amp;quot;179&amp;quot;&amp;gt;
    &amp;lt;img src=&amp;quot;${typescriptLogo}&amp;quot; class=&amp;quot;framework&amp;quot; alt=&amp;quot;TypeScript logo&amp;quot;/&amp;gt;
    &amp;lt;img src=&amp;quot;${viteLogo}&amp;quot; class=&amp;quot;vite&amp;quot; alt=&amp;quot;Vite logo&amp;quot; /&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;h1&amp;gt;Get started&amp;lt;/h1&amp;gt;
    &amp;lt;p&amp;gt;Edit &amp;lt;code&amp;gt;src/main.ts&amp;lt;/code&amp;gt; and save to test &amp;lt;code&amp;gt;HMR&amp;lt;/code&amp;gt;&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;button id=&amp;quot;counter&amp;quot; type=&amp;quot;button&amp;quot; class=&amp;quot;counter&amp;quot;&amp;gt;&amp;lt;/button&amp;gt;
&amp;lt;/section&amp;gt;

&amp;lt;div class=&amp;quot;ticks&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;

&amp;lt;section id=&amp;quot;next-steps&amp;quot;&amp;gt;
  &amp;lt;div id=&amp;quot;docs&amp;quot;&amp;gt;
    &amp;lt;svg class=&amp;quot;icon&amp;quot; role=&amp;quot;presentation&amp;quot; aria-hidden=&amp;quot;true&amp;quot;&amp;gt;&amp;lt;use href=&amp;quot;/icons.svg#documentation-icon&amp;quot;&amp;gt;&amp;lt;/use&amp;gt;&amp;lt;/svg&amp;gt;
    &amp;lt;h2&amp;gt;Documentation&amp;lt;/h2&amp;gt;
    &amp;lt;p&amp;gt;Your questions, answered&amp;lt;/p&amp;gt;
    &amp;lt;ul&amp;gt;
      &amp;lt;li&amp;gt;
        &amp;lt;a href=&amp;quot;https://vite.dev/&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;
          &amp;lt;img class=&amp;quot;logo&amp;quot; src=&amp;quot;${viteLogo}&amp;quot; alt=&amp;quot;&amp;quot; /&amp;gt;
          Explore Vite
        &amp;lt;/a&amp;gt;
      &amp;lt;/li&amp;gt;
      &amp;lt;li&amp;gt;
        &amp;lt;a href=&amp;quot;https://www.typescriptlang.org&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;
          &amp;lt;img class=&amp;quot;button-icon&amp;quot; src=&amp;quot;${typescriptLogo}&amp;quot; alt=&amp;quot;&amp;quot;&amp;gt;
          Learn more
        &amp;lt;/a&amp;gt;
      &amp;lt;/li&amp;gt;
    &amp;lt;/ul&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div id=&amp;quot;social&amp;quot;&amp;gt;
    &amp;lt;svg class=&amp;quot;icon&amp;quot; role=&amp;quot;presentation&amp;quot; aria-hidden=&amp;quot;true&amp;quot;&amp;gt;&amp;lt;use href=&amp;quot;/icons.svg#social-icon&amp;quot;&amp;gt;&amp;lt;/use&amp;gt;&amp;lt;/svg&amp;gt;
    &amp;lt;h2&amp;gt;Connect with us&amp;lt;/h2&amp;gt;
    &amp;lt;p&amp;gt;Join the Vite community&amp;lt;/p&amp;gt;
    &amp;lt;ul&amp;gt;
      &amp;lt;li&amp;gt;&amp;lt;a href=&amp;quot;https://github.com/vitejs/vite&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;&amp;lt;svg class=&amp;quot;button-icon&amp;quot; role=&amp;quot;presentation&amp;quot; aria-hidden=&amp;quot;true&amp;quot;&amp;gt;&amp;lt;use href=&amp;quot;/icons.svg#github-icon&amp;quot;&amp;gt;&amp;lt;/use&amp;gt;&amp;lt;/svg&amp;gt;GitHub&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;
      &amp;lt;li&amp;gt;&amp;lt;a href=&amp;quot;https://chat.vite.dev/&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;&amp;lt;svg class=&amp;quot;button-icon&amp;quot; role=&amp;quot;presentation&amp;quot; aria-hidden=&amp;quot;true&amp;quot;&amp;gt;&amp;lt;use href=&amp;quot;/icons.svg#discord-icon&amp;quot;&amp;gt;&amp;lt;/use&amp;gt;&amp;lt;/svg&amp;gt;Discord&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;
      &amp;lt;li&amp;gt;&amp;lt;a href=&amp;quot;https://x.com/vite_js&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;&amp;lt;svg class=&amp;quot;button-icon&amp;quot; role=&amp;quot;presentation&amp;quot; aria-hidden=&amp;quot;true&amp;quot;&amp;gt;&amp;lt;use href=&amp;quot;/icons.svg#x-icon&amp;quot;&amp;gt;&amp;lt;/use&amp;gt;&amp;lt;/svg&amp;gt;X.com&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;
      &amp;lt;li&amp;gt;&amp;lt;a href=&amp;quot;https://bsky.app/profile/vite.dev&amp;quot; target=&amp;quot;_blank&amp;quot;&amp;gt;&amp;lt;svg class=&amp;quot;button-icon&amp;quot; role=&amp;quot;presentation&amp;quot; aria-hidden=&amp;quot;true&amp;quot;&amp;gt;&amp;lt;use href=&amp;quot;/icons.svg#bluesky-icon&amp;quot;&amp;gt;&amp;lt;/use&amp;gt;&amp;lt;/svg&amp;gt;Bluesky&amp;lt;/a&amp;gt;&amp;lt;/li&amp;gt;
    &amp;lt;/ul&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/section&amp;gt;

&amp;lt;div class=&amp;quot;ticks&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;
&amp;lt;section id=&amp;quot;spacer&amp;quot;&amp;gt;&amp;lt;/section&amp;gt;
`

setupCounter(document.querySelector&amp;lt;HTMLButtonElement&amp;gt;(&apos;#counter&apos;)!)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I&apos;m probably being &lt;em&gt;real&lt;/em&gt; nit picky here, but having 99% of the HTML for something in JavaScript versus HTML that gets enhanced feels off to me. But again - I&apos;m being picky, and the actual code spit out is pretty minimal. (I could go on for ages about what&apos;s &amp;quot;appropriate&amp;quot; for scaffolded projects and such. Give me a soap box and I&apos;ll tell you more.)&lt;/p&gt;
&lt;p&gt;So yeah, that&apos;s how I started. I pretty much nuked most of the code in main.ts and simplified even more.&lt;/p&gt;
&lt;p&gt;I mentioned earlier I still used a bit of AI in my exploration, and one example was to help clarify things a bit. For example, my very high level basic understanding of TypeScript basically came down to type annotations like so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let name:string = &amp;quot;Ray&amp;quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This line in the sample code confused me:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;document.querySelector&amp;lt;HTMLDivElement&amp;gt;(&apos;#app&apos;)!.innerHTML = `
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So I simply asked Claude to explain it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;The &amp;lt;HTMLDivElement&amp;gt; (generic type argument)

By default, querySelector returns Element | null. Element is a very generic type — 
TypeScript doesn&apos;t know whether it&apos;s a div, an input, an SVG circle, etc. That 
matters because different elements have different properties: Element doesn&apos;t 
have .value, for example, but HTMLInputElement does.

Writing querySelector&amp;lt;HTMLDivElement&amp;gt;(&apos;#app&apos;) tells TypeScript: &amp;quot;trust me, the 
thing this selector finds is a div.&amp;quot; So the return type becomes 
HTMLDivElement | null instead of Element | null, and you get proper autocomplete 
and type checking for div-specific properties.

Like the !, this is a compile-time-only assertion — TypeScript doesn&apos;t verify 
it. If #app were actually a &amp;lt;span&amp;gt;, the code would still compile and run; you&apos;d 
just have a lying type.

Fun fact: in this particular line the generic is technically unnecessary, since 
.innerHTML exists on Element anyway. It&apos;s there mostly out of habit/convention 
(this looks like the Vite starter template). It becomes genuinely useful when 
you need element-specific properties like .value, .src, or .checked.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cool. And this is something I&apos;ve really liked about AI in the past. I feel like my question, a particular syntax being used, wouldn&apos;t Google well and AI tools do a better job of getting to the right answer.&lt;/p&gt;
&lt;p&gt;At this point, I felt ready to write something.&lt;/p&gt;
&lt;h2 id=&quot;actually%2C-let&apos;s-not-build-for-the-web...&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#actually%2C-let&apos;s-not-build-for-the-web...&quot;&gt;Actually, let&apos;s not build for the web...&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;So when I figured out a good simple thing to build, I realized I didn&apos;t actually &lt;em&gt;need&lt;/em&gt; a web UI for it, at least not initially, so I did a quick pivot and looked up info on &lt;a href=&quot;https://vitest.dev/&quot;&gt;vitest&lt;/a&gt;, a &amp;quot;Vite-native&amp;quot; testing framework. I actually don&apos;t have a huge amount of experience with unit tests as so much of my work is educational, demo based, and such, that tests aren&apos;t really required. That being said, I&apos;ve written them, I&apos;m familiar with Jest (which vitest is compatible with), so it seemed like an easy win.&lt;/p&gt;
&lt;p&gt;I did a quick &lt;code&gt;npm i -D vitest&lt;/code&gt; and added this to my &lt;code&gt;package.json&lt;/code&gt; scripts block: &lt;code&gt;&amp;quot;test&amp;quot;: &amp;quot;vitest&amp;quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This let me run &lt;code&gt;npm run test&lt;/code&gt; in my terminal and get immediate feedback on what I was writing.&lt;/p&gt;
&lt;h2 id=&quot;roll-the-dice...&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#roll-the-dice...&quot;&gt;Roll the dice...&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;About five years ago, I thought it would be fun, and surely pretty simple, to build an RPG in Vue.js (here&apos;s the first entry: &lt;a href=&quot;https://www.raymondcamden.com/2020/07/17/testing-vuejs-application-files-that-arent-components&quot;&gt;&amp;quot;Testing Vue.js Application Files That Aren&apos;t Components&amp;quot;&lt;/a&gt;. I never finished that project, but I don&apos;t care - it was fun to work on.&lt;/p&gt;
&lt;p&gt;As part of that project, I built a simple &amp;quot;dice&amp;quot; utility that supported typical D&amp;amp;D dice rolls of the form: xDy. This translates to roll a Y-sided dice X times. It can get fancy too, so &lt;code&gt;2d6+2&lt;/code&gt; means roll a six sided die two times and then add two tp the final result. (You could imagine a scenario where a player is attempting to do something they are talented at, so the &lt;code&gt;+2&lt;/code&gt; represents a bonus to their chance to do the thing.)&lt;/p&gt;
&lt;p&gt;The code for that looked like so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export const dice = {

	roll(style) {
		let bonus=0, total=0;
		if(style.indexOf(&apos;+&apos;) &amp;gt; -1) {
			[style, bonus] = style.split(&apos;+&apos;);
		} 
		
		let [rolls, sided] = style.split(&apos;d&apos;);
		
		//console.log(rolls, sided);
		for(let i=0;i&amp;lt;rolls;i++) {
			total += getRandomIntInclusive(1, sided);
		}
		total += parseInt(bonus);
		return total;
	}
}

function getRandomIntInclusive(min, max) {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min + 1)) + min; //The maximum is inclusive and the minimum is inclusive 
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I thought a good first test would be to attempt to rewrite this in a TypeScript file. I actually broke it out into two files, one for the roll action and a &apos;util&apos; one for the random number selection.&lt;/p&gt;
&lt;p&gt;I&apos;ll share the utility function first as it was the simplest change:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export function getRandomInt(min: number, max: number): number {
  min = Math.ceil(min)
  max = Math.floor(max)
  return Math.floor(Math.random() * (max - min + 1)) + min
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This was really simple - just adding types to my arguments and result. I also removed &lt;code&gt;inclusive&lt;/code&gt; from the name as I never see myself needing a non-inclusive random number selection.&lt;/p&gt;
&lt;p&gt;The test for this was pretty simple too:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { describe, it, expect } from &apos;vitest&apos;
import { getRandomInt } from &apos;./utils&apos;

describe(&apos;getRandomInt&apos;, () =&amp;gt; {
  it(&apos;returns a number between min and max&apos;, () =&amp;gt; {
    const min = 1;
    const max = 2;
    const randomInt = getRandomInt(min, max);
    expect(randomInt).toBeGreaterThanOrEqual(min);
    expect(randomInt).toBeLessThanOrEqual(max);
  })

  it(&apos;returns an integer&apos;, () =&amp;gt; {
    const randomInt = getRandomInt(1, 10);
    expect(Number.isInteger(randomInt)).toBe(true);
  })
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I discovered &lt;em&gt;after&lt;/em&gt; writing this that you can actually screw with &lt;code&gt;Math.random()&lt;/code&gt; which is cool af as the kids say, so I may return to this. But now let&apos;s turn to the dice stuff which got REALLY complicated, but in a fun way.&lt;/p&gt;
&lt;p&gt;My first version went a bit like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { getRandomInt } from &apos;./utils.ts&apos;;

export const dice = {

	/*
	xdy

	x rolls of a y sided dice

	xdy+n
	xdy

	x rolls of a y side dice + n
	*/
	roll(styleInput: string): number {
		let bonus:number=0, total:number=0;
        let bonusStr:string=&apos;&apos;;
        let style = &apos;&apos;;

        if(styleInput.indexOf(&apos;+&apos;) &amp;gt; -1) {
			[style, bonusStr] = styleInput.split(&apos;+&apos;);
			bonus = Number(bonusStr);
		} else style = styleInput;
		
		const [rollsStr, sidedStr] = style.split(&apos;d&apos;);
		const rolls = Number(rollsStr);
		const sided = Number(sidedStr);

		//console.log(rolls, sided);
		for(let i=0;i&amp;lt;rolls;i++) {
			total += getRandomInt(1, sided);
		}

		total += Number(bonus);
		return total;
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And you&apos;ll notice it&apos;s a bit longer than the original version, and I don&apos;t mean more characters, I expected that, but some things took me a bit by surprise. Take this (original) line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let [rolls, sided] = style.split(&apos;d&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My original code just coerced &lt;code&gt;rolls&lt;/code&gt; into a number, which is bad, but I tried this and it didn&apos;t work:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let [rolls:number, sided: number] = style.split(&apos;d&apos;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which is obvious - the result of the string split function is strings. What didn&apos;t occur to me at the time was to chain to &lt;code&gt;map&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let [rolls, sided] = s.split(&apos;d&apos;).map(Number);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Live and learn, right? I built some basic tests for that and was curious what else I could do. I turned to my AI agent (Claude in this case) and just asked what other &apos;dice format&apos; options were available, and was pleasantly surprised to discover quite a few. In particular I thought these made sense:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep highest, lowest, using KH or KL. So for example, &lt;code&gt;3D6KH1&lt;/code&gt; means roll a six sided die three times, keep the highest one.&lt;/li&gt;
&lt;li&gt;Given a dice string input, what&apos;s the lowest value, and what&apos;s the highest value?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At this point, I decided to let AI help me write a bit of the code and handle updating the library to be a bit more flexible. The final result supports all of the above:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { getRandomInt } from &apos;./utils.ts&apos;;

const VALID_ROLL_REGEX =
    /^(\d*)d(\d+)(?:(kh|kl|dh|dl)(\d+))?([+-]\d+)?$/i;

function validRoll(style: string): boolean {
    return VALID_ROLL_REGEX.test(style);
}

function parseRoll(styleInput: string) {
    if (!validRoll(styleInput)) {
        throw new Error(`Invalid dice roll style: ${styleInput}`);
    }

    const match = styleInput.match(VALID_ROLL_REGEX)!;

    return {
        rolls: match[1] ? Number(match[1]) : 1,
        sided: Number(match[2]),
        keepMode: match[3]?.toLowerCase(),
        keepCount: match[4] ? Number(match[4]) : 0,
        bonus: match[5] ? Number(match[5]) : 0,
    };
}

function applyKeepDrop(
    dice: number[],
    keepMode: string | undefined,
    keepCount: number,
): number[] {
    if (!keepMode || keepCount === 0) {
        return dice;
    }

    dice.sort((a, b) =&amp;gt; a - b);

    switch (keepMode) {
        case &apos;kh&apos;:
            return dice.slice(-keepCount);
        case &apos;kl&apos;:
            return dice.slice(0, keepCount);
        case &apos;dh&apos;:
            return dice.slice(0, -keepCount);
        case &apos;dl&apos;:
            return dice.slice(keepCount);
        default:
            return dice;
    }
}

export function roll(styleInput: string): number {
    const { rolls, sided, keepMode, keepCount, bonus } =
        parseRoll(styleInput);

    const dice: number[] = [];

    for (let i = 0; i &amp;lt; rolls; i++) {
        dice.push(getRandomInt(1, sided));
    }

    const total = applyKeepDrop(dice, keepMode, keepCount)
        .reduce((sum, die) =&amp;gt; sum + die, 0);

    return total + bonus;
}

export function rollWithAdvantage(styleInput: string): number {
    return Math.max(roll(styleInput), roll(styleInput));
}

export function rollWithDisadvantage(styleInput: string): number {
    return Math.min(roll(styleInput), roll(styleInput));
}

export function minRoll(styleInput: string): number {
    const { rolls, keepMode, keepCount, bonus } = parseRoll(styleInput);
    const dice = Array(rolls).fill(1);

    return applyKeepDrop(dice, keepMode, keepCount)
        .reduce((sum, die) =&amp;gt; sum + die, 0) + bonus;
}

export function maxRoll(styleInput: string): number {
    const { rolls, sided, keepMode, keepCount, bonus } =
        parseRoll(styleInput);
    const dice = Array(rolls).fill(sided);

    return applyKeepDrop(dice, keepMode, keepCount)
        .reduce((sum, die) =&amp;gt; sum + die, 0) + bonus;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I won&apos;t share the entire test file (don&apos;t worry, I&apos;ll link to the repo in a bit), but as I mentioned above, you can actually &amp;quot;hack&amp;quot; random and that&apos;s really freaking sweet. For example, here is a test for rolls where you drop the lowest value:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;it(&apos;handles drop lowest&apos;, () =&amp;gt; {
    const spy = vi.spyOn(Math, &apos;random&apos;)
        .mockReturnValueOnce(0.1)  // first roll → low
        .mockReturnValueOnce(0.9);  // second roll → high

    const result = roll(&apos;2d6dl1&apos;);
    expect(result).toBe(6);

    spy.mockRestore();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Looking at it just now I see the second mock isn&apos;t needed - I&apos;ll fix that (eventually, honest) - but you get the idea.&lt;/p&gt;
&lt;h2 id=&quot;the-bits%2C-and-nothing-but-the-bits...&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-bits%2C-and-nothing-but-the-bits...&quot;&gt;The bits, and nothing but the bits...&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;So this isn&apos;t a &amp;quot;real&amp;quot; project, but I loved getting my feet wet in TypeScript and - tbh - just writing some darn code. The repo is here: https://github.com/cfjedimaster/typescript-stuff/tree/main/vite-one&lt;/p&gt;
&lt;p&gt;I&apos;ve already got an idea for a next project, one that will actually have a web presence, so I&apos;ll share that when I get the time.&lt;/p&gt;
&lt;p&gt;And this is where you come in. I&apos;d be willing to bet 99% of you are writing TypeScript daily and have plenty of comments - so let me know. Just be gentle. Please.&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="typescript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/09/06/building-a-document-qa-system-with-on-device-ai</id>
                <title>Building a Document Q&amp;A System with On-Device AI</title>
                <updated>2026-09-06T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/09/06/building-a-document-qa-system-with-on-device-ai" rel="alternate" type="text/html" title="Building a Document Q&amp;A System with On-Device AI"/>
                <content type="html">
				
                        &lt;p&gt;Every now and then I build a demo for a presentation and think to myself - this deserves its own blog post. I then promptly forget to actually do that. Even better, I completely forget to even show the demo in the presentation. A few weeks ago I made my first presentation at RenderATL and gave my talk on Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/built-in&quot;&gt;on-device AI&lt;/a&gt; technology. I&apos;ve been blogging about this particular set of APIs for a few years now and I&apos;ve given talks on it at numerous conferences. While these APIs are still evolving, many of them (and the ones I&apos;m using today in particular) are now fully GA for Chrome. For this demo, I put together a few things I&apos;ve done before into one cohesive tool - a completely client-side application that lets you pick a file, get a summary, and then chat with it.&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-stack&quot;&gt;The Stack&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The demo is built from a few different things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;First, I&apos;m making use of the &lt;strong&gt;incredibly excellent&lt;/strong&gt; &lt;a href=&quot;https://officeparser.harshankur.com/&quot;&gt;officeParser&lt;/a&gt; library by Harsh Ankur. This library can take any Office document, or PDF, or ODT, or CSV, and so on, and creates a parsed version you can do nearly anything with. For me, the most important feature is the ability to convert the file into Markdown which can then be used with AI.&lt;/li&gt;
&lt;li&gt;Next, it makes use of Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/summarizer-api&quot;&gt;Summarizer API&lt;/a&gt; which - you guessed it - provides summaries of textual input.&lt;/li&gt;
&lt;li&gt;Finally, it makes use of Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/prompt-api&quot;&gt;Prompt API&lt;/a&gt; to let you ask questions about the input.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The web app itself is a simple Vite app using vanilla JavaScript. I used my AI agent to set up the application and create the design and there was quite a bit of back and forth to get things locked down, but the end result is pretty cool I think.&lt;/p&gt;
&lt;h2 id=&quot;the-app&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-app&quot;&gt;The App&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, I&apos;m obviously going to link to it, and the source, too, but I thought I&apos;d share a few screenshots for people who may be on Safari, or maybe reading this on iOS where Chrome is locked down to the Safari engine.&lt;/p&gt;
&lt;p&gt;On opening the app, you get a three panel UI. The left side is for selecting and previewing the document. The upper right is for the summary. The rest of the right column is for Q and A:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/09/dqa1.png&quot; loading=&quot;lazy&quot; alt=&quot;Initial view&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;I selected my &lt;a href=&quot;/resume&quot;&gt;resume&lt;/a&gt; and after a few seconds, I get a summary. Now - I should clarify - the reason it works fast for me is that Chrome has already downloaded the roughly 4 gig model. That&apos;s absolutely &lt;em&gt;not&lt;/em&gt; insignificant. But it&apos;s also a model shared by the browser for &lt;em&gt;any&lt;/em&gt; web site making use of these APIs so it&apos;s not something a user has to download again and again.&lt;/p&gt;
&lt;p&gt;Here&apos;s the summary that was displayed:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/09/dqa2.png&quot; loading=&quot;lazy&quot; alt=&quot;Summary&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;This is &lt;em&gt;nearly&lt;/em&gt; perfect. For some reason it thinks I worked at Microsoft and as far as I know I only mention Microsoft once in the resume and it&apos;s in regard to work done at Webflow.&lt;/p&gt;
&lt;p&gt;But that brings up a great first use of the Q and A - I specifically asked if Ray worked at Microsoft and I got the proper response:&lt;/p&gt;
&lt;p&gt;&amp;quot;The provided document does not mention Ray Camden working at Microsoft.&amp;quot;&lt;/p&gt;
&lt;p&gt;I asked a followup question about PHP and I liked the response:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/09/dqa3.png&quot; loading=&quot;lazy&quot; alt=&quot;Q and A example&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Honestly that&apos;s a pretty deep answer and kind of impressive I think.&lt;/p&gt;
&lt;p&gt;Anyway, if you like this, and are using an up to date Chrome (on desktop or Android), you can check it out here: &lt;a href=&quot;https://document-question-answer.netlify.app/&quot;&gt;https://document-question-answer.netlify.app/&lt;/a&gt;. You can peruse the source up here: &lt;a href=&quot;https://github.com/cfjedimaster/doc_qa&quot;&gt;https://github.com/cfjedimaster/doc_qa&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/09/02/my-family-allowance-a-simple-allowance-tracking-site</id>
                <title>My Family Allowance - a Simple Allowance Tracking Site</title>
                <updated>2026-09-02T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/09/02/my-family-allowance-a-simple-allowance-tracking-site" rel="alternate" type="text/html" title="My Family Allowance - a Simple Allowance Tracking Site"/>
                <content type="html">
				
                        &lt;p&gt;As you can probably tell, I&apos;ve been on a bit of a builder kick the last few weeks. Today&apos;s &amp;quot;release&amp;quot; (it&apos;s been live for a few days already) is a tool to help fix a manual process we had in our family - tracking allowance for the kids.&lt;/p&gt;
&lt;p&gt;Previously we made use of a Google Sheet. Each kid had a column. When a kid would ask for something, we&apos;d look to see when we last added allowance, multiply the number of weeks by their rate, add it, and then add another row to reduce by the amount they were spending. A simple formula on the right hand side would give a running total so they could see what their current balance was.&lt;/p&gt;
&lt;p&gt;I decided to take this manual process and turn it into a proper application.&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/09/over.webp&quot; loading=&quot;lazy&quot; alt=&quot;Overengineering everywhere&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Using Cursor, I created an application with the following features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The parent signs up with their Google account.&lt;/li&gt;
&lt;li&gt;They then have a simple admin to add, edit, and delete kids while setting their weekly allowance.&lt;/li&gt;
&lt;li&gt;The parent can then add or remove funds when necessary.&lt;/li&gt;
&lt;li&gt;Every Sunday, automatically, allowance is paid.&lt;/li&gt;
&lt;li&gt;They are given a unique URL for their family and can set a basic password. A great idea for this would be the same password used for wifi in the house.&lt;/li&gt;
&lt;li&gt;The &amp;quot;family&amp;quot; URL lets kids see their totals, and even click in to see a transaction history if they want to remember what they spent money on.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The stack is Astro, hosted on Netlify, with their Netlify DB as the storage system.&lt;/p&gt;
&lt;p&gt;That&apos;s it! You can check it out here: &lt;a href=&quot;https://www.myallowance.family/&quot;&gt;https://www.myallowance.family/&lt;/a&gt;. If you want to check out the source, you can check out the repo: &lt;a href=&quot;https://github.com/cfjedimaster/myfamilyallowance&quot;&gt;https://github.com/cfjedimaster/myfamilyallowance&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@igalness?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Igal Ness&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/person-holding-fan-of-100-us-dollar-bill-9wY2ofzQ9Us?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="astro" />
                
                
                <category term="misc" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
</feed>