<?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-27T23:05:15+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/27/generating-pdf-invoices-from-word-templates-with-officeparser</id>
                <title>Generating PDF Invoices from Word Templates with officeParser</title>
                <updated>2026-10-27T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/10/27/generating-pdf-invoices-from-word-templates-with-officeparser" rel="alternate" type="text/html" title="Generating PDF Invoices from Word Templates with officeParser"/>
                <content type="html">
				
                        &lt;p&gt;Last week I shared a demo (&lt;a href=&quot;https://www.raymondcamden.com/2026/10/17/generating-pdfs-from-word-templates-with-officeparser%5D&quot;&gt;&amp;quot;Generating PDFs from Word Templates with officeParser&amp;quot;&lt;/a&gt;) that made use of the awesome &lt;a href=&quot;https://officeparser.harshankur.com/&quot;&gt;officeParser&lt;/a&gt; open source library. That demo was a simple &amp;quot;customer report&amp;quot; web app that took custom data and a Word template. Once the form was submitted, the library converted tokens in the Word template to real data and output a PDF. I decided to build another demo using the library, this time on the server with a simple Node.js script.&lt;/p&gt;
&lt;p&gt;As I mentioned previously, this new feature mimics Adobe&apos;s &lt;a href=&quot;https://developer.adobe.com/document-services/docs/overview/document-generation-api/&quot;&gt;Document Generation API&lt;/a&gt;. At the time I wrote my blog post, the library just supported simple transformations. So given `` in your Word template, if you passed a name value of &apos;Ray&apos;, you would see that in the resulting PDF.&lt;/p&gt;
&lt;p&gt;Now the library&apos;s been updated to support:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Conditional logic&lt;/li&gt;
&lt;li&gt;Looping&lt;/li&gt;
&lt;li&gt;Formatting of values&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Basically, and no shade to Adobe (not that I think they care), this open source library replicates the most of the same functionality, on your server, for free.&lt;/p&gt;
&lt;p&gt;Given these new features, I thought a simple &amp;quot;data invoicing system&amp;quot; would be a good demo. The idea being:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You have data for invoices, usually in a database, but for me, just a JSON file.&lt;/li&gt;
&lt;li&gt;You have a Word template for the invoice.&lt;/li&gt;
&lt;li&gt;A script combines this together and spits out PDFs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first thing I built was the Word template. I used one of the built-in Word templates and modified it a bit. The screenshot below is from Word&apos;s online viewer which frankly looks pretty jank. In my desktop Word it looked a lot better:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/10/word1.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Word template&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;In this template you can see the simple tokens but also an example of looping in the table using &lt;code&gt;for line in lineItems&lt;/code&gt;. Also note the formatting on the dollar amounts. I could have found a way to get conditionals in, but figured this was interesting enough.&lt;/p&gt;
&lt;p&gt;My raw data is just JSON. Here&apos;s part of the file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;invoices&amp;quot;: [
    {
      &amp;quot;invoiceNumber&amp;quot;: 100,
      &amp;quot;name&amp;quot;: &amp;quot;Ada Lovelace&amp;quot;,
      &amp;quot;email&amp;quot;: &amp;quot;ada@example.com&amp;quot;,
      &amp;quot;lineItems&amp;quot;: [
        {
          &amp;quot;description&amp;quot;: &amp;quot;Consulting&amp;quot;,
          &amp;quot;quantity&amp;quot;: 3,
          &amp;quot;unitCost&amp;quot;: 150
        },
        {
          &amp;quot;description&amp;quot;: &amp;quot;Travel expenses&amp;quot;,
          &amp;quot;quantity&amp;quot;: 1,
          &amp;quot;unitCost&amp;quot;: 48.75
        }
      ]
    },
    {
      &amp;quot;invoiceNumber&amp;quot;: 101,
      &amp;quot;name&amp;quot;: &amp;quot;Grace Hopper&amp;quot;,
      &amp;quot;email&amp;quot;: &amp;quot;grace@example.com&amp;quot;,
      &amp;quot;lineItems&amp;quot;: [
        {
          &amp;quot;description&amp;quot;: &amp;quot;Database review&amp;quot;,
          &amp;quot;quantity&amp;quot;: 2,
          &amp;quot;unitCost&amp;quot;: 225
        }
      ]
    },
    {
      &amp;quot;invoiceNumber&amp;quot;: 102,
      &amp;quot;name&amp;quot;: &amp;quot;Katherine Johnson&amp;quot;,
      &amp;quot;email&amp;quot;: &amp;quot;katherine@example.com&amp;quot;,
      &amp;quot;lineItems&amp;quot;: [
        {
          &amp;quot;description&amp;quot;: &amp;quot;Orbital trajectory analysis&amp;quot;,
          &amp;quot;quantity&amp;quot;: 2,
          &amp;quot;unitCost&amp;quot;: 310
        },
        {
          &amp;quot;description&amp;quot;: &amp;quot;Technical review&amp;quot;,
          &amp;quot;quantity&amp;quot;: 3,
          &amp;quot;unitCost&amp;quot;: 85.5
        },
        {
          &amp;quot;description&amp;quot;: &amp;quot;Project meeting&amp;quot;,
          &amp;quot;quantity&amp;quot;: 1,
          &amp;quot;unitCost&amp;quot;: 120
        }
      ]
    },
    {
      &amp;quot;invoiceNumber&amp;quot;: 103,
      &amp;quot;name&amp;quot;: &amp;quot;Alan Turing&amp;quot;,
      &amp;quot;email&amp;quot;: &amp;quot;alan@example.com&amp;quot;,
      &amp;quot;lineItems&amp;quot;: [
        {
          &amp;quot;description&amp;quot;: &amp;quot;Systems consulting&amp;quot;,
          &amp;quot;quantity&amp;quot;: 4,
          &amp;quot;unitCost&amp;quot;: 175
        },
        {
          &amp;quot;description&amp;quot;: &amp;quot;Prototype review&amp;quot;,
          &amp;quot;quantity&amp;quot;: 2,
          &amp;quot;unitCost&amp;quot;: 240
        },
        {
          &amp;quot;description&amp;quot;: &amp;quot;Documentation&amp;quot;,
          &amp;quot;quantity&amp;quot;: 6,
          &amp;quot;unitCost&amp;quot;: 42.5
        },
        {
          &amp;quot;description&amp;quot;: &amp;quot;Materials&amp;quot;,
          &amp;quot;quantity&amp;quot;: 1,
          &amp;quot;unitCost&amp;quot;: 63.25
        }
      ]
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And finally, here&apos;s the script that does the processing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const { readFile, mkdir, writeFile } = require(&apos;node:fs/promises&apos;)
const path = require(&apos;node:path&apos;)
const { OfficeConverter, OfficeTemplate } = require(&apos;officeparser&apos;)

function isRecord(value) {
  return value !== null &amp;amp;&amp;amp; typeof value === &apos;object&apos; &amp;amp;&amp;amp; !Array.isArray(value)
}

function requireText(value, label) {
  if (typeof value !== &apos;string&apos; || value.trim() === &apos;&apos;) {
    throw new Error(`${label} must be a non-empty string.`)
  }

  return value.trim()
}

function toCents(value, label) {
  if (typeof value !== &apos;number&apos; || !Number.isFinite(value) || value &amp;lt; 0) {
    throw new Error(`${label} must be a non-negative number.`)
  }

  const cents = Math.round((value + Number.EPSILON) * 100)
  if (!Number.isSafeInteger(cents)) {
    throw new Error(`${label} is too large to represent as currency.`)
  }

  return cents
}

function buildInvoiceData(json, issuedOn = new Date()) {
  if (!isRecord(json) || !Array.isArray(json.invoices) || json.invoices.length === 0) {
    throw new Error(&apos;The JSON file must contain a non-empty &amp;quot;invoices&amp;quot; array.&apos;)
  }

  const invoiceDate = [
    issuedOn.getFullYear(),
    String(issuedOn.getMonth() + 1).padStart(2, &apos;0&apos;),
    String(issuedOn.getDate()).padStart(2, &apos;0&apos;),
  ].join(&apos;-&apos;)

  return json.invoices.map((invoice, invoiceIndex) =&amp;gt; {
    const invoiceLabel = `Invoice ${invoiceIndex + 1}`
    if (!isRecord(invoice)) {
      throw new Error(`${invoiceLabel} must be an object.`)
    }

    if (!Number.isSafeInteger(invoice.invoiceNumber) || invoice.invoiceNumber &amp;lt;= 0) {
      throw new Error(`${invoiceLabel} invoiceNumber must be a positive whole number.`)
    }

    const recipientName = requireText(invoice.name, `${invoiceLabel} name`)
    const recipientEmail = requireText(invoice.email, `${invoiceLabel} email`)
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipientEmail)) {
      throw new Error(`${invoiceLabel} email must be a valid email address.`)
    }
    if (!Array.isArray(invoice.lineItems) || invoice.lineItems.length === 0) {
      throw new Error(`${invoiceLabel} must contain at least one line item.`)
    }

    let subtotalCents = 0
    const lineItems = invoice.lineItems.map((item, itemIndex) =&amp;gt; {
      const itemLabel = `${invoiceLabel} line item ${itemIndex + 1}`
      if (!isRecord(item)) {
        throw new Error(`${itemLabel} must be an object.`)
      }

      const description = requireText(item.description, `${itemLabel} description`)
      if (!Number.isSafeInteger(item.quantity) || item.quantity &amp;lt;= 0) {
        throw new Error(`${itemLabel} quantity must be a positive whole number.`)
      }

      const unitCostCents = toCents(item.unitCost, `${itemLabel} unitCost`)
      const lineTotalCents = unitCostCents * item.quantity
      if (!Number.isSafeInteger(lineTotalCents) || !Number.isSafeInteger(subtotalCents + lineTotalCents)) {
        throw new Error(`${itemLabel} total is too large to represent as currency.`)
      }
      subtotalCents += lineTotalCents

      return {
        description,
        quantity: item.quantity,
        unitCost: unitCostCents / 100,
        total: lineTotalCents / 100,
        lineTotal: lineTotalCents / 100,
      }
    })

    const invoiceNumber = String(invoice.invoiceNumber)
    const salesTaxCents = Math.round(subtotalCents / 20)
    const totalCents = subtotalCents + salesTaxCents
    if (!Number.isSafeInteger(totalCents)) {
      throw new Error(`${invoiceLabel} total due is too large to represent as currency.`)
    }

    return {
      invoiceNumber,
      invoiceDate,
      recipientName,
      recipientEmail,
      lineItems,
      subtotal: subtotalCents / 100,
      salesTax: salesTaxCents / 100,
      total: totalCents / 100,
    }
  })
}

function makeFilename(invoice) {
  const customerSlug = invoice.recipientName
    .normalize(&apos;NFKD&apos;)
    .replace(/[\u0300-\u036f]/g, &apos;&apos;)
    .replace(/[^a-z0-9]+/gi, &apos;-&apos;)
    .replace(/^-|-$/g, &apos;&apos;)
    .toLowerCase() || &apos;customer&apos;

  return `${invoice.invoiceNumber.toLowerCase()}-${customerSlug}.pdf`
}

async function generateInvoices(inputPath, outputPath, templatePath) {
  const input = JSON.parse(await readFile(inputPath, &apos;utf8&apos;))
  const invoices = buildInvoiceData(input)
  const template = await readFile(templatePath)

  await mkdir(outputPath, { recursive: true })

  const renderedDocuments = await OfficeTemplate.render(template, {
    data: invoices,
    onMissing: &apos;error&apos;,
  })
  if (!Array.isArray(renderedDocuments) || renderedDocuments.length !== invoices.length) {
    throw new Error(&apos;officeparser did not return one rendered document per invoice.&apos;)
  }

  for (const [index, invoice] of invoices.entries()) {
    const renderedDocx = renderedDocuments[index]
    if (!(renderedDocx instanceof Uint8Array)) {
      throw new Error(`officeparser did not return a rendered document for ${invoice.invoiceNumber}.`)
    }

    const { value: pdfBytes } = await OfficeConverter.convert(renderedDocx, &apos;pdf&apos;, {
      generatorConfig: {
        pdfConfig: { engine: &apos;native&apos; },
      },
    })

    if (!(pdfBytes instanceof Uint8Array)) {
      throw new Error(`officeparser did not return PDF data for ${invoice.invoiceNumber}.`)
    }

    const outputFile = path.join(outputPath, makeFilename(invoice))
    await writeFile(outputFile, pdfBytes)
    console.log(`Created ${outputFile}`)
  }
}

async function main(args) {
  if (args[0] === &apos;--help&apos; || args[0] === &apos;-h&apos;) {
    console.log(&apos;Usage: node script.js [invoices.json] [output-directory]&apos;)
    return
  }
  if (args.length &amp;gt; 2) {
    throw new Error(&apos;Usage: node script.js [invoices.json] [output-directory]&apos;)
  }

  const inputPath = args[0]
    ? path.resolve(args[0])
    : path.join(__dirname, &apos;invoices.json&apos;)
  const outputPath = args[1]
    ? path.resolve(args[1])
    : path.join(__dirname, &apos;output&apos;)
  const templatePath = path.join(__dirname, &apos;invoiceTemplate.docx&apos;)

  await generateInvoices(inputPath, outputPath, templatePath)
}

if (require.main === module) {
  main(process.argv.slice(2)).catch((error) =&amp;gt; {
    console.error(&apos;Invoice generation failed:&apos;, error)
    process.exitCode = 1
  })
}

module.exports = { buildInvoiceData, generateInvoices, makeFilename }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It can be run at the CLI and takes arguments for where the data is and where to write it out - along with basic defaults for each. &lt;code&gt;buildInvoiceData&lt;/code&gt; does the job of mapping the &apos;raw&apos; data to what I need for the operation. This is something I did with Adobe&apos;s APIs as well. Rarely was the source data in a shape that mapped directly to the Word template, so a bit of shaping handles that transformation.&lt;/p&gt;
&lt;p&gt;The officeParser library part is here:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const renderedDocuments = await OfficeTemplate.render(template, {
	data: invoices,
	onMissing: &apos;error&apos;,
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That second arg is interesting. If the Word template references a value you don&apos;t pass, you can have the library simply ignore it, or throw an error. I like throwing an error as it makes it obvious when you&apos;ve screwed something up.&lt;/p&gt;
&lt;p&gt;After the conversion is done, the bits are all saved out to PDFs using the invoice number and name of the person getting the invoice.&lt;/p&gt;
&lt;p&gt;There&apos;s a bit more to the script above, but the gist is - read the data, shape it, pass it along with the template, save the result.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example of how one looks:&lt;/p&gt;
&lt;div id=&quot;pdf-viewer-1&quot; style=&quot;width: 100%; height: 500px; margin-bottom: 30px&quot;&gt;&lt;/div&gt;
&lt;script type=&quot;module&quot;&gt;
import EmbedPDF, { ZoomMode } from &apos;https://cdn.jsdelivr.net/npm/@embedpdf/snippet@2/dist/embedpdf.js&apos;;
 
EmbedPDF.init({
	type: &apos;container&apos;,
	target: document.getElementById(&apos;pdf-viewer-1&apos;),
	src: &apos;https://static.raymondcamden.com/images/2026/10/106-radia-perlman.pdf&apos;,
	theme: { preference: &apos;system&apos; },
	disabledCategories: [&apos;annotation&apos;],
	zoom: {
		defaultZoomLevel: ZoomMode.FitWidth
	}
});
&lt;/script&gt;
&lt;p&gt;Pretty cool, right? I mean, we all agree, PDFs are REALLY COOL! Anyway, you can grab all the bits, along with the Word template and sample output here: &lt;a href=&quot;https://github.com/cfjedimaster/webdemos/tree/master/officeparser/invoice&quot;&gt;https://github.com/cfjedimaster/webdemos/tree/master/officeparser/invoice&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/23/building-the-unicorn-themed-app-of-my-dreams-with-tinyjs</id>
                <title>Building the Unicorn-themed app of my dreams with TinyJS</title>
                <updated>2026-10-23T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/10/23/building-the-unicorn-themed-app-of-my-dreams-with-tinyjs" rel="alternate" type="text/html" title="Building the Unicorn-themed app of my dreams with TinyJS"/>
                <content type="html">
				
                        &lt;p&gt;Everyone likes to talk about light versus dark theme while no one is addressing the real need for our users - a proper unicorn theme. I mean, take a look at this beauty:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/10/uni1.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Uninote desktop app running&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;This is &lt;a href=&quot;https://github.com/cfjedimaster/tinyjsdemos/tree/main/uninote&quot;&gt;Uninote&lt;/a&gt;, a desktop note taking application that - well - takes notes. You create notebooks, write notes, and that&apos;s it. No AI features. Nothing complex, just a note taking app. I&apos;ve had a love/hate relationship with these apps for years. I was a &lt;em&gt;huge&lt;/em&gt; Evernote fan until it went to poo. I currently use OneNote and... it&apos;s ok but I wish it was better. And yes, before folks chime in with suggestions, I&apos;m aware I&apos;ve got choices, but I &amp;quot;share&amp;quot; note access with my wife so one of the caveats is that I need an app she&apos;s comfortable with and I can deal with as well.&lt;/p&gt;
&lt;p&gt;That being said - &lt;a href=&quot;https://tinyjs.app/index.html&quot;&gt;TinyJS&lt;/a&gt; is a cool as hell platform to build desktop apps and I thought I&apos;d try building a basic note application with it. You can &lt;a href=&quot;https://github.com/cfjedimaster/tinyjsdemos/tree/main/uninote&quot;&gt;peruse&lt;/a&gt; all the code you want, and if you clone the repo and use the CLI, run a build yourself (I don&apos;t have any plans to release a &amp;quot;real&amp;quot; app - maybe) if you want to see it in action. Most of it is &amp;quot;just&amp;quot; HTML, CSS, and JavaScript, so let me share some of the more interesting tidbits.&lt;/p&gt;
&lt;h2 id=&quot;design&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#design&quot;&gt;Design&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Before getting into the code, let me talk about the design. I began in Claude and used this prompt:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;im building a simple desktop app with TinyJS that is a note taking application, 
like Evernote and OneNote. the app will be called Uninote, and is unicorn 
themed. functionality is pretty simple - you have notebooks that have a name, 
you can add, edit, and remove notebooks. notebooks have notes with a title, 
last edited value, and just a block of text (no support for binary data). it 
should support light/dark mode

can you give me 3 mocks for this - images only if you can, no HTML. as i said, 
its unicorn themed so it should slick, but cute
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let me just say - I greatly appreciate AI tools that can deal with my typos. Claude did a pretty good job with the three mocks it created, but the design you see above was a clear favorite for me. While I love the light version, the dark version is pretty great as well:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/10/uni2.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Uninote desktop app running - dark theme&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;This blew away most of my daily Claude usage however, so I took it to Copilot in Visual Studio Code to convert the mock to HTML. I forgot to tell it to ignore the desktop UI (ie OSX app frame stuff) so I had to ask it to do that as a followup, but it worked well.&lt;/p&gt;
&lt;p&gt;This was done in a new app scaffolded by the TinyJS CLI. As I mentioned in my &lt;a href=&quot;https://www.raymondcamden.com/2026/10/08/tiny-apps-with-tinyjs&quot;&gt;first post&lt;/a&gt;, you can scaffold an app with Alpine and TypeScript, and Copilot had no issues going from mock to layout with that setup. From there, I basically &amp;quot;rolled back&amp;quot; the mock data into Alpine directives. So for example, my mock had a list of notebooks that I was able to convert to a proper list in Alpine based on data.&lt;/p&gt;
&lt;h2 id=&quot;code&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#code&quot;&gt;Code&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;So most of this is vanilla Alpine type stuff, where the interesting bit comes in is the persistence, which uses SQLite. SQLite access is provided in the &amp;quot;backend&amp;quot; code. You begin with one import:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { Database } from &apos;tjs:sqlite&apos;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then handle your database initialization. For me it looks like so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let db = null;

export const api = {
  initDB: async () =&amp;gt; {
    db.exec(`
CREATE TABLE IF NOT EXISTS notebooks (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL
);      
     `);

    db.exec(`
CREATE TABLE IF NOT EXISTS notes (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  notebook_id INTEGER NOT NULL,
  created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (notebook_id) REFERENCES notebooks(id) ON DELETE CASCADE
);      
     `);
    db.exec(&apos;PRAGMA foreign_keys = ON&apos;);

    return true;
  },
  // more stuff
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My database is two simple tables - one for notebooks and one for notes. TinyJS takes any method defined in the &lt;code&gt;api&lt;/code&gt; export and lets you call it from the client. So in my Alpine startup I&apos;ve got:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async init() {
    await tiny.api.call(&apos;initDB&apos;)
    // more stuff
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The rest of the &apos;CRUD&apos; (Creating, Reading, Updating, Deleting), are just more DB calls. On the front end, I gather the data, pass it to the backend via &lt;code&gt;tiny.api.call&lt;/code&gt;, and write the SQL back there.&lt;/p&gt;
&lt;p&gt;So for example, to just get a list of notebooks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// in the front end
this.notebooks = await tiny.api.call(&apos;getNotebooks&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;getNotebooks: async () =&amp;gt; {
if (!db) throw new Error(&apos;Database not initialized. Call initDB first.&apos;);
return db.prepare(`
    SELECT notebooks.id, notebooks.name, COUNT(notes.id) AS noteCount
    FROM notebooks LEFT JOIN notes ON notes.notebook_id = notebooks.id
    GROUP BY notebooks.id ORDER BY notebooks.id
`).all();
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s basically it. Let me share two more little TinyJS bits. To copy to the clipboard, I did not use the usual web platform way, but rather:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;await tiny.clipboard.write({ text: `${note.title}\n\n${note.content}` })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I&apos;m not sure what that offers in contrast to the &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Clipboard&quot;&gt;Clipboard API&lt;/a&gt; and will find some time to figure that out.&lt;/p&gt;
&lt;p&gt;The second is a small thing, but super useful. Your TinyJS app has a &apos;controller&apos; JSON file that lets you set a heck of a lot of properties. One of those is the window size:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;&amp;quot;size&amp;quot;: &amp;quot;960x640&amp;quot;,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But out of the box, you can set a default size &lt;em&gt;and&lt;/em&gt; have the app automatically remember the window size and position:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;&amp;quot;size&amp;quot;: &amp;quot;960x640&amp;quot;,
&amp;quot;rememberWindows&amp;quot;: true,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I was convinced I was going to need a &amp;quot;listen for window resize and persist&amp;quot; type logic but nope - one line in JSON. Perfect.&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@anniespratt?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Annie Spratt&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/white-and-gold-ceramic-unicorn-figurine-near-coins-Jr8byYZmTTU?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/21/adding-a-pdf-display-to-eleventy</id>
                <title>Adding a PDF Display to Eleventy</title>
                <updated>2026-10-21T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/10/21/adding-a-pdf-display-to-eleventy" rel="alternate" type="text/html" title="Adding a PDF Display to Eleventy"/>
                <content type="html">
				
                        &lt;p&gt;This will be a short one. I&apos;ve talked about embedding PDFs on web pages for probably close to ten years now. Blame Adobe. Blame me being a nerd and finding this stuff interesting. Blame whoever. While I&apos;ve blogged about the topic, I&apos;ve also actually done that on my site here, in a variety of different ways.&lt;/p&gt;
&lt;p&gt;A while back I realized I wasn&apos;t being consistent with how I did... &amp;quot;stuff&amp;quot; here, like call outs, Youtube links, etc. I started a &lt;a href=&quot;https://github.com/cfjedimaster/raymondcamden2023/blob/main/guide.md&quot;&gt;guide doc&lt;/a&gt; so I&apos;d have a quick way to remember what I did last time, and be more consistent.&lt;/p&gt;
&lt;p&gt;One item on there I had left as &amp;quot;figure it out later&amp;quot; was proper PDF embedding. This week I finally nailed that down. &lt;a href=&quot;https://www.embedpdf.com/&quot;&gt;EmbedPDF&lt;/a&gt; is a library I&apos;ve mentioned here before so I leaned on it, and went with an incredibly simple snippet:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div id=&amp;quot;pdf-dis&amp;quot; style=&amp;quot;width: 100%; height: 400px; margin-bottom: 30px&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;
 
&amp;lt;script type=&amp;quot;module&amp;quot;&amp;gt;
import EmbedPDF, { ZoomMode } from &apos;https://cdn.jsdelivr.net/npm/@embedpdf/snippet@2/dist/embedpdf.js&apos;;
 
EmbedPDF.init({
	type: &apos;container&apos;,
	target: document.getElementById(&apos;pdf-dis&apos;),
	src: &apos;https:// ....&apos;,
	theme: { preference: &apos;system&apos; },
	disabledCategories: [&apos;annotation&apos;],
	zoom: {
		defaultZoomLevel: ZoomMode.FitWidth
	}
});
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This does a few things that make sense on my blog, and may not for you. I use the &lt;code&gt;ZoomMode&lt;/code&gt; feature to make the PDF document automatically be as wide as the container. Honestly, I don&apos;t know why this isn&apos;t the default, but given that PDFs can specify their own default view, the library may just usually let it decide. The rest of the code is pretty much vanilla from the docs.&lt;/p&gt;
&lt;p&gt;So that&apos;s the snippet. To make it a shortcode in Eleventy, I added this to my page which defines all the shortcodes for my blog:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const embedPDF = (id, url, height=&amp;quot;500px&amp;quot;) =&amp;gt; {
  return `
&amp;lt;div id=&amp;quot;${id}&amp;quot; style=&amp;quot;width: 100%; height: ${height}; margin-bottom: 30px&amp;quot;&amp;gt;&amp;lt;/div&amp;gt;
 
&amp;lt;script type=&amp;quot;module&amp;quot;&amp;gt;
import EmbedPDF, { ZoomMode } from &apos;https://cdn.jsdelivr.net/npm/@embedpdf/snippet@2/dist/embedpdf.js&apos;;
 
EmbedPDF.init({
	type: &apos;container&apos;,
	target: document.getElementById(&apos;${id}&apos;),
	src: &apos;${url}&apos;,
	theme: { preference: &apos;system&apos; },
	disabledCategories: [&apos;annotation&apos;],
	zoom: {
		defaultZoomLevel: ZoomMode.FitWidth
	}
});
&amp;lt;/script&amp;gt;
  `;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gets loaded by my Eleventy config script and inside a post, I can:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{% embedPDF &amp;quot;pdf-viewer-1&amp;quot;, &amp;quot;https://static.raymondcamden.com/assets/document.pdf&amp;quot; %}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which gives you:&lt;/p&gt;
&lt;div id=&quot;pdf-viewer-1&quot; style=&quot;width: 100%; height: 500px; margin-bottom: 30px&quot;&gt;&lt;/div&gt;
&lt;script type=&quot;module&quot;&gt;
import EmbedPDF, { ZoomMode } from &apos;https://cdn.jsdelivr.net/npm/@embedpdf/snippet@2/dist/embedpdf.js&apos;;
 
EmbedPDF.init({
	type: &apos;container&apos;,
	target: document.getElementById(&apos;pdf-viewer-1&apos;),
	src: &apos;https://static.raymondcamden.com/assets/document.pdf&apos;,
	theme: { preference: &apos;system&apos; },
	disabledCategories: [&apos;annotation&apos;],
	zoom: {
		defaultZoomLevel: ZoomMode.FitWidth
	}
});
&lt;/script&gt;
&lt;p&gt;I found this useful, and I hope you do too.&lt;/p&gt;

                        
                
				</content>

                
                <category term="eleventy" />
                
                
                <category term="jamstack" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/10/19/links-for-you-101926</id>
                <title>Links For You (10/19/26)</title>
                <updated>2026-10-19T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/10/19/links-for-you-101926" rel="alternate" type="text/html" title="Links For You (10/19/26)"/>
                <content type="html">
				
                        &lt;p&gt;Wowza - I&apos;m like nearly a month late on this. This is where I&apos;d try to come up with a good excuse, but I think if I just randomly point around at things and said... &amp;quot;life&amp;quot; most of you would completely understand where I&apos;m coming from. It&apos;s been a difficult few months for myself, and for nearly every person I know. This week I&apos;m going to try focus on the positive and the things that make me smile, and I hope all my lovely readers can do the same!&lt;/p&gt;
&lt;h2 id=&quot;make-music%2C-not-war&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#make-music%2C-not-war&quot;&gt;Make Music, Not War&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;First up is the appropriately named &lt;a href=&quot;https://hyperblam.how/&quot;&gt;Hyperblam&lt;/a&gt;, a music making tool that lets you use a declarative language to create sick beats as the kids say. What does that look like? Here&apos;s one example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!-- bus! (master signal chain) --&amp;gt;
&amp;lt;chain-blam id=&amp;quot;bus&amp;quot;&amp;gt;
&amp;lt;gain-blam id=&amp;quot;bits&amp;quot; gain=&amp;quot;0.25&amp;quot; bypass&amp;gt;
    &amp;lt;lfo-blam type=&amp;quot;square&amp;quot; beats=&amp;quot;0.001&amp;quot; prop=&amp;quot;gain&amp;quot; gain=&amp;quot;0.25&amp;quot;&amp;gt;
    &amp;lt;blam-blam from=&amp;quot;#kick&amp;quot; prop=&amp;quot;beats&amp;quot; value=&amp;quot;0.001~0.003&amp;quot; chance=&amp;quot;0.3&amp;quot;&amp;gt;&amp;lt;/blam-blam&amp;gt;
    &amp;lt;/lfo-blam&amp;gt;
&amp;lt;/gain-blam&amp;gt;
&amp;lt;phaser-blam id=&amp;quot;swoosh&amp;quot; center=&amp;quot;6000&amp;quot; depth=&amp;quot;4000&amp;quot; q=&amp;quot;0.5&amp;quot; bypass&amp;gt;&amp;lt;/phaser-blam&amp;gt;
&amp;lt;analyser-blam id=&amp;quot;analyser&amp;quot; smoothing=&amp;quot;0.6&amp;quot;&amp;gt;&amp;lt;/analyser-blam&amp;gt;
&amp;lt;/chain-blam&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And you can play the song this is a part of here:&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;-2&quot; data-height=&quot;500&quot; data-pen-title=&quot;HYPERBLAM (HELLO HYPERBLAM)&quot; data-preview=&quot;true&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;RNKgMEe&quot; data-user=&quot;heydon&quot; style=&quot;height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/heydon/pen/RNKgMEe&quot;&gt;
  HYPERBLAM (HELLO HYPERBLAM)&lt;/a&gt; by Heydon (&lt;a href=&quot;https://codepen.io/heydon&quot;&gt;@heydon&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
&lt;h2 id=&quot;this-ui-library-will-make-you-jelly...&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#this-ui-library-will-make-you-jelly...&quot;&gt;This UI library will make you jelly...&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Jelly as in jealous - and again - that&apos;s what the kids say, right? &lt;a href=&quot;https://jelly-ui.com/&quot;&gt;Jelly UI&lt;/a&gt; is a UI web component library for &amp;quot;jelly&amp;quot; type interfaces. Think fat, but soft, bright colors and such. I love web components for libraries like this, and this one is on my list to try soon in a demo.&lt;/p&gt;
&lt;p&gt;I copied a few samples from their docs and threw it in a CodePen:&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;-2&quot; data-height=&quot;500&quot; data-pen-title=&quot;Jelly UI&quot; data-preview=&quot;true&quot; data-version=&quot;2&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;azJGPMW&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/editor/cfjedimaster/pen/01a15441-9c56-7fc4-beda-0c7c391a2e01&quot;&gt;
  Jelly UI&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
&lt;h2 id=&quot;your-new-rss-feed-reader&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#your-new-rss-feed-reader&quot;&gt;Your new RSS Feed Reader&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Last but definitely not least is an incredible new product from my buddy &lt;a href=&quot;https://kisdigital.com/&quot;&gt;Robert&lt;/a&gt; - &lt;a href=&quot;https://feedhouse.app/&quot;&gt;Feedhouse&lt;/a&gt; is an RSS reader, but with so many features it goes far beyond just RSS parsing. It has great social aspects (you can mark up and comment on items in your feed, you can share collections of feeds), it supports sites without RSS feeds, it lets you sign up for newsletters with a custom address and automatically parse the items into your feed, and so much more. If you want to learn more about how it&apos;s built, Robert wrote up a great post on it: &lt;a href=&quot;https://kisdigital.com/posts/2026/10/everything-feedhouse-does-from-the-boring-parts-up&quot;&gt;Everything Feedhouse Does, From the Boring Parts Up&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;just-for-fun&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#just-for-fun&quot;&gt;Just For Fun&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;My readers know I&apos;m a huge fan of covers and imagine my surprise when I discovered this one by School of Seven Bells... enjoy!&lt;/p&gt;
&lt;lite-youtube videoid=&quot;bQlVb4ffhCs&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/bQlVb4ffhCs/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=bQlVb4ffhCs&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/10/17/generating-pdfs-from-word-templates-with-officeparser</id>
                <title>Generating PDFs from Word Templates with officeParser</title>
                <updated>2026-10-17T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/10/17/generating-pdfs-from-word-templates-with-officeparser" rel="alternate" type="text/html" title="Generating PDFs from Word Templates with officeParser"/>
                <content type="html">
				
                        &lt;p&gt;I&apos;ve been singing the praises of the &lt;a href=&quot;https://officeparser.harshankur.com/&quot;&gt;officeParser&lt;/a&gt; library for a while. It&apos;s an open source JavaScript library that can parse, extract, and convert between PDFs and Office documents, as well as OpenOffice, ePub and more. My demos have all focused on client-side examples but obviously the library would work great in a server side workflow as well. I haven&apos;t worked at Adobe for years now, but I still get strangely excited working with documents. I&apos;ll just add that to the long list of reasons I&apos;m a big nerd.&lt;/p&gt;
&lt;p&gt;A few weeks ago, I reached out to &lt;a href=&quot;https://harshankur.com/&quot;&gt;Harsh Ankur&lt;/a&gt; about an idea inspired by Adobe&apos;s &lt;a href=&quot;https://developer.adobe.com/document-services/docs/overview/document-generation-api/&quot;&gt;Document Generation API&lt;/a&gt;. This was probably my favorite API in their suite of PDF services. You take a Word document, put in tokens, and call the API with your data. The API would swap out the tokens with real data and give you a PDF in return. (Or Word again if you wanted that.) I suggested this to Harsh and then - voila - he added it!&lt;/p&gt;
&lt;p&gt;At this time, the token system is simply a replacement system. You add &lt;code&gt;My name is &lt;/code&gt; in your Word document, and if you pass &lt;code&gt;{ &amp;quot;name&amp;quot;:&amp;quot;Juniper Montage&amp;quot;}&lt;/code&gt; via the library&apos;s APIs you&apos;ll get &lt;code&gt;My name is Juniper Montage&lt;/code&gt; on the way out.&lt;/p&gt;
&lt;p&gt;This new support is a core API, &lt;a href=&quot;https://officeparser.harshankur.com/#spec/template&quot;&gt;OfficeTemplate&lt;/a&gt;, and supports both replacing values from a regular JavaScript object or returning an array of results from a JavaScript array of objects. (Which, by the way, is something I was trying to get Adobe to add to their APIs. One of their competitors, &lt;a href=&quot;https://www.docugenerate.com/&quot;&gt;DocuGenerate&lt;/a&gt;, has had this for a while.)&lt;/p&gt;
&lt;p&gt;Here&apos;s how simple it is to use. First, create and save a Word document. Formatting doesn&apos;t matter - make it as pretty or simple as you would like. Here&apos;s an example with a few tokens:&lt;/p&gt;
&lt;div id=&quot;pdf-viewer-1&quot; style=&quot;width: 100%; height: 500px; margin-bottom: 30px&quot;&gt;&lt;/div&gt;
&lt;script type=&quot;module&quot;&gt;
import EmbedPDF, { ZoomMode } from &apos;https://cdn.jsdelivr.net/npm/@embedpdf/snippet@2/dist/embedpdf.js&apos;;
 
EmbedPDF.init({
	type: &apos;container&apos;,
	target: document.getElementById(&apos;pdf-viewer-1&apos;),
	src: &apos;https://static.raymondcamden.com/assets/document.pdf&apos;,
	theme: { preference: &apos;system&apos; },
	disabledCategories: [&apos;annotation&apos;],
	zoom: {
		defaultZoomLevel: ZoomMode.FitWidth
	}
});
&lt;/script&gt;
&lt;p&gt;Imagine this is stored as &lt;code&gt;customerReport.docx&lt;/code&gt;. To turn this into a PDF, you would first import the relevant libraries (in the sample below I&apos;m using an npm installed version, not CDN):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { OfficeConverter, OfficeTemplate } from &apos;officeparser/browser-native-pdf&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then - grab the Word document as binary data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const response = await fetch(&apos;/assets/customerReport.docx&apos;)
const template = new Uint8Array(await response.arrayBuffer())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Get some sample data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const data = {
  customerName: &apos;Northwind &amp;amp; Co&apos;,
  date: &apos;2026-10-16&apos;,
  productsTotal: 7,
  reportYear: 2026,
  profit: 2500,
  losses: 125,
  recommendation: &apos;Continue growing. Duh&apos;,
  reporter: &apos;Juniper Montage&apos;,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, use the library to convert the template:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const docx = await OfficeTemplate.render(template, { data })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, turn the rendered document into a PDF. The result&apos;s &lt;code&gt;value&lt;/code&gt; is the PDF&apos;s binary
data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const { value: pdfBytes } = await OfficeConverter.convert(docx, &apos;pdf&apos;, {
  generatorConfig: { pdfConfig: { engine: &apos;native&apos; } },
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At this point you can do whatever, like open it in a new tab:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const pdf = new Blob([pdfBytes], { type: &apos;application/pdf&apos; })
window.open(URL.createObjectURL(pdf), &apos;_blank&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As a note, this requires an additional package, &lt;strong&gt;only in the browser&lt;/strong&gt;, &lt;a href=&quot;https://www.npmjs.com/package/pdf-lib&quot;&gt;pdf-lib&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;a-real-demo&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#a-real-demo&quot;&gt;A Real Demo&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To show this in action, I built a simple demo that lets you fill in a form, and get the result as a PDF.&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/10/form1.png&quot; loading=&quot;lazy&quot; alt=&quot;The entry form&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;This form is tied to a bit of JavaScript that grabs the values, makes use of &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl&quot;&gt;Intl&lt;/a&gt; to format dates, numbers, and currencies, and then uses officeParser to create a dynamic PDF from your values. Here&apos;s an example:&lt;/p&gt;
&lt;div id=&quot;pdf-viewer-2&quot; style=&quot;width: 100%; height: 500px; margin-bottom: 30px&quot;&gt;&lt;/div&gt;
&lt;script type=&quot;module&quot;&gt;
import EmbedPDF, { ZoomMode } from &apos;https://cdn.jsdelivr.net/npm/@embedpdf/snippet@2/dist/embedpdf.js&apos;;
 
EmbedPDF.init({
	type: &apos;container&apos;,
	target: document.getElementById(&apos;pdf-viewer-2&apos;),
	src: &apos;https://static.raymondcamden.com/assets/foo.pdf&apos;,
	theme: { preference: &apos;system&apos; },
	disabledCategories: [&apos;annotation&apos;],
	zoom: {
		defaultZoomLevel: ZoomMode.FitWidth
	}
});
&lt;/script&gt;
&lt;p&gt;Try this yourself here: &lt;a href=&quot;https://cfjedimaster.github.io/webdemos/officeparser/wordtemplate/dist/index.html&quot;&gt;https://cfjedimaster.github.io/webdemos/officeparser/wordtemplate/dist/index.html&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You can find the complete source for this here: &lt;a href=&quot;https://github.com/cfjedimaster/webdemos/tree/master/officeparser/wordtemplate&quot;&gt;https://github.com/cfjedimaster/webdemos/tree/master/officeparser/wordtemplate&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;As I mentioned above, currently the token system is relatively simple, but I&apos;ve been talking with Harsh about some improvements that I bet will land pretty soon. Let me know what you think!&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/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>
        
</feed>