Skip to content

Commit 4d5cdf4

Browse files
codercatdevclaude
andcommitted
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
1 parent 90b26d7 commit 4d5cdf4

34 files changed

Lines changed: 2786 additions & 3886 deletions

‎apps/site/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"dependencies": {
1919
"@astrojs/cloudflare": "^14.2.1",
2020
"@astrojs/react": "^6.0.2",
21+
"@portabletext/types": "^4.0.2",
2122
"@sanity/client": "^7.26.2",
2223
"@sanity/image-url": "^2.1.1",
2324
"@sanity/visual-editing-standalone": "^2.0.1",
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
interface Props {
3+
links: Array<{ title: string; href?: string }>;
4+
}
5+
6+
const { links } = Astro.props;
7+
---
8+
9+
<nav class="my-10 text-lg md:text-xl" aria-label="Breadcrumb">
10+
<ol class="flex flex-wrap items-center gap-1.5">
11+
<li><a href="/" class="hover:underline">Home</a></li>
12+
{
13+
links.map((link, index) => (
14+
<>
15+
<li aria-hidden="true" class="text-muted-foreground">
16+
/
17+
</li>
18+
<li>
19+
{link.href && index < links.length - 1 ? (
20+
<a href={link.href} class="hover:underline">
21+
{link.title}
22+
</a>
23+
) : (
24+
<span class="text-muted-foreground" aria-current="page">
25+
{link.title}
26+
</span>
27+
)}
28+
</li>
29+
</>
30+
))
31+
}
32+
</ol>
33+
</nav>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
import { stegaClean } from "@sanity/client/stega";
3+
import type { SanityImageSource } from "@sanity/image-url";
4+
import CoverImage from "@/components/CoverImage.astro";
5+
import PtYouTube from "@/components/portable-text/PtYouTube.astro";
6+
7+
/**
8+
* Picks the hero medium for a content page, in the Next app's precedence:
9+
* uploaded video, then YouTube, then the cover image.
10+
*/
11+
interface Props {
12+
image?: (SanityImageSource & { alt?: string }) | null;
13+
video?: { asset?: { _ref?: string } } | null;
14+
youtube?: string | null;
15+
class?: string;
16+
}
17+
18+
const { image, video, youtube, class: className } = Astro.props;
19+
20+
/**
21+
* Sanity file refs are `file-{hash}-{ext}`. The Next version read the project
22+
* id and dataset from `process.env.NEXT_PUBLIC_*` at module scope, which does
23+
* not exist on Workers — resolved from the per-request config instead.
24+
*/
25+
function fileUrl(ref: string | undefined) {
26+
if (!ref?.startsWith("file-")) {
27+
return undefined;
28+
}
29+
const parts = ref.split("-");
30+
const ext = parts.pop();
31+
const hash = parts.slice(1).join("-");
32+
if (!ext || !hash) {
33+
return undefined;
34+
}
35+
const { projectId, dataset } = Astro.locals.sanity.config;
36+
return `https://cdn.sanity.io/files/${projectId}/${dataset}/${hash}.${ext}`;
37+
}
38+
39+
const videoUrl = fileUrl(stegaClean(video?.asset?._ref));
40+
const youtubeUrl = stegaClean(youtube);
41+
---
42+
43+
{
44+
videoUrl ? (
45+
<div class="shadow-md transition-shadow duration-200 sm:mx-0">
46+
<video
47+
class:list={["w-full h-auto rounded-md", className]}
48+
width={1920}
49+
height={1080}
50+
controls
51+
preload="metadata"
52+
>
53+
<source src={videoUrl} />
54+
</video>
55+
</div>
56+
) : youtubeUrl ? (
57+
<PtYouTube node={{ _type: "youtube", youtube: youtubeUrl }} index={0} isInline={false} />
58+
) : (
59+
<CoverImage image={image} priority class={className} />
60+
)
61+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
import { stegaClean } from "@sanity/client/stega";
3+
4+
interface Props {
5+
title: string;
6+
audioUrl?: string | null;
7+
youtube?: string | null;
8+
}
9+
10+
const { title, audioUrl, youtube } = Astro.props;
11+
12+
const src = stegaClean(audioUrl);
13+
const youtubeUrl = stegaClean(youtube);
14+
const cleanTitle = stegaClean(title) ?? "";
15+
16+
const spotifyUrl = `https://open.spotify.com/search/codingcat.dev%20${encodeURIComponent(cleanTitle)}/episodes`;
17+
const appleUrl =
18+
"https://podcasts.apple.com/us/podcast/codingcat-dev-podcast/id1491655542";
19+
20+
const buttonClass =
21+
"inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground";
22+
---
23+
24+
{
25+
src && (
26+
<div class="flex w-full flex-col flex-wrap items-center justify-start gap-2 border border-foreground p-2 sm:flex-row sm:gap-4 sm:p-4">
27+
<h2 class="w-full text-center text-xl font-bold sm:text-start">
28+
Listening Options
29+
</h2>
30+
{/*
31+
* A native audio element, not the floating persistent player — that
32+
* player is a React island and lands with the rest of the islands.
33+
* This keeps every episode playable in the meantime with no JS.
34+
*/}
35+
<audio class="w-full sm:flex-1" controls preload="none" src={src}>
36+
<track kind="captions" />
37+
</audio>
38+
<div class="flex gap-1">
39+
<a
40+
href={spotifyUrl}
41+
target="_blank"
42+
rel="noreferrer noopener"
43+
class={buttonClass}
44+
aria-label="Search for this episode on Spotify"
45+
>
46+
<svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
47+
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0Zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02Zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2Zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3Z" />
48+
</svg>
49+
<span class="sr-only">Listen on Spotify</span>
50+
</a>
51+
<a
52+
href={appleUrl}
53+
target="_blank"
54+
rel="noreferrer noopener"
55+
class={buttonClass}
56+
aria-label="Listen on Apple Podcasts"
57+
>
58+
<svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
59+
<path d="M5.34 0A5.328 5.328 0 0 0 0 5.34v13.32A5.328 5.328 0 0 0 5.34 24h13.32A5.328 5.328 0 0 0 24 18.66V5.34A5.328 5.328 0 0 0 18.66 0Zm6.525 2.568c2.336 0 4.448.902 6.056 2.587 1.224 1.272 1.912 2.619 2.264 4.392.12.59.12 2.2.007 2.864a8.506 8.506 0 0 1-3.24 5.296c-.608.46-2.096 1.261-2.336 1.261-.088 0-.096-.091-.056-.46.072-.592.144-.715.48-.856.535-.224 1.447-.874 2.008-1.435a7.644 7.644 0 0 0 2.008-3.536c.208-.824.184-2.656-.048-3.504-.728-2.672-2.512-4.544-5.096-5.336-.784-.24-2.44-.336-3.384-.2-2.36.32-4.448 1.664-5.616 3.616-1.096 1.832-1.264 4.24-.44 6.224.6 1.448 1.784 2.792 3.048 3.472.28.152.352.28.416.72.048.36.024.44-.056.44-.048 0-.416-.16-.816-.36l-.024-.008c-1.472-.72-2.72-2.032-3.448-3.648-1.472-3.248-.688-7.02 1.928-9.352 1.72-1.53 3.88-2.28 6.344-2.28z" />
60+
</svg>
61+
<span class="sr-only">Listen on Apple Podcasts</span>
62+
</a>
63+
{youtubeUrl && (
64+
<a
65+
href={youtubeUrl}
66+
target="_blank"
67+
rel="noreferrer noopener"
68+
class={buttonClass}
69+
aria-label="Watch on YouTube"
70+
>
71+
<svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
72+
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814ZM9.545 15.568V8.432L15.818 12l-6.273 3.568Z" />
73+
</svg>
74+
<span class="sr-only">Watch on YouTube</span>
75+
</a>
76+
)}
77+
</div>
78+
</div>
79+
)
80+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
import { stegaClean } from "@sanity/client/stega";
3+
4+
interface Pick {
5+
name?: string | null;
6+
site?: string | null;
7+
user?: { title?: string | null } | null;
8+
}
9+
10+
interface Props {
11+
picks?: Array<Pick | null> | null;
12+
}
13+
14+
const { picks } = Astro.props;
15+
16+
// Group by the picking guest, alphabetically — same as the Next version, but
17+
// without the shadcn Card/Table primitives it used for what is a list.
18+
const grouped = new Map<string, Pick[]>();
19+
for (const pick of picks ?? []) {
20+
const author = pick?.user?.title;
21+
if (!pick || !author) {
22+
continue;
23+
}
24+
const existing = grouped.get(author);
25+
if (existing) {
26+
existing.push(pick);
27+
} else {
28+
grouped.set(author, [pick]);
29+
}
30+
}
31+
32+
const sorted = [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b));
33+
---
34+
35+
{
36+
sorted.length > 0 && (
37+
<>
38+
<hr class="mb-8 mt-8 border-border sm:mb-24 sm:mt-28" />
39+
<h2 class="mb-8 text-6xl font-bold leading-tight tracking-tighter md:text-7xl">
40+
Picks
41+
</h2>
42+
<section class="grid gap-4 p-0 sm:gap-8 sm:p-12">
43+
{sorted.map(([author, items]) => (
44+
<article class="rounded-xl border border-border bg-card">
45+
<h3 class="border-b border-border px-6 py-4 text-xl font-bold sm:text-2xl">
46+
{author}
47+
</h3>
48+
<ul>
49+
{items.map((pick) => {
50+
const site = stegaClean(pick.site);
51+
return (
52+
<li class="border-b border-border last:border-b-0">
53+
{site ? (
54+
<a
55+
href={site}
56+
target="_blank"
57+
rel="noopener noreferrer"
58+
class="flex items-center justify-between p-4 text-base transition-colors hover:bg-accent sm:text-xl"
59+
>
60+
<span>{pick.name}</span>
61+
<svg
62+
class="size-4 shrink-0"
63+
viewBox="0 0 24 24"
64+
fill="none"
65+
stroke="currentColor"
66+
stroke-width="2"
67+
stroke-linecap="round"
68+
stroke-linejoin="round"
69+
aria-hidden="true"
70+
>
71+
<path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
72+
</svg>
73+
</a>
74+
) : (
75+
<span class="block p-4 text-base sm:text-xl">{pick.name}</span>
76+
)}
77+
</li>
78+
);
79+
})}
80+
</ul>
81+
</article>
82+
))}
83+
</section>
84+
</>
85+
)
86+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
import { stegaClean } from "@sanity/client/stega";
3+
import CoverImage from "@/components/CoverImage.astro";
4+
import type { CoverImage as CoverImageType } from "@/lib/sanity/types.gen";
5+
6+
interface Sponsor {
7+
_id: string;
8+
title: string;
9+
slug?: string | null;
10+
excerpt?: string | null;
11+
coverImage?: CoverImageType | null;
12+
url?: string | null;
13+
}
14+
15+
interface Props {
16+
sponsors?: Sponsor[] | null;
17+
}
18+
19+
const { sponsors } = Astro.props;
20+
const items = sponsors ?? [];
21+
---
22+
23+
{
24+
items.length > 0 && (
25+
<div class="grid justify-center gap-6 p-4 md:p-6 lg:auto-cols-[1fr] lg:grid-flow-col">
26+
{items.map((sponsor) => {
27+
const external = stegaClean(sponsor.url);
28+
const href = external || `/sponsor/${sponsor.slug}`;
29+
return (
30+
<a
31+
href={href}
32+
target={external ? "_blank" : undefined}
33+
rel={external ? "noopener noreferrer sponsored" : undefined}
34+
class="flex"
35+
>
36+
<article class="flex max-w-xl grow flex-col overflow-hidden rounded-xl border border-border bg-card shadow-md transition-all hover:scale-[1.02] hover:shadow-lg">
37+
<CoverImage image={sponsor.coverImage} />
38+
<div class="grow p-6">
39+
<h3 class="mb-3 text-3xl text-balance">{sponsor.title}</h3>
40+
</div>
41+
{sponsor.excerpt && (
42+
<p class="px-6 pb-6 text-lg leading-relaxed text-pretty">
43+
{sponsor.excerpt}
44+
</p>
45+
)}
46+
</article>
47+
</a>
48+
);
49+
})}
50+
</div>
51+
)
52+
}

0 commit comments

Comments
 (0)