From fb372885fcd24bf31c5acf33ea3736ab7be7523b Mon Sep 17 00:00:00 2001 From: Ryan Workman Date: Sun, 24 May 2026 19:00:19 -0600 Subject: [PATCH 1/3] =?UTF-8?q?Add=20Pok=C3=A9mon=20TCG=20scanner=20post,?= =?UTF-8?q?=20tag=20pills,=20and=20link=20security?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Problem The blog had no published posts, tags rendered as plain comma-separated text with no visual weight, and external links in post markdown opened in the same tab without `rel="noopener"`, leaving the site exposed to reverse tabnabbing. # Solution Added the first blog post documenting a one-night build of a Pokémon card scanner using Claude Code, Next.js, and an agent-driven XP workflow. The post covers architecture decisions (structured tool output, lazy price caching, server-first rendering), honest limitations, and a broader reflection on lowered barriers to building software. Refactored tag rendering site-wide to use styled pill components. Tags now render via a `renderTagPills` helper in build.js that wraps each tag in a `` element. Supporting CSS adds dark background, rounded corners, and tight typographic treatment. This replaces comma-separated text across post pages, theme listing pages, and the homepage post list. Secured all external links: markdown-authored links in post body copy now get `target="_blank" rel="noopener"` via a markdown-it renderer override in build.js, scoped only to post content so site navigation stays in-tab. Static external links in index.html were updated with matching `rel="noopener"` attributes by hand. # Notes The generated HTML files (index.html, side-projects/index.html, side-projects/2026-05-24-*.html) are committed directly — that's intentional for the GitHub Pages build pipeline. Empty `.keep` files in js/ and posts/ were removed as dead scaffolding, and .claude/ was added to .gitignore. --- .gitignore | 1 + ...05-24-pokemon-card-scanner-in-one-night.md | 151 +++++++++++++++ build.js | 32 +++- css/style.css | 22 +++ index.html | 106 ++++++++--- js/.keep | 0 posts/.keep | 0 ...-24-pokemon-card-scanner-in-one-night.html | 173 ++++++++++++++++++ side-projects/index.html | 52 ++++++ 9 files changed, 504 insertions(+), 33 deletions(-) create mode 100644 _posts/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.md delete mode 100644 js/.keep delete mode 100644 posts/.keep create mode 100644 side-projects/2026-05-24-pokemon-card-scanner-in-one-night.html create mode 100644 side-projects/index.html diff --git a/.gitignore b/.gitignore index a4b2385..658c39d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ _local_preview.html node_modules/ +.claude/ diff --git a/_posts/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.md b/_posts/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.md new file mode 100644 index 0000000..ed7c827 --- /dev/null +++ b/_posts/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.md @@ -0,0 +1,151 @@ +--- +title: i built a pokémon card scanner in one night with claude code +date: 2026-05-24 +description: A garage sale gave me a reason to finally figure out what my Pokémon cards are worth, so I built a photo-to-catalog scanner in one night with Claude vision, Next.js, and an agent-led XP workflow. +tags: [ai, claude-code, agentic-development, nextjs, side-projects] +--- + +I was selling off a chunk of my Pokémon card collection at a neighborhood +garage sale. A few buyers messaged ahead to say they were coming specifically +for the cards, which is when it hit me that I had no idea what most of them +were actually worth. + +So I needed something quick and functional. Point my phone at a page of cards, +snap a photo, and get a current price on the rarer ones. No standing there +flipping through TCGPlayer one card at a time while a line of buyers waits on +me. + +They say necessity is the mother of invention... thus my [Pokémon TCG Catalog](https://pokemontcg.workman.tech) was born. + +## a side project, run like a real project + +My team at work has leaned in hard on agent-driven engineering recently, and +I've been driving a good part of that shift. This project was a chance to take +the same practices I use professionally and point them at a problem of my own. + +The architecture was obvious before I wrote a line. A JS framework for the app, +a small SQL database, Claude vision to read the cards, and a free Pokémon API +for the canonical data and prices. It was the perfect tightly-scoped app to be +built using a few agents without babysitting every keystroke. + +So I spent about 30 minutes with Claude Opus 4.7 hashing out the architecture, +a handful of user stories and how I envisioned all of the pieces coming +together. +Out of that came six phases of work, each cut into four or five +single-responsibility tasks. + +This is just how I run projects at work now. I lean on Kent Beck's Extreme +Programming fundamentals and bend them to fit agent-led execution. The planning +is just-in-time. A top-level `ROADMAP.md` lays the phases out as high-level +road-signs, just enough to confirm the order makes sense, and I only flesh a +phase out in full right before I execute it. A phase is roughly the chunk of +work you'd scope in a single backlog grooming, no more. You wouldn't point and +prioritize months of stories in one sitting, and that restraint still matters +even though Claude could happily plan hundreds of tasks up front. Just because it +can doesn't mean it should. A spec that detailed would be wrong by phase three +anyway. + +Each phase gets its own directory, and at the top sits an `AGENT_INIT.md`. +That's the primer an agent reads first: the goal of the phase, the constraints, +the files to read and in what order, the decisions already made, and what's +explicitly out of scope. Under it are numbered task files, each one basically a +ticket scoped tight enough to land as a single PR. Small, focused, independently +verifiable. The `AGENT_INIT.md` is the source of truth for the phase. It's where +the agents go to figure out intent, and where pivots get written down when +reality disagrees with the plan. Reality usually disagrees with the plan. The +nice part is I can move in small steps and change direction halfway through +without losing the bigger thread. + +Alright, plan in hand and the first phase sketched, I handed the tasks to +Claude Code and started working through them. + +## what got built + +Here's the shape of what came out the other side: + +- **Next.js 16 (App Router) on React 19, in TypeScript.** Server-first by + default. The database, the auth, and the external API calls all live on the + server, and the client is just a rendering layer. +- **Neon (serverless Postgres) via Drizzle ORM.** One small schema. Cards, the + copies I own, and their condition, foil status, and price. +- **Claude Sonnet 4.6 for the actual identification.** This is the heart of it. + One photo of up to nine cards goes out in a single API call, and Claude hands + back each card's name, set, number, foil status, a rough condition read, and + a normalized bounding box. The app uses those boxes to crop one tight image + per card right on the device, so every row in my inventory ends up with its + own clean photo. +- **pokemontcg.io for canonical data and prices.** It's free, it indexes every + set back to Base Set, and it bundles daily TCGPlayer and Cardmarket prices + right into the card response. No second integration just for pricing, which + was a nice surprise. +- **Vercel for hosting and Blob storage**, with HeroUI v3 on Tailwind v4 for a + clean, mobile-first UI that stays out of the way of the card art. + +A couple choices I was glad I made. + +**Forced structured output instead of parsing JSON.** Rather than ask Claude +for JSON and hope, I gave it a single tool, `report_identified_cards`, with a +strict JSON Schema, and forced the model to call it. Anthropic validates the +shape server-side before any of it reaches my code. No `JSON.parse`, no +stripping markdown fences, no schema drift to defend against. The client just +reads the validated payload. Boring, in the best possible way. + +**Caching over cron.** The identification system prompt never changes, so it +goes out with prompt caching, and after the first call the bulk of it is about +90% cheaper. That keeps each multi-card photo down to roughly a couple cents. +And instead of a scheduled job to keep prices fresh, the app caches each card +for 24 hours and lazily refreshes it on view. That matches how often the +upstream prices actually move, and it was one less moving part to build at +11pm. + +## the result + +I worked through all six phases and had the thing live on Vercel in about four +hours. Then I did the one thing it was built for. I scanned in my highest-value +cards, watched them resolve to the right printings with current prices, and +walked into the garage sale with a catalog on my phone instead of a search box +and a prayer. + +It worked exactly as designed + +## the honest version + +Let me be clear about what this is and what it isn't. It's a personal tool that +solves a personal problem, and it has the rough edges to prove it. If I wanted +to run this for anyone other than me, I'd have real work to do on performance +and load balancing, and I'd have to swap the single shared-password admin for +an auth setup that's actually resilient and scalable. And the scanning, the +whole centerpiece of the thing, only nails the identification cleanly about +half the time. So I built a manual search fallback for the cards Claude whiffs +on. + +None of that changes the takeaway though. The cards were just the excuse. That +30 minutes of planning with Opus up front, figuring out what to build and in +what order, is the entire reason four hours of agent-driven building actually +landed a working app. I had a real problem the night before a garage sale, and +I went to bed with it solved. Hard to be mad at that. + +## the part i'm actually excited about + +Here's the thing I keep coming back to. A few years ago, solving a problem this +specific meant one of two things. Either I find a SaaS product that handles 80% +of it, pay every month, and bend my actual problem to fit the 20% it was never +designed for. Or I block out a whole weekend to wire up a framework, a database, +auth, and hosting by hand before I can even start on the fun part. Both of those +are a lot of friction for "I want to price my Pokémon cards at a garage sale." + +This time I described what I wanted, planned it out, and had a working tool by +bedtime. A couple cents in API calls and a free Vercel plan. That's genuinely +wild, and I don't think we should pretend it isn't. + +I came into tech as a career changer, and the best part of this whole ride has +been watching the barriers keep falling. The stuff that used to gatekeep +building software, the setup, the boilerplate, the sheer pile of things you had +to know before you could make anything at all, keeps getting lower. Tools like +Claude Code just knocked another big chunk of it down. That's not a threat. That +is the dream. More people building more specific things for themselves is +exactly the point. + +So if you've been sitting on some dumb little idea, some problem only you have, +consider this your sign. The floor has never been lower than it is right now. Go +build the thing. I can't wait to see what you make diff --git a/build.js b/build.js index 1615c93..ba6b111 100644 --- a/build.js +++ b/build.js @@ -29,6 +29,20 @@ const md = new MarkdownIt({ typographer: true, }); +// Open links authored in post markdown in a new tab. This applies ONLY to +// links written in the .md source — the site's own navigation links (back +// links, theme/post-list links) are built as plain strings elsewhere and +// stay in the same tab. rel="noopener" is the required security partner for +// target="_blank" (blocks reverse tabnabbing). +const defaultLinkOpen = + md.renderer.rules.link_open || + ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options)); +md.renderer.rules.link_open = (tokens, idx, options, env, self) => { + tokens[idx].attrSet("target", "_blank"); + tokens[idx].attrSet("rel", "noopener"); + return defaultLinkOpen(tokens, idx, options, env, self); +}; + // ---------- helpers ---------- const FILENAME_RE = /^(\d{4})-(\d{2})-(\d{2})-([a-z0-9][a-z0-9-]*)\.md$/i; @@ -210,12 +224,20 @@ function renderPostBody(post) { return md.render(post.bodyMarkdown); } +function renderTagPills(tags) { + if (!tags || tags.length === 0) return ""; + const pills = tags + .map((t) => `${escapeHtml(t)}`) + .join(""); + return `${pills}`; +} + function themeAndTagsLine(post) { const themeLink = `${escapeHtml( post.themeDisplay, )}`; if (post.tags.length === 0) return themeLink; - return `${themeLink} · tags: ${post.tags.map(escapeHtml).join(", ")}`; + return `${themeLink} · ${renderTagPills(post.tags)}`; } function canonicalUrlForPost(post) { @@ -250,9 +272,7 @@ function renderThemeListing(template, themeSlug, posts) { .map((p) => { const href = `/${p.themeSlug}/${p.filename}`; const tagLine = - p.tags.length > 0 - ? `
tags: ${p.tags.map(escapeHtml).join(", ")}` - : ""; + p.tags.length > 0 ? `
${renderTagPills(p.tags)}` : ""; const desc = p.description ? `
${escapeHtml(p.description)}` : ""; @@ -290,9 +310,7 @@ function renderHomepagePostList(byTheme) { .map((p) => { const href = `/${p.themeSlug}/${p.filename}`; const tagLine = - p.tags.length > 0 - ? ` (${p.tags.map(escapeHtml).join(", ")})` - : ""; + p.tags.length > 0 ? ` ${renderTagPills(p.tags)}` : ""; return ( `
  • \n` + ` ${escapeHtml(p.title)}` + diff --git a/css/style.css b/css/style.css index d6a3ca5..5414783 100644 --- a/css/style.css +++ b/css/style.css @@ -9,6 +9,8 @@ --color-quote-rule: black; --color-table-rule: black; --color-code-bg: #edf2f7; + --color-tag-bg: #18181b; + --color-tag-text: #fafafa; /* Spacing scale */ --space-1: 0.25rem; @@ -125,6 +127,26 @@ pre { opacity: var(--muted-opacity); } +.tags { + display: inline-flex; + flex-wrap: wrap; + gap: var(--space-2); + vertical-align: middle; +} + +.tag { + display: inline-flex; + align-items: center; + background: var(--color-tag-bg); + color: var(--color-tag-text); + border-radius: 0.5rem; + padding: 0.2rem 0.625rem; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.4; + white-space: nowrap; +} + header, main { margin: 0 auto; diff --git a/index.html b/index.html index 8fe02a5..07ea5d5 100644 --- a/index.html +++ b/index.html @@ -1,21 +1,44 @@ - + - + workman.tech - - - - - - - - - - - - + + + + + + + + + + + + @@ -23,18 +46,22 @@

    workman.tech 👋🏻 hello world

    -

    - let's chat about tech -

    +

    let's chat about tech

    - Here are some things that I've been kicking around in my head that I thought were interesting enough to - write down and share with the world. + Here are some things that I've been kicking around in my head + that I thought were interesting enough to write down and share with + the world.

    -

    New posts coming soon.

    +

    Side Projects

    +
    @@ -43,13 +70,32 @@

    let's chat some more

    - Feel free to check out some of the stuff I made and reach out to kick around some ideas. + Feel free to check out some of the stuff I made and reach out to + kick around some ideas.

    @@ -57,8 +103,16 @@

    let's chat some more

    diff --git a/js/.keep b/js/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/posts/.keep b/posts/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.html b/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.html new file mode 100644 index 0000000..35238fe --- /dev/null +++ b/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.html @@ -0,0 +1,173 @@ + + + + + + + workman.tech — i built a pokémon card scanner in one night with claude code + + + + + + + + + + + + + + + + + +
    +

    workman.tech 👋🏻 hello world

    +
    +

    + <- Back +

    + +

    + i built a pokémon card scanner in one night with claude code
    + +

    + +

    + Side Projects · aiclaude-codeagentic-developmentnextjsside-projects +

    + +
    +

    I was selling off a chunk of my Pokémon card collection at a neighborhood +garage sale. A few buyers messaged ahead to say they were coming specifically +for the cards, which is when it hit me that I had no idea what most of them +were actually worth.

    +

    So I needed something quick and functional. Point my phone at a page of cards, +snap a photo, and get a current price on the rarer ones. No standing there +flipping through TCGPlayer one card at a time while a line of buyers waits on +me.

    +

    They say necessity is the mother of invention… thus my Pokémon TCG Catalog was born.

    +

    a side project, run like a real project

    +

    My team at work has leaned in hard on agent-driven engineering recently, and +I’ve been driving a good part of that shift. This project was a chance to take +the same practices I use professionally and point them at a problem of my own.

    +

    The architecture was obvious before I wrote a line. A JS framework for the app, +a small SQL database, Claude vision to read the cards, and a free Pokémon API +for the canonical data and prices. It was the perfect tightly-scoped app to be +built using a few agents without babysitting every keystroke.

    +

    So I spent about 30 minutes with Claude Opus 4.7 hashing out the architecture, +a handful of user stories and how I envisioned all of the pieces coming +together. +Out of that came six phases of work, each cut into four or five +single-responsibility tasks.

    +

    This is just how I run projects at work now. I lean on Kent Beck’s Extreme +Programming fundamentals and bend them to fit agent-led execution. The planning +is just-in-time. A top-level ROADMAP.md lays the phases out as high-level +road-signs, just enough to confirm the order makes sense, and I only flesh a +phase out in full right before I execute it. A phase is roughly the chunk of +work you’d scope in a single backlog grooming, no more. You wouldn’t point and +prioritize months of stories in one sitting, and that restraint still matters +even though Claude could happily plan hundreds of tasks up front. Just because it +can doesn’t mean it should. A spec that detailed would be wrong by phase three +anyway.

    +

    Each phase gets its own directory, and at the top sits an AGENT_INIT.md. +That’s the primer an agent reads first: the goal of the phase, the constraints, +the files to read and in what order, the decisions already made, and what’s +explicitly out of scope. Under it are numbered task files, each one basically a +ticket scoped tight enough to land as a single PR. Small, focused, independently +verifiable. The AGENT_INIT.md is the source of truth for the phase. It’s where +the agents go to figure out intent, and where pivots get written down when +reality disagrees with the plan. Reality usually disagrees with the plan. The +nice part is I can move in small steps and change direction halfway through +without losing the bigger thread.

    +

    Alright, plan in hand and the first phase sketched, I handed the tasks to +Claude Code and started working through them.

    +

    what got built

    +

    Here’s the shape of what came out the other side:

    +
      +
    • Next.js 16 (App Router) on React 19, in TypeScript. Server-first by +default. The database, the auth, and the external API calls all live on the +server, and the client is just a rendering layer.
    • +
    • Neon (serverless Postgres) via Drizzle ORM. One small schema. Cards, the +copies I own, and their condition, foil status, and price.
    • +
    • Claude Sonnet 4.6 for the actual identification. This is the heart of it. +One photo of up to nine cards goes out in a single API call, and Claude hands +back each card’s name, set, number, foil status, a rough condition read, and +a normalized bounding box. The app uses those boxes to crop one tight image +per card right on the device, so every row in my inventory ends up with its +own clean photo.
    • +
    • pokemontcg.io for canonical data and prices. It’s free, it indexes every +set back to Base Set, and it bundles daily TCGPlayer and Cardmarket prices +right into the card response. No second integration just for pricing, which +was a nice surprise.
    • +
    • Vercel for hosting and Blob storage, with HeroUI v3 on Tailwind v4 for a +clean, mobile-first UI that stays out of the way of the card art.
    • +
    +

    A couple choices I was glad I made.

    +

    Forced structured output instead of parsing JSON. Rather than ask Claude +for JSON and hope, I gave it a single tool, report_identified_cards, with a +strict JSON Schema, and forced the model to call it. Anthropic validates the +shape server-side before any of it reaches my code. No JSON.parse, no +stripping markdown fences, no schema drift to defend against. The client just +reads the validated payload. Boring, in the best possible way.

    +

    Caching over cron. The identification system prompt never changes, so it +goes out with prompt caching, and after the first call the bulk of it is about +90% cheaper. That keeps each multi-card photo down to roughly a couple cents. +And instead of a scheduled job to keep prices fresh, the app caches each card +for 24 hours and lazily refreshes it on view. That matches how often the +upstream prices actually move, and it was one less moving part to build at +11pm.

    +

    the result

    +

    I worked through all six phases and had the thing live on Vercel in about four +hours. Then I did the one thing it was built for. I scanned in my highest-value +cards, watched them resolve to the right printings with current prices, and +walked into the garage sale with a catalog on my phone instead of a search box +and a prayer.

    +

    It worked exactly as designed

    +

    the honest version

    +

    Let me be clear about what this is and what it isn’t. It’s a personal tool that +solves a personal problem, and it has the rough edges to prove it. If I wanted +to run this for anyone other than me, I’d have real work to do on performance +and load balancing, and I’d have to swap the single shared-password admin for +an auth setup that’s actually resilient and scalable. And the scanning, the +whole centerpiece of the thing, only nails the identification cleanly about +half the time. So I built a manual search fallback for the cards Claude whiffs +on.

    +

    None of that changes the takeaway though. The cards were just the excuse. That +30 minutes of planning with Opus up front, figuring out what to build and in +what order, is the entire reason four hours of agent-driven building actually +landed a working app. I had a real problem the night before a garage sale, and +I went to bed with it solved. Hard to be mad at that.

    +

    the part i’m actually excited about

    +

    Here’s the thing I keep coming back to. A few years ago, solving a problem this +specific meant one of two things. Either I find a SaaS product that handles 80% +of it, pay every month, and bend my actual problem to fit the 20% it was never +designed for. Or I block out a whole weekend to wire up a framework, a database, +auth, and hosting by hand before I can even start on the fun part. Both of those +are a lot of friction for “I want to price my Pokémon cards at a garage sale.”

    +

    This time I described what I wanted, planned it out, and had a working tool by +bedtime. A couple cents in API calls and a free Vercel plan. That’s genuinely +wild, and I don’t think we should pretend it isn’t.

    +

    I came into tech as a career changer, and the best part of this whole ride has +been watching the barriers keep falling. The stuff that used to gatekeep +building software, the setup, the boilerplate, the sheer pile of things you had +to know before you could make anything at all, keeps getting lower. Tools like +Claude Code just knocked another big chunk of it down. That’s not a threat. That +is the dream. More people building more specific things for themselves is +exactly the point.

    +

    So if you’ve been sitting on some dumb little idea, some problem only you have, +consider this your sign. The floor has never been lower than it is right now. Go +build the thing. I can’t wait to see what you make

    + +
    +
    +
    + + + + diff --git a/side-projects/index.html b/side-projects/index.html new file mode 100644 index 0000000..496b59e --- /dev/null +++ b/side-projects/index.html @@ -0,0 +1,52 @@ + + + + + + + workman.tech — Side Projects + + + + + + + + + + + + + + + + + +
    +

    workman.tech 👋🏻 hello world

    +
    +

    + <- Back +

    + +

    + Side Projects +

    + +
    +
      +
    • + i built a pokémon card scanner in one night with claude code
      A garage sale gave me a reason to finally figure out what my Pokémon cards are worth, so I built a photo-to-catalog scanner in one night with Claude vision, Next.js, and an agent-led XP workflow.
      aiclaude-codeagentic-developmentnextjsside-projects +
    • +
    +
    +
    +
    + + + + From d5dbf6d5c96d5e2d070358911b5cdfe4a6a39104 Mon Sep 17 00:00:00 2001 From: Ryan Workman Date: Sun, 24 May 2026 19:30:23 -0600 Subject: [PATCH 2/3] add ci and lint tooling --- .github/workflows/build-check.yml | 40 ++ .htmlvalidate.json | 9 + .prettierignore | 16 + CLAUDE.md | 33 +- README.md | 13 +- _template.html | 73 +-- _theme_template.html | 71 +-- build.js | 77 ++-- index.html | 24 +- package-lock.json | 206 +++++++++ package.json | 8 +- ...-24-pokemon-card-scanner-in-one-night.html | 420 ++++++++++++------ side-projects/index.html | 94 ++-- 13 files changed, 812 insertions(+), 272 deletions(-) create mode 100644 .github/workflows/build-check.yml create mode 100644 .htmlvalidate.json create mode 100644 .prettierignore diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml new file mode 100644 index 0000000..b5a2a1d --- /dev/null +++ b/.github/workflows/build-check.yml @@ -0,0 +1,40 @@ +name: Verify build is up to date + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + build-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - run: npm ci + + - name: Build the site + run: npm run build + + - name: Lint generated HTML + run: npm run lint:html + + - name: Fail if the committed output is stale + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "::error::Committed output is out of date with the source. Run 'npm run build' locally and commit the result." + echo "Changed files:" + git status --porcelain + echo "Diff:" + git diff + exit 1 + fi + echo "Committed output matches a fresh build." diff --git a/.htmlvalidate.json b/.htmlvalidate.json new file mode 100644 index 0000000..19c1335 --- /dev/null +++ b/.htmlvalidate.json @@ -0,0 +1,9 @@ +{ + "extends": ["html-validate:recommended"], + "root": true, + "rules": { + "void-style": "off", + "doctype-style": ["error", { "style": "lowercase" }], + "long-title": "off" + } +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f2fa146 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,16 @@ +node_modules +package-lock.json + +# Generated favicon assets (created by an external tool, not hand-maintained). +images + +# Generated theme output is formatted by build.js, not by lint:fix. +# (Leaving it here is harmless since the output is already Prettier-clean, +# but build.js is the source of truth for it.) + +# Hand-authored post prose. build.js renders + formats the HTML output; +# leave the markdown sources alone. +_posts + +# Local, gitignored personal layer (SOUL.md, skills, drafts). +.claude diff --git a/CLAUDE.md b/CLAUDE.md index ebd2529..7f63550 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,9 +22,11 @@ landing pages and regenerates the homepage post list. `title` and `date` are required. `description` is optional but recommended. `tags` is optional and defaults to `[]`. + 3. Run `npm install` once, then `npm run build` whenever you change a post. 4. Review the diff (it will include both the source `.md` and the generated - HTML) and commit everything. + HTML) and commit everything. CI fails the PR if the committed HTML isn't a + fresh build. The published URL will be `https://workman.tech//YYYY-MM-DD-slug.html`. @@ -37,24 +39,45 @@ The published URL will be - `_theme_template.html` — per-theme landing page template. - `build.js` — single-file Node script. Walks `_posts/`, renders posts and theme landing pages, regenerates the post-list region inside `index.html` - between `` and `` markers. + between `` and `` markers. Every file it + writes is run through Prettier first, so the committed HTML is deterministically + formatted. - `/` — generated output directories (committed; GH Pages needs them). Each generated HTML file starts with a `` marker so `npm run clean` can safely identify and remove them. - `index.html` — hand-edited, but the region between the two marker comments is rewritten on every build. -Dependencies: `markdown-it` and `gray-matter`. That's it. +Dependencies: `markdown-it` and `gray-matter` at runtime, plus dev tools +`prettier` (which `build.js` runs over every rendered file so the generated HTML +is consistently formatted) and `html-validate` (HTML linting, config in +`.htmlvalidate.json`). ## Commands - `npm run dev` — local preview at (wraps `python3 -m http.server 3000`; needs Python 3 on `PATH`). -- `npm run build` — render everything. +- `npm run build` — render everything (each file formatted with Prettier). +- `npm run lint:html` — run `html-validate` over `index.html` and the generated + theme HTML. +- `npm run lint:fix` — format source files with Prettier (`prettier --write .`; + scope controlled by `.prettierignore`). html-validate has no autofix, so it is + not part of this. - `npm run clean` — remove generated theme directories (only directories whose HTML files all start with the generated marker are touched) and reset the homepage post-list region to the empty-state placeholder. +## Continuous integration + +`.github/workflows/build-check.yml` runs on every pull request and on pushes to +`main`. It runs `npm ci && npm run build` and fails if the working tree changed +afterward (`git status` non-empty). That guards the one invariant that matters +here: the committed HTML must be a fresh render of the source. It catches stale +generated HTML, generated files that were never committed, and `build.js` errors +(bad frontmatter or filenames make the build exit non-zero). If it fails, run +`npm run build` locally and commit the result. The workflow then runs +`npm run lint:html` (html-validate) over the output. + ## Follow-up ideas - Code-block syntax highlighting (markdown-it emits @@ -64,6 +87,4 @@ Dependencies: `markdown-it` and `gray-matter`. That's it. - RSS feed. - `sitemap.xml`. - Per-post Open Graph images. -- GitHub Action to build on push and fail PRs when the committed HTML is - out of date with the source. - Formal tests for `build.js` (currently validated manually). diff --git a/README.md b/README.md index 193e465..d336c0a 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ Source for Ryan Workman's personal blog at [workman.tech](https://workman.tech). ## Tech stack -- Node.js build script (`markdown-it` + `gray-matter`) +- Node.js build script (`markdown-it` + `gray-matter`), with `prettier` formatting the generated HTML and `html-validate` linting it - Vanilla HTML and CSS - GitHub Pages for hosting -- No frameworks, no bundler, no CI build step +- No frameworks or bundler. A GitHub Actions workflow validates each pull request (build freshness + HTML lint) ## Local setup @@ -30,6 +30,7 @@ Source for Ryan Workman's personal blog at [workman.tech](https://workman.tech). ``` `title` and `date` are required. `description` and `tags` are optional. + 3. Run `npm run build`. 4. Review the diff — both the source `.md` and the generated HTML. 5. Commit everything: the markdown source and the generated HTML inside `/`. @@ -37,12 +38,14 @@ Source for Ryan Workman's personal blog at [workman.tech](https://workman.tech). The published URL pattern is `https://workman.tech//YYYY-MM-DD-slug.html`. -> **Note:** The build runs locally. There is no GitHub Action that builds on push — if you commit a new post's source `.md` without running `npm run build`, the homepage and the theme landing page will not update on the live site. Always run `npm run build` and commit the generated HTML alongside the source. +> **Note:** The build runs locally; CI does not build the site for you. Instead, a GitHub Actions workflow re-runs the build on every pull request and fails if the committed HTML doesn't match a fresh build (it also lints the HTML). So if you commit a post's source `.md` without running `npm run build`, CI will catch it. Always run `npm run build` and commit the generated HTML alongside the source. ## Commands - `npm run dev` — start a local static server on port 3000 for previewing the site. -- `npm run build` — render all posts and regenerate the homepage post list. +- `npm run build` — render all posts and regenerate the homepage post list. Every generated file is formatted with Prettier. +- `npm run lint:html` — validate `index.html` and the generated theme HTML with `html-validate`. +- `npm run lint:fix` — auto-format source files with Prettier. (html-validate has no autofix, so HTML problems must be fixed by hand.) - `npm run clean` — remove generated theme directories and reset the homepage post-list region to its empty state. Only touches files that start with the `` marker, so hand-edited content is safe. ## Deployment @@ -51,4 +54,4 @@ GitHub Pages serves the repo root. Any push to `main` triggers a redeploy and is ## More -See `CLAUDE.md` for the architecture details and the list of deferred follow-ups (RSS, sitemap, per-tag pages, syntax highlighting, OG images, build-on-push action). +See `CLAUDE.md` for the architecture details and the list of deferred follow-ups (RSS, sitemap, per-tag pages, syntax highlighting, OG images). diff --git a/_template.html b/_template.html index 7bcbf87..d4c0d66 100644 --- a/_template.html +++ b/_template.html @@ -1,53 +1,70 @@ - + - + workman.tech — {{title}} - - - - - - - - - - - - - + + + + + + + + + + + + + -
    +

    workman.tech 👋🏻 hello world

    -
    +

    - <- Back + <- Back

    - {{title}}
    + {{title}}

    -

    - {{theme_and_tags_line}} -

    +

    {{theme_and_tags_line}}

    -
    -{{content}} -
    +
    {{content}}
    -