Skip to content

perf(rss): prerender the feed, keeping its content type via _headers - #33

Closed
dyascj wants to merge 262 commits into
mainfrom
prerender-rss
Closed

perf(rss): prerender the feed, keeping its content type via _headers#33
dyascj wants to merge 262 commits into
mainfrom
prerender-rss

Conversation

@dyascj

@dyascj dyascj commented Aug 8, 2026

Copy link
Copy Markdown
Owner

rss.xml was the last recurring Worker invocation besides /contact: feed readers poll it around the clock, and each poll ran the Worker. This prerenders it with the rest of the content and adds a root _headers rule so the static asset still serves as application/rss+xml rather than the extension-derived application/xml.

Verified locally against the Workers runtime (wrangler dev): /rss.xml returns 200 with Content-Type: application/rss+xml; charset=utf-8, and the autogenerated immutable-asset header block still merges after the custom rule.

After this, the only Worker invocations left are /contact (form actions need a live handler) and 404s from bot probes.

Charles Dyas and others added 30 commits December 1, 2025 22:31
fix: Resolve framer-motion variant type error on Home page
fix: Resolve remaining framer-motion type errors
- Update hero tagline to reflect design + IT leadership focus
- Revise about section with new bio emphasizing product design and IT ops
- Add real-world usage section to Pomodorii project with classroom photo
- Streamline Pomodorii article by consolidating sound-first UI content
New article about rediscovering the joy of computing through Linux.
- Link "The Linux Command Line" book to linuxcommand.org
- Add Wine and Proton links in gaming section
- Rename blog post file for consistency
- Update thumbnail from JPG to PNG
…ation

- Add RSS feed at /rss.xml with auto-generated XML from blog posts
- Add blog filtering by category (Technology, Design, Articles)
- Add category badges with color-coded dots on blog cards
- Add RSS subscribe button on blog page
- Add RSS auto-discovery in blog page metadata
- Update README with complete codebase documentation
- Install @vercel/analytics package
- Add Analytics component to root layout
Add content:encoded and media:content elements so RSS readers can
display full blog post content including inline images and thumbnails.
Delete the how-to-run-deepseek-r1-locally-on-macos blog post.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add reading progress bar component that shows scroll progress
- Add sticky "On this page" table of contents sidebar for desktop
- Add floating TOC button for mobile devices
- Extract headings from markdown for TOC navigation
- Add "Introduction" section when content exists before first heading
- Unify blog and project page layouts with:
  - Gradient title styling
  - 4-column meta grid (author, date, category, client/read time)
  - Featured image matching content + TOC width
  - Consistent footer with author info and navigation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
feat: Add reading progress bar, table of contents, and unified page layout
The TableOfContents and MobileTableOfContents components were importing
TOCItem from @/lib/blog, but the projects page passes project.toc which
uses TOCItem from @/lib/projects. TypeScript treated these as different
types even though they're structurally identical, causing pages to fail.

Fixed by defining the TOCItem interface inline in each component.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
fix: Define TOCItem interface inline to fix type mismatch
Headings like "### 1. Frictionless Filtering" generate IDs starting with
numbers (e.g., 1-frictionless-filtering). CSS selectors cannot start with
a digit, so document.querySelector('#1-...') throws a DOMException.

getElementById works correctly with IDs that start with numbers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
dyascj and others added 26 commits July 20, 2026 21:13
Replace the IBM Plex Mono terminal theme with a light and dark system built on
Inter: a grey ground with white surfaces floating on it, pill geometry, and
generous spacing.

Design system
- app.css rewritten as tokens plus shared primitives for nav, buttons, cards,
  rows, prose, forms, and the content blocks used inside project markdown
- Dark mode is a second depth model rather than an inversion. The ground is the
  darkest value and surfaces step up the tonal ladder, so elevation reads
  without borders
- Drop the pixel icon dependency, Icon.svelte and ProjectIcon.svelte. Only
  stroked arrow affordances remain

Layout
- Landing page leads with visual work cards, then about, writing, experience
  and contact
- Blog posts are a single centred column with the header above the text
- Project case studies keep the wider layout, with the spec block split by
  content shape instead of an even grid that left large gaps
- New /contact route owns the contact form

Interaction
- Nav is a dark notch that expands on hover or focus, with the same four
  destinations on every page
- Back button in the corner on pages that sit below another
- Section index is a magnifying tick rail with a preview card
- Cross-fade page transitions via the View Transitions API, with the nav held
  static across route changes
- Scroll reveals run off one deterministic pass, so a section skipped by a deep
  link or a fast scroll can never stay invisible

Fixes
- Article text overflowed on phones because grid columns could not shrink below
  their content width
- 404 pages rendered the generic error message: +error.svelte read status from
  props, which SvelteKit does not pass to error pages
- /contact was missing from the sitemap
- The closed mobile index was aria-hidden while its links stayed focusable
- Tertiary text contrast raised from 2.48:1, which was below the UI threshold
- Author card bled to the screen edges on mobile
- Row separators no longer cut across the hovered row's rounded corners

Testing
- 28 end-to-end tests, including a click-through suite that navigates the whole
  site by clicking rather than by URL
- Remove the unused ibm-plex-mono dependency
Remove dead code and split the stylesheet so the repository reads clearly.

- Drop the GitHub contributions module. The landing page stopped rendering the
  activity grid, but the server load still awaited a scrape of github.com on
  every render of the most visited page and nothing consumed the result
- Delete LocalTime.svelte, orphaned when the terminal top bar was replaced
- Remove computeDuration, and the social icon field and its type, all unused
  since the icon font was dropped
- Split app.css into src/styles layers imported in cascade order. Verified
  behaviour-neutral by comparing 40 computed properties on 764 elements across
  28 page states in both themes at two widths, with no differences
- Delete the stale design docs describing the previous theme
- Correct a stale comment about the nav and refresh the README structure
CI has been red since before the redesign: it runs a bare npm audit, which
includes devDependencies, and six advisories sat in the eslint and tar
dependency chains. The published site never depended on any of them, but the
gate failed on every push.

Pin brace-expansion and tar through overrides rather than taking the breaking
eslint 10 upgrade npm audit fix wanted. Verified eslint, prettier, svelte-check,
the unit tests, the browser suite and the production build all still pass, and
npm audit now reports no vulnerabilities.
actions/checkout and actions/setup-node were on v4, which targets Node 20 and
was being force-run on Node 24 with a deprecation warning on every job. Both are
now on v7, which targets Node 24 natively.

Neither major touches the inputs used here. checkout v7 restricts checking out
fork pull requests, but only for pull_request_target and workflow_run, and this
workflow triggers on pull_request and push.
It is no longer a solo three-month macOS project. Replace the outcome with
what is verifiable from the repository and releases, and correct three errors:
the license is AGPL-3.0 and not MIT, the desktop app ships for Windows and
Linux as well as macOS, and the timeline is ongoing rather than ending in March.

Add the contributor story. An outside contributor has shipped a streaming voice
pipeline, chat history, task scheduling, context window configuration and
OpenAI-compatible model discovery into the core, which reframes the project from
a solo build into a maintained one.
Reworked the project write-ups so they answer what a hiring manager actually
asks: what was the problem, what did you decide, what did it cost, and what
happened. Adds a team field so the professional work stops reading as solo.

Smart Directory, rewritten
- It had no results section at all. It now closes on the real deployment:
  five cities, more than fifty double-sided kiosks, east coast to midwest
- Corrected the role from Lead Product Designer to Product Designer, which is
  the title actually held at the time, and named the COO, design lead and
  engineering team
- Replaced the feature tour with the decisions behind it. The central one was
  making customization a system with a hard floor: cities choose content,
  palette and apps, but never spacing, type scale, touch targets or contrast
- Added trade-offs, which it was missing

Rove iQ web and mobile
- The stat row described the work rather than measuring it. It now leads with
  the 70+ B2B accounts running on the product
- "a handful of customers" was the only proof of the core hypothesis; the
  hardware-free sales and the account total are now stated plainly
- Moved the legal disclaimer off the top, where it was the first thing read
- Named the team, and cut unattributed customer praise

Mizu and loggrep
- Mizu had no adoption evidence. Two teams ship production AI products on it,
  powering Utsuwa and an assistant platform in closed beta
- loggrep is used by the whole ops team and saves each of them about two hours
  a week. Its role was listed as Developer, which undersold a UX problem
- Both credited as solo design and build

Ordering
- The two professional case studies were fifth and seventh on the work index.
  Deployed infrastructure and business impact now lead
- The landing page shows both professional pieces plus Utsuwa instead of
  leading with a component library

Also removed the meta-commentary about what makes a good case study from three
files, which described the writing instead of the work.
Swap the rounded 1,900+ for 1,980, dated July 2026. The count moved by six while
writing this, so the date is what keeps an exact number honest rather than
turning it into a claim that quietly goes stale.
Case studies now use the same centred single column as blog posts, with the
header centred above the text, so both article types read the same way. The
two-column hero and the article grid that carried it are gone.

The section rail is fixed beside the column instead of occupying a grid column
of its own. Its ticks align on the edge facing the text and grow outward into
the margin, and the preview card opens outward with them, so the whole component
reads as attached to the side of the page rather than crowding the body.

Raised the rail's breakpoint from 1100px to 1280px. The card opens outward and
needs roughly 630px of margin from centre; below that it clipped off-screen
without ever widening the document, because its ancestor is fixed.

Also ported the hero figure styles to the new markup. They were scoped to the
removed wrapper, so the image lost its rounded frame and shadow and the caption
lost the layout that separates the two labels.
Reverts the nav tuck. Hiding the chrome on scroll solved the wrong problem.

- Header is left-aligned again. Centring a four-line dek and a row of facts read
  weakly, and a shared left edge ties the title, image, outcome and prose together
- The block under the hero image was a wall: a two-column grid of label and value
  pairs, then tags, then an outcome heading. It is now one quiet line of facts in
  the header, then an unlabelled outcome summary, then the contribution tags. The
  outcome heading also duplicated the Outcome section every case study already has
- Fixed the real spacing bug. The prose zeroes its first child's margin, so the
  opening section heading sat flush against the tags above it with a zero gap
- The video figure was a card wrapping a video that carried its own frame and
  shadow: two sets of rounded corners that did not agree, with the caption trapped
  inside. It is now a single framed clip with the caption beneath, matching the
  hero figure
- Widened the heading scale. h2 and h3 were 27px and 21px at the same weight and
  colour, which flattened the outline on the longer studies
- Shrank the section rail and lowered its breakpoint from 1280px to 1152px, so it
  appears on more screens. Ticks and card are smaller, and the card still clears
  the viewport at the new minimum
- Removed the dead two-column hero rules left behind by the centred layout
The contribution row repeated what the prose already covers, so it is gone from
every case study, along with its derived value and the tag styles nothing renders
any more.

The facts read as a definition list now: bold field name in its own column, value
beside it. Laid out inline they wrapped into ragged rows, because Team runs much
longer than the others, and the dot delimiters could land at the end of a wrapped
line.

Note on the previous commit: it claimed those dot separators were already removed.
They were not. The edit silently failed to match after the file had been
reformatted, so the change never applied. Asserting on each replacement caught it
this time.
The caption under the hero image repeated the category and client, both already
in the overline directly above it. Removing it also let the frame move onto the
image itself instead of a wrapper div.

The outcome summary is labelled TL;DR. It reads as a summary up front and does not
collide with the Outcome or Result section every case study already has further
down, which is why an "Outcome" label there felt duplicated.
- Pomodorii and Automancer are drafts now, so they leave the work index, the
  sitemap and their own routes while the writing stays in the repo
- Removed the category filter. Every live project is product design, so the
  control had nothing left to filter and the count beside it was noise
- Work index order is explicit: Utsuwa, the web and mobile redesign, then the
  rest. The landing page leads with the same three
- The section rail now runs on blog posts as well as case studies

Two tests depended on the archived content. The unit test reached through
Pomodorii for the only YouTube embed in the repo, so transformYouTube is exported
and tested directly; archiving a document can no longer take its fixture with it.
The filtering tests became an order assertion and an archive assertion that checks
both routes 404 and neither slug lingers in the sitemap.
Rebuilt the downloadable resume from the resume-template.docx structure:
centred name, contact rule, shaded and ruled section bands, a three-column
competency grid, and company / role / bullets blocks. Set in Inter with the
site's ink palette instead of Constantia and Word's navy.

Fonts are static Inter instances cut from the variable font. Chromium embeds a
variable-font instance as unnamed Type3 glyph procedures, which extracts badly;
the static instances embed as named CIDFontType2 subsets instead.

Content is grounded in the case studies: 50+ kiosks across five cities, 70+ B2B
accounts, 79 Mizu components, 22 Utsuwa releases and 1,980 downloads, the
pending kiosk mapping patent. Education is omitted.
Swaps adapter-vercel (pinned to nodejs22.x) for adapter-cloudflare and adds
wrangler config. Adds the nodejs_compat flag, which SvelteKit's server
runtime needs for node:async_hooks; without it the Worker throws at runtime
rather than failing the build.

The contact form rate limiter kept its counter in a module-level Map. That
survives on a warm Node lambda but not on Workers, where each isolate would
get its own empty counter and the limit would barely apply. checkRateLimit
now prefers the Workers rate limiting binding and falls back to the
in-memory limiter locally, in tests, and if the binding throws.

The binding's period is restricted to 10 or 60 seconds, so this is a
5-per-60s burst guard rather than the previous 5-per-10-minutes window.

Verified against the real workerd runtime with wrangler dev: every route
including sitemap, rss, robots and llms.txt, and a contact form POST that
exercised CSRF, the rate limit binding (5 through, 6th and 7th blocked) and
the Resend call.
All content is bundled at build time through import.meta.glob, so nothing
needed a per-request render. Prerendering the site turns 19 routes into
static assets, which Cloudflare serves free and without invoking a Worker.

Only two routes stay dynamic: /contact, because form actions need a live
handler, and /rss.xml, because as a static file it would be typed
application/xml by extension and lose the application/rss+xml header that
some feed readers expect. The e2e suite caught that one.

Dropped the s-maxage header on the homepage; it existed for Vercel's CDN
and a prerendered page never renders per request.

Also makes the Playwright base URL overridable via E2E_BASE_URL so the
suite can run against the Workers build rather than the Vite dev server.

Verified against workerd: 28 e2e, 13 unit, typecheck clean.
injectAnalytics posts to /_vercel/insights, which only exists on Vercel. Left
in place it would have produced failing requests and console noise on every
page view after the move, while collecting nothing.

Cloudflare Web Analytics covers the same ground from the dashboard with no
client code, if we want it back.

Also updates the README, which still described adapter-vercel.
…29)

Commits the custom domain routes and workers_dev fallback that the cutover
added, so the deployed configuration lives in git rather than only on
Cloudflare.

Adds a second CI job that runs the e2e suite against workerd instead of the
Vite dev server, and fails the build if wrangler reports a missing Node
builtin. Both bugs that surfaced during the migration were invisible to the
existing job: the nodejs_compat flag throws at runtime rather than at build,
and a prerendered feed served the wrong content type. The old suite passed
green through both.

The nodejs_compat guard is verified by removing the flag and confirming the
job fails, so it is not a check that can only pass.

Co-authored-by: Charles Dyas <dyascj@gmail.com>
Turnstile's managed mode is free with no practical ceiling here: 20 widgets
per account and a million siteverify calls a month.

The widget only renders when a site key is present, and verification is
skipped entirely when no secret is configured, so plain vite dev and the
test suite are unaffected. The site key comes from a wrangler var rather
than $env/static/public, because a static import is baked in at build time
and the CI builder has no .env.local; it would have shipped empty.

Two deliberate escape hatches. A missing token is rejected, but if
siteverify itself is unreachable the submission is allowed through rather
than letting a Cloudflare outage silently kill the contact form. The rate
limiter still sits in front of it either way.

The e2e stub that existed for Vercel Analytics now covers Turnstile
instead, since Turnstile logs its own noise to console.error and will not
issue a token to an automated browser, which is rather the point of it.

Verified against workerd: the widget renders with the site key, a
submission with no token is rejected, 18 unit tests, 28 e2e, typecheck and
build clean. The human path is unverified by design, see the PR.

Co-authored-by: Charles Dyas <dyascj@gmail.com>
Rejecting submissions with no Turnstile token took the form down for real
people. The widget was not rendering: auto-render fires once when the
script loads, before hydration has settled, and the node it renders into
can be replaced out from under it. So no token was ever produced and every
submission was refused.

Renders explicitly after mount instead, against a node held by bind:this.

Also stops treating a missing token as a rejection. A missing token means
the widget did not render or something blocked it, not that the sender is a
bot, and failing closed there breaks the form for anyone running a script
blocker. It is logged instead. A token that is present and invalid is still
rejected, and the rate limiter still applies either way.

Turnstile is defence in depth here, not a gate the contact form depends on.
The permissive setting was a stopgap from when the widget was not
rendering. It let anything without a token through, which waves past the
crudest spam: a bot that never runs JavaScript produces no token at all,
so Turnstile was only catching the ones that tried and failed.

Now that the widget is confirmed working for real visitors, a missing token
is refused again. The message points anyone whose blocker eats
challenges.cloudflare.com at the other contact links rather than leaving
them at a dead end.

Verified as an unpromoted version on real infrastructure before taking
production traffic, since a Turnstile token cannot be produced from an
automated browser and this same change broke the form last time.
rss.xml was the last per-request Worker route besides /contact. As a
static asset it would be typed application/xml by extension, so a root
_headers rule overrides it to application/rss+xml (verified against the
local Workers runtime). Feed polls now cost zero invocations.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 8, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
cjdyas-design 49b3bfe Commit Preview URL

Branch Preview URL
Aug 08 2026, 01:03 PM

@dyascj dyascj closed this Aug 13, 2026
@dyascj
dyascj deleted the prerender-rss branch August 13, 2026 01:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants