<?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-07-09T16:44: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/07/08/turning-cursor-performance-work-repeatable-skill</id>
                <title>Turning my Cursor Performance Work into a Repeatable Skill</title>
                <updated>2026-07-08T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/07/08/turning-cursor-performance-work-repeatable-skill" rel="alternate" type="text/html" title="Turning my Cursor Performance Work into a Repeatable Skill"/>
                <content type="html">
				
                        &lt;p&gt;Yesterday, I wrote about using Cursor to &lt;a href=&quot;https://www.raymondcamden.com/2026/07/07/asking-cursor-to-review-my-blog-for-performance&quot;&gt;review my blog for performance&lt;/a&gt;. Cursor (and again, for full disclosure I work here now ;) did a &lt;em&gt;dang&lt;/em&gt; good job of finding performance issues with my site, handling the huge size and various languages, platforms, and so forth. As I said, it worked well, but I wanted to look into making this more of a repeatable process.&lt;/p&gt;
&lt;p&gt;I&apos;m saying &amp;quot;repeatable&amp;quot;, not &amp;quot;automated&amp;quot;, on purpose here. I &lt;em&gt;can&lt;/em&gt; automate the process, but a full performance review feels much more like something I should run when I think it makes sense, for example, when I know I&apos;ve tweaked my blog at the &apos;code&apos; level versus writing cat-related blogs. I do plan to look into automations with Cursor later, but for now, what I&apos;ve done is something I&apos;ll run when I think it makes the most sense.&lt;/p&gt;
&lt;h2 id=&quot;what&apos;s-a-skill-again%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what&apos;s-a-skill-again%3F&quot;&gt;What&apos;s a skill again?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;At the simplest level, a skill simply gives direction to an AI agent on how to perform a task. It also describes itself in such a way that your AI agent knows when to invoke the skill. This is done in a simple Markdown file that can, and probably should, be checked into your repo.&lt;/p&gt;
&lt;p&gt;So for example, if I had a skill related to checking the health of my cat, my AI agent should be smart enough to recognize when it should use the skill based on my prompt. So for example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I&apos;m worried about Donut&apos;s health, anything I can do?&lt;/li&gt;
&lt;li&gt;Sir Fluffalot is doing fine, but what steps can I do to ensure she&apos;s healthy?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&apos;s a pretty quick summary but you get the idea. Where the benefit comes in is helping document and describe a process that you need to run again and again, or a process your organization/team/herd of cats needs to standardize on and wants to be consistent.&lt;/p&gt;
&lt;h2 id=&quot;turning-my-performance-review-into-a-skill&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#turning-my-performance-review-into-a-skill&quot;&gt;Turning my performance review into a skill&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;When I first did my performance review, I kept the prompt pretty simple. I did this on another machine so I don&apos;t have it in front of me, but I believe it was literally as simple as:&lt;/p&gt;
&lt;blockquote&gt;
Review this site and tell me of any performance improvements you can find.
&lt;/blockquote&gt;
&lt;p&gt;That generated the review I shared in my &lt;a href=&quot;https://www.raymondcamden.com/2026/07/07/asking-cursor-to-review-my-blog-for-performance&quot;&gt;last post&lt;/a&gt; and guided my development.&lt;/p&gt;
&lt;p&gt;Cursor provides a few ways to make skills:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Since it&apos;s just a Markdown file, you can just make a file, old school like we did in the old days.&lt;/li&gt;
&lt;li&gt;Cursor has a skill to make skills: &lt;code&gt;/create-skill i need a skill to help with cat care&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;You can also just ask Cursor to do this in a prompt.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The last option is what I did, asking Cursor to make a skill like my &lt;a href=&quot;https://github.com/cfjedimaster/raymondcamden2023/blob/main/.agents/skills/prepublish/SKILL.md&quot;&gt;&lt;code&gt;prepublish&lt;/code&gt;&lt;/a&gt; one.&lt;/p&gt;
&lt;p&gt;Cursor responded with:&lt;/p&gt;
 &lt;script type=&quot;module&quot; src=&quot;https://cdn.jsdelivr.net/npm/zero-md@3?register&quot;&gt;&lt;/script&gt;
&lt;zero-md&gt;
&lt;template data-append&gt;
&lt;style&gt;
.markdown-body {
padding: 20px;
}
&lt;/style&gt;
&lt;/template&gt;
  &lt;script type=&quot;text/markdown&quot;&gt;
&lt;p&gt;Created a &lt;strong&gt;&lt;code&gt;precheck&lt;/code&gt;&lt;/strong&gt; skill as the engine-code counterpart to &lt;code&gt;prepublish&lt;/code&gt;. It lives in both skill directories, same as your blog skill:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.claude/skills/precheck/SKILL.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.agents/skills/precheck/SKILL.md&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;how-to-use-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#how-to-use-it&quot;&gt;How to use it&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Before committing engine changes, run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/precheck
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or ask something like “review my engine changes before I commit.”&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what-it-does&quot;&gt;What it does&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Finds scope&lt;/strong&gt; — Uses &lt;code&gt;git diff&lt;/code&gt; / &lt;code&gt;git status&lt;/code&gt; and filters to engine paths (&lt;code&gt;eleventy.config.js&lt;/code&gt;, &lt;code&gt;config/&lt;/code&gt;, &lt;code&gt;src/_data/&lt;/code&gt;, &lt;code&gt;netlify/&lt;/code&gt;, &lt;code&gt;scripts/&lt;/code&gt;, Algolia/search templates, client JS). Ignores &lt;code&gt;src/posts/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Smoke test&lt;/strong&gt; — Runs &lt;code&gt;SKIP_REMOTE_DATA=1 npx eleventy&lt;/code&gt; when config, data, or search templates changed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reviews&lt;/strong&gt; — Groups findings as Critical / Warning / Note, focused on build breaks, deploy/runtime issues, performance (collection loops, async shortcodes, caching), and Algolia/search compatibility.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Applies fixes only on request&lt;/strong&gt; — Same handoff pattern as &lt;code&gt;prepublish&lt;/code&gt;; you commit when ready.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;your-workflow&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#your-workflow&quot;&gt;Your workflow&lt;/a&gt;&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change type&lt;/th&gt;
&lt;th&gt;Skill&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New blog post&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/prepublish&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eleventy, Netlify, JS/TS engine&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/precheck&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;No hooks involved — you invoke it manually when you want it.&lt;/p&gt;
&lt;p&gt;If you want the description tuned so it auto-suggests itself when you say “commit my eleventy changes,” I can adjust the frontmatter trigger phrases.&lt;/p&gt;
  &lt;/script&gt;
&lt;/zero-md&gt;
&lt;p&gt;A note on the last paragraph of the response. I had started my conversation with Cursor initially thinking I was going to automate it with a hook, but changed my mind. That&apos;s why it&apos;s mentioned here. The last bit &amp;quot;frontmatter trigger phrases&amp;quot; goes back to what I said earlier - a skill is both a guide on how to do something as well as a description of when it should be run.&lt;/p&gt;
&lt;p&gt;The result was pretty astounding I think, specifically in how Cursor recognized the &amp;quot;engine&amp;quot; parts of my blog versus the &amp;quot;content&amp;quot; bits. It did this well in the one off so I&apos;m not surprised, but I love having the various parts spelled out specifically in the skill itself. Like yesterday, I&apos;ll share the entire skill at the end, but let me talk a bit about what it did when I tried running it.&lt;/p&gt;
&lt;h2 id=&quot;running-the-skill&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#running-the-skill&quot;&gt;Running the Skill&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;When I ran the skill, I didn&apos;t expect it to find anything as my last commit was just a blog post. Cursor responded as such mentioning there was nothing staged. It noted two files that were dirty but not important:&lt;/p&gt;
 &lt;script type=&quot;module&quot; src=&quot;https://cdn.jsdelivr.net/npm/zero-md@3?register&quot;&gt;&lt;/script&gt;
&lt;zero-md&gt;
&lt;template data-append&gt;
&lt;style&gt;
.markdown-body {
padding: 20px;
}
&lt;/style&gt;
&lt;/template&gt;
  &lt;script type=&quot;text/markdown&quot;&gt;
&lt;p&gt;&lt;strong&gt;Precheck result: nothing in scope to review.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Your working tree has two changes, neither in the engine paths this skill covers:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;In scope?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;package-lock.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;modified&lt;/td&gt;
&lt;td&gt;No — lockfile only; syncs &lt;code&gt;@netlify/edge-functions&lt;/code&gt; already in &lt;code&gt;package.json&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/test.liquid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;untracked&lt;/td&gt;
&lt;td&gt;No — scratch page (moon-phase font test), not engine/build code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;No staged changes. No diffs under &lt;code&gt;eleventy.config.js&lt;/code&gt;, &lt;code&gt;config/&lt;/code&gt;, &lt;code&gt;src/_data/&lt;/code&gt;, &lt;code&gt;netlify/&lt;/code&gt;, &lt;code&gt;scripts/&lt;/code&gt;, search/feed templates, client JS, or deploy config.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stopping here&lt;/strong&gt; per the skill workflow.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you meant something else:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Recent commits&lt;/strong&gt; (e.g. &lt;code&gt;1117ccc2 perf changes&lt;/code&gt;) — say “precheck the last N commits” and I’ll review those engine diffs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Before you commit&lt;/strong&gt; &lt;code&gt;package-lock.json&lt;/code&gt; — worth committing alongside the existing &lt;code&gt;package.json&lt;/code&gt; dep so installs stay reproducible; no code review needed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;src/test.liquid&lt;/code&gt;&lt;/strong&gt; — looks like a local experiment; don’t commit unless you want it published.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Want me to review a specific commit or branch instead?&lt;/p&gt;
  &lt;/script&gt;
&lt;/zero-md&gt;
&lt;p&gt;I asked it to review the last two and it also noticed they didn&apos;t have anything relevant either - but what got me excited was that it went ahead and looked at one more and noted relevant changes there. Here&apos;s how that response ended:&lt;/p&gt;
&lt;blockquote&gt;
Want me to precheck 1117ccc2 (or 1117ccc2 + 06626459 together)? That is where the meaningful review would be.
&lt;/blockquote&gt;
&lt;p&gt;As I mentioned, I didn&apos;t do &lt;em&gt;everything&lt;/em&gt; in the initial performance check, so I figured, why not, and I approved this scan. It noted two places I had used &lt;code&gt;rss-parser&lt;/code&gt; and not yet added the timeout change so I went ahead and let Cursor make those changes as well.&lt;/p&gt;
&lt;h2 id=&quot;the-skill&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-skill&quot;&gt;The Skill&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Here&apos;s the &lt;a href=&quot;https://github.com/cfjedimaster/raymondcamden2023/blob/main/.agents/skills/prepublish/SKILL.md&quot;&gt;skill&lt;/a&gt; created by Cursor. Let me know what you think! Also, check out Cursor&apos;s &lt;a href=&quot;https://cursor.com/docs/skills&quot;&gt;docs on Skills&lt;/a&gt; - it&apos;s not just specific to Cursor and is a great reference in general.&lt;/p&gt;
 &lt;script type=&quot;module&quot; src=&quot;https://cdn.jsdelivr.net/npm/zero-md@3?register&quot;&gt;&lt;/script&gt;
&lt;zero-md&gt;
&lt;template data-append&gt;
&lt;style&gt;
.markdown-body {
padding: 20px;
}
&lt;/style&gt;
&lt;/template&gt;
  &lt;script type=&quot;text/markdown&quot;&gt;
&lt;hr&gt;
&lt;p&gt;name: precheck&lt;/p&gt;
&lt;p&gt;description: Review engine and infrastructure changes before commit. Use when the user runs /precheck, asks to review site engine code, or wants a pre-commit check on JavaScript/TypeScript, Eleventy config, Netlify functions, or build tooling — not blog posts.&lt;/p&gt;
&lt;hr&gt;
&lt;h1 id=&quot;precheck-engine-review&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#precheck-engine-review&quot;&gt;Precheck engine review&lt;/a&gt;&lt;/h1&gt;
&lt;p&gt;Run this before committing changes to the site&apos;s core engine — Eleventy config, build filters, data fetchers, Netlify functions, search/indexing, or client JS that affects builds or performance. Blog posts in &lt;code&gt;src/posts/&lt;/code&gt; are out of scope; use &lt;code&gt;prepublish&lt;/code&gt; for those.&lt;/p&gt;
&lt;h2 id=&quot;step-1-%E2%80%94-find-the-changes&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#step-1-%E2%80%94-find-the-changes&quot;&gt;Step 1 — Find the changes&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Use git to determine what changed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git status --short
git diff
git diff --cached
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Filter to engine-relevant paths only:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Paths&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Eleventy entry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;eleventy.config.js&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engine modules&lt;/td&gt;
&lt;td&gt;&lt;code&gt;config/**/*.js&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build-time data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/_data/*.js&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Netlify runtime&lt;/td&gt;
&lt;td&gt;&lt;code&gt;netlify/functions/**&lt;/code&gt;, &lt;code&gt;netlify/edge-functions/**&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance scripts&lt;/td&gt;
&lt;td&gt;&lt;code&gt;scripts/**&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search / feeds&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/misc/algolia_*.liquid&lt;/code&gt;, &lt;code&gt;src/search.liquid&lt;/code&gt;, &lt;code&gt;src/feed*.liquid&lt;/code&gt;, &lt;code&gt;src/sitemap.njk&lt;/code&gt;, &lt;code&gt;src/llms.liquid&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client JS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/assets/js/**&lt;/code&gt;, &lt;code&gt;src/assets/service-worker.js&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy config&lt;/td&gt;
&lt;td&gt;&lt;code&gt;netlify.toml&lt;/code&gt;, &lt;code&gt;src/_redirects&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Exclude:&lt;/strong&gt; &lt;code&gt;src/posts/**&lt;/code&gt;, static pages (&lt;code&gt;about.md&lt;/code&gt;, &lt;code&gt;speaking.md&lt;/code&gt;, etc.), images/CSS unless they affect build behavior, &lt;code&gt;_site/&lt;/code&gt;, &lt;code&gt;node_modules/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If nothing in scope changed, say so and stop. If both engine files and blog posts changed, review only the engine files unless the user asks otherwise.&lt;/p&gt;
&lt;p&gt;Confirm in one line which files you are reviewing before proceeding.&lt;/p&gt;
&lt;h2 id=&quot;step-2-%E2%80%94-build-smoke-test&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#step-2-%E2%80%94-build-smoke-test&quot;&gt;Step 2 — Build smoke test&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;If any of these changed — &lt;code&gt;eleventy.config.js&lt;/code&gt;, &lt;code&gt;config/**&lt;/code&gt;, &lt;code&gt;src/_data/**&lt;/code&gt;, search/feed templates — run a local build:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;SKIP_REMOTE_DATA=1 npx eleventy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Report success or paste the error. If the change is limited to &lt;code&gt;netlify/functions/**&lt;/code&gt;, &lt;code&gt;scripts/**&lt;/code&gt;, or client JS with no template/config impact, skip the build and note why.&lt;/p&gt;
&lt;p&gt;Do not auto-fix build failures. Diagnose and report.&lt;/p&gt;
&lt;h2 id=&quot;step-3-%E2%80%94-code-review&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#step-3-%E2%80%94-code-review&quot;&gt;Step 3 — Code review&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Read every changed in-scope file. Review for:&lt;/p&gt;
&lt;h3 id=&quot;build-breaks&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#build-breaks&quot;&gt;Build breaks&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Broken imports, mixed ESM/CJS (&lt;code&gt;scripts/rebuildalgolia.js&lt;/code&gt; is CommonJS; the rest is ESM)&lt;/li&gt;
&lt;li&gt;Collection mutations in &lt;code&gt;getPosts&lt;/code&gt; / &lt;code&gt;categories&lt;/code&gt; — permalink and &lt;code&gt;outputPath&lt;/code&gt; changes affect every post&lt;/li&gt;
&lt;li&gt;New &lt;code&gt;_data&lt;/code&gt; files that ignore &lt;code&gt;SKIP_REMOTE_DATA=1&lt;/code&gt; (local dev must not require live API keys)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;eleventy.after&lt;/code&gt; markdown copy hook — path bugs silently skip posts for content negotiation&lt;/li&gt;
&lt;li&gt;Algolia JSON shape in &lt;code&gt;src/misc/algolia_*.liquid&lt;/code&gt; — must stay compatible with &lt;code&gt;deploy-succeeded&lt;/code&gt; partial reindex and &lt;code&gt;scripts/rebuildalgolia.js&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;runtime-%2F-deploy-breaks&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#runtime-%2F-deploy-breaks&quot;&gt;Runtime / deploy breaks&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Netlify functions: missing env vars, timeout risk, uncached Algolia calls&lt;/li&gt;
&lt;li&gt;Edge function &lt;code&gt;content-negotiation.ts&lt;/code&gt; — must not break HTML serving for normal browsers&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deploy-succeeded&lt;/code&gt; — partial Algolia update logic; a bad filter can leave the index stale&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;performance&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#performance&quot;&gt;Performance&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Work added inside &lt;code&gt;getPosts&lt;/code&gt; or other per-post loops (runs on every build)&lt;/li&gt;
&lt;li&gt;New async shortcodes or &lt;code&gt;_data&lt;/code&gt; fetchers without caching (&lt;code&gt;@11ty/eleventy-fetch&lt;/code&gt; pattern in &lt;code&gt;stoot.js&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cssmin&lt;/code&gt; filter global cache — stale output if inputs change without cache invalidation&lt;/li&gt;
&lt;li&gt;&lt;code&gt;htmlmin&lt;/code&gt; transform — CI-only; errors are swallowed; new HTML patterns may minify badly&lt;/li&gt;
&lt;li&gt;Client JS: blocking scripts, large bundles, service-worker cache busting&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;correctness-and-safety&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#correctness-and-safety&quot;&gt;Correctness and safety&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Secrets or API keys hardcoded instead of env vars&lt;/li&gt;
&lt;li&gt;Error handling that fails open vs fails closed (especially fetchers returning &lt;code&gt;[]&lt;/code&gt; on error)&lt;/li&gt;
&lt;li&gt;Redirect or header changes in &lt;code&gt;netlify.toml&lt;/code&gt; / &lt;code&gt;src/_redirects&lt;/code&gt; with unintended side effects&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Do NOT&lt;/strong&gt; nitpick style, naming, or formatting unless it risks a real bug. This site has no linter; focus on things that break builds, deploys, search, or page performance.&lt;/p&gt;
&lt;p&gt;Present findings grouped by severity:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Critical&lt;/strong&gt; — will break build, deploy, or search; must fix before commit&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Warning&lt;/strong&gt; — likely problem under load, in CI, or at runtime&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Note&lt;/strong&gt; — worth knowing, optional improvement&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each item: one or two sentences, file path, and line number when possible. Do not rewrite code — point at the issue and let the user decide.&lt;/p&gt;
&lt;h2 id=&quot;step-4-%E2%80%94-apply-changes&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#step-4-%E2%80%94-apply-changes&quot;&gt;Step 4 — Apply changes&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ask which suggestions to act on. Apply only what is confirmed. Then stop — the user handles the actual commit/push.&lt;/p&gt;
  &lt;/script&gt;
&lt;/zero-md&gt;

                        
                
				</content>

                
                <category term="generative ai" />
                
                <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/07/07/asking-cursor-to-review-my-blog-for-performance</id>
                <title>Asking Cursor to Review My Blog for Performance</title>
                <updated>2026-07-07T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/07/07/asking-cursor-to-review-my-blog-for-performance" rel="alternate" type="text/html" title="Asking Cursor to Review My Blog for Performance"/>
                <content type="html">
				
                        &lt;p&gt;Last week I decided to try something interesting. I opened my blog up in Cursor and asked for a basic performance review. That seems like a no-brainer, but keep in mind, my &lt;a href=&quot;https://github.com/cfjedimaster/raymondcamden2023&quot;&gt;blog&apos;s source code&lt;/a&gt; clocks in at near &lt;strong&gt;seven thousand&lt;/strong&gt; files (ignoring &lt;code&gt;node_modules&lt;/code&gt; of course), so this wasn&apos;t some small request.&lt;/p&gt;
&lt;p&gt;My blog is built with the &lt;a href=&quot;https://www.11ty.dev/&quot;&gt;Eleventy&lt;/a&gt; static site generator. It&apos;s a mix of JavaScript and Markdown primarily, with a &lt;em&gt;huge&lt;/em&gt; portion of the codebase being Markdown and not &apos;code&apos; per se, but me rambling on about cats and Star Wars. There&apos;s also Liquid templates which are parsed into HTML by a JavaScript library. But that doesn&apos;t quite tell the whole story.&lt;/p&gt;
&lt;p&gt;In Eleventy, when converting my Markdown into HTML, it also applies the Liquid template syntax to Markdown which means my blog posts can actually be dynamic, at build time anyway. This impacts how fast the site builds in ways I can completely forget about down the road.&lt;/p&gt;
&lt;p&gt;This is why I let Cursor look over the entire code base instead of ignoring my Markdown (&lt;a href=&quot;https://cursor.com/docs/reference/ignore-file&quot;&gt;Cursor docs on ignore files&lt;/a&gt;), and I&apos;m glad I did. I&apos;ll share the complete report at the end of this post, but let me talk about the highlights.&lt;/p&gt;
&lt;h2 id=&quot;rss-parsing-and-a-dead-mastodon-server&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#rss-parsing-and-a-dead-mastodon-server&quot;&gt;RSS Parsing and a dead Mastodon server&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The biggest fix Cursor discovered concerned code I had to work with, and render, Mastodon posts. I&apos;ve got a shortcode that given a server and username will find the last post via the RSS feed generated for the account. I use another shortcode that renders the post into HTML.&lt;/p&gt;
&lt;p&gt;Cursor discovered I had multiple calls out to &lt;code&gt;botsin.space&lt;/code&gt;, a Mastodon server that was built to target bots and unfortunately had to be shut down. I had multiple blog posts trying to get content from that server. The code, which made use &lt;a href=&quot;https://www.npmjs.com/package/rss-parser&quot;&gt;rss-parser&lt;/a&gt;, would get a quick failure but was keeping an HTTP socket open until it timed out, roughly 70 seconds later.&lt;/p&gt;
&lt;p&gt;To be clear, the &apos;bug&apos; here was in my implementation, not the RSS library. The fix was pretty simple. Going from:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import Parser from &apos;rss-parser&apos;;

const parser = new Parser();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import https from &apos;https&apos;;
import Parser from &apos;rss-parser&apos;;

const parser = new Parser({
  requestOptions: {
    timeout: 5000,
    agent: new https.Agent({ keepAlive: false }),
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This alone helped, but I also found instances in my Markdown where I was calling out to botsin.space and replaced it with mastodon.social (where I moved my bots).&lt;/p&gt;
&lt;h2 id=&quot;trying-to-be-ai-friendly-had-an-unintended-consequence&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#trying-to-be-ai-friendly-had-an-unintended-consequence&quot;&gt;Trying to be AI friendly had an unintended consequence&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A few months back I made a change to my blog to be more AI friendly. That involved two steps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;For every post, I now have a copy of the Markdown version. So for example, this &lt;a href=&quot;https://www.raymondcamden.com/2026/07/06/my-new-role-helping-ai-adoption-at-cursor&quot;&gt;https://www.raymondcamden.com/2026/07/06/my-new-role-helping-ai-adoption-at-cursor&lt;/a&gt; goes to HTML, and you can go to &lt;a href=&quot;https://www.raymondcamden.com/2026/07/06/my-new-role-helping-ai-adoption-at-cursor/index.md&quot;&gt;https://www.raymondcamden.com/2026/07/06/my-new-role-helping-ai-adoption-at-cursor/index.md&lt;/a&gt; for the Markdown&lt;/li&gt;
&lt;li&gt;I used a Netlify Edge serverless function to sniff for a content request that prefers plain text or Markdown, what an AI agent would send, and redirects to that automatically. You can see that code here: &lt;a href=&quot;https://github.com/cfjedimaster/raymondcamden2023/blob/main/netlify/edge-functions/content-negotiation.ts&quot;&gt;https://github.com/cfjedimaster/raymondcamden2023/blob/main/netlify/edge-functions/content-negotiation.ts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To enable the Markdown copy, my &lt;code&gt;eleventy.config.js&lt;/code&gt; uses an event handler for builds:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;eleventyConfig.on(&amp;quot;eleventy.after&amp;quot;, async ({ dir }) =&amp;gt; {
    const inputDir = dir.input;   
    const outputDir = dir.output; 

    // blog posts only
    const mdFiles = glob.sync(&amp;quot;posts/**/*.md&amp;quot;, {
        cwd: inputDir,
        ignore: [&amp;quot;node_modules/**&amp;quot;],
    });

    for (const file of mdFiles) {
        const srcPath = path.join(inputDir, file);
        /*
        My input file looks like so:
        src/posts/2026/03/04/2026-03-04-dyanimically-adjusting-image-text-for-contrast.md
        I need to save to

        outputdir/2026/03/04/dynamically etc
        */
        let newFile = file.replace(&apos;posts&apos;, &apos;&apos;);
        newFile = newFile.replace(/[0-9]{4}-[0-9]{2}-[0-9]{2}-/,&apos;&apos;);
        newFile = newFile.replace(&apos;.md&apos;,&apos;/index.md&apos;);

        const destPath = path.join(outputDir, newFile);


        fs.mkdirSync(path.dirname(destPath), { recursive: true });
        fs.copyFileSync(srcPath, destPath);
    }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important bit is the glob. When I run my blog locally, I have an &lt;code&gt;.eleventyignore&lt;/code&gt; file which ignores blog posts from 2003 to 2024 or so, basically 90% of my blog posts (I &lt;a href=&quot;/stats&quot;&gt;blogged a lot more&lt;/a&gt; in the old days). However, my glob wasn&apos;t using that and every change resulted in 6k+ files being copied. Locally that meant every change I made while writing a post would take about 3 seconds to process. That&apos;s not bad in theory, but I tend to write, check the HTML, edit, etc, quickly, and I absolutely noticed times when I&apos;d have to reload again because I reloaded too quickly the first time.&lt;/p&gt;
&lt;p&gt;For the fix, I went back to Cursor and simply asked for help and the response was perfect:&lt;/p&gt;
  &lt;div class=&quot;callout&quot;&gt;The simplest fix is to stop globbing and copy only posts Eleventy actually built. The eleventy.after event gives you a results array of everything Eleventy processed, which already respects all ignore sources.&lt;/div&gt;
&lt;p&gt;I missed this in Eleventy&apos;s &lt;a href=&quot;https://www.11ty.dev/docs/events/#eleventy-after&quot;&gt;docs on the event&lt;/a&gt; that&apos;s 100% on me. I let Cursor update the code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;eleventyConfig.on(&amp;quot;eleventy.after&amp;quot;, async ({ directories, results }) =&amp;gt; {
    const inputDir = directories.input;
    const outputDir = directories.output;

    // Only copy posts Eleventy actually built (respects .eleventyignore)
    const postResults = results.filter((r) =&amp;gt; {
        const rel = path.relative(inputDir, r.inputPath);
        return rel.startsWith(`posts${path.sep}`) &amp;amp;&amp;amp; rel.endsWith(&apos;.md&apos;);
    });

    // rest of event...
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;bypassing-api-calls-locally&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#bypassing-api-calls-locally&quot;&gt;Bypassing API calls locally&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;One last thing it suggested that I implemented involved my &lt;code&gt;_data&lt;/code&gt; files. These files use remote APIs to fetch data for my site, most of which you can see on my &lt;a href=&quot;/now&quot;&gt;Now&lt;/a&gt; page. I had code to look for a local run, or a lack of an environment variable for a key. My code though was a bit of a hack, like looking for &lt;code&gt;/home/ray&lt;/code&gt;, and that failed on new machines where my local username didn&apos;t match. It didn&apos;t dramatically impact my builds, but Cursor pointing this out made me build a nicer solution - which was just to look for an environment variable instead, one I include in my npm scripts now:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&amp;quot;dev2&amp;quot;: &amp;quot;SKIP_REMOTE_DATA=1 eleventy --serve --quiet&amp;quot;,
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(The use of &apos;dev2&apos; is another story and related to a bug in the Netlify CLI.)&lt;/p&gt;
&lt;h2 id=&quot;wrap-up&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#wrap-up&quot;&gt;Wrap Up&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, I work at Cursor, I&apos;m biased, but of course other tools could create similar results. I&apos;m just impressed by how &lt;em&gt;deep&lt;/em&gt; Cursor looked into my code and found things that I never would have realized. As I write this and save, I&apos;m seeing build times of 0.33 seconds or so, which is freaking great. My &amp;quot;full&amp;quot; builds aren&apos;t necessarily faster, but that&apos;s fine. (Netlify and Eleventy both perform incredibly well with a typical site build of less than four minutes.)&lt;/p&gt;
&lt;p&gt;Here&apos;s the complete initial report Cursor created.&lt;/p&gt;
 &lt;script type=&quot;module&quot; src=&quot;https://cdn.jsdelivr.net/npm/zero-md@3?register&quot;&gt;&lt;/script&gt;
&lt;zero-md&gt;
&lt;template data-append&gt;
&lt;style&gt;
.markdown-body {
padding: 20px;
}
&lt;/style&gt;
&lt;/template&gt;
  &lt;script type=&quot;text/markdown&quot;&gt;
&lt;p&gt;site_performance_recommendations.md
Open File
View Rendered&lt;/p&gt;
&lt;h1 id=&quot;site-performance-recommendations&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#site-performance-recommendations&quot;&gt;Site Performance Recommendations&lt;/a&gt;&lt;/h1&gt;
&lt;p&gt;Analysis of the &lt;code&gt;raymondcamden2023&lt;/code&gt; Eleventy build (June 30, 2026).&lt;/p&gt;
&lt;h2 id=&quot;summary&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#summary&quot;&gt;Summary&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Local builds report &lt;strong&gt;~4–5 seconds&lt;/strong&gt; of actual Eleventy work, but the full process takes &lt;strong&gt;~72 seconds&lt;/strong&gt;. Most of that gap is not template rendering — it is the Node process waiting on open I/O handles after Eleventy finishes.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Active posts built (2023+)&lt;/td&gt;
&lt;td&gt;~463&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total markdown files on disk&lt;/td&gt;
&lt;td&gt;6,784&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files written per build&lt;/td&gt;
&lt;td&gt;~571&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files in &lt;code&gt;_site&lt;/code&gt; (including markdown copies)&lt;/td&gt;
&lt;td&gt;~13,785&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eleventy-reported build time&lt;/td&gt;
&lt;td&gt;~4–5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actual wall-clock build time&lt;/td&gt;
&lt;td&gt;~72s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id=&quot;1.-biggest-win%3A-fix-the-~67s-post-build-hang&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#1.-biggest-win%3A-fix-the-~67s-post-build-hang&quot;&gt;1. Biggest Win: Fix the ~67s Post-Build Hang&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;After Eleventy prints &lt;code&gt;Wrote 571 files&lt;/code&gt;, the Node process sits idle for about &lt;strong&gt;67 more seconds&lt;/strong&gt; before exiting. Profiling shows it waiting in the event loop on open I/O handles.&lt;/p&gt;
&lt;h3 id=&quot;root-cause&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#root-cause&quot;&gt;Root cause&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;lasttoot&lt;/code&gt; shortcode in &lt;code&gt;config/shortcodes/index.js&lt;/code&gt; uses &lt;code&gt;rss-parser&lt;/code&gt; without disabling HTTP keep-alive. A failing fetch to &lt;code&gt;botsin.space&lt;/code&gt; (visible in build logs) leaves a socket open until it times out (~70s).&lt;/p&gt;
&lt;p&gt;This post still runs the shortcode at build time:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;src/posts/2023/03/17/2023-03-17-another-week-another-mastodon-bot-random-album-cover.md
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-liquid&quot;&gt;{% capture &amp;quot;lasttoot_rac&amp;quot; %}
{% lasttoot &amp;quot;botsin.space&amp;quot;, &amp;quot;randomalbumcover&amp;quot; %}
{% endcapture %}

{% stoot &amp;quot;botsin.space&amp;quot;, lasttoot_rac %}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;verification&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#verification&quot;&gt;Verification&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;A single failed &lt;code&gt;botsin.space&lt;/code&gt; RSS fetch via &lt;code&gt;rss-parser&lt;/code&gt; fails in ~0.8s, but the process does not exit for ~71s.&lt;/li&gt;
&lt;li&gt;Adding &lt;code&gt;keepAlive: false&lt;/code&gt; drops total time to &lt;strong&gt;under 1 second&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Successful fetches to other hosts (e.g. Letterboxd) exit in ~0.3s with no hang.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;recommended-fix&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#recommended-fix&quot;&gt;Recommended fix&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Update &lt;code&gt;lasttoot&lt;/code&gt; (and the &lt;code&gt;_data&lt;/code&gt; RSS files) to disable keep-alive:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import https from &apos;https&apos;;
import Parser from &apos;rss-parser&apos;;

const parser = new Parser({
  requestOptions: {
    timeout: 5000,
    agent: new https.Agent({ keepAlive: false }),
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Alternatively, migrate &lt;code&gt;lasttoot&lt;/code&gt; to &lt;code&gt;@11ty/eleventy-fetch&lt;/code&gt; (as &lt;code&gt;stoot&lt;/code&gt; already does in &lt;code&gt;config/shortcodes/stoot.js&lt;/code&gt;).&lt;/p&gt;
&lt;h3 id=&quot;expected-impact&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#expected-impact&quot;&gt;Expected impact&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Full builds should drop from &lt;strong&gt;~72s to ~5s&lt;/strong&gt; locally, with similar improvement on Netlify.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;2.-high-impact%3A-stop-copying-6%2C784-markdown-files-after-every-build&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#2.-high-impact%3A-stop-copying-6%2C784-markdown-files-after-every-build&quot;&gt;2. High Impact: Stop Copying 6,784 Markdown Files After Every Build&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;eleventy.after&lt;/code&gt; hook in &lt;code&gt;eleventy.config.js&lt;/code&gt; copies &lt;strong&gt;all&lt;/strong&gt; posts to &lt;code&gt;_site&lt;/code&gt;, including years ignored by &lt;code&gt;.eleventyignore&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;eleventyConfig.on(&amp;quot;eleventy.after&amp;quot;, async ({ dir }) =&amp;gt; {
  const mdFiles = glob.sync(&amp;quot;posts/**/*.md&amp;quot;, {
    cwd: inputDir,
    ignore: [&amp;quot;node_modules/**&amp;quot;],
  });

  for (const file of mdFiles) {
    // ... copies every file ...
    fs.copyFileSync(srcPath, destPath);
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;6,784&lt;/strong&gt; markdown files exist on disk&lt;/li&gt;
&lt;li&gt;Only &lt;strong&gt;~463&lt;/strong&gt; are actively built (2023+)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.eleventyignore&lt;/code&gt; excludes 2000–2022 but the after-hook ignores that&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_site&lt;/code&gt; grows to &lt;strong&gt;~13,785&lt;/strong&gt; files because of this&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;recommended-fix-1&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#recommended-fix-1&quot;&gt;Recommended fix&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Respect the same ignore rules as &lt;code&gt;.eleventyignore&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Only copy files Eleventy actually built&lt;/li&gt;
&lt;li&gt;Or move archived posts out of &lt;code&gt;src/posts/&lt;/code&gt; entirely&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;expected-impact-1&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#expected-impact-1&quot;&gt;Expected impact&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;~1s saved on the copy loop itself&lt;/li&gt;
&lt;li&gt;Much faster deploy uploads&lt;/li&gt;
&lt;li&gt;Smaller &lt;code&gt;_site&lt;/code&gt; output&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;3.-medium-impact%3A-expensive-templates&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#3.-medium-impact%3A-expensive-templates&quot;&gt;3. Medium Impact: Expensive Templates&lt;/a&gt;&lt;/h2&gt;
&lt;h3 id=&quot;%2Fall-page-%E2%80%94-o(years-%C3%97-posts)-loop&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#%2Fall-page-%E2%80%94-o(years-%C3%97-posts)-loop&quot;&gt;&lt;code&gt;/all&lt;/code&gt; page — O(years × posts) loop&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;File: &lt;code&gt;src/misc/all.liquid&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-liquid&quot;&gt;{% for year in (firstYear..thisYear) reversed %}
  {% for post in posts %}
    {% if postYear == year %}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This nested loop iterates all posts for every year. A prior benchmark noted &lt;strong&gt;~3.6s+&lt;/strong&gt; for this page alone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Pre-group posts by year in a collection or JS data file and iterate once.&lt;/p&gt;
&lt;h3 id=&quot;algolia_full.liquid-%E2%80%94-all-posts%2C-full-content&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#algolia_full.liquid-%E2%80%94-all-posts%2C-full-content&quot;&gt;&lt;code&gt;algolia_full.liquid&lt;/code&gt; — all posts, full content&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;File: &lt;code&gt;src/misc/algolia_full.liquid&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Runs &lt;code&gt;templateContent | algExcerpt&lt;/code&gt; across every post in one JSON file. The regex work on full rendered HTML adds up across ~463 posts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Generate this only in CI/deploy, or only when search index content changes. (&lt;code&gt;algolia_new.liquid&lt;/code&gt; already limits to 5 posts for deploy hooks.)&lt;/p&gt;
&lt;h3 id=&quot;tag-pagination-generates-many-pages&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#tag-pagination-generates-many-pages&quot;&gt;Tag pagination generates many pages&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Files: &lt;code&gt;src/tags.liquid&lt;/code&gt;, &lt;code&gt;src/tag_feeds.liquid&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Both paginate over &lt;strong&gt;all collections&lt;/strong&gt; (not just tags), generating two pages per collection entry.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Use an explicit tag list instead of &lt;code&gt;collections&lt;/code&gt; as pagination data.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;4.-medium-impact%3A-build-time-network-calls&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#4.-medium-impact%3A-build-time-network-calls&quot;&gt;4. Medium Impact: Build-Time Network Calls&lt;/a&gt;&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/_data/medium.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;RSS fetch every build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/_data/letterboxd_films.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;RSS fetch every build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/_data/hardcover_books.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GraphQL fetch when &lt;code&gt;HARDCOVER_BOOKS&lt;/code&gt; env var is set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lasttoot&lt;/code&gt; / &lt;code&gt;stoot&lt;/code&gt; in ~4 posts&lt;/td&gt;
&lt;td&gt;Mastodon API/RSS at build time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;notes&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#notes&quot;&gt;Notes&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;untappd_beers.js&lt;/code&gt; already short-circuits locally when &lt;code&gt;ELEVENTY_ROOT&lt;/code&gt; includes &lt;code&gt;/raymondcamden2023&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;medium.js&lt;/code&gt; and &lt;code&gt;letterboxd_films.js&lt;/code&gt; only skip on &lt;code&gt;/home/ray&lt;/code&gt; — inconsistent with local dev on other paths&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/bots&lt;/code&gt; page (&lt;code&gt;src/misc/bots.md&lt;/code&gt;) already loads toots &lt;strong&gt;client-side&lt;/strong&gt;; build-time &lt;code&gt;stoot&lt;/code&gt;/&lt;code&gt;lasttoot&lt;/code&gt; embeds in old posts may not be worth the complexity&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;recommended-fix-2&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#recommended-fix-2&quot;&gt;Recommended fix&lt;/a&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Add a consistent &lt;code&gt;SKIP_REMOTE_DATA&lt;/code&gt; env flag (or broaden the local path short-circuit)&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;@11ty/eleventy-fetch&lt;/code&gt; with disk cache for all remote data&lt;/li&gt;
&lt;li&gt;Consider replacing build-time Mastodon embeds with static IDs or client-side loading&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;5.-lower-impact-%2F-polish&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#5.-lower-impact-%2F-polish&quot;&gt;5. Lower Impact / Polish&lt;/a&gt;&lt;/h2&gt;
&lt;h3 id=&quot;htmlmin-transform-in-ci&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#htmlmin-transform-in-ci&quot;&gt;&lt;code&gt;htmlmin&lt;/code&gt; transform in CI&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;In &lt;code&gt;eleventy.config.js&lt;/code&gt;, HTML minification runs on every &lt;code&gt;.html&lt;/code&gt; file when &lt;code&gt;process.env.CI&lt;/code&gt; is set. Netlify gzip/brotli may make this redundant.&lt;/p&gt;
&lt;h3 id=&quot;eleventy-incremental-mode&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#eleventy-incremental-mode&quot;&gt;Eleventy incremental mode&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Use &lt;code&gt;eleventy --incremental&lt;/code&gt; during dev when editing a single post.&lt;/p&gt;
&lt;h3 id=&quot;netlify-build-cache&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#netlify-build-cache&quot;&gt;Netlify build cache&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Cache &lt;code&gt;node_modules&lt;/code&gt; and &lt;code&gt;.cache/eleventy-fetch-*&lt;/code&gt; between deploys (Eleventy Fetch cache already exists under &lt;code&gt;.cache/&lt;/code&gt;).&lt;/p&gt;
&lt;h3 id=&quot;comment-includes&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#comment-includes&quot;&gt;Comment includes&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;5,111 legacy comment &lt;code&gt;.inc&lt;/code&gt; files under &lt;code&gt;src/_includes/comments/&lt;/code&gt;. The &lt;code&gt;hasAnyComments&lt;/code&gt; / &lt;code&gt;commentInclude&lt;/code&gt; shortcodes do sync &lt;code&gt;fs.existsSync&lt;/code&gt; on every post via &lt;code&gt;src/_includes/mydisqus.liquid&lt;/code&gt;. Minor cost, but could precompute a Set at build start.&lt;/p&gt;
&lt;h3 id=&quot;%40rknightuk%2Feleventy-plugin-post-graph&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#%40rknightuk%2Feleventy-plugin-post-graph&quot;&gt;&lt;code&gt;@rknightuk/eleventy-plugin-post-graph&lt;/code&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Bundles its own Eleventy 2.x copy — housekeeping item, not a major speed issue. Only used on &lt;code&gt;src/misc/postgraph.md&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;archived-posts-on-disk&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#archived-posts-on-disk&quot;&gt;Archived posts on disk&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;6,784 markdown files remain in &lt;code&gt;src/posts/&lt;/code&gt; even though 2000–2022 are in &lt;code&gt;.eleventyignore&lt;/code&gt;. Moving archives out of the input tree reduces repo noise and glob scan scope.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;suggested-priority&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#suggested-priority&quot;&gt;Suggested Priority&lt;/a&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Fix &lt;code&gt;lasttoot&lt;/code&gt; / &lt;code&gt;rss-parser&lt;/code&gt; keep-alive&lt;/strong&gt; (~67s saved — do this first)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Limit the &lt;code&gt;eleventy.after&lt;/code&gt; markdown copy&lt;/strong&gt; to active posts only&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Optimize &lt;code&gt;/all&lt;/code&gt; and &lt;code&gt;algolia_full.liquid&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add a consistent skip flag&lt;/strong&gt; for remote &lt;code&gt;_data&lt;/code&gt; fetches in dev&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enable incremental builds + Netlify caching&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&quot;posts-still-using-build-time-mastodon-shortcodes&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#posts-still-using-build-time-mastodon-shortcodes&quot;&gt;Posts Still Using Build-Time Mastodon Shortcodes&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;These active (2023+) posts invoke &lt;code&gt;stoot&lt;/code&gt; and/or &lt;code&gt;lasttoot&lt;/code&gt; at build time:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;src/posts/2024/10/23/2024-10-23-getting-and-displaying-a-mostodon-post-in-client-side-javascript.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/posts/2023/08/14/2023-08-14-building-a-mastodon-bot-with-cloudflare-workers.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/posts/2023/03/17/2023-03-17-another-week-another-mastodon-bot-random-album-cover.md&lt;/code&gt; (includes failing &lt;code&gt;lasttoot&lt;/code&gt; to botsin.space)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/posts/2023/02/17/2023-02-17-links-for-you.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;src/posts/2023/02/15/2023-02-15-building-a-mastodon-bot-listing-page-in-eleventy.md&lt;/code&gt; (examples in &lt;code&gt;{% raw %}&lt;/code&gt; blocks; not executed)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;current-stack-(for-context)&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#current-stack-(for-context)&quot;&gt;Current Stack (for context)&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Site generator:&lt;/strong&gt; Eleventy (11ty) v3&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Hosting:&lt;/strong&gt; Netlify&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Content:&lt;/strong&gt; Markdown posts under &lt;code&gt;src/posts/&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Templates:&lt;/strong&gt; Liquid, EJS, Nunjucks&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Output:&lt;/strong&gt; Static HTML in &lt;code&gt;_site/&lt;/code&gt;&lt;/p&gt;
&lt;/script&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/zero-md&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@tschannik?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Yannik Zimmermann&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/a-cat-that-is-walking-across-a-street-aT432COAiFs?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="generative ai" />
                
                <category term="eleventy" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/07/06/my-new-role-helping-ai-adoption-at-cursor</id>
                <title>My New Role - Helping AI Adoption at Cursor</title>
                <updated>2026-07-06T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/07/06/my-new-role-helping-ai-adoption-at-cursor" rel="alternate" type="text/html" title="My New Role - Helping AI Adoption at Cursor"/>
                <content type="html">
				
                        &lt;p&gt;This will be a short and sweet post to announce that today is my first day at &lt;a href=&quot;https://cursor.com&quot;&gt;Cursor&lt;/a&gt;!&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/07/happy-dance.gif&quot; loading=&quot;lazy&quot; alt=&quot;Happy Dance&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;I&apos;m starting today as an AI Adoption Engineer, which basically boils down to helping our customers make better use of Cursor and AI in general. If you&apos;ve been visiting my site for a while now, you know I&apos;ve been using AI for quite some time, but more importantly, I&apos;ve also been adopting AI in my own workflows and processes. It takes time and energy but the payoff can be so incredible, and that&apos;s what I get to help our developers with.&lt;/p&gt;
&lt;p&gt;Happy Day One!&lt;/p&gt;

                        
                
				</content>

                
                <category term="generative ai" />
                
                
                <category term="misc" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/07/05/links-for-you-7526</id>
                <title>Links For You (7/5/26)</title>
                <updated>2026-07-05T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/07/05/links-for-you-7526" rel="alternate" type="text/html" title="Links For You (7/5/26)"/>
                <content type="html">
				
                        &lt;p&gt;In my &lt;a href=&quot;https://www.raymondcamden.com/2026/06/21/links-for-you-62126&quot;&gt;last links post&lt;/a&gt;, I hinted that I may have some good news on the job front, and if you follow me on &lt;a href=&quot;https://linkedin.com/in/raymondcamden&quot;&gt;LinkedIn&lt;/a&gt; you already know that I&apos;ve signed on to my next gig. Tomorrow morning I&apos;ll share on here (and on LI) details about the new gig, but I am &lt;em&gt;beyond&lt;/em&gt; excited about this new job. With that out of the way, how about some happy links for what&apos;s going to be a really dang good week?&lt;/p&gt;
&lt;h2 id=&quot;javascript-in-the-shadows&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#javascript-in-the-shadows&quot;&gt;JavaScript in the Shadows&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;As one of the new proposals for JavaScript, the &lt;a href=&quot;https://github.com/tc39/proposal-shadowrealm&quot;&gt;ShadowRealm API&lt;/a&gt; is pretty interesting. It allows for the creation of a realm, or in my mind, an environment, with JavaScript that is 100% disconnected from the main environment of your browser window. Mat Marquis gives a great introduction to this in &lt;a href=&quot;https://css-tricks.com/soon-we-can-finally-banish-javascript-to-the-shadowrealm/&quot;&gt;Soon We Can Finally Banish JavaScript to the ShadowRealm&lt;/a&gt; over on CSS Tricks. (If for some reason you&apos;ve never heard of &lt;a href=&quot;https://css-tricks.com&quot;&gt;css-tricks&lt;/a&gt;, you should absolutely bookmark it and make it part of your required reading.) Mat&apos;s introduction is a pretty fun read as well.&lt;/p&gt;
&lt;h2 id=&quot;brownies-for-storage&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#brownies-for-storage&quot;&gt;Brownies for Storage&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I&apos;m a sucker for anything browser-storage related, and &lt;a href=&quot;https://github.com/franciscop/brownies&quot;&gt;Brownies&lt;/a&gt; is a great example of that. Brownies is a library that wraps cookies, Web Storage, and IndexedDB, all in one. Here&apos;s an example from their docs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { cookies, local, db } from &apos;brownies&apos;;

cookies.token = 42;     // Set it
let t = cookies.token;  // Get it
delete cookies.token;   // Eat it

local.token = 42;       // Set it
let t = local.token;    // Get it
delete local.token;     // Del it

// db is ASYNC so read is different
db.token = 42;          // Set it
let t = await db.token; // Get it
delete db.token;        // Del it
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This looks quite delicious. It&apos;s probably not fair to say it is a wrapper for IndexedDB as from what I can see, it&apos;s just doing a key/value store and not letting you specify custom object stores and such. In fact, the docs for &lt;code&gt;db&lt;/code&gt; seem to be missing, and the repository doesn&apos;t have issues enabled so I&apos;m going to have to find another way to leave that feedback, but check it out nonetheless. (I sent the author an email about this.)&lt;/p&gt;
&lt;h2 id=&quot;don&apos;t-use-javascript!&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#don&apos;t-use-javascript!&quot;&gt;Don&apos;t use JavaScript!&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;My first two links were JavaScript related, so why not advise &lt;em&gt;against&lt;/em&gt; JavaScript in my third link? While not an &amp;quot;anti&amp;quot; JavaScript resource, &lt;a href=&quot;https://aarontgrogg.github.io/NoLoJS/&quot;&gt;NoLoJS&lt;/a&gt; is a resource showing multiple different UX/UI components that can be built with no, or at least little, JavaScript. As I &lt;a href=&quot;https://www.raymondcamden.com/2026/07/01/building-custom-form-selection-blocks-no-js-all-css&quot;&gt;discovered last week&lt;/a&gt;, there&apos;s a lot you can do with CSS and removing JavaScript dependencies is just one benefit.&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;Yesterday was July 4th which means hot dogs and fireworks and that&apos;s all... right? Unsurprisingly, there&apos;s quite a few examples of fireworks being rendered in HTML. Here&apos;s one fun example that&apos;s pure CSS:&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-height=&quot;700&quot; data-pen-title=&quot;Pure CSS Fireworks&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;WNMmQX&quot; data-user=&quot;yshlin&quot; style=&quot;height: 700px; 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/yshlin/pen/WNMmQX&quot;&gt;
  Pure CSS Fireworks&lt;/a&gt; by Eddie Lin (&lt;a href=&quot;https://codepen.io/yshlin&quot;&gt;@yshlin&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
                        
                
				</content>

                
                <category term="links4you" />
                
                
                <category term="misc" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/07/03/building-custom-form-selection-boxes-working-on-accessibility</id>
                <title>Building Custom Form Selection Boxes - Working on Accessibility</title>
                <updated>2026-07-03T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/07/03/building-custom-form-selection-boxes-working-on-accessibility" rel="alternate" type="text/html" title="Building Custom Form Selection Boxes - Working on Accessibility"/>
                <content type="html">
				
                        &lt;p&gt;Whenever I find myself needing to update a previous blog post, I either correct it inline and add a small note on top, for small tweaks, or write a whole new piece for larger changes. My &lt;a href=&quot;https://www.raymondcamden.com/2026/07/01/building-custom-form-selection-blocks-no-js-all-css&quot;&gt;last blog post&lt;/a&gt; talked about how to use CSS to style a &amp;quot;block&amp;quot; such that it acted like a form radio button. When I worked on that demo, I was a bit worried about accessibility. I did one quick check with an online tool, and thought I was ok. I was not.&lt;/p&gt;
&lt;p&gt;When I shared my post on LinkedIn, &lt;a href=&quot;https://www.linkedin.com/in/kevbonett/&quot;&gt;Kevin Bonett&lt;/a&gt; shared this feedback with me:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;
I would argue that, despite using correct semantic HTML, the &quot;custom&quot; radio buttons are inaccessible.
&lt;/p&gt;
&lt;p&gt;
If you&apos;re a sighted keyboard user, how will you know how to interact with the radios, because they look nothing like traditional controls?
&lt;/p&gt;
&lt;p&gt;
Radio selection is usually made with ARROW keys. There&apos;s nothing to visually suggest this.
&lt;/p&gt;
&lt;p&gt;
Users may become frustrated because they&apos;re trying to use TAB key instead to access the 2nd radio.
&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This all made sense to me, so I turned to &lt;a href=&quot;https://cursor.com&quot;&gt;Cursor&lt;/a&gt; for help. The first thing I did was download the CodePen assets locally. I knew my agent in Cursor could work with the remote URL, but as far as I know, could not edit my CodePen for me, and I wanted to give the agent the ability to edit locally so I could test and confirm.&lt;/p&gt;
&lt;p&gt;With the files in place locally, I started with this prompt:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;I built this demo to showcase how to have a &apos;custom&apos; block of HTML that
could be selected and used in a form. I did an online check for
accessibility and while it reported a few issues, none seemed to
imply to the form concept that I was testing, the customized 
radio blocks. I blogged about this demo here. https://www.raymondcamden.com/2026/07/01/building-custom-form-selection-blocks-no-js-all-css. 

When I shared this online, a user sent this feedback:

&amp;quot;I would argue that, despite using correct semantic HTML,
the &amp;quot;custom&amp;quot; radio buttons are inaccessible.

If you&apos;re a sighted keyboard user, how will you know 
how to interact with the radios, because they look
nothing like traditional controls?

Radio selection is usually made with ARROW keys. There&apos;s
nothing to visually suggest this.

Users may become frustrated because they&apos;re trying to use
TAB key instead to access the 2nd radio.&amp;quot;

Given this feedback, what do you think, and what can we do to 
improve the demo?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The response from this was pretty stellar, and I think a lot better than if I had simply said &amp;quot;work on accessibility&amp;quot;. By bringing in Kevin&apos;s details, I think it really helped focus the agent&apos;s work when looking at my code.&lt;/p&gt;
&lt;p&gt;Here&apos;s the response, and it&apos;s pretty extensive, you would be fine scrolling down to the Summary:&lt;/p&gt;
&lt;script type=&quot;module&quot; src=&quot;https://cdn.jsdelivr.net/npm/zero-md@3?register&quot;&gt;&lt;/script&gt;
&lt;zero-md&gt;
&lt;template data-append&gt;
&lt;style&gt;
.markdown-body {
padding: 20px;
}
&lt;/style&gt;
&lt;/template&gt;
  &lt;script type=&quot;text/markdown&quot;&gt;
The feedback is fair, and it points at a real gap — not broken semantics, but missing **keyboard affordance** and **visible focus**.
&lt;h2 id=&quot;my-take&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#my-take&quot;&gt;My take&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Your HTML foundation is sound. Real &lt;code&gt;&amp;lt;input type=&amp;quot;radio&amp;quot;&amp;gt;&lt;/code&gt; elements inside &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt; wrappers is the right pattern: screen readers get a proper radio group, arrow keys work natively, and form submission behaves correctly. An automated checker can pass that and still miss the sighted keyboard-user experience.&lt;/p&gt;
&lt;p&gt;The reviewer is describing that gap. Custom cards that look like clickable tiles don’t read as “radio group” to someone tabbing through the form. That’s an &lt;strong&gt;operability&lt;/strong&gt; and &lt;strong&gt;perceivability&lt;/strong&gt; problem, not a semantic one.&lt;/p&gt;
&lt;h2 id=&quot;what-actually-happens-for-keyboard-users&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what-actually-happens-for-keyboard-users&quot;&gt;What actually happens for keyboard users&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Native radio groups behave like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Tab&lt;/strong&gt; moves into the group (to the selected radio, or the first one if nothing is selected).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tab again&lt;/strong&gt; skips the other radios and goes to the next control (Submit).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Arrow keys&lt;/strong&gt; move between options in the group.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That Tab behavior is correct per the spec, but your demo gives users almost no signal for it. The radios are hidden with zero size and no opacity:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-1:6:style.css&quot;&gt;.custom-card input[type=&amp;quot;radio&amp;quot;] {
    position: absolute;
    opacity: 0;
    width: 0;
    height: 0;
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There’s also no &lt;code&gt;:focus&lt;/code&gt; or &lt;code&gt;:focus-within&lt;/code&gt; styling on the cards. A sighted keyboard user can land on Option 1, press Tab expecting Option 2, jump straight to Submit, and never see which card had focus. Arrow keys may work, but nothing suggests they should try them.&lt;/p&gt;
&lt;p&gt;So: &lt;strong&gt;technically functional, practically confusing&lt;/strong&gt; for that audience.&lt;/p&gt;
&lt;h2 id=&quot;what-to-improve-(still-no-js)&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what-to-improve-(still-no-js)&quot;&gt;What to improve (still no JS)&lt;/a&gt;&lt;/h2&gt;
&lt;h3 id=&quot;1.-visible-focus-on-the-card-(%3Afocus-within)-%E2%80%94-highest-impact&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#1.-visible-focus-on-the-card-(%3Afocus-within)-%E2%80%94-highest-impact&quot;&gt;1. Visible focus on the card (&lt;code&gt;:focus-within&lt;/code&gt;) — highest impact&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;When the hidden radio inside a label has focus, style the whole card:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.custom-card:focus-within {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

.custom-card:focus-within:not(:has(input:checked)) {
  border-color: #005fcc;
  box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.25);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This alone fixes “I tabbed here and have no idea where I am.”&lt;/p&gt;
&lt;h3 id=&quot;2.-group-label-with-%3Cfieldset%3E-and-%3Clegend%3E&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#2.-group-label-with-%3Cfieldset%3E-and-%3Clegend%3E&quot;&gt;2. Group label with &lt;code&gt;&amp;lt;fieldset&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;legend&amp;gt;&lt;/code&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Wrap the options so the group has a name and context:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;fieldset&amp;gt;
  &amp;lt;legend&amp;gt;Choose a cat&amp;lt;/legend&amp;gt;
  &amp;lt;!-- your custom-card labels --&amp;gt;
&amp;lt;/fieldset&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Screen readers announce the group; sighted users get a clear heading for the choice block.&lt;/p&gt;
&lt;h3 id=&quot;3.-short-keyboard-hint-(visible-text)&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#3.-short-keyboard-hint-(visible-text)&quot;&gt;3. Short keyboard hint (visible text)&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;One line above or below the cards:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Use &lt;strong&gt;Tab&lt;/strong&gt; to reach this choice, then &lt;strong&gt;arrow keys&lt;/strong&gt; to switch options.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;You shouldn’t rely on this alone, but it sets expectations for the non-obvious Tab vs. arrow behavior. &lt;code&gt;aria-describedby&lt;/code&gt; can tie it to the group for assistive tech.&lt;/p&gt;
&lt;h3 id=&quot;4.-a-small-%E2%80%9Csingle-choice%E2%80%9D-visual-cue&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#4.-a-small-%E2%80%9Csingle-choice%E2%80%9D-visual-cue&quot;&gt;4. A small “single choice” visual cue&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;You don’t need to restore default radio chrome. A corner circle, checkmark when selected, or “Selected” badge helps users map cards to “pick one of several.” Selection state is only a blue background today, which is weaker for color vision and for understanding control type.&lt;/p&gt;
&lt;h3 id=&quot;5.-prefer-a-proper-visually-hidden-pattern-over-width%3A-0%3B-height%3A-0&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#5.-prefer-a-proper-visually-hidden-pattern-over-width%3A-0%3B-height%3A-0&quot;&gt;5. Prefer a proper visually-hidden pattern over &lt;code&gt;width: 0; height: 0&lt;/code&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Clip-based hiding keeps inputs reliably focusable and is a well-known pattern:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.custom-card input[type=&amp;quot;radio&amp;quot;] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pair that with &lt;code&gt;:focus-within&lt;/code&gt; on the label so focus is always visible on the card, not on a 1×1 pixel.&lt;/p&gt;
&lt;h3 id=&quot;6.-don%E2%80%99t-fight-native-tab-behavior&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#6.-don%E2%80%99t-fight-native-tab-behavior&quot;&gt;6. Don’t fight native Tab behavior&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Making each card a separate tab stop (e.g. &lt;code&gt;tabindex=&amp;quot;0&amp;quot;&lt;/code&gt; on labels) would &lt;strong&gt;break&lt;/strong&gt; expected radio keyboard behavior. Keep native radios; improve focus visibility and set expectations instead.&lt;/p&gt;
&lt;h2 id=&quot;what-you-can-say-in-the-blog&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what-you-can-say-in-the-blog&quot;&gt;What you can say in the blog&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Something like:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The pattern uses real radio inputs for semantics and form behavior. Custom styling hides the native control, so we add &lt;code&gt;:focus-within&lt;/code&gt; on each card, group the options in a &lt;code&gt;fieldset&lt;/code&gt;, and include a brief keyboard hint. Arrow keys switch options; Tab moves to the next field — standard radio behavior that’s easy to miss when the UI looks like generic cards.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That shows you’re addressing operability, not just checkbox-audit semantics.&lt;/p&gt;
&lt;h2 id=&quot;summary&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#summary&quot;&gt;Summary&lt;/a&gt;&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Valid?&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Doesn’t look like radios&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Focus ring, optional radio/check indicator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arrow keys not suggested&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Hint text + fieldset/legend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tab doesn’t reach 2nd option&lt;/td&gt;
&lt;td&gt;Partially — that’s correct radio behavior&lt;/td&gt;
&lt;td&gt;Explain it; don’t change tab order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semantics wrong&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Keep current HTML pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;I’d treat this as “good foundation, needs keyboard UX polish,” not “start over.” The changes are mostly CSS and a bit of markup — still no JavaScript.&lt;/p&gt;
&lt;p&gt;If you want, I can apply these updates directly in &lt;code&gt;index.html&lt;/code&gt; and &lt;code&gt;style.css&lt;/code&gt; so the demo matches what you’d recommend in a follow-up post.
&lt;/script&gt;
&lt;/zero-md&gt;&lt;/p&gt;
&lt;p&gt;I went ahead and gave Cursor permission to edit and update my files, and while I&apos;m keeping the original CodePen as is, I pushed up the new files to Netlify: &lt;a href=&quot;https://formselectionblock.netlify.app/&quot;&gt;https://formselectionblock.netlify.app/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The keyboard navigation is improved and the text helps too. I passed this by Kevin; he agreed with the updates, so going forward when I use this UX on my sites, I&apos;ll work with this version. Let me know what you think!&lt;/p&gt;

                        
                
				</content>

                
                <category term="css" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/07/01/building-custom-form-selection-blocks-no-js-all-css</id>
                <title>Building Custom Form Selection Blocks - no JS, all CSS</title>
                <updated>2026-07-01T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/07/01/building-custom-form-selection-blocks-no-js-all-css" rel="alternate" type="text/html" title="Building Custom Form Selection Blocks - no JS, all CSS"/>
                <content type="html">
				
                        &lt;p&gt;&lt;strong&gt;Edit on July 3 - See my &lt;a href=&quot;/2026/07/03/building-custom-form-selection-boxes-working-on-accessibility&quot;&gt;update&lt;/a&gt;.&lt;/strong&gt; I apologize for what may be a slightly misleading title. The topic for this post is something I&apos;ve had on my list of things to explore for some time now, and while I wait for the new job to start, I&apos;ve found myself with time to kill. Let me explain what I&apos;m talking about and hopefully it will make a bit more sense. You are, dear reader, familiar with form controls and how to build forms both big and small. One type of user interface I&apos;ve seen from time to time is the ability to select an item where the &amp;quot;item&amp;quot; is an arbitrary block of code. What do I mean by that?&lt;/p&gt;
&lt;p&gt;Typically a choice element in a form is either a select, checkbox, or radio set. But I&apos;ve seen some forms where you select something a bit more abstract. Consider the screen shot below:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/css1.png&quot; loading=&quot;lazy&quot; alt=&quot;Form Sample&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;See the two regular text fields and the cat selection? If I pick one, it visually updates:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/css2.png&quot; alt=&quot;Form sample with selected cat&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;On submitting the form, there will be something representing the selected value, along with any other form field values as well. In theory, the &amp;quot;thing&amp;quot; you select can be any arbitrary block with the selection look and feel being whatever makes sense for your site.&lt;/p&gt;
&lt;p&gt;So given this UX, I was curious how I&apos;d build it myself.&lt;/p&gt;
&lt;h2 id=&quot;first-thought---web-components&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#first-thought---web-components&quot;&gt;First thought - Web Components&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Initially, I thought this would be a great use for web components. Web components can participate in forms (see &lt;a href=&quot;https://www.raymondcamden.com/2023/05/24/adding-form-participation-support-to-web-components&quot;&gt;my post&lt;/a&gt; from... wow, three years ago!) so in theory, I could build something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;form method=&amp;quot;post&amp;quot;&amp;gt;

	&amp;lt;p&amp;gt;
	&amp;lt;label for=&amp;quot;name&amp;quot;&amp;gt;Name:&amp;lt;/label&amp;gt;
	&amp;lt;input type=&amp;quot;text&amp;quot; name=&amp;quot;name&amp;quot; id=&amp;quot;name&amp;quot;&amp;gt;
	&amp;lt;/p&amp;gt;
	&amp;lt;p&amp;gt;
	&amp;lt;label for=&amp;quot;email&amp;quot;&amp;gt;Email:&amp;lt;/label&amp;gt;
	&amp;lt;input type=&amp;quot;email&amp;quot; name=&amp;quot;email&amp;quot; id=&amp;quot;email&amp;quot;&amp;gt;
	&amp;lt;/p&amp;gt;
	
	&amp;lt;area-select name=&amp;quot;foo&amp;quot; value=&amp;quot;1&amp;quot;&amp;gt;
		This is area select 1.
	&amp;lt;/area-select&amp;gt;

	&amp;lt;area-select name=&amp;quot;foo&amp;quot; value=&amp;quot;2&amp;quot;&amp;gt;
		This is area select 2.
	&amp;lt;/area-select&amp;gt;

	&amp;lt;input type=&amp;quot;submit&amp;quot;&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the sample code above, &lt;code&gt;area-select&lt;/code&gt; is a web component which will participate in a form by setting the &lt;code&gt;foo&lt;/code&gt; value to whatever is selected, using the string in &lt;code&gt;value&lt;/code&gt; as the value.&lt;/p&gt;
&lt;p&gt;I wrote this out and was about to start hacking away at the code. Because of course JavaScript would be needed for this, right?&lt;/p&gt;
&lt;h2 id=&quot;sorry-i-doubted-you-css...&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#sorry-i-doubted-you-css...&quot;&gt;Sorry I doubted you CSS...&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Before I started writing JavaScript, I thought - let me Google some more and research this because maybe, just maybe, a pure CSS solution would work?&lt;/p&gt;
&lt;p&gt;I&apos;m glad I did.&lt;/p&gt;
&lt;p&gt;Turns out - it&apos;s actually relatively simple to achieve this with a bit of CSS. First, let&apos;s consider this version of the HTML:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;form method=&amp;quot;post&amp;quot; action=&amp;quot;https://postman-echo.com/post&amp;quot;&amp;gt;
	
	&amp;lt;p&amp;gt;
		&amp;lt;label for=&amp;quot;name&amp;quot;&amp;gt;Name:&amp;lt;/label&amp;gt;
		&amp;lt;input type=&amp;quot;text&amp;quot; name=&amp;quot;name&amp;quot; id=&amp;quot;name&amp;quot;&amp;gt;
	&amp;lt;/p&amp;gt;
	&amp;lt;p&amp;gt;
		&amp;lt;label for=&amp;quot;email&amp;quot;&amp;gt;Email:&amp;lt;/label&amp;gt;
		&amp;lt;input type=&amp;quot;email&amp;quot; name=&amp;quot;email&amp;quot; id=&amp;quot;email&amp;quot;&amp;gt;
	&amp;lt;/p&amp;gt;

	&amp;lt;label class=&amp;quot;custom-card&amp;quot;&amp;gt;
	  &amp;lt;input type=&amp;quot;radio&amp;quot; name=&amp;quot;option&amp;quot; value=&amp;quot;1&amp;quot;&amp;gt;
	  &amp;lt;h3&amp;gt;Option 1 - Luna&amp;lt;/h3&amp;gt;
	  &amp;lt;img src=&amp;quot;https://placecats.com/200/250&amp;quot;&amp;gt;
	&amp;lt;/label&amp;gt;

	&amp;lt;label class=&amp;quot;custom-card&amp;quot;&amp;gt;
	  &amp;lt;input type=&amp;quot;radio&amp;quot; name=&amp;quot;option&amp;quot; value=&amp;quot;2&amp;quot;&amp;gt;
	  &amp;lt;h3&amp;gt;Option 2 - Pig&amp;lt;/h3&amp;gt;
	  &amp;lt;img src=&amp;quot;https://placecats.com/300/200&amp;quot;&amp;gt;
	&amp;lt;/label&amp;gt;

	&amp;lt;p&amp;gt;
	&amp;lt;input type=&amp;quot;submit&amp;quot;&amp;gt;
	&amp;lt;/p&amp;gt;
&amp;lt;/form&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My two &amp;quot;custom selectable doohickies&amp;quot; are just &lt;code&gt;label&lt;/code&gt; elements with an internal radio button. After the button I&apos;ve got arbitrary HTML.&lt;/p&gt;
&lt;p&gt;Now for the CSS:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.custom-card input[type=&amp;quot;radio&amp;quot;] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

/* Style the label container itself as the button */
.custom-card {
  display: inline-block;
  padding: 12px 24px;
  border: 2px solid #ccc;
  border-radius: 6px;
  cursor: pointer;
  transition: all 0.2s ease;
  width: 350px;
  height: 350px;
  vertical-align: top;
}

/* Style change when active */
.custom-card:has(input[type=&amp;quot;radio&amp;quot;]:checked) {
  background-color: #007bff;
  color: white;
  border-color: #007bff;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This does a few things. It completely hides the radio field, styles the label like a &amp;quot;card&amp;quot; element, and defines the &amp;quot;selected&amp;quot; look to add a blue border and background. It also swaps out the text color to white. Because I&apos;m wrapping everything in a label, the entire block is selectable, and a cursor pointer helps make that clear to the user.&lt;/p&gt;
&lt;p&gt;In theory... that&apos;s it. I spent a bit of time tweaking the initial CSS I had found and more could be done of course, but this ended up being simpler than I imagined.&lt;/p&gt;
&lt;p&gt;You can play with the full demo here: &lt;a href=&quot;https://capital-wood-marlin.codepen.app/&quot;&gt;https://capital-wood-marlin.codepen.app/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;And here&apos;s the full CodePen:&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-height=&quot;600&quot; data-pen-title=&quot;Custom Radio&quot; data-version=&quot;2&quot; data-default-tab=&quot;html,result&quot; data-slug-hash=&quot;xbgXbxd&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 600px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/editor/cfjedimaster/pen/019f1383-8203-7ff3-ab04-5aa9afa75d18&quot;&gt;
  Custom Radio&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
                        
                
				</content>

                
                <category term="css" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/06/26/decoding-vins-with-api</id>
                <title>Decoding VINs with an API</title>
                <updated>2026-06-26T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/06/26/decoding-vins-with-api" rel="alternate" type="text/html" title="Decoding VINs with an API"/>
                <content type="html">
				
                        &lt;p&gt;Today&apos;s post took a bit of a pivot. I decided to work on a demo idea I had created way back in March. As I worked on it, I ran into multiple roadblocks, and while that original idea for a demo may still see the light of the day, I figured I&apos;d at least share something that &lt;em&gt;did&lt;/em&gt; work.&lt;/p&gt;
&lt;h2 id=&quot;what&apos;s-a-vin%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what&apos;s-a-vin%3F&quot;&gt;What&apos;s a VIN?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A VIN is a vehicle identification number. It&apos;s a standard that &lt;a href=&quot;https://en.wikipedia.org/wiki/Vehicle_identification_number&quot;&gt;dates back to 1954&lt;/a&gt; and identifies a particular car by manufacturer, make, model, year, and a heck of a lot more. A VIN is 17 characters avoiding the letters O, I, U, and Q to avoid confusion with some numbers.&lt;/p&gt;
&lt;p&gt;You can break down a VIN into various &lt;a href=&quot;https://en.wikipedia.org/wiki/Vehicle_identification_number#Components&quot;&gt;components&lt;/a&gt; if you want - but would still need to know various lookup values, for example a manufacturer referred to as &lt;code&gt;1FT&lt;/code&gt; maps to Ford Truck.&lt;/p&gt;
&lt;h2 id=&quot;decoding-a-vin-via-api&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#decoding-a-vin-via-api&quot;&gt;Decoding a VIN via API&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;As part of the demo that didn&apos;t quite make it, I needed to decode a VIN value. I had a commercial service in mind but it had a pretty limited free tier (50 calls). Turns out, the &lt;a href=&quot;https://www.nhtsa.gov/&quot;&gt;National Highway Traffic Safety Administration&lt;/a&gt; actually has a bunch of APIs, multiple of which work with VINs.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://vpic.nhtsa.dot.gov/api/&quot;&gt;Vehicle API&lt;/a&gt; is free, with no limits (outside of not being a jerk), and requires no API keys at all.&lt;/p&gt;
&lt;p&gt;The one we want is the &lt;code&gt;Decode VIN Extended (flat format)&lt;/code&gt; endpoint. Why &amp;quot;flat format&amp;quot;? The initial API returns information that very much looks like XML translated to JSON, which to be fair, it is, and while the data is all there, it&apos;s a bit ugly. The &lt;code&gt;flat format&lt;/code&gt; makes it look quite a bit nicer.&lt;/p&gt;
&lt;p&gt;To use the API, simply include the VIN in the URL and ensure you add &lt;code&gt;?format=json&lt;/code&gt;: &lt;a href=&quot;https://vpic.nhtsa.dot.gov/api/vehicles/decodevinvalues/3VV3B7AXXNM077525?format=json&quot;&gt;https://vpic.nhtsa.dot.gov/api/vehicles/decodevinvalues/3VV3B7AXXNM077525?format=json&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This returns:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;Count&amp;quot;: 1,
  &amp;quot;Message&amp;quot;: &amp;quot;Results returned successfully. NOTE: Any missing decoded values should be interpreted as NHTSA does not have data on the specific variable. Missing value should NOT be interpreted as an indication that a feature or technology is unavailable for a vehicle.&amp;quot;,
  &amp;quot;SearchCriteria&amp;quot;: &amp;quot;VIN(s): 3VV3B7AXXNM077525&amp;quot;,
  &amp;quot;Results&amp;quot;: [
    {
      &amp;quot;ABS&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;ActiveSafetySysNote&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;AdaptiveCruiseControl&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;AdaptiveDrivingBeam&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;AdaptiveHeadlights&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;AdditionalErrorText&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;AirBagLocCurtain&amp;quot;: &amp;quot;1st Row (Driver and Passenger)&amp;quot;,
      &amp;quot;AirBagLocFront&amp;quot;: &amp;quot;1st Row (Driver and Passenger)&amp;quot;,
      &amp;quot;AirBagLocKnee&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;AirBagLocSeatCushion&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;AirBagLocSide&amp;quot;: &amp;quot;1st Row (Driver and Passenger)&amp;quot;,
      &amp;quot;AutoReverseSystem&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;AutomaticPedestrianAlertingSound&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;AxleConfiguration&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;Axles&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BasePrice&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BatteryA&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BatteryA_to&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BatteryCells&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BatteryInfo&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BatteryKWh&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BatteryKWh_to&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BatteryModules&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BatteryPacks&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BatteryType&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BatteryV&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BatteryV_to&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BedLengthIN&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BedType&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BlindSpotIntervention&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BlindSpotMon&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;BodyCabType&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BodyClass&amp;quot;: &amp;quot;Sport Utility Vehicle [SUV]/Multipurpose Vehicle [MPV]&amp;quot;,
      &amp;quot;BrakeSystemDesc&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BrakeSystemType&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BusFloorConfigType&amp;quot;: &amp;quot;Not Applicable&amp;quot;,
      &amp;quot;BusLength&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;BusType&amp;quot;: &amp;quot;Not Applicable&amp;quot;,
      &amp;quot;CAN_AACN&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;CIB&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;CashForClunkers&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;ChargerLevel&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;ChargerPowerKW&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;CombinedBrakingSystem&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;CoolingType&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;CurbWeightLB&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;CustomMotorcycleType&amp;quot;: &amp;quot;Not Applicable&amp;quot;,
      &amp;quot;DaytimeRunningLight&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;DestinationMarket&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;DisplacementCC&amp;quot;: &amp;quot;2000.0&amp;quot;,
      &amp;quot;DisplacementCI&amp;quot;: &amp;quot;122.04748818946&amp;quot;,
      &amp;quot;DisplacementL&amp;quot;: &amp;quot;2.0&amp;quot;,
      &amp;quot;Doors&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;DriveType&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;DriverAssist&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;DynamicBrakeSupport&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;EDR&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;ESC&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;EVDriveUnit&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;ElectrificationLevel&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;EngineConfiguration&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;EngineCycles&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;EngineCylinders&amp;quot;: &amp;quot;4&amp;quot;,
      &amp;quot;EngineHP&amp;quot;: &amp;quot;184&amp;quot;,
      &amp;quot;EngineHP_to&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;EngineKW&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;EngineManufacturer&amp;quot;: &amp;quot;Volkswagen&amp;quot;,
      &amp;quot;EngineModel&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;EntertainmentSystem&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;ErrorCode&amp;quot;: &amp;quot;0&amp;quot;,
      &amp;quot;ErrorText&amp;quot;: &amp;quot;0 - VIN decoded clean. Check Digit (9th position) is correct&amp;quot;,
      &amp;quot;ForwardCollisionWarning&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;FuelInjectionType&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;FuelTankMaterial&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;FuelTankType&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;FuelTypePrimary&amp;quot;: &amp;quot;Gasoline&amp;quot;,
      &amp;quot;FuelTypeSecondary&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;GCWR&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;GCWR_to&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;GVWR&amp;quot;: &amp;quot;Class 1D: 5,001 - 6,000 lb (2,268 - 2,722 kg)&amp;quot;,
      &amp;quot;GVWR_to&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;KeylessIgnition&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;LaneCenteringAssistance&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;LaneDepartureWarning&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;LaneKeepSystem&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;LowerBeamHeadlampLightSource&amp;quot;: &amp;quot;LED&amp;quot;,
      &amp;quot;Make&amp;quot;: &amp;quot;VOLKSWAGEN&amp;quot;,
      &amp;quot;MakeID&amp;quot;: &amp;quot;482&amp;quot;,
      &amp;quot;Manufacturer&amp;quot;: &amp;quot;VOLKSWAGEN DE MEXICO SA DE CV&amp;quot;,
      &amp;quot;ManufacturerId&amp;quot;: &amp;quot;16478&amp;quot;,
      &amp;quot;Model&amp;quot;: &amp;quot;Tiguan&amp;quot;,
      &amp;quot;ModelID&amp;quot;: &amp;quot;8151&amp;quot;,
      &amp;quot;ModelYear&amp;quot;: &amp;quot;2022&amp;quot;,
      &amp;quot;MotorcycleChassisType&amp;quot;: &amp;quot;Not Applicable&amp;quot;,
      &amp;quot;MotorcycleSuspensionType&amp;quot;: &amp;quot;Not Applicable&amp;quot;,
      &amp;quot;NCSABodyType&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;NCSAMake&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;NCSAMapExcApprovedBy&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;NCSAMapExcApprovedOn&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;NCSAMappingException&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;NCSAModel&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;NCSANote&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;NonLandUse&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;Note&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;OtherBusInfo&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;OtherEngineInfo&amp;quot;: &amp;quot;Test Group: NVGAJ02.0V3A / Emission Std.: Tier 3 BIN 30 LEV3 SULEV30&amp;quot;,
      &amp;quot;OtherMotorcycleInfo&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;OtherRestraintSystemInfo&amp;quot;: &amp;quot;Seat Belt: Active-Dr/Pass / Advanced Front Airbags&amp;quot;,
      &amp;quot;OtherTrailerInfo&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;ParkAssist&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;PedestrianAutomaticEmergencyBraking&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;PlantCity&amp;quot;: &amp;quot;PUEBLA&amp;quot;,
      &amp;quot;PlantCompanyName&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;PlantCountry&amp;quot;: &amp;quot;MEXICO&amp;quot;,
      &amp;quot;PlantState&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;PossibleValues&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;Pretensioner&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;RearAutomaticEmergencyBraking&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;RearCrossTrafficAlert&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;RearVisibilitySystem&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;SAEAutomationLevel&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;SAEAutomationLevel_to&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;SeatBeltsAll&amp;quot;: &amp;quot;Manual&amp;quot;,
      &amp;quot;SeatRows&amp;quot;: &amp;quot;2&amp;quot;,
      &amp;quot;Seats&amp;quot;: &amp;quot;5&amp;quot;,
      &amp;quot;SemiautomaticHeadlampBeamSwitching&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;Series&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;Series2&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;SteeringLocation&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;SuggestedVIN&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;TPMS&amp;quot;: &amp;quot;Indirect&amp;quot;,
      &amp;quot;TopSpeedMPH&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;TrackWidth&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;TractionControl&amp;quot;: &amp;quot;Standard&amp;quot;,
      &amp;quot;TrailerBodyType&amp;quot;: &amp;quot;Not Applicable&amp;quot;,
      &amp;quot;TrailerLength&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;TrailerType&amp;quot;: &amp;quot;Not Applicable&amp;quot;,
      &amp;quot;TransmissionSpeeds&amp;quot;: &amp;quot;8&amp;quot;,
      &amp;quot;TransmissionStyle&amp;quot;: &amp;quot;Automatic&amp;quot;,
      &amp;quot;Trim&amp;quot;: &amp;quot;SE&amp;quot;,
      &amp;quot;Trim2&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;Turbo&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;VIN&amp;quot;: &amp;quot;3VV3B7AXXNM077525&amp;quot;,
      &amp;quot;ValveTrainDesign&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;VehicleDescriptor&amp;quot;: &amp;quot;3VV3B7AX*NM&amp;quot;,
      &amp;quot;VehicleType&amp;quot;: &amp;quot;MULTIPURPOSE PASSENGER VEHICLE (MPV)&amp;quot;,
      &amp;quot;WheelBaseLong&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;WheelBaseShort&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;WheelBaseType&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;WheelSizeFront&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;WheelSizeRear&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;WheelieMitigation&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;Wheels&amp;quot;: &amp;quot;&amp;quot;,
      &amp;quot;Windows&amp;quot;: &amp;quot;&amp;quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is, indeed, my car and the data is spot on. If you pass an invalid VIN, the values for &lt;code&gt;ErrorCode&lt;/code&gt; and &lt;code&gt;ErrorText&lt;/code&gt; will include pertinent information. As an example, this &lt;a href=&quot;https://vpic.nhtsa.dot.gov/api/vehicles/decodevinvalues/999999999993VV3B7AXXNM077525?format=json&quot;&gt;URL&lt;/a&gt; with a messed up VIN gives:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;{ 
// All the usual keys
&amp;quot;ErrorCode&amp;quot;: &amp;quot;1,7,400&amp;quot;,
&amp;quot;ErrorText&amp;quot;: &amp;quot;1 - Check Digit (9th position) does not calculate properly; 7 - Manufacturer is not registered with NHTSA for sale or importation in the U.S. for use on U.S roads; Please contact the manufacturer directly for more information; 400 - Invalid Characters Present&amp;quot;,
 // more
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that as I said in the code block, you still get &lt;em&gt;all&lt;/em&gt; the other keys as well, so you need to check for &lt;code&gt;ErrorCode&lt;/code&gt; being &amp;quot;0&amp;quot; (note that it&apos;s a string, not a number) before you can use the data.&lt;/p&gt;
&lt;p&gt;But - the API tries its best to decode as much as possible. So for example, if I remove one value from my VIN, I get an error (&amp;quot;6 - Incomplete VIN&amp;quot;), but it still returns the right make and model for my car.&lt;/p&gt;
&lt;p&gt;Depending on your particular need, you may not want to treat errors as a boolean state and see what you can get away with.&lt;/p&gt;
&lt;h2 id=&quot;putting-a-bow-on-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#putting-a-bow-on-it&quot;&gt;Putting a Bow on It&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Alright, so while this worked, I thought I&apos;d make a quick trip to &lt;a href=&quot;https://val.town&quot;&gt;Val Town&lt;/a&gt; to build a slightly nicer API wrapper for this. My code would do two things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Return an object with two keys, &lt;code&gt;vehicle&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vehicle&lt;/code&gt; is the vehicle information result, just the first one, but with keys lowercased because there&apos;s no need to write more APIs the Microsoft way.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;error&lt;/code&gt; will check if &lt;code&gt;ErrorCode&lt;/code&gt; isn&apos;t &amp;quot;0&amp;quot;, and if so, will be populated with &lt;code&gt;ErrorText&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In Val Town, I created a new val with a HTTP trigger. I look for the VIN in the URL, and if it&apos;s there, pass it to the NHTSA&apos;s API and shape the API as I described above. That&apos;s literally it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export default async function (req: Request): Promise&amp;lt;Response&amp;gt; {
  const url = new URL(req.url);
  const vin = url.searchParams.get(&amp;quot;vin&amp;quot;);

  if (!vin) {
    return new Response(
      JSON.stringify({ error: &amp;quot;Missing &apos;vin&apos; query parameter&amp;quot; }),
      {
        status: 400,
        headers: { &amp;quot;Content-Type&amp;quot;: &amp;quot;application/json&amp;quot; },
      },
    );
  }

  const vinreq = await fetch(
    `https://vpic.nhtsa.dot.gov/api/vehicles/decodevinvalues/${vin}?format=json`,
  );
  const data = await vinreq.json();

  const result = {
    vehicle: lowerCaseKeys(data.Results[0]),
    error: null,
  };

  if (data.Results[0].ErrorCode !== &amp;quot;0&amp;quot;) {
    result.error = data.Results[0].ErrorText;
  }

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

function lowerCaseKeys(obj) {
  const result = {};
  for (const key of Object.keys(obj)) {
    result[key.toLowerCase()] = obj[key];
  }
  return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This API is up and running at https://raymondcamden--a5e2f648718311f1ace11607ee4eb77e.web.val.run, and you can see the full Val here, &lt;a href=&quot;https://www.val.town/x/raymondcamden/vin-api&quot;&gt;https://www.val.town/x/raymondcamden/vin-api&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;quick-demo&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#quick-demo&quot;&gt;Quick Demo&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I went to &lt;em&gt;another&lt;/em&gt; code hosting service, Code Pen, to create a quick and simple demo. If you open your browser to &lt;a href=&quot;https://new-thunder-treefrog.codepen.app/&quot;&gt;https://new-thunder-treefrog.codepen.app/&lt;/a&gt;, you can enter a VIN, hit the button, and see the result dumped on screen. The entire Val Town integration was just a &lt;code&gt;fetch&lt;/code&gt; call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let req = await fetch(`https://raymondcamden--a5e2f648718311f1ace11607ee4eb77e.web.val.run/?vin=${vin}`);
let result = await req.json();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here&apos;s the CodePen itself if you want to fork, or run the demo here.&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;-2&quot; data-height=&quot;600&quot; data-pen-title=&quot;VIN API Tester&quot; data-preview=&quot;true&quot; data-version=&quot;2&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;MYJvybX&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 600px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/editor/cfjedimaster/pen/019f0545-661f-7a5d-9000-89ba9b41fca3&quot;&gt;
  VIN API Tester&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@elisasch?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Elisa Schmidt&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/cat-on-a-car-during-daytime-C2c4G3by4ZE?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/06/24/parsing-arbitrary-dates-in-strings-with-chrono-and-a-web-component</id>
                <title>Parsing Arbitrary Dates in Strings with Chrono and a Web Component</title>
                <updated>2026-06-24T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/06/24/parsing-arbitrary-dates-in-strings-with-chrono-and-a-web-component" rel="alternate" type="text/html" title="Parsing Arbitrary Dates in Strings with Chrono and a Web Component"/>
                <content type="html">
				
                        &lt;p&gt;Yesterday I had an idea for a possible experiment using Chrome&apos;s built-in AI support - looking for &amp;quot;date&amp;quot; references in strings. So for example: &amp;quot;I will have my new job in 12 days&amp;quot;. Could the AI model recognize &amp;quot;12 days&amp;quot; as a date and determine what the actual date is, assuming a reference date of now? I was about to start working on a simple POC when I thought... wait... is there already a JavaScript library for this?&lt;/p&gt;
&lt;p&gt;Of course there is.&lt;/p&gt;
&lt;p&gt;The aptly named &lt;a href=&quot;https://github.com/wanasit/chrono&quot;&gt;Chrono&lt;/a&gt; library does just that. It can parse a string with one assumed date and return the date, so for example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import * as chrono from &apos;chrono-node&apos;;

chrono.parseDate(&apos;An appointment on Sep 12-13&apos;); 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This returns: &lt;code&gt;Sat Sep 12 2026 12:00:00 GMT-0500 (Central Daylight Time)&lt;/code&gt;. It can also take a string and give you a parsed set of results, so for example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;chrono.parse(&apos;An appointment on Sep 12-13&apos;);
/* [{ 
    index: 18,
    text: &apos;Sep 12-13&apos;,
    start: ...
}] */
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;parse&lt;/code&gt; method will return one result per matched item, with the result including the original string, lots of info on how it parsed it, and the ability to get the proper date object from it. It can also handle durations and a reference date too, so if your input was, &amp;quot;I will eat sushi tomorrow&amp;quot; and the reference was January 2nd, 2026, it would recognize that tomorrow is January 3rd, 2026.&lt;/p&gt;
&lt;p&gt;So, how about a demo? I created a quick CodePen that has a textarea for arbitrary input. You can type anything, and when it finds stuff, it dumps it out below and then follows up that dump with parsed dates. I&apos;ll skip over the HTML (you can see it in the embedded CodePen), but here&apos;s the JavaScript:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import * as chrono from &apos;chrono-node&apos;;

const $input = document.querySelector(&apos;#input&apos;);
const $output = document.querySelector(&apos;#output&apos;);

$input.addEventListener(&apos;input&apos;, () =&amp;gt; {
	const input = $input.value.trim();
	$output.innerText = &apos;&apos;;
	if(input === &apos;&apos;) return;
	/*
	temp test to set a reference:
	let yesterday = new Date();
	yesterday.setDate(yesterday.getDate() - 1);
	let parsed = chrono.parse(input, yesterday);
	*/
	let parsed = chrono.parse(input);

	if(parsed.length) {
		$output.innerText = JSON.stringify(parsed, null, &apos;\t&apos;) + &apos;\n&apos;;
	
		for(const p of parsed) {
			console.log(p.start.date());
			$output.innerText += `Parsed date: ${p.start.date()} `;
			if(p.end) $output.innerText += ` - ${p.end.date()}`;
			$output.innerText += &apos;\n&apos;;
		}
	}
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Basically import the library, note changes in the textarea, and parse. If results are found I do the simple dump as I mentioned. One small oddity with the library is that the initial result does &lt;em&gt;not&lt;/em&gt; actually include the date. You have to run &lt;code&gt;.date()&lt;/code&gt; on the &lt;code&gt;start&lt;/code&gt; and (optional) &lt;code&gt;end&lt;/code&gt; values to get that. Hence that for loop. You can also see, commented out, how easy it is to use a reference date.&lt;/p&gt;
&lt;p&gt;Go ahead and test it below.&lt;/p&gt;
&lt;p&gt;I will say I found the &apos;duration&apos; support didn&apos;t work terribly well. By that I mean it would find the date reference, but not always the &apos;end&apos;. The sample input, &amp;quot;I have an appointment tomorrow from 10 to 11 AM&amp;quot;, worked fine, but in my tests it was hit or miss. Again though, it got the start every time so that&apos;s something.&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;-2&quot; data-height=&quot;500&quot; data-pen-title=&quot;chrono test&quot; data-preview=&quot;true&quot; data-version=&quot;2&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;WbROxQm&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/019ef9de-fbbf-778f-9e68-7a9a0d44ecc6&quot;&gt;
  chrono test&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
&lt;h2 id=&quot;turning-it-into-a-web-component&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#turning-it-into-a-web-component&quot;&gt;Turning it into a Web Component&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;At this point, I thought it might be cool to build a web component that would:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use Chrono to find the date strings&lt;/li&gt;
&lt;li&gt;Wrap each matched item with a &lt;code&gt;&amp;lt;time&amp;gt;&lt;/code&gt; element, setting &lt;code&gt;datetime&lt;/code&gt; and &lt;code&gt;title&lt;/code&gt; to the parsed date&lt;/li&gt;
&lt;li&gt;Use a bit of CSS to highlight the text&lt;/li&gt;
&lt;li&gt;Allow for an attribute that lets you specify the date. In my mind, the main use for this is UGC (user generated content), things like comments for example. Comments are made at a point in time which means it should be easy to pass that to the component.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I named my component &lt;code&gt;time-parse&lt;/code&gt; because I&apos;m incredibly creative. Here&apos;s an example of it in play, both with and without a reference date:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div&amp;gt;
    &amp;lt;time-parse&amp;gt;
    I&apos;ll be eating tomorrow. Or maybe in 3 hours? I&apos;m not sure.
    &amp;lt;/time-parse&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;div&amp;gt;
    &amp;lt;time-parse date=&amp;quot;2026-06-04&amp;quot;&amp;gt;
        This is an example in the past. I set the date to June 4th. 
    I&apos;ll be eating tomorrow. Or maybe in 3 hours? I&apos;m not sure.
    &amp;lt;/time-parse&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here&apos;s the code I used. I&apos;ll talk a bit about my design decisions after:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import * as chrono from &apos;chrono-node&apos;;

class TimeParseComponent extends HTMLElement {

	constructor() {
		super();
	}
	
	connectedCallback() {

		// doing an inline style so i dont have to worry about specifying it just for the WC. This feels wrong a bit...
		const STYLE = &apos;text-decoration: underline;text-decoration-color: #ccc;&apos;;

		let dateRef = null;
		
		// look for date attribute
		if(this.hasAttribute(&amp;quot;date&amp;quot;)) {
			console.log(&apos;Date found, using as ref&apos;);
			dateRef = this.#parseLocalDate(this.getAttribute(&amp;quot;date&amp;quot;));
			console.log(&apos;dateRef is &apos;, dateRef);
		}
		
		let input = this.innerHTML;
		const parsed = chrono.parse(input, dateRef);
		if(!parsed || !parsed.length) return;
		console.log(`Found ${parsed.length} items to update`);
		/*
		For each, find the initial string, and wrap it with:
		&amp;lt;time datetime=PARSEDTIME title=PARSEDTIME&amp;gt;orig&amp;lt;/time&amp;gt;

		Go backwards as we are changing the string
		*/
		for(let x=parsed.length-1; x &amp;gt;= 0; x--) {
			console.log(parsed[x]);
			// first get the date, currently not supporting end
			let date = this.#formatDateTime(parsed[x].start.date());
			let orig = input.substring(parsed[x].index, parsed[x].index + parsed[x].text.length);
			// now make the new string
			let newStr = `
&amp;lt;time datetime=&amp;quot;${date}&amp;quot; title=&amp;quot;${date}&amp;quot; style=&amp;quot;${STYLE}&amp;quot;&amp;gt;${orig}&amp;lt;/time&amp;gt;
			`.trim();
			//console.log(newStr);

			input = input.slice(0, parsed[x].index) + newStr + input.slice(parsed[x].index + parsed[x].text.length);
		}

		// All done - 
		console.log(&apos;Final Result&apos;, input);
		this.innerHTML = input;
	}

	#formatDateTime(x) {
	  const pad = (n) =&amp;gt; String(n).padStart(2, &apos;0&apos;);
	
	  return [
	    x.getFullYear(),
	    pad(x.getMonth() + 1),
	    pad(x.getDate()),
	  ].join(&apos;-&apos;) + &apos; &apos; + [
	    pad(x.getHours()),
	    pad(x.getMinutes()),
	    pad(x.getSeconds()),
	  ].join(&apos;:&apos;);
	}

	#parseLocalDate(isoDate) {
	  const [y, m, d] = isoDate.split(&apos;-&apos;).map(Number);
	  return new Date(y, m - 1, d); // local midnight on that date
	}
}

if(!customElements.get(&apos;time-parse&apos;)) customElements.define(&apos;time-parse&apos;, TimeParseComponent);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Alright, so ignoring the basic web component setup, you can see the basic flow is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Look to see if a &lt;code&gt;date&lt;/code&gt; attribute was used, if so, that&apos;s our reference date.&lt;/li&gt;
&lt;li&gt;Get the text inside the component and parse it.&lt;/li&gt;
&lt;li&gt;For each found result, looping backwards, replace the match with new HTML that uses the &lt;code&gt;time&lt;/code&gt; tag.&lt;/li&gt;
&lt;li&gt;Finally, update the inner HTML inside the component.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Up top you&apos;ll notice a &lt;code&gt;STYLE&lt;/code&gt; variable. I went with an inline style for... I don&apos;t know. I just didn&apos;t want to think about the shadow DOM and so forth. I figure with something so small it&apos;s ok. Feel free to fork the pen. ;)&lt;/p&gt;
&lt;p&gt;You can try it out below. Also, once again, I love web components.&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;-2&quot; data-height=&quot;600&quot; data-pen-title=&quot;time-parse demo&quot; data-preview=&quot;true&quot; data-version=&quot;2&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;pvRwbwv&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 600px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/editor/cfjedimaster/pen/019ef9fa-d6e2-7d49-a140-43a457c7d347&quot;&gt;
  time-parse demo&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
                        
                
				</content>

                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/06/22/use-ai-to-not-use-ai-as-much</id>
                <title>Use AI to not use AI (as much)</title>
                <updated>2026-06-22T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/06/22/use-ai-to-not-use-ai-as-much" rel="alternate" type="text/html" title="Use AI to not use AI (as much)"/>
                <content type="html">
				
                        &lt;p&gt;This squarely falls into the &amp;quot;everyone probably knows this but it didn&apos;t click with me right away&amp;quot; category so please feel free to laugh at my ignorance, but it&apos;s something I realized over the past few months, and as I just used this technique this morning, I figured I&apos;d share it on the blog. The idea is simple - it&apos;s trivial to ask a Gen AI tool to do something for you - and depending on the ask, may work great. But what I realized a few months back, especially in regards to having AI parse data, is that you can also use the opportunity to generate a tool (like a Python or Node script) so you don&apos;t need to return to the AI tool again. This becomes especially useful if you want to slightly tweak the output over time or gradually add more features.&lt;/p&gt;
&lt;p&gt;For my example today, it was a GitHub alert. Last month I got a few emails about Actions storage usage and it repeated this month:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/ai1.jpg&quot; loading=&quot;lazy&quot; alt=&quot;Email from GitHub warning about Actions Usage&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;I know &lt;em&gt;of&lt;/em&gt; GitHub Actions, but I don&apos;t really use them much myself. That being said, GitHub&apos;s billing and usage reports were good for an aggregate high level look at my account, but were surprisingly &lt;em&gt;unhelpful&lt;/em&gt; in terms of telling me &lt;em&gt;where&lt;/em&gt; my usage was coming from. Apparently I was supposed to go through my 300+ repositories and find this out by checking the settings for each? That&apos;s crazy. It&apos;s also completely possible I missed a way to get this value easier, but honestly, I wasn&apos;t sure what to do.&lt;/p&gt;
&lt;p&gt;I finally found a usage report generator in my settings, I used that, and quickly got a CSV report. I took a quick look at it, nothing stood out, so I went to my AI tool and simply asked it to parse it for me.&lt;/p&gt;
&lt;p&gt;And it did - swimmingly - giving me a culprit to point to - or in my case - simply delete. It was a repo I had set up to test something that was apparently storing some artifacts I didn&apos;t need. Problem solved.&lt;/p&gt;
&lt;p&gt;But... I wasn&apos;t convinced this wasn&apos;t going to happen again, so I used the technique that first occurred to me a few months back, and is pretty obvious, but I simply asked my tool to generate a Python script for me that would create a report.&lt;/p&gt;
&lt;p&gt;For this I used &lt;a href=&quot;https://cursor.com&quot;&gt;Cursor&lt;/a&gt;, a tool I&apos;ve been using a lot lately, and in Plan mode, simply asked:&lt;/p&gt;
&lt;p&gt;&amp;quot;pay attention to the csv. it is a GitHub usage report. I need a general Python script that can be run at the command line that parses this CSV and reports on usage. the idea is to help me figure out what repo is causing the most usage&amp;quot;&lt;/p&gt;
&lt;p&gt;It chewed on the CSV a bit, and then asked what it should prioritize in terms of the report. Now, my email warning was about &lt;em&gt;storage&lt;/em&gt;, but Cursor suggested total cost, so I went along. After it built the script, I liked the output, but the &apos;problem&apos; repo (that I had already corrected) wasn&apos;t necessarily given enough attention.&lt;/p&gt;
&lt;p&gt;I then went back to Cursor with:&lt;/p&gt;
&lt;p&gt;&amp;quot;this works, but i noticed that testing-boxlang-desktop, which used a lot of storage, doesn&apos;t get call out quite as much. can we maybe add another report after the gross amount one that shows top repos by stoage?&amp;quot;&lt;/p&gt;
&lt;p&gt;Thank god AI doesn&apos;t complain about typos so much as I didn&apos;t even notice that till just now. This iteration added a second report and really made it a great v1 for the script. Speaking of...&lt;/p&gt;
&lt;h2 id=&quot;just-show-me-the-code-already...&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#just-show-me-the-code-already...&quot;&gt;Just show me the code already...&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, so if you are also looking at a GitHub usage report and not sure what repo to blame, generate your summary, save the file, and use this script:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;#!/usr/bin/env python3
&amp;quot;&amp;quot;&amp;quot;Parse GitHub summarized usage CSV exports and report repo-level usage.&amp;quot;&amp;quot;&amp;quot;

from __future__ import annotations

import argparse
import csv
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path

REQUIRED_COLUMNS = {
    &amp;quot;date&amp;quot;,
    &amp;quot;product&amp;quot;,
    &amp;quot;sku&amp;quot;,
    &amp;quot;quantity&amp;quot;,
    &amp;quot;unit_type&amp;quot;,
    &amp;quot;gross_amount&amp;quot;,
    &amp;quot;net_amount&amp;quot;,
    &amp;quot;discount_amount&amp;quot;,
    &amp;quot;repository&amp;quot;,
}


def parse_float(value: str) -&amp;gt; float:
    if not value or not value.strip():
        return 0.0
    return float(value)


def parse_args() -&amp;gt; argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=&amp;quot;Parse a GitHub summarized usage CSV and rank repositories by cost.&amp;quot;
    )
    parser.add_argument(&amp;quot;csv_file&amp;quot;, type=Path, help=&amp;quot;Path to summarized usage CSV&amp;quot;)
    parser.add_argument(
        &amp;quot;--top&amp;quot;,
        type=int,
        default=10,
        metavar=&amp;quot;N&amp;quot;,
        help=&amp;quot;Number of repositories to show in ranking (default: 10)&amp;quot;,
    )
    parser.add_argument(
        &amp;quot;--product&amp;quot;,
        help=&amp;quot;Filter rows by product (e.g. actions, git_lfs)&amp;quot;,
    )
    parser.add_argument(&amp;quot;--sku&amp;quot;, help=&amp;quot;Filter rows by SKU&amp;quot;)
    parser.add_argument(
        &amp;quot;--from&amp;quot;,
        dest=&amp;quot;date_from&amp;quot;,
        metavar=&amp;quot;YYYY-MM-DD&amp;quot;,
        help=&amp;quot;Include rows on or after this date&amp;quot;,
    )
    parser.add_argument(
        &amp;quot;--to&amp;quot;,
        dest=&amp;quot;date_to&amp;quot;,
        metavar=&amp;quot;YYYY-MM-DD&amp;quot;,
        help=&amp;quot;Include rows on or before this date&amp;quot;,
    )
    parser.add_argument(
        &amp;quot;--json&amp;quot;,
        action=&amp;quot;store_true&amp;quot;,
        help=&amp;quot;Emit structured JSON instead of formatted text&amp;quot;,
    )
    return parser.parse_args()


def load_rows(path: Path) -&amp;gt; list[dict[str, str]]:
    if not path.is_file():
        print(f&amp;quot;Error: file not found: {path}&amp;quot;, file=sys.stderr)
        sys.exit(1)

    with path.open(newline=&amp;quot;&amp;quot;, encoding=&amp;quot;utf-8-sig&amp;quot;) as handle:
        reader = csv.DictReader(handle)
        if reader.fieldnames is None:
            print(f&amp;quot;Error: {path} is empty or has no header row&amp;quot;, file=sys.stderr)
            sys.exit(1)

        missing = REQUIRED_COLUMNS - set(reader.fieldnames)
        if missing:
            missing_list = &amp;quot;, &amp;quot;.join(sorted(missing))
            print(
                f&amp;quot;Error: {path} is missing required columns: {missing_list}&amp;quot;,
                file=sys.stderr,
            )
            sys.exit(1)

        return list(reader)


def filter_rows(
    rows: list[dict[str, str]],
    *,
    product: str | None,
    sku: str | None,
    date_from: str | None,
    date_to: str | None,
) -&amp;gt; list[dict[str, str]]:
    filtered: list[dict[str, str]] = []
    for row in rows:
        if product and row[&amp;quot;product&amp;quot;] != product:
            continue
        if sku and row[&amp;quot;sku&amp;quot;] != sku:
            continue
        if date_from and row[&amp;quot;date&amp;quot;] &amp;lt; date_from:
            continue
        if date_to and row[&amp;quot;date&amp;quot;] &amp;gt; date_to:
            continue
        filtered.append(row)
    return filtered


@dataclass
class SkuBreakdown:
    sku: str
    unit_type: str
    quantity: float = 0.0
    gross_amount: float = 0.0


@dataclass
class RepoUsage:
    repository: str
    gross_amount: float = 0.0
    net_amount: float = 0.0
    discount_amount: float = 0.0
    row_count: int = 0
    skus: dict[tuple[str, str], SkuBreakdown] = field(default_factory=dict)

    def add_row(self, row: dict[str, str]) -&amp;gt; None:
        gross = parse_float(row[&amp;quot;gross_amount&amp;quot;])
        net = parse_float(row[&amp;quot;net_amount&amp;quot;])
        discount = parse_float(row[&amp;quot;discount_amount&amp;quot;])
        quantity = parse_float(row[&amp;quot;quantity&amp;quot;])
        sku = row[&amp;quot;sku&amp;quot;]
        unit_type = row[&amp;quot;unit_type&amp;quot;]

        self.gross_amount += gross
        self.net_amount += net
        self.discount_amount += discount
        self.row_count += 1

        key = (sku, unit_type)
        if key not in self.skus:
            self.skus[key] = SkuBreakdown(sku=sku, unit_type=unit_type)
        breakdown = self.skus[key]
        breakdown.quantity += quantity
        breakdown.gross_amount += gross


def aggregate_by_repo(rows: list[dict[str, str]]) -&amp;gt; list[RepoUsage]:
    repos: dict[str, RepoUsage] = {}
    for row in rows:
        repository = row[&amp;quot;repository&amp;quot;].strip() or &amp;quot;(unknown)&amp;quot;
        if repository not in repos:
            repos[repository] = RepoUsage(repository=repository)
        repos[repository].add_row(row)

    return sorted(repos.values(), key=lambda repo: repo.gross_amount, reverse=True)


def is_storage_row(row: dict[str, str]) -&amp;gt; bool:
    return &amp;quot;storage&amp;quot; in row[&amp;quot;sku&amp;quot;].lower()


@dataclass
class RepoStorage:
    repository: str
    quantity: float = 0.0
    gross_amount: float = 0.0
    row_count: int = 0
    skus: dict[str, SkuBreakdown] = field(default_factory=dict)

    def add_row(self, row: dict[str, str]) -&amp;gt; None:
        gross = parse_float(row[&amp;quot;gross_amount&amp;quot;])
        quantity = parse_float(row[&amp;quot;quantity&amp;quot;])
        sku = row[&amp;quot;sku&amp;quot;]
        unit_type = row[&amp;quot;unit_type&amp;quot;]

        self.gross_amount += gross
        self.quantity += quantity
        self.row_count += 1

        if sku not in self.skus:
            self.skus[sku] = SkuBreakdown(sku=sku, unit_type=unit_type)
        breakdown = self.skus[sku]
        breakdown.quantity += quantity
        breakdown.gross_amount += gross


def aggregate_storage_by_repo(rows: list[dict[str, str]]) -&amp;gt; list[RepoStorage]:
    repos: dict[str, RepoStorage] = {}
    for row in rows:
        if not is_storage_row(row):
            continue
        repository = row[&amp;quot;repository&amp;quot;].strip() or &amp;quot;(unknown)&amp;quot;
        if repository not in repos:
            repos[repository] = RepoStorage(repository=repository)
        repos[repository].add_row(row)

    return sorted(repos.values(), key=lambda repo: repo.quantity, reverse=True)


def storage_repo_payload(repo: RepoStorage, total_storage: float) -&amp;gt; dict:
    return {
        &amp;quot;repository&amp;quot;: repo.repository,
        &amp;quot;quantity_gigabyte_hours&amp;quot;: repo.quantity,
        &amp;quot;gross_amount&amp;quot;: repo.gross_amount,
        &amp;quot;percent_of_total_storage&amp;quot;: (
            (repo.quantity / total_storage * 100) if total_storage else 0.0
        ),
        &amp;quot;row_count&amp;quot;: repo.row_count,
        &amp;quot;skus&amp;quot;: [
            {
                &amp;quot;sku&amp;quot;: item.sku,
                &amp;quot;unit_type&amp;quot;: item.unit_type,
                &amp;quot;quantity&amp;quot;: item.quantity,
                &amp;quot;gross_amount&amp;quot;: item.gross_amount,
            }
            for item in sorted(repo.skus.values(), key=lambda sku_item: sku_item.quantity, reverse=True)
        ],
    }


def format_money(amount: float) -&amp;gt; str:
    return f&amp;quot;${amount:.4f}&amp;quot;


def format_percent(part: float, whole: float) -&amp;gt; str:
    if whole == 0:
        return &amp;quot;0.0%&amp;quot;
    return f&amp;quot;{(part / whole) * 100:.1f}%&amp;quot;


def print_storage_table(storage_repos: list[RepoStorage], top: int) -&amp;gt; None:
    print(&amp;quot;Top repositories by storage&amp;quot;)
    print(&amp;quot;-&amp;quot; * 28)

    if not storage_repos:
        print(&amp;quot;No storage usage found.&amp;quot;)
        print()
        return

    total_storage = sum(repo.quantity for repo in storage_repos)
    print(
        f&amp;quot;{&apos;#&apos;:&amp;gt;3}  {&apos;Repository&apos;:&amp;lt;26} {&apos;GB-hours&apos;:&amp;gt;10} {&apos;%&apos;:&amp;gt;7} {&apos;Gross&apos;:&amp;gt;10} {&apos;Rows&apos;:&amp;gt;5}&amp;quot;
    )
    for index, repo in enumerate(storage_repos[:top], start=1):
        print(
            f&amp;quot;{index:&amp;gt;3}  {repo.repository:&amp;lt;26} &amp;quot;
            f&amp;quot;{repo.quantity:&amp;gt;10.2f} &amp;quot;
            f&amp;quot;{format_percent(repo.quantity, total_storage):&amp;gt;7} &amp;quot;
            f&amp;quot;{format_money(repo.gross_amount):&amp;gt;10} &amp;quot;
            f&amp;quot;{repo.row_count:&amp;gt;5}&amp;quot;
        )
    print()


def print_text_report(
    path: Path,
    rows: list[dict[str, str]],
    repos: list[RepoUsage],
    storage_repos: list[RepoStorage],
    top: int,
) -&amp;gt; None:
    total_gross = sum(repo.gross_amount for repo in repos)
    total_net = sum(repo.net_amount for repo in repos)
    dates = [row[&amp;quot;date&amp;quot;] for row in rows]
    date_range = f&amp;quot;{min(dates)} to {max(dates)}&amp;quot; if dates else &amp;quot;n/a&amp;quot;

    print(&amp;quot;GitHub Usage Report&amp;quot;)
    print(&amp;quot;=&amp;quot; * 19)
    print(f&amp;quot;File: {path.name}&amp;quot;)
    print(f&amp;quot;Period: {date_range} ({len(rows)} rows)&amp;quot;)
    print(f&amp;quot;Total gross: {format_money(total_gross)}   Total net: {format_money(total_net)}&amp;quot;)
    print()

    if not repos:
        print(&amp;quot;No matching usage rows found.&amp;quot;)
        return

    print(&amp;quot;Top repositories by gross amount&amp;quot;)
    print(&amp;quot;-&amp;quot; * 32)
    print(f&amp;quot;{&apos;#&apos;:&amp;gt;3}  {&apos;Repository&apos;:&amp;lt;26} {&apos;Gross&apos;:&amp;gt;10} {&apos;%&apos;:&amp;gt;7} {&apos;Net&apos;:&amp;gt;10} {&apos;Rows&apos;:&amp;gt;5}&amp;quot;)
    for index, repo in enumerate(repos[:top], start=1):
        print(
            f&amp;quot;{index:&amp;gt;3}  {repo.repository:&amp;lt;26} &amp;quot;
            f&amp;quot;{format_money(repo.gross_amount):&amp;gt;10} &amp;quot;
            f&amp;quot;{format_percent(repo.gross_amount, total_gross):&amp;gt;7} &amp;quot;
            f&amp;quot;{format_money(repo.net_amount):&amp;gt;10} &amp;quot;
            f&amp;quot;{repo.row_count:&amp;gt;5}&amp;quot;
        )

    print()
    print_storage_table(storage_repos, top)
    print(&amp;quot;Details for top repos&amp;quot;)
    print(&amp;quot;-&amp;quot; * 19)
    for repo in repos[:top]:
        print(f&amp;quot;{repo.repository} ({format_money(repo.gross_amount)} gross)&amp;quot;)
        sku_items = sorted(repo.skus.values(), key=lambda item: item.gross_amount, reverse=True)
        for item in sku_items:
            print(
                f&amp;quot;  {item.sku:&amp;lt;22} {item.quantity:&amp;gt;10.2f} {item.unit_type:&amp;lt;16} &amp;quot;
                f&amp;quot;{format_money(item.gross_amount):&amp;gt;10}&amp;quot;
            )
        print()


def print_json_report(
    path: Path,
    rows: list[dict[str, str]],
    repos: list[RepoUsage],
    storage_repos: list[RepoStorage],
    top: int,
) -&amp;gt; None:
    total_gross = sum(repo.gross_amount for repo in repos)
    total_net = sum(repo.net_amount for repo in repos)
    total_storage = sum(repo.quantity for repo in storage_repos)
    dates = [row[&amp;quot;date&amp;quot;] for row in rows]

    payload = {
        &amp;quot;file&amp;quot;: str(path),
        &amp;quot;row_count&amp;quot;: len(rows),
        &amp;quot;date_from&amp;quot;: min(dates) if dates else None,
        &amp;quot;date_to&amp;quot;: max(dates) if dates else None,
        &amp;quot;total_gross_amount&amp;quot;: total_gross,
        &amp;quot;total_net_amount&amp;quot;: total_net,
        &amp;quot;total_storage_gigabyte_hours&amp;quot;: total_storage,
        &amp;quot;repositories&amp;quot;: [
            {
                &amp;quot;rank&amp;quot;: index,
                &amp;quot;repository&amp;quot;: repo.repository,
                &amp;quot;gross_amount&amp;quot;: repo.gross_amount,
                &amp;quot;net_amount&amp;quot;: repo.net_amount,
                &amp;quot;discount_amount&amp;quot;: repo.discount_amount,
                &amp;quot;row_count&amp;quot;: repo.row_count,
                &amp;quot;percent_of_total_gross&amp;quot;: (
                    (repo.gross_amount / total_gross * 100) if total_gross else 0.0
                ),
                &amp;quot;skus&amp;quot;: [
                    {
                        &amp;quot;sku&amp;quot;: item.sku,
                        &amp;quot;unit_type&amp;quot;: item.unit_type,
                        &amp;quot;quantity&amp;quot;: item.quantity,
                        &amp;quot;gross_amount&amp;quot;: item.gross_amount,
                    }
                    for item in sorted(
                        repo.skus.values(),
                        key=lambda sku_item: sku_item.gross_amount,
                        reverse=True,
                    )
                ],
            }
            for index, repo in enumerate(repos[:top], start=1)
        ],
        &amp;quot;storage_repositories&amp;quot;: [
            {&amp;quot;rank&amp;quot;: index, **storage_repo_payload(repo, total_storage)}
            for index, repo in enumerate(storage_repos[:top], start=1)
        ],
    }
    print(json.dumps(payload, indent=2))


def main() -&amp;gt; None:
    args = parse_args()
    rows = load_rows(args.csv_file)
    filtered = filter_rows(
        rows,
        product=args.product,
        sku=args.sku,
        date_from=args.date_from,
        date_to=args.date_to,
    )
    repos = aggregate_by_repo(filtered)
    storage_repos = aggregate_storage_by_repo(filtered)

    if args.json:
        print_json_report(args.csv_file, filtered, repos, storage_repos, args.top)
    else:
        print_text_report(args.csv_file, filtered, repos, storage_repos, args.top)


if __name__ == &amp;quot;__main__&amp;quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can also find this here: &lt;a href=&quot;https://github.com/cfjedimaster/pythondemos/tree/main/ghusageparser&quot;&gt;https://github.com/cfjedimaster/pythondemos/tree/main/ghusageparser&lt;/a&gt;. Note that this uses no external dependencies outside of standard library modules. Usage is simple:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python -h
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which gives:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/ai2.png&quot; loading=&quot;lazy&quot; alt=&quot;Help text from CLI tool&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Honestly, none of those arguments were ideas I had but they all make sense. And here&apos;s how the report looks:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/ai3.png&quot; loading=&quot;lazy&quot; alt=&quot;Help text from CLI tool&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;The &apos;culprit&apos; was &lt;code&gt;testing-boxlang-desktop&lt;/code&gt; which as I said, was just for testing, so easy to delete. Honestly, &lt;code&gt;tweetback&lt;/code&gt; surprised me. This is a repo I created as an export from Twitter and apparently it&apos;s got a lot of media. I didn&apos;t delete it - but may do so in the future.&lt;/p&gt;
&lt;p&gt;Anyway, let me know what you think and if this could be helpful to you!&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@simonkadula?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Simon Kadula&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/a-factory-filled-with-lots-of-orange-machines-8gr6bObQLOI?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="python" />
                
                <category term="generative ai" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/06/21/links-for-you-62126</id>
                <title>Links For You (6/21/26)</title>
                <updated>2026-06-21T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/06/21/links-for-you-62126" rel="alternate" type="text/html" title="Links For You (6/21/26)"/>
                <content type="html">
				
                        &lt;p&gt;Greetings and salutations, readers. It&apos;s been a few weeks since I shared one of these, mostly due to the job search being somewhat exhausting, but I&apos;ve got a backup of links so it&apos;s time to get back in the habit. And of course, it&apos;s Father&apos;s Day and I want to wish all the dads out there (myself included) a very happy father&apos;s day. This weekend I got to officiate my first wedding (for my brother-in-law and his fiance) so my plan today is to do... nothing. Enjoy your links!&lt;/p&gt;
&lt;h2 id=&quot;mastodon-and-translation-with-chrome-ai&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#mastodon-and-translation-with-chrome-ai&quot;&gt;Mastodon and Translation with Chrome AI&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;First up is a presentation by Thomas Steiner demonstrating Chrome built-in AI APIs doing language detection and translation for Mastodon. I&apos;m a pretty big fan of those APIs (my last presentation was on them!) so I enjoyed this quite a bit. Thomas has been a &lt;em&gt;big&lt;/em&gt; help with my demos and posts on these APIs and is a scary smart dev.&lt;/p&gt;
&lt;lite-youtube videoid=&quot;-l2BUUdBDS8&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/-l2BUUdBDS8/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=-l2BUUdBDS8&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;
&lt;h2 id=&quot;datatype---text-into-charts&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#datatype---text-into-charts&quot;&gt;Datatype - Text into Charts&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Next is &lt;a href=&quot;https://franktisellano.github.io/datatype/&quot;&gt;Datatype&lt;/a&gt;, a font that creates charts. Seriously. For example:&lt;/p&gt;
&lt;style&gt;
@font-face {
  font-family: &apos;Datatype&apos;;
  src: url(&apos;https://static.raymondcamden.com/fonts/Datatype-Regular.woff2&apos;) format(&apos;woff2&apos;);
  font-weight: 100 900;      /* Variable weight axis */
  font-stretch: 0% 100%;     /* Variable width axis */
  font-display: swap;
}

.chart {
  font-family: &apos;Datatype&apos;, monospace;
}
&lt;/style&gt;
&lt;p&gt;Sales &lt;span class=&quot;chart&quot;&gt;{l:20,45,60,55,80,95}&lt;/span&gt; are up this quarter.&lt;/p&gt;
&lt;p&gt;And this was driven by CSS and text:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;style&amp;gt;
@font-face {
  font-family: &apos;Datatype&apos;;
  src: url(&apos;https://static.raymondcamden.com/fonts/Datatype-Regular.woff2&apos;) format(&apos;woff2&apos;);
  font-weight: 100 900;      /* Variable weight axis */
  font-stretch: 0% 100%;     /* Variable width axis */
  font-display: swap;
}

.chart {
  font-family: &apos;Datatype&apos;, monospace;
}
&amp;lt;/style&amp;gt;

&amp;lt;span class=&amp;quot;chart&amp;quot;&amp;gt;{l:20,45,60,55,80,95}&amp;lt;/span&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It supports bar charts, sparklines, and pie charts. As it&apos;s just text, in theory it would be easy to animate and make it &apos;live&apos; with just a bit of JavaScript.&lt;/p&gt;
&lt;h2 id=&quot;you-don&apos;t-know-html-lists&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#you-don&apos;t-know-html-lists&quot;&gt;You Don&apos;t Know HTML Lists&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Finally there&apos;s a spectacular deep dive into HTML lists by Frank Taylor, &lt;a href=&quot;https://blog.frankmtaylor.com/2026/05/13/you-dont-know-html-lists/&quot;&gt;You don&apos;t know HTML lists&lt;/a&gt;. You know this is going to be a great article with a sentence like this in the intro:&lt;/p&gt;
&lt;p&gt;&amp;quot;We&apos;re skipping over the MDN and W3Schools introductory pages and instead we&apos;re going into the kind of stuff you discover after accidentally taking your cousin&apos;s Ritalin right before you open up the W3C specs.&amp;quot;&lt;/p&gt;
&lt;p&gt;I knew maybe... half of this? Maybe a bit more? Either way, it&apos;s a damn good investigation into a part of HTML you may not know even got that complex.&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;Looking for your next read? Are you mostly through your current book and getting a bit of anxiety over whether or not you&apos;ll have your next read as soon as you finish? Good news, you&apos;re in luck. I&apos;ve spoken about Scott Stroz in the past (and linked to some of his cool blogs too), he&apos;s one of my oldest, and best friends. He&apos;s a father as well to two incredible kids, one of which just released his first novel, &amp;quot;The Dreaded.&amp;quot;&lt;/p&gt;
&lt;p&gt;
&lt;a href=&quot;https://www.amazon.com/dp/B0H623KMN7/ref=mp_s_a_1_1&quot;&gt;&lt;img src=&quot;https://static.raymondcamden.com/images/2026/06/dreaded.png&quot; loading=&quot;lazy&quot; alt=&quot;The Dreaded cover&quot; class=&quot;imgborder imgcenter&quot;&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;p&gt;Tyler&apos;s shared some of his works for me in the past so as soon as this one became available, I went ahead and purchased a copy for myself. Grab your &lt;a href=&quot;https://www.amazon.com/dp/B0H623KMN7/ref=mp_s_a_1_1&quot;&gt;copy&lt;/a&gt; today - available on Kindle, paper and hardback.&lt;/p&gt;

                        
                
				</content>

                
                <category term="links4you" />
                
                
                <category term="misc" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
</feed>