Skip to content

feat: Astro 7 SSR Cloudflare migration, Sanity semantic search, SearchModal & syndication - #685

Merged
codercatdev merged 28 commits into
devfrom
feat/astro-cloudflare
Oct 7, 2026
Merged

codercatdev merged 28 commits into
devfrom
feat/astro-cloudflare

Conversation

@codercatdev

Copy link
Copy Markdown
Contributor

Summary

This PR completes the full feature parity migration of codingcat.dev from Next.js to Astro 7 SSR on Cloudflare Workers, replacing Algolia with Sanity Content Lake native semantic search, adding a global accessible search modal, implementing isolated content syndication via Sanity Blueprints & Functions, and establishing automated CI/CD with Cloudflare Worker previews.

What's Changed

  1. Astro 7 SSR on Cloudflare Workers (@codingcatdev/site):

    • Full route parity across /blog, /post/:slug, /podcasts, /podcast/:slug, /authors, /guests, /sponsors, and dynamic /[slug].
    • Dynamic OG image generation running on Cloudflare Workers edge runtime.
    • Exact parity with production baseline contracts for sitemaps, robots.txt, and RSS/JSON feeds.
    • Updated official AJ-with-Tag favicon and branding across SVG, PNG, and multi-resolution ICO files.
  2. Native Sanity Content Lake Semantic Search & Search Modal:

    • Replaced external Algolia client and indexing dependency with Sanity's native Content Lake Semantic Search (text::semanticSimilarity() + GROQ score() / boost()).
    • Added /api/search endpoint with edge caching and automatic text fallback.
    • Implemented accessible <SearchModal /> component with global ⌘K / Ctrl+K keyboard shortcuts, live debounced querying, and arrow navigation.
    • Cleaned out all legacy Algolia SDK dependencies and environment variables.
  3. Isolated Content Syndication (Sanity Blueprints & Functions):

    • Created dedicated syndication document schema referencing target content (target: { type: 'reference', to: ['post', 'podcast'] }).
    • Eliminates infinite event loops by avoiding mutations on the parent editorial documents.
    • Added Sanity Blueprint (sanity.blueprint.ts) and event handler (functions/syndicate-content).
    • In dev/preview environments, automatically simulates syndication and marks status: 'simulated_complete' without making third-party API calls.
    • In production, publishes live to Dev.to and Hashnode APIs.
  4. CI/CD & Cloudflare Previews:

    • .github/workflows/deploy-workers.yml: Auto-deploy to dev.codingcat.dev on push to dev, and to codingcat.dev on push to main.
    • .github/workflows/ci.yml: Runs verification and uploads Cloudflare Worker Previews on PRs targeting dev.

Verification

  • E2E Tests: 145 / 145 Passing (100%)
  • Lint: 0 errors / warnings (biome check)
  • Typecheck: 0 errors across site (astro check) and studio (tsc --noEmit)
  • Builds: Successful dev and production builds
  • Live Worker: Verified live on dev.codingcat.dev

Miriad and others added 23 commits March 13, 2026 19:05
V5b showed 2/4 infographic frames with gray/off-white backgrounds despite
the color rule saying 'Use ONLY black'. Restructured as numbered rules
with explicit rejection language. Background rule is now #1 priority.
Task 1D: engineConfig singleton + mediaAsset + automatedVideo v2 (PR #665, 6ab3833). Task 1E: Review dashboard with fail-closed auth on all server actions (PR #666, 18dee75). All code reviewed by @pm + workers.
… apps/sanity

Replace the Astro apps/web on dev with main's Next.js app, strip the embedded
Studio route, wire typegen to apps/sanity's schema extract, and trim CI to
Sanity Studio deploys only (Vercel handles apps/web).

Co-authored-by: Cursor <cursoragent@cursor.com>
…tion

- Changed the import path for route types in next-env.d.ts to point to the development directory.
- Modified the client configuration in route.tsx to disable CDN usage for draft mode, ensuring accurate content retrieval during editing.
- Upgraded Sanity-related packages to their latest versions for improved performance and features.
- Implemented dynamic fetching options in various components to support draft mode, enhancing content visibility during editing.
- Adjusted the sitemap and page components to utilize the new fetching methods, ensuring accurate data retrieval based on the selected perspective.
- Introduced caching mechanisms for improved efficiency in data fetching.
…configuration

- Removed the obsolete sanity-typegen.json file.
- Integrated typegen configuration directly into the Sanity CLI setup, ensuring schema extraction and type generation are streamlined within the cliConfig.
- Upgraded React and React DOM to version 19.2.6 for improved performance and compatibility.
- Updated Next.js to version 16.2.6 to leverage the latest features and enhancements.
- Upgraded pnpm to version 10.34.1 for improved package management.
- Added new skills to skills-lock.json for better content modeling and SEO practices.
- Introduced schema-dts version 2.0.0 to enhance TypeScript support.
- Updated sitemap generation to utilize SITE_URL for dynamic URL handling across various components.
- Enhanced metadata generation for authors, guests, podcasts, posts, and sponsors to include structured data for better SEO.
Dashboard pages are auth-gated and render live data per request, but were
being prerendered into the static shell, tripping Cache Components rules
(Date.now in lib/config, uncached Sanity fetches outside Suspense).

- Refactor dashboard pages to the three-layer pattern: static shell plus a
  Suspense-wrapped child that calls connection() before fetching
  (config, content, pipeline, review, review/[id], sponsors, videos)
- Wrap the live time-reading components on the dashboard index in Suspense
- Wrap NavHeaderSlot and Footer in the (main) layout in Suspense so the one
  dynamic route (/podcast/preview/[token]) can produce a static shell
- Make /api/youtube/rss.xml dynamic via connection() (external fetch + Date)

Co-authored-by: Cursor <cursoragent@cursor.com>
- Replaced the deprecated `buildFeed` and `buildPodcastFeed` functions with `getFeedJson` and `getPodcastFeedXml` for improved performance and clarity.
- Streamlined the fetching of feed data to reduce payload size and enhance caching efficiency.
- Updated related route handlers to utilize the new feed generation methods, ensuring consistent data retrieval across JSON and XML feeds.
- Changed cache directive from "use cache" to "use cache: remote" in the `sanityFetchStaticParams` and `sanityFetchMetadata` functions to clarify the caching strategy.
- This update enhances the understanding of data fetching behavior in relation to remote caching.
…omponents

- Updated dashboard components to improve data fetching efficiency by implementing a more structured caching approach.
- Refactored components to better utilize Suspense for dynamic data rendering, ensuring a smoother user experience.
- Adjusted the integration of live data fetching methods to align with recent caching updates, enhancing overall performance.
Adds apps/site (@codingcatdev/site) as a new workspace package alongside the
existing Next app, which keeps building until the dashboard is ported.

Added rather than replacing apps/web because the pre-Next Astro app (branch
`dev`) was also named @codingcatdev/web — replacing in place would make every
`pnpm --filter` and the git history ambiguous.

- output: "server" + @astrojs/cloudflare 14 (SSR on Workers)
- wrangler.jsonc with dev/production envs; D1 binding deliberately omitted
  until the codingcat.dev Cloudflare account is available
- rawFonts() Vite plugin harvested verbatim from dev:apps/web/astro.config.mjs
  (Workers has no filesystem; needed for OG image generation)
- Dropped that config's Vite alias/dedupe/optimizeDeps block — it existed to
  fight @sanity/visual-editing under Astro 6 and is obsoleted by
  @sanity/visual-editing-standalone
- Node pinned to >=22.12 (.nvmrc + engines), required by sanity 6 / @sanity/ui 4
- baseline/ holds pre-migration production sitemap + RSS snapshots for
  post-cutover parity diffing (472 URLs, 50 items per feed)

Verified: pnpm build succeeds and wrangler dev serves SSR HTML via workerd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2
sanity 5.17.1 -> 6.9.2, @sanity/ui 3 -> 4, @sanity/vision 6.9.2,
@sanity/icons 3 -> 5, @sanity/assist 6.1.19, @sanity/code-input 7.3.5,
sanity-plugin-media 4 -> 6, @portabletext/react 6 -> 7 (matching sanity@6.9.2's
own dep so toPlainText resolves a single copy).

Four blockers surfaced during the upgrade:

1. @sanity/icons v5 removed every icon from the root entry (they remain as
   `declare const X: never` with a deprecation naming the fix). All eight
   imports moved to subpaths, e.g. @sanity/icons/User.

2. The workspace override "@portabletext/sanity-bridge": "^1.2.14" starved
   sanity@6.9.2, which needs ^3.2.5 for getSanitySubSchema. Removed; pnpm now
   resolves 1.2.14 for apps/web's sanity@5 and 3.2.5 for the Studio.

3. @codingcatdev/sanity-plugin-podcast-rss could not simply be dropped:
   schemas/documents/podcast.ts declares a field of type "podcastRssEpisode",
   which only that plugin registers, so removing it broke schema extraction.
   Vendored into plugins/podcast-rss/ (see its README) migrated to
   @sanity/ui v4 — Menu/MenuButton/MenuItem from the /menu subpath, `space` ->
   `gap` — and off xml2js onto the native DOMParser, since xml2js only resolved
   via a Vite polyfill that Vite 8 no longer provides. Field shapes are kept
   byte-identical so existing podcast documents are unaffected.

4. @sanity/ui v4: Stack `space` -> `gap` (2 call sites) and Avatar `alt` ->
   `title`.

Also fixed pre-existing issues the upgrade exposed: react-inlinesvg was
imported by the Studio but declared only in apps/web; SharePreviewActionButton
imported five symbols from "@/sanity/lib/api" despite the Studio tsconfig
having no such path alias (all unused); CodeSandboxPreview imported an unused
RenderPreviewCallbackProps. Removed the stray unused podcast-rss dependency
from apps/web. tsconfig target ES2020 -> ES2022 for Array.prototype.at.

Verified: builds, typechecks clean, dev server boots on Vite 8.2.1, and schema
extract succeeds with zero types removed (two added by sanity-plugin-media v6).
Still needs a human click-test of markdown paste in the Portable Text editor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2
The repo had no CI at all — no lint, typecheck, build, or test ran on any push.
Two latent breakages had accumulated unnoticed as a result:

- apps/web/biome.json is invalid for Biome 2 (`jsxRuntime: "automatic"` is not
  an accepted value), so `pnpm lint` there has been failing on a config error
  rather than linting anything. Left as-is since apps/web is being retired;
  apps/site gets a corrected Biome 2 config (`assist.actions.source`,
  `files.includes` with negations, `jsxRuntime: "transparent"`).
- apps/sanity/extract.json can silently drift from the schema, which is how
  three divergent copies accumulated. CI now re-runs the extract and fails if
  the result differs from what is committed.

ci.yml runs lint + typecheck + build for the site, typecheck + build for the
Studio, and the extract drift check. Uses node-version-file: .nvmrc rather than
a bare "22", which is what actually enforces the >=22.12 floor that sanity 6 and
@sanity/ui 4 require; deploy.yml switched to the same.

deploy.yml also gains a paths filter so site-only changes stop redeploying the
Studio on every push.

Adds a root CLAUDE.md (the repo had no root docs) recording the package layout,
the in-flight migration, where the vendored Sanity skills live, and the
conventions that have already caused real bugs here — no getStaticPaths under
output: "server", GROQ must live in .ts files for TypeGen, never read secrets
from import.meta.env, bindings are not inherited into env.production, and
stegaClean anything bound to an attribute or feed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2
Adds apps/site/src/lib/sanity/* and converts all 26 GROQ queries from
`groq` tagged templates to `defineQuery`, so generated types flow to call sites
and the `as` casts the Next app needed are gone.

Two assumptions from the migration plan turned out to be wrong, and were
verified empirically before committing to a layout:

- TypeGen DOES resolve const interpolation inside defineQuery, including across
  module boundaries. The shared projection partials (baseFieldsNoContent,
  contentFields, ...) are therefore kept in queries/shared.ts rather than being
  inlined into every query.
- @astrojs/cloudflare v14 reduced `Runtime` to `{ cfContext }` and marks
  `locals.runtime` a deprecated getter. Bindings and vars are read via
  `import { env } from "cloudflare:workers"` instead — which is also what the
  pre-Next Astro app did.

Three bugs in the harvested fetch layer (dev:apps/web/src/utils/sanity.ts) are
fixed rather than carried over:

- `useCdn: false` on the base client. Under Next this was masked by ISR; under
  pure SSR it would put every anonymous page view on the uncached live API.
  Published reads now use the CDN, which Sanity purges on publish.
- stega configured on the base client and suppressed per-request. Inverted:
  off by default, on only in previewClient, so RSS/sitemap/OG/JSON-LD cannot
  leak zero-width characters into hrefs and XML.
- read token sourced from `import.meta.env`, which Vite inlines into the server
  bundle — that would bake the token into the deployed Worker script. Now read
  per-request from the runtime env, and preview fails closed when absent.

Config is resolved per-request because wrangler `vars` reach the Worker through
the runtime env, not `import.meta.env`. apiVersion is pinned to 2025-09-30 to
match apps/sanity/sanity.config.ts; the old Astro app used 2026-03-17, a silent
divergence between what authors preview in Vision and what the site renders.

Middleware builds the request context onto `locals.sanity` and sets
`no-store, private` + `noindex` on preview responses, so a page containing
unpublished drafts cannot be written to the edge cache.

Queries that took a $type parameter (docCount, rssQuery) are split per type —
a parameterised _type widens the generated result to a union of every document
type. urlForImage is consolidated to one implementation keeping the
`.auto("format")` behaviour; resolveHref now covers the whole public route map
instead of only "post".

Typegen moves entirely into apps/sanity: apps/web/sanity.cli.ts is deleted along
with its typegen/predev/prebuild scripts and its `sanity` devDependency, and the
two stale schema copies (apps/web/sanity/extract.json, apps/web/schema.json) are
removed. CI regenerates types before typechecking and fails if the committed
extract.json has drifted.

Verified end to end: `wrangler dev` serves the home page with real Sanity
content from the dev dataset, typechecking clean with no casts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2
Ports the shared layout and every listing route from the Next app, with
production parity as the target rather than a redesign.

Design tokens come from apps/web/app/globals.css — the live production
stylesheet — not the pre-Next Astro app on `dev`, which carried a
different, never-shipped dark-first token set that would have been a
visual regression at cutover. Dashboard-only tokens are dropped.

Routes: /, /blog + /blog/page/[num], /podcasts + /podcasts/page/[num],
/authors|guests|sponsors/page/[num], and 404. Pagination is a runtime
bounds check on Astro.params — there is no getStaticPaths — rejecting
anything that is not a bare positive integer so one page cannot be
served under unlimited URLs, and 302-ing out-of-range requests to the
last page.

Redirects mirror production exactly: /authors, /guests and /sponsors
redirect to /page/1, and the bare /{base}/page stubs go to /page/1 (not
to the section index, which is what the scaffold assumed).

Components ship no framework JS. next-themes becomes an inline script,
the Radix Sheet becomes a details-free drawer, react-icons paths are
inlined, and usePathname-based active-nav detection is resolved at
render time. A blog listing page now carries 1.4 KB of inline script and
no external bundle.

Two fixes over the Next behaviour:

- Canonical and og:url were derived from build-time `Astro.site`, but
  SITE_URL is a per-environment wrangler var and CI builds once for both
  Workers — the dev Worker would have claimed production URLs. Resolved
  per request in middleware as `Astro.locals.siteUrl`.
- JSON-LD advertised an Organization logo at /icon.svg, which 404s in
  production because Next serves that file at a hashed metadata URL. The
  asset is now in public/ and the URL resolves.

The home page's two-column grids drop the `slice(0, length / 2)` index
arithmetic, which silently dropped the last item on an odd count.

Verified against a real Worker under `wrangler dev`: all routes 200,
/blog/page/abc 404s, /blog/page/99999 302s to the last page, no stega
leakage into attributes, and .assetsignore keeps _worker.js unserved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2
Twelve Astro renderers for the custom block types, plus the detail routes
that consume them: /post/[slug], /podcast/[slug], /author/[slug],
/guest/[slug], /sponsor/[slug], and the /[slug] catch-all for page
documents.

The pre-Next Astro app rendered Portable Text with no component map at
all, so every custom block rendered as nothing. This is the piece that
was genuinely missing rather than portable.

astro-portabletext uses SINGULAR `type` / `block` / `mark` keys where
@portabletext/react uses `types` / `marks`, and mark components receive
the mark definition on `node.markDef` rather than on the node. Both fail
silently — the components simply never run — so both are called out at
the definition sites.

Dependencies dropped, not ported:

- react-syntax-highlighter and the hand-maintained Prism language list
  become Astro's bundled Shiki, highlighting at render time. Language
  ids are normalised through an allowlist first: `@sanity/code-input`
  does not constrain the field, and one live document carries
  `language: "Zanzibar"`, which Shiki throws on. The allowlist is typed
  as a literal union so a typo in it is a compile error rather than a
  runtime 500.
- react-markdown per table cell. No table block exists in production —
  every cell in the dataset is null — so it was never exercised. Cells
  render as text.
- embla-carousel for YouTube shorts becomes a scroll-snap row.
- react-twitter-embed becomes the official blockquote markup, with
  widgets.js loaded only when a page actually contains a post. It now
  degrades to a readable link when the script is blocked.
- react-icons for the 20 social platforms: paths inlined.

Fixes carried in:

- CodePen and CodeSandbox URL parsing was positional destructuring and
  an unguarded double `.at(-1)`; a malformed URL threw at render. Both
  validate now.
- Tables get a header row. The Next version emitted every row as `<td>`
  in a bare `<tbody>`, leaving no column headers for screen readers.
- Sanity's file-asset URL for cover video read NEXT_PUBLIC_* env at
  module scope, which does not exist on Workers; it uses the
  per-request config.
- Socials rendered an empty clickable link for any key without an icon
  (`email`, `polywork`); unknown keys are skipped and both now have one.

Podcast episodes play through a native audio element for now — the
persistent floating player is a React island and lands with the rest of
the islands.

Verified against a real Worker: code, quote, image, htmlBlock and
twitter blocks all render from live dev-dataset documents, Shiki emits
dual-theme output with the dark variant activated, and every detail
route 200s while unknown slugs 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2
Ports /blog/rss.{xml,json}, /podcasts/rss.{xml,json}, /sitemap.xml and
/robots.txt, all serialized by hand — the `feed` package reaches for Node
built-ins that are awkward on workerd, and these are fixed shapes.

Verified against the pre-migration production snapshots in
apps/site/baseline by running the Worker with the production config:

- sitemap.xml: byte-identical, all 472 URLs
- podcasts/rss.xml: byte-identical apart from timestamps
- blog/rss.xml: identical apart from <generator>, which no longer names a
  library we do not use
- both JSON feeds: same key order, same 50 ids in the same order
- robots.txt: identical

GUID stability was the thing to get right and it holds: all 100 feed
items keep their Sanity _id, in order. A changed podcast GUID would
re-publish the entire back catalogue to Apple and Spotify.

Two live bugs fixed rather than reproduced:

- The production blog feed is not well-formed XML. The `feed` library
  emits unescaped `&` in enclosure URLs, so `ET.parse` rejects
  blog_rss.xml at line 25. Ours escapes them.
- Feed bodies were serialized by `toHTML` with no component map, so
  internal links rendered as `<span class="unknown__pt__mark__internalLink">`
  and code, images, tables and embeds were dropped or stubbed. There is
  now a map; links resolve to absolute URLs so they work inside a reader.

Deliberately reproduced, including the odd parts: `image/jpg` rather than
`image/jpeg` for enclosures, one `<author>` element per author, and
`<description>` omitted entirely when the excerpt is empty. Churning
those would change what existing subscribers see for no benefit.

robots.txt now serves `Disallow: /` outside production, so the dev Worker
and preview aliases cannot be indexed alongside the real site.

Also fixes a deployment trap found while verifying this. The Cloudflare
adapter flattens wrangler.jsonc at BUILD time and writes
.wrangler/deploy/config.json, which redirects wrangler away from the
source config — `env.production` is dropped, keeping only its name. So
`wrangler deploy --env production` on a default build would have shipped
the dev dataset under the dev worker name. The environment is selected by
`CLOUDFLARE_ENV` at build time; added a `build:production` script and
documented it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2
Four Satori-backed endpoints — /api/og/{default,blog,podcast,person}.png
— recovered from the pre-Next Astro app along with the two Inter TTFs and
the rawFonts Vite plugin that inlines them as Uint8Array (Workers has no
filesystem for Satori to read from).

The load-bearing part of that recovery is its workaround, kept verbatim
with its explanation: workers-og parses HTML with HTMLRewriter, which
corrupts the HTML-to-VDOM conversion on Workers, so these build React
element objects and hand them to ImageResponse directly.

Wired in as a FALLBACK, not a replacement. Where a document has a cover
image the card is still that image, byte-for-byte what the Next app
served. Documents without one previously emitted no og:image at all;
those now get a generated card. The home page and section indexes get one
too.

Verified on real workerd under `wrangler dev`, not `astro dev` — all four
endpoints return valid 1200x630 PNGs, and the rendered images were
inspected rather than just status-checked. That caught the one visual
defect: the recovered logo used a cat emoji, and Satori renders only
glyphs present in the fonts it is given, so it came out as a tofu box on
every card. Replaced with a monogram tile in the brand gradient.

Brand tokens are literal hex rather than the CSS custom properties the
rest of the site uses: Satori resolves neither var() nor oklch().

Font imports are now typed via a `*.ttf` module declaration, since Astro's
own asset typings declare them as URL strings — the opposite of what the
rawFonts plugin actually returns. That removes the `@ts-ignore` pair and
the `as any` in loadFonts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2
@vercel

vercel Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
codingcat-dev Ignored Ignored Oct 7, 2026 10:29pm UTC

@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

⚡ Cloudflare Worker Preview Ready!

Environment URL
PR Preview https://b6c56f99-codingcatdev-dev.codingcatdev.workers.dev
Dev Custom Domain https://dev.codingcat.dev
  • Worker Version ID: b6c56f99-0c16-4a7f-aac8-ef20752e98db
  • Protection: Restricted to @codingcat.dev accounts via Cloudflare Zero Trust Access

@codercatdev
codercatdev merged commit 3a7e401 into dev Oct 7, 2026
4 checks passed
codercatdev added a commit that referenced this pull request Oct 7, 2026
…archModal & syndication (#686)

* feat: Astro 7 SSR Cloudflare migration, Sanity semantic search, SearchModal & syndication (#685)

* fix: stronger black bg enforcement in Gemini image prompts

V5b showed 2/4 infographic frames with gray/off-white backgrounds despite
the color rule saying 'Use ONLY black'. Restructured as numbered rules
with explicit rejection language. Background rule is now #1 priority.

* temp

* refactor: consolidate monorepo with Next.js in apps/web and Sanity in apps/sanity

Replace the Astro apps/web on dev with main's Next.js app, strip the embedded
Studio route, wire typegen to apps/sanity's schema extract, and trim CI to
Sanity Studio deploys only (Vercel handles apps/web).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: update route type imports and adjust draft mode client configuration

- Changed the import path for route types in next-env.d.ts to point to the development directory.
- Modified the client configuration in route.tsx to disable CDN usage for draft mode, ensuring accurate content retrieval during editing.

* feat: update Sanity dependencies and enhance draft mode functionality

- Upgraded Sanity-related packages to their latest versions for improved performance and features.
- Implemented dynamic fetching options in various components to support draft mode, enhancing content visibility during editing.
- Adjusted the sitemap and page components to utilize the new fetching methods, ensuring accurate data retrieval based on the selected perspective.
- Introduced caching mechanisms for improved efficiency in data fetching.

* refactor: replace deprecated sanity-typegen.json with inline typegen configuration

- Removed the obsolete sanity-typegen.json file.
- Integrated typegen configuration directly into the Sanity CLI setup, ensuring schema extraction and type generation are streamlined within the cliConfig.

* chore: update dependencies for React and Next.js

- Upgraded React and React DOM to version 19.2.6 for improved performance and compatibility.
- Updated Next.js to version 16.2.6 to leverage the latest features and enhancements.

* chore: update package dependencies and enhance sitemap functionality

- Upgraded pnpm to version 10.34.1 for improved package management.
- Added new skills to skills-lock.json for better content modeling and SEO practices.
- Introduced schema-dts version 2.0.0 to enhance TypeScript support.
- Updated sitemap generation to utilize SITE_URL for dynamic URL handling across various components.
- Enhanced metadata generation for authors, guests, podcasts, posts, and sponsors to include structured data for better SEO.

* fix: resolve Cache Components prerender errors blocking production build

Dashboard pages are auth-gated and render live data per request, but were
being prerendered into the static shell, tripping Cache Components rules
(Date.now in lib/config, uncached Sanity fetches outside Suspense).

- Refactor dashboard pages to the three-layer pattern: static shell plus a
  Suspense-wrapped child that calls connection() before fetching
  (config, content, pipeline, review, review/[id], sponsors, videos)
- Wrap the live time-reading components on the dashboard index in Suspense
- Wrap NavHeaderSlot and Footer in the (main) layout in Suspense so the one
  dynamic route (/podcast/preview/[token]) can produce a static shell
- Make /api/youtube/rss.xml dynamic via connection() (external fetch + Date)

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: update RSS feed generation methods for podcasts and blogs

- Replaced the deprecated `buildFeed` and `buildPodcastFeed` functions with `getFeedJson` and `getPodcastFeedXml` for improved performance and clarity.
- Streamlined the fetching of feed data to reduce payload size and enhance caching efficiency.
- Updated related route handlers to utilize the new feed generation methods, ensuring consistent data retrieval across JSON and XML feeds.

* refactor: update cache usage in live data fetching functions

- Changed cache directive from "use cache" to "use cache: remote" in the `sanityFetchStaticParams` and `sanityFetchMetadata` functions to clarify the caching strategy.
- This update enhances the understanding of data fetching behavior in relation to remote caching.

* refactor: enhance data fetching and caching strategies in dashboard components

- Updated dashboard components to improve data fetching efficiency by implementing a more structured caching approach.
- Refactored components to better utilize Suspense for dynamic data rendering, ensuring a smoother user experience.
- Adjusted the integration of live data fetching methods to align with recent caching updates, enhancing overall performance.

* feat: scaffold Astro 7 site on Cloudflare Workers

Adds apps/site (@codingcatdev/site) as a new workspace package alongside the
existing Next app, which keeps building until the dashboard is ported.

Added rather than replacing apps/web because the pre-Next Astro app (branch
`dev`) was also named @codingcatdev/web — replacing in place would make every
`pnpm --filter` and the git history ambiguous.

- output: "server" + @astrojs/cloudflare 14 (SSR on Workers)
- wrangler.jsonc with dev/production envs; D1 binding deliberately omitted
  until the codingcat.dev Cloudflare account is available
- rawFonts() Vite plugin harvested verbatim from dev:apps/web/astro.config.mjs
  (Workers has no filesystem; needed for OG image generation)
- Dropped that config's Vite alias/dedupe/optimizeDeps block — it existed to
  fight @sanity/visual-editing under Astro 6 and is obsoleted by
  @sanity/visual-editing-standalone
- Node pinned to >=22.12 (.nvmrc + engines), required by sanity 6 / @sanity/ui 4
- baseline/ holds pre-migration production sitemap + RSS snapshots for
  post-cutover parity diffing (472 URLs, 50 items per feed)

Verified: pnpm build succeeds and wrangler dev serves SSR HTML via workerd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2

* chore: upgrade Sanity Studio to v6.9.2

sanity 5.17.1 -> 6.9.2, @sanity/ui 3 -> 4, @sanity/vision 6.9.2,
@sanity/icons 3 -> 5, @sanity/assist 6.1.19, @sanity/code-input 7.3.5,
sanity-plugin-media 4 -> 6, @portabletext/react 6 -> 7 (matching sanity@6.9.2's
own dep so toPlainText resolves a single copy).

Four blockers surfaced during the upgrade:

1. @sanity/icons v5 removed every icon from the root entry (they remain as
   `declare const X: never` with a deprecation naming the fix). All eight
   imports moved to subpaths, e.g. @sanity/icons/User.

2. The workspace override "@portabletext/sanity-bridge": "^1.2.14" starved
   sanity@6.9.2, which needs ^3.2.5 for getSanitySubSchema. Removed; pnpm now
   resolves 1.2.14 for apps/web's sanity@5 and 3.2.5 for the Studio.

3. @codingcatdev/sanity-plugin-podcast-rss could not simply be dropped:
   schemas/documents/podcast.ts declares a field of type "podcastRssEpisode",
   which only that plugin registers, so removing it broke schema extraction.
   Vendored into plugins/podcast-rss/ (see its README) migrated to
   @sanity/ui v4 — Menu/MenuButton/MenuItem from the /menu subpath, `space` ->
   `gap` — and off xml2js onto the native DOMParser, since xml2js only resolved
   via a Vite polyfill that Vite 8 no longer provides. Field shapes are kept
   byte-identical so existing podcast documents are unaffected.

4. @sanity/ui v4: Stack `space` -> `gap` (2 call sites) and Avatar `alt` ->
   `title`.

Also fixed pre-existing issues the upgrade exposed: react-inlinesvg was
imported by the Studio but declared only in apps/web; SharePreviewActionButton
imported five symbols from "@/sanity/lib/api" despite the Studio tsconfig
having no such path alias (all unused); CodeSandboxPreview imported an unused
RenderPreviewCallbackProps. Removed the stray unused podcast-rss dependency
from apps/web. tsconfig target ES2020 -> ES2022 for Array.prototype.at.

Verified: builds, typechecks clean, dev server boots on Vite 8.2.1, and schema
extract succeeds with zero types removed (two added by sanity-plugin-media v6).
Still needs a human click-test of markdown paste in the Portable Text editor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2

* ci: add build/lint/typecheck gate and repo agent docs

The repo had no CI at all — no lint, typecheck, build, or test ran on any push.
Two latent breakages had accumulated unnoticed as a result:

- apps/web/biome.json is invalid for Biome 2 (`jsxRuntime: "automatic"` is not
  an accepted value), so `pnpm lint` there has been failing on a config error
  rather than linting anything. Left as-is since apps/web is being retired;
  apps/site gets a corrected Biome 2 config (`assist.actions.source`,
  `files.includes` with negations, `jsxRuntime: "transparent"`).
- apps/sanity/extract.json can silently drift from the schema, which is how
  three divergent copies accumulated. CI now re-runs the extract and fails if
  the result differs from what is committed.

ci.yml runs lint + typecheck + build for the site, typecheck + build for the
Studio, and the extract drift check. Uses node-version-file: .nvmrc rather than
a bare "22", which is what actually enforces the >=22.12 floor that sanity 6 and
@sanity/ui 4 require; deploy.yml switched to the same.

deploy.yml also gains a paths filter so site-only changes stop redeploying the
Studio on every push.

Adds a root CLAUDE.md (the repo had no root docs) recording the package layout,
the in-flight migration, where the vendored Sanity skills live, and the
conventions that have already caused real bugs here — no getStaticPaths under
output: "server", GROQ must live in .ts files for TypeGen, never read secrets
from import.meta.env, bindings are not inherited into env.production, and
stegaClean anything bound to an attribute or feed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2

* feat: Astro Sanity data layer and consolidated typegen

Adds apps/site/src/lib/sanity/* and converts all 26 GROQ queries from
`groq` tagged templates to `defineQuery`, so generated types flow to call sites
and the `as` casts the Next app needed are gone.

Two assumptions from the migration plan turned out to be wrong, and were
verified empirically before committing to a layout:

- TypeGen DOES resolve const interpolation inside defineQuery, including across
  module boundaries. The shared projection partials (baseFieldsNoContent,
  contentFields, ...) are therefore kept in queries/shared.ts rather than being
  inlined into every query.
- @astrojs/cloudflare v14 reduced `Runtime` to `{ cfContext }` and marks
  `locals.runtime` a deprecated getter. Bindings and vars are read via
  `import { env } from "cloudflare:workers"` instead — which is also what the
  pre-Next Astro app did.

Three bugs in the harvested fetch layer (dev:apps/web/src/utils/sanity.ts) are
fixed rather than carried over:

- `useCdn: false` on the base client. Under Next this was masked by ISR; under
  pure SSR it would put every anonymous page view on the uncached live API.
  Published reads now use the CDN, which Sanity purges on publish.
- stega configured on the base client and suppressed per-request. Inverted:
  off by default, on only in previewClient, so RSS/sitemap/OG/JSON-LD cannot
  leak zero-width characters into hrefs and XML.
- read token sourced from `import.meta.env`, which Vite inlines into the server
  bundle — that would bake the token into the deployed Worker script. Now read
  per-request from the runtime env, and preview fails closed when absent.

Config is resolved per-request because wrangler `vars` reach the Worker through
the runtime env, not `import.meta.env`. apiVersion is pinned to 2025-09-30 to
match apps/sanity/sanity.config.ts; the old Astro app used 2026-03-17, a silent
divergence between what authors preview in Vision and what the site renders.

Middleware builds the request context onto `locals.sanity` and sets
`no-store, private` + `noindex` on preview responses, so a page containing
unpublished drafts cannot be written to the edge cache.

Queries that took a $type parameter (docCount, rssQuery) are split per type —
a parameterised _type widens the generated result to a union of every document
type. urlForImage is consolidated to one implementation keeping the
`.auto("format")` behaviour; resolveHref now covers the whole public route map
instead of only "post".

Typegen moves entirely into apps/sanity: apps/web/sanity.cli.ts is deleted along
with its typegen/predev/prebuild scripts and its `sanity` devDependency, and the
two stale schema copies (apps/web/sanity/extract.json, apps/web/schema.json) are
removed. CI regenerates types before typechecking and fails if the committed
extract.json has drifted.

Verified end to end: `wrangler dev` serves the home page with real Sanity
content from the dev dataset, typechecking clean with no casts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2

* feat: Astro layout, components, and listing routes

Ports the shared layout and every listing route from the Next app, with
production parity as the target rather than a redesign.

Design tokens come from apps/web/app/globals.css — the live production
stylesheet — not the pre-Next Astro app on `dev`, which carried a
different, never-shipped dark-first token set that would have been a
visual regression at cutover. Dashboard-only tokens are dropped.

Routes: /, /blog + /blog/page/[num], /podcasts + /podcasts/page/[num],
/authors|guests|sponsors/page/[num], and 404. Pagination is a runtime
bounds check on Astro.params — there is no getStaticPaths — rejecting
anything that is not a bare positive integer so one page cannot be
served under unlimited URLs, and 302-ing out-of-range requests to the
last page.

Redirects mirror production exactly: /authors, /guests and /sponsors
redirect to /page/1, and the bare /{base}/page stubs go to /page/1 (not
to the section index, which is what the scaffold assumed).

Components ship no framework JS. next-themes becomes an inline script,
the Radix Sheet becomes a details-free drawer, react-icons paths are
inlined, and usePathname-based active-nav detection is resolved at
render time. A blog listing page now carries 1.4 KB of inline script and
no external bundle.

Two fixes over the Next behaviour:

- Canonical and og:url were derived from build-time `Astro.site`, but
  SITE_URL is a per-environment wrangler var and CI builds once for both
  Workers — the dev Worker would have claimed production URLs. Resolved
  per request in middleware as `Astro.locals.siteUrl`.
- JSON-LD advertised an Organization logo at /icon.svg, which 404s in
  production because Next serves that file at a hashed metadata URL. The
  asset is now in public/ and the URL resolves.

The home page's two-column grids drop the `slice(0, length / 2)` index
arithmetic, which silently dropped the last item on an odd count.

Verified against a real Worker under `wrangler dev`: all routes 200,
/blog/page/abc 404s, /blog/page/99999 302s to the last page, no stega
leakage into attributes, and .assetsignore keeps _worker.js unserved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2

* feat: portable text renderers and content detail routes

Twelve Astro renderers for the custom block types, plus the detail routes
that consume them: /post/[slug], /podcast/[slug], /author/[slug],
/guest/[slug], /sponsor/[slug], and the /[slug] catch-all for page
documents.

The pre-Next Astro app rendered Portable Text with no component map at
all, so every custom block rendered as nothing. This is the piece that
was genuinely missing rather than portable.

astro-portabletext uses SINGULAR `type` / `block` / `mark` keys where
@portabletext/react uses `types` / `marks`, and mark components receive
the mark definition on `node.markDef` rather than on the node. Both fail
silently — the components simply never run — so both are called out at
the definition sites.

Dependencies dropped, not ported:

- react-syntax-highlighter and the hand-maintained Prism language list
  become Astro's bundled Shiki, highlighting at render time. Language
  ids are normalised through an allowlist first: `@sanity/code-input`
  does not constrain the field, and one live document carries
  `language: "Zanzibar"`, which Shiki throws on. The allowlist is typed
  as a literal union so a typo in it is a compile error rather than a
  runtime 500.
- react-markdown per table cell. No table block exists in production —
  every cell in the dataset is null — so it was never exercised. Cells
  render as text.
- embla-carousel for YouTube shorts becomes a scroll-snap row.
- react-twitter-embed becomes the official blockquote markup, with
  widgets.js loaded only when a page actually contains a post. It now
  degrades to a readable link when the script is blocked.
- react-icons for the 20 social platforms: paths inlined.

Fixes carried in:

- CodePen and CodeSandbox URL parsing was positional destructuring and
  an unguarded double `.at(-1)`; a malformed URL threw at render. Both
  validate now.
- Tables get a header row. The Next version emitted every row as `<td>`
  in a bare `<tbody>`, leaving no column headers for screen readers.
- Sanity's file-asset URL for cover video read NEXT_PUBLIC_* env at
  module scope, which does not exist on Workers; it uses the
  per-request config.
- Socials rendered an empty clickable link for any key without an icon
  (`email`, `polywork`); unknown keys are skipped and both now have one.

Podcast episodes play through a native audio element for now — the
persistent floating player is a React island and lands with the rest of
the islands.

Verified against a real Worker: code, quote, image, htmlBlock and
twitter blocks all render from live dev-dataset documents, Shiki emits
dual-theme output with the dark variant activated, and every detail
route 200s while unknown slugs 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2

* feat: feeds, sitemap, and robots

Ports /blog/rss.{xml,json}, /podcasts/rss.{xml,json}, /sitemap.xml and
/robots.txt, all serialized by hand — the `feed` package reaches for Node
built-ins that are awkward on workerd, and these are fixed shapes.

Verified against the pre-migration production snapshots in
apps/site/baseline by running the Worker with the production config:

- sitemap.xml: byte-identical, all 472 URLs
- podcasts/rss.xml: byte-identical apart from timestamps
- blog/rss.xml: identical apart from <generator>, which no longer names a
  library we do not use
- both JSON feeds: same key order, same 50 ids in the same order
- robots.txt: identical

GUID stability was the thing to get right and it holds: all 100 feed
items keep their Sanity _id, in order. A changed podcast GUID would
re-publish the entire back catalogue to Apple and Spotify.

Two live bugs fixed rather than reproduced:

- The production blog feed is not well-formed XML. The `feed` library
  emits unescaped `&` in enclosure URLs, so `ET.parse` rejects
  blog_rss.xml at line 25. Ours escapes them.
- Feed bodies were serialized by `toHTML` with no component map, so
  internal links rendered as `<span class="unknown__pt__mark__internalLink">`
  and code, images, tables and embeds were dropped or stubbed. There is
  now a map; links resolve to absolute URLs so they work inside a reader.

Deliberately reproduced, including the odd parts: `image/jpg` rather than
`image/jpeg` for enclosures, one `<author>` element per author, and
`<description>` omitted entirely when the excerpt is empty. Churning
those would change what existing subscribers see for no benefit.

robots.txt now serves `Disallow: /` outside production, so the dev Worker
and preview aliases cannot be indexed alongside the real site.

Also fixes a deployment trap found while verifying this. The Cloudflare
adapter flattens wrangler.jsonc at BUILD time and writes
.wrangler/deploy/config.json, which redirects wrangler away from the
source config — `env.production` is dropped, keeping only its name. So
`wrangler deploy --env production` on a default build would have shipped
the dev dataset under the dev worker name. The environment is selected by
`CLOUDFLARE_ENV` at build time; added a `build:production` script and
documented it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2

* feat: OG image generation on Workers

Four Satori-backed endpoints — /api/og/{default,blog,podcast,person}.png
— recovered from the pre-Next Astro app along with the two Inter TTFs and
the rawFonts Vite plugin that inlines them as Uint8Array (Workers has no
filesystem for Satori to read from).

The load-bearing part of that recovery is its workaround, kept verbatim
with its explanation: workers-og parses HTML with HTMLRewriter, which
corrupts the HTML-to-VDOM conversion on Workers, so these build React
element objects and hand them to ImageResponse directly.

Wired in as a FALLBACK, not a replacement. Where a document has a cover
image the card is still that image, byte-for-byte what the Next app
served. Documents without one previously emitted no og:image at all;
those now get a generated card. The home page and section indexes get one
too.

Verified on real workerd under `wrangler dev`, not `astro dev` — all four
endpoints return valid 1200x630 PNGs, and the rendered images were
inspected rather than just status-checked. That caught the one visual
defect: the recovered logo used a cat emoji, and Satori renders only
glyphs present in the fonts it is given, so it came out as a tofu box on
every card. Replaced with a monogram tile in the brand gradient.

Brand tokens are literal hex rather than the CSS custom properties the
rest of the site uses: Satori resolves neither var() nor oklch().

Font imports are now typed via a `*.ttf` module declaration, since Astro's
own asset typings declare them as URL strings — the opposite of what the
rawFonts plugin actually returns. That removes the `@ts-ignore` pair and
the `as any` in loadFonts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2

* feat: Astro SSR parity, Sanity semantic search, SearchModal, and syndication

* ci: add cf-typegen step and automatic PR preview comment with Cloudflare preview URL

* fix(ci): fix yaml indentation and use echo for pr comment markdown

* fix(ci): fix hardcoded font paths in test mocks and enhance pr preview url reporting

* fix(ci): fix yaml indentation and clean up secrets syntax in workflows

* feat(sanity): dedicated studio dataset workspaces and deploy workflow environment passing

---------

Co-authored-by: Miriad <miriad@miriad.systems>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sanity): refresh extract.json cleanly after merge

---------

Co-authored-by: Miriad <miriad@miriad.systems>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant