diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..46c657e91 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,153 @@ +name: CI + +on: + pull_request: + push: + branches: [dev, main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + # node-version-file (not a bare "22") is what actually enforces the + # >=22.12 floor required by sanity 6 / @sanity/ui 4. + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Generates apps/sanity/extract.json and the site's types.gen.ts, both of + # which are derived artifacts and therefore gitignored. Runs before + # typecheck because the site imports the generated types. + - name: Typegen (Sanity) + run: pnpm typegen + env: + SANITY_STUDIO_PROJECT_ID: hfh83o0w + SANITY_STUDIO_DATASET: production + + - name: Typegen (Cloudflare) + run: pnpm --filter @codingcatdev/site cf-typegen + + - name: Lint (site) + run: pnpm --filter @codingcatdev/site lint + + - name: Typecheck (site) + run: pnpm --filter @codingcatdev/site typecheck + + - name: Typecheck (studio) + run: pnpm --filter @codingcatdev/sanity exec tsc --noEmit + + - name: Build site + run: pnpm --filter @codingcatdev/site build + + - name: Build studio + run: pnpm --filter @codingcatdev/sanity build + env: + SANITY_STUDIO_PROJECT_ID: hfh83o0w + SANITY_STUDIO_DATASET: production + + - name: Run E2E Tests + run: pnpm --filter @codingcatdev/site test:e2e + + # extract.json is committed and is the single source of truth for typegen. + # The Typegen step above regenerates it; if that changed the file, the + # commit is stale. This is what stopped three divergent copies before. + - name: Verify extract.json is current + run: | + if ! git diff --quiet -- apps/sanity/extract.json; then + echo "::error::apps/sanity/extract.json is stale. Run 'pnpm typegen' and commit the result." + git diff --stat -- apps/sanity/extract.json + exit 1 + fi + + preview: + needs: verify + if: github.event_name == 'pull_request' && github.base_ref == 'dev' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Sanity Types + run: pnpm typegen + env: + SANITY_STUDIO_PROJECT_ID: hfh83o0w + SANITY_STUDIO_DATASET: dev + + - name: Generate Cloudflare Types + run: pnpm --filter @codingcatdev/site cf-typegen + + - name: Build site (preview) + run: pnpm --filter @codingcatdev/site build + + - name: Upload Cloudflare Worker Preview Version + id: upload_preview + run: | + OUTPUT=$(pnpm --filter @codingcatdev/site exec wrangler versions upload --tag "pr-${PR_NUMBER}" --message "PR #${PR_NUMBER}: ${PR_TITLE}") + echo "$OUTPUT" + + PREVIEW_URL=$(echo "$OUTPUT" | grep -i "Version Preview URL:" | awk '{print $NF}' | head -n 1) + VERSION_ID=$(echo "$OUTPUT" | grep -i "Worker Version ID:" | awk '{print $NF}' | head -n 1) + + echo "preview_url=$PREVIEW_URL" >> $GITHUB_OUTPUT + echo "version_id=$VERSION_ID" >> $GITHUB_OUTPUT + + echo "::notice title=Cloudflare Worker Preview URL::${PREVIEW_URL}" + + echo "### ⚡ Cloudflare Worker Preview Ready" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Preview URL**: [${PREVIEW_URL}](${PREVIEW_URL})" >> $GITHUB_STEP_SUMMARY + echo "- **Version ID**: \`${VERSION_ID}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Target Worker**: \`codingcatdev-dev\`" >> $GITHUB_STEP_SUMMARY + + if [ -n "$PREVIEW_URL" ]; then + { + echo "### ⚡ Cloudflare Worker Preview Ready!" + echo "" + echo "| Environment | URL |" + echo "|---|---|" + echo "| **PR Preview** | [${PREVIEW_URL}](${PREVIEW_URL}) |" + echo "| **Dev Custom Domain** | [https://dev.codingcat.dev](https://dev.codingcat.dev) |" + echo "" + echo "- **Worker Version ID**: \`${VERSION_ID}\`" + echo "- **Protection**: Restricted to \`@codingcat.dev\` accounts via Cloudflare Zero Trust Access" + } > pr_comment.md + + COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" --jq '.[] | select((.user.login=="github-actions[bot]" or .user.login=="app/github-actions") and (.body | contains("Cloudflare Worker Preview Ready"))) | .id' | tail -n 1) + + if [ -n "$COMMENT_ID" ]; then + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" -f body="$(cat pr_comment.md)" + else + gh pr comment "$PR_NUMBER" --body-file pr_comment.md + fi + fi + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + diff --git a/.github/workflows/deploy-workers.yml b/.github/workflows/deploy-workers.yml new file mode 100644 index 000000000..0b40510c9 --- /dev/null +++ b/.github/workflows/deploy-workers.yml @@ -0,0 +1,83 @@ +name: Deploy Site to Cloudflare Workers + +on: + push: + branches: [dev, main] + paths: + - "apps/site/**" + - "packages/**" + - "pnpm-lock.yaml" + - ".github/workflows/deploy-workers.yml" + +concurrency: + group: deploy-site-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy-dev: + if: github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Sanity Types + run: pnpm typegen + env: + SANITY_STUDIO_PROJECT_ID: hfh83o0w + SANITY_STUDIO_DATASET: dev + + - name: Generate Cloudflare Types + run: pnpm --filter @codingcatdev/site cf-typegen + + - name: Build site (dev) + run: pnpm --filter @codingcatdev/site build + + - name: Deploy to Cloudflare Workers (dev.codingcat.dev) + run: pnpm --filter @codingcatdev/site exec wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + deploy-production: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Sanity Types + run: pnpm typegen + env: + SANITY_STUDIO_PROJECT_ID: hfh83o0w + SANITY_STUDIO_DATASET: production + + - name: Generate Cloudflare Types + run: pnpm --filter @codingcatdev/site cf-typegen + + - name: Build site (production) + run: pnpm --filter @codingcatdev/site build:production + + - name: Deploy to Cloudflare Workers (codingcat.dev) + run: pnpm --filter @codingcatdev/site exec wrangler deploy --env production + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1c81e474e..af82605d8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,12 +1,17 @@ name: Deploy Sanity Studio on: + workflow_dispatch: push: branches: [dev, main] + paths: + - "apps/sanity/**" + - "pnpm-lock.yaml" + - ".github/workflows/deploy.yml" jobs: deploy-dev: - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest environment: dev @@ -17,7 +22,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "22" + node-version-file: ".nvmrc" cache: "pnpm" - name: Install dependencies @@ -29,6 +34,8 @@ jobs: env: SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} SANITY_STUDIO_HOSTNAME: ${{ vars.SANITY_STUDIO_HOSTNAME }} + SANITY_STUDIO_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} + SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} deploy-production: if: github.ref == 'refs/heads/main' @@ -42,7 +49,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "22" + node-version-file: ".nvmrc" cache: "pnpm" - name: Install dependencies @@ -54,3 +61,5 @@ jobs: env: SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} SANITY_STUDIO_HOSTNAME: ${{ vars.SANITY_STUDIO_HOSTNAME }} + SANITY_STUDIO_DATASET: ${{ vars.SANITY_STUDIO_DATASET }} + SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} diff --git a/.gitignore b/.gitignore index c14bd61fb..746942e8e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ yarn-error.log* !.vscode/mcp.json # pnpm workspace — root pnpm-lock.yaml is the lockfile package-lock.json + +# agents teamwork +.agents/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..c94711948 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.23.2 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..2a485f4c7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# codingcat.dev + +pnpm monorepo. Node >= 22.12 (see `.nvmrc`); pnpm 10.34.1. + +## Packages + +| Path | Package | What it is | +|---|---|---| +| `apps/site` | `@codingcatdev/site` | **Astro 7 SSR on Cloudflare Workers** — the public site. Active development. | +| `apps/web` | `@codingcatdev/web` | Next.js 16 app. Being retired: its public half is replaced by `apps/site`; the dashboard + sponsor portal move to `admin.codingcat.dev`. | +| `apps/sanity` | `@codingcatdev/sanity` | Sanity Studio v6, deployed to Sanity hosting. | + +Root scripts target `apps/site`. Use `pnpm dev:dashboard` / `build:dashboard` for the Next app, `pnpm dev:studio` for the Studio. + +## In-flight migration + +Moving from Next.js on Vercel to Astro 7 SSR on Cloudflare Workers, with Sanity +upgraded to v6 and the video/AI pipeline (Remotion, Gemini, ElevenLabs, +NotebookLM, GCS, YouTube upload) dropped. Auth moves from Supabase to +better-auth on Cloudflare D1. + +Branch note: `dev` still holds the **pre-Next Astro app** and `main` holds the +Next app — `main` is ahead. Harvest old Astro code with `git show dev:`, +never by checking `dev` out over the working tree. + +## Conventions that bite + +- **`output: "server"` — there is no `getStaticPaths` anywhere.** Pagination is a + runtime bounds check on `Astro.params`, not a build-time param list. +- **GROQ queries must live in `.ts` files.** Sanity TypeGen cannot parse `.astro` + frontmatter, so `defineQuery` calls in `.astro` files are silently skipped. +- **Never read secrets from `import.meta.env`.** Vite inlines non-`PUBLIC_` vars + into the server bundle, baking them into the deployed Worker script. Read them + from `import { env } from "cloudflare:workers"` — `@astrojs/cloudflare` v14 + reduced `Runtime` to `{ cfContext }` and deprecated `locals.runtime`. +- **Cloudflare bindings are not inherited into `env.production`.** Anything added + to the top level of `wrangler.jsonc` must be repeated there. +- **The environment is chosen at BUILD time, via `CLOUDFLARE_ENV`.** The adapter + flattens `wrangler.jsonc` into `dist/server/wrangler.json` and writes + `.wrangler/deploy/config.json`, which redirects wrangler away from the source + config — so `wrangler deploy --env production` on a default build silently + ships the *dev* dataset under the *dev* worker name. Use + `pnpm --filter @codingcatdev/site build:production`. +- **`Astro.site` is baked in at build time**, so it cannot carry a per-env + origin. Canonicals, `og:url`, feeds and the sitemap read `Astro.locals.siteUrl`, + resolved per request from the `SITE_URL` var in middleware. +- **`stegaClean` anything bound to an attribute**, ``, JSON-LD, or XML. + Stega's zero-width characters are harmless in text nodes and corrupting in + `href`, `src`, and feeds. +- **`@sanity/icons` v5 has no root-entry icon exports.** Import from subpaths: + `import {UserIcon} from "@sanity/icons/User"`. +- **`astro-portabletext` uses singular `type` / `block` / `mark` keys**, and mark + components read the mark definition from `node.markDef`, not `node`. Both fail + silently — the component simply never runs. +- **A self-closing ` diff --git a/apps/site/src/components/HomeSection.astro b/apps/site/src/components/HomeSection.astro new file mode 100644 index 000000000..01ff6f5e9 --- /dev/null +++ b/apps/site/src/components/HomeSection.astro @@ -0,0 +1,63 @@ +--- +import CoverImage from "@/components/CoverImage.astro"; +import type { ContentListItem } from "@/lib/content"; +import { resolveHref } from "@/lib/sanity/resolve-href"; + +interface Props { + title: string; + description: string; + items: ContentListItem[]; + /** Alternating band colour, matching the Next home page. */ + muted?: boolean; +} + +const { title, description, items, muted = false } = Astro.props; +--- + +{ + items.length > 0 && ( +
+
+
+
+

{title}

+

+ {description} +

+
+
+
+ {items.map((item) => { + const href = resolveHref(item._type, item.slug ?? undefined); + return ( + + {item.coverImage && ( + + )} +
+

{item.title}

+

+ {item.excerpt} +

+
+
+ ); + })} +
+
+
+ ) +} diff --git a/apps/site/src/components/JsonLd.astro b/apps/site/src/components/JsonLd.astro new file mode 100644 index 000000000..9b12f7f1c --- /dev/null +++ b/apps/site/src/components/JsonLd.astro @@ -0,0 +1,23 @@ +--- +interface Props { + data: unknown; +} + +const { data } = Astro.props; + +/** + * `set:html` is deliberate: the contents of + * `` sequence inside a string value + * closing the element early; JSON parsers read `<` as a plain `<`, so + * the structured data is unchanged. + * + * Values are stegaClean'd at the call sites that build these nodes — zero-width + * characters would otherwise end up in structured data. + */ +const json = JSON.stringify(data).replace(/ diff --git a/apps/site/src/components/NavLink.astro b/apps/site/src/components/NavLink.astro new file mode 100644 index 000000000..ddf244a5f --- /dev/null +++ b/apps/site/src/components/NavLink.astro @@ -0,0 +1,49 @@ +--- +import { stegaClean } from "@sanity/client/stega"; + +/** + * Replaces the Next `nav-link.tsx` client component. Active-path detection + * ran through `usePathname()` there, which forced the whole nav to be a + * client island; under SSR the pathname is already known at render time, so + * this ships zero JS. + */ +interface Props { + href: string; + class?: string; + target?: string; + rel?: string; + /** Match descendant routes too, e.g. /blog marks /blog/page/2 active. */ + prefix?: boolean; +} + +const { + href, + class: className = "", + target, + rel, + prefix = true, +} = Astro.props; + +const cleanHref = stegaClean(href) || "/"; +const current = Astro.url.pathname.replace(/\/+$/, "") || "/"; +const normalized = cleanHref.replace(/\/+$/, "") || "/"; + +const isExternal = /^https?:\/\//.test(cleanHref); +const isActive = + !isExternal && + (normalized === "/" + ? current === "/" + : prefix + ? current === normalized || current.startsWith(`${normalized}/`) + : current === normalized); +--- + + + + diff --git a/apps/site/src/components/Pagination.astro b/apps/site/src/components/Pagination.astro new file mode 100644 index 000000000..7efe48e92 --- /dev/null +++ b/apps/site/src/components/Pagination.astro @@ -0,0 +1,76 @@ +--- +/** + * Ported from the Next `paginate-list.tsx`, which used the shadcn Pagination + * primitives. Same link structure and same `/{base}/page/{n}` URLs — those + * are indexed and must not change. + */ +interface Props { + base: string; + page: number; + totalPages: number; +} + +const { base, page, totalPages } = Astro.props; + +const href = (n: number) => `/${base}/page/${n}`; + +const linkClass = + "inline-flex items-center justify-center h-9 min-w-9 px-3 rounded-md text-sm hover:bg-accent hover:text-accent-foreground transition-colors"; +const activeClass = "border border-border bg-accent text-accent-foreground"; +--- + +{ + totalPages > 1 && ( + + ) +} diff --git a/apps/site/src/components/PodcastListen.astro b/apps/site/src/components/PodcastListen.astro new file mode 100644 index 000000000..36ca8a9a4 --- /dev/null +++ b/apps/site/src/components/PodcastListen.astro @@ -0,0 +1,80 @@ +--- +import { stegaClean } from "@sanity/client/stega"; + +interface Props { + title: string; + audioUrl?: string | null; + youtube?: string | null; +} + +const { title, audioUrl, youtube } = Astro.props; + +const src = stegaClean(audioUrl); +const youtubeUrl = stegaClean(youtube); +const cleanTitle = stegaClean(title) ?? ""; + +const spotifyUrl = `https://open.spotify.com/search/codingcat.dev%20${encodeURIComponent(cleanTitle)}/episodes`; +const appleUrl = + "https://podcasts.apple.com/us/podcast/codingcat-dev-podcast/id1491655542"; + +const buttonClass = + "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"; +--- + +{ + src && ( +
+

+ Listening Options +

+ {/* + * A native audio element, not the floating persistent player — that + * player is a React island and lands with the rest of the islands. + * This keeps every episode playable in the meantime with no JS. + */} + + +
+ ) +} diff --git a/apps/site/src/components/PodcastPicks.astro b/apps/site/src/components/PodcastPicks.astro new file mode 100644 index 000000000..a974e9ab5 --- /dev/null +++ b/apps/site/src/components/PodcastPicks.astro @@ -0,0 +1,86 @@ +--- +import { stegaClean } from "@sanity/client/stega"; + +interface Pick { + name?: string | null; + site?: string | null; + user?: { title?: string | null } | null; +} + +interface Props { + picks?: Array | null; +} + +const { picks } = Astro.props; + +// Group by the picking guest, alphabetically — same as the Next version, but +// without the shadcn Card/Table primitives it used for what is a list. +const grouped = new Map(); +for (const pick of picks ?? []) { + const author = pick?.user?.title; + if (!pick || !author) { + continue; + } + const existing = grouped.get(author); + if (existing) { + existing.push(pick); + } else { + grouped.set(author, [pick]); + } +} + +const sorted = [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)); +--- + +{ + sorted.length > 0 && ( + <> +
+

+ Picks +

+
+ {sorted.map(([author, items]) => ( +
+

+ {author} +

+
    + {items.map((pick) => { + const site = stegaClean(pick.site); + return ( +
  • + {site ? ( + + {pick.name} + + + ) : ( + {pick.name} + )} +
  • + ); + })} +
+
+ ))} +
+ + ) +} diff --git a/apps/site/src/components/PodmatchBadge.astro b/apps/site/src/components/PodmatchBadge.astro new file mode 100644 index 000000000..726f81555 --- /dev/null +++ b/apps/site/src/components/PodmatchBadge.astro @@ -0,0 +1,59 @@ +--- +/** + * Ported from `podmatch-badge.tsx`. The Next version was a client component + * purely for an `onError` fallback on the artwork; `onerror` as an inline + * attribute does the same without hydration. + */ +--- + + + + diff --git a/apps/site/src/components/SearchModal.astro b/apps/site/src/components/SearchModal.astro new file mode 100644 index 000000000..18859c614 --- /dev/null +++ b/apps/site/src/components/SearchModal.astro @@ -0,0 +1,421 @@ +--- +// SearchModal.astro — Accessible semantic search modal dialog +--- + + + +
+ + + + + + + + + + + + + + +
+ + +
+ Filter: + + + + + +
+ + +
+ + + + + + + + +
+ + +
+
+ ↑ ↓ navigate + ↵ select + esc close +
+ +
+
+ + diff --git a/apps/site/src/components/SectionHeader.astro b/apps/site/src/components/SectionHeader.astro new file mode 100644 index 000000000..01fca36c5 --- /dev/null +++ b/apps/site/src/components/SectionHeader.astro @@ -0,0 +1,30 @@ +--- +import { stegaClean } from "@sanity/client/stega"; + +interface Props { + title: string; + href?: string; + linkText?: string; + showHr?: boolean; +} + +const { title, href, linkText = "View More", showHr = true } = Astro.props; +--- + +{showHr &&
} + +
+

+ {title} +

+ { + href && ( + + {linkText} + + ) + } +
diff --git a/apps/site/src/components/SocialMeta.astro b/apps/site/src/components/SocialMeta.astro new file mode 100644 index 000000000..8a6c1d326 --- /dev/null +++ b/apps/site/src/components/SocialMeta.astro @@ -0,0 +1,90 @@ +--- +import { stegaClean } from "@sanity/client/stega"; +import { SITE_NAME } from "@/lib/site"; + +/** + * Every value here lands in a `content` attribute, so all of it is + * stegaClean'd. Zero-width characters are invisible in a rendered page and + * corrupting in an OG scrape or a canonical URL. + */ +interface Props { + title: string; + description?: string | null; + image?: { url: string; alt?: string; width?: number; height?: number } | null; + type?: "website" | "article"; + publishedAt?: string | null; + modifiedAt?: string | null; + authors?: Array; + canonical?: string | URL; + noindex?: boolean; +} + +const { + title, + description, + image, + type = "website", + publishedAt, + modifiedAt, + authors = [], + canonical = Astro.url, + noindex = false, +} = Astro.props; + +// Per-request, not `Astro.site`: the origin differs between the dev and +// production Workers but the build is shared. +const site = Astro.locals.siteUrl; +const canonicalUrl = new URL( + stegaClean(typeof canonical === "string" ? canonical : canonical.pathname), + site, +).href; + +const cleanTitle = stegaClean(title) ?? SITE_NAME; +const cleanDescription = stegaClean(description) ?? undefined; +const imageUrl = image?.url ? new URL(stegaClean(image.url), site).href : undefined; +--- + +{cleanTitle} +{cleanDescription && } + +{noindex && } + + + + +{cleanDescription && } + +{ + imageUrl && ( + <> + + {image?.width && } + {image?.height && } + {image?.alt && } + + ) +} + + + + +{cleanDescription && } +{imageUrl && } + +{ + type === "article" && ( + <> + {publishedAt && ( + + )} + {modifiedAt && ( + + )} + {authors + .filter((name): name is string => Boolean(name)) + .map((name) => ( + + ))} + + ) +} diff --git a/apps/site/src/components/SponsorCard.astro b/apps/site/src/components/SponsorCard.astro new file mode 100644 index 000000000..49c5b156a --- /dev/null +++ b/apps/site/src/components/SponsorCard.astro @@ -0,0 +1,52 @@ +--- +import { stegaClean } from "@sanity/client/stega"; +import CoverImage from "@/components/CoverImage.astro"; +import type { CoverImage as CoverImageType } from "@/lib/sanity/types.gen"; + +interface Sponsor { + _id: string; + title: string; + slug?: string | null; + excerpt?: string | null; + coverImage?: CoverImageType | null; + url?: string | null; +} + +interface Props { + sponsors?: Sponsor[] | null; +} + +const { sponsors } = Astro.props; +const items = sponsors ?? []; +--- + +{ + items.length > 0 && ( +
+ {items.map((sponsor) => { + const external = stegaClean(sponsor.url); + const href = external || `/sponsor/${sponsor.slug}`; + return ( + +
+ +
+

{sponsor.title}

+
+ {sponsor.excerpt && ( +

+ {sponsor.excerpt} +

+ )} +
+
+ ); + })} +
+ ) +} diff --git a/apps/site/src/components/ThemeScript.astro b/apps/site/src/components/ThemeScript.astro new file mode 100644 index 000000000..dbf4a2772 --- /dev/null +++ b/apps/site/src/components/ThemeScript.astro @@ -0,0 +1,28 @@ +--- +/** + * Replaces `next-themes`. Runs before first paint to avoid a flash of the + * wrong theme; `is:inline` keeps it out of the module graph so it is not + * deferred behind hydration. + */ +--- + + diff --git a/apps/site/src/components/ThemeToggle.astro b/apps/site/src/components/ThemeToggle.astro new file mode 100644 index 000000000..583da1979 --- /dev/null +++ b/apps/site/src/components/ThemeToggle.astro @@ -0,0 +1,54 @@ +--- +/** + * Replaces the Next `mode-toggle.tsx` (next-themes + Radix dropdown) with a + * two-state button. The dropdown offered light / dark / system; system is + * still the default via ThemeScript, and clearing the stored preference is + * not worth a dropdown's worth of JS on every page. + */ +--- + + + + diff --git a/apps/site/src/components/UserSocials.astro b/apps/site/src/components/UserSocials.astro new file mode 100644 index 000000000..1aebbaf46 --- /dev/null +++ b/apps/site/src/components/UserSocials.astro @@ -0,0 +1,78 @@ +--- +import { stegaClean } from "@sanity/client/stega"; +import { SOCIAL_ICONS } from "@/lib/social-icons"; +import type { Socials } from "@/lib/sanity/types.gen"; + +interface Props { + socials?: Socials | null; + websites?: Array<{ + site?: string; + link?: { href?: string; blank?: boolean }; + }> | null; +} + +const { socials, websites } = Astro.props; + +/** + * Values are stored as full URLs. Anything that is not one is dropped — the + * Next version rendered a link for every populated key regardless, so a key + * with no matching icon produced an empty clickable target. + */ +const links = Object.entries(socials ?? {}) + .map(([key, value]) => { + const href = stegaClean(value); + const icon = SOCIAL_ICONS[key]; + if (!href || !icon) { + return null; + } + const isEmail = key === "email"; + return { + key, + icon, + href: isEmail && !href.startsWith("mailto:") ? `mailto:${href}` : href, + external: !isEmail, + }; + }) + .filter((link) => link !== null); + +const sites = (websites ?? []) + .map((entry) => ({ + label: entry.site, + href: stegaClean(entry.link?.href), + })) + .filter((site): site is { label: string | undefined; href: string } => + Boolean(site.href), + ); +--- + +{ + (links.length > 0 || sites.length > 0) && ( +
+ {links.map((link) => ( + + + {link.icon.label} + + ))} + {sites.map((site) => ( + + {site.label || site.href} + + ))} +
+ ) +} diff --git a/apps/site/src/components/icons/AjHeadphones.astro b/apps/site/src/components/icons/AjHeadphones.astro new file mode 100644 index 000000000..f6cb2c55c --- /dev/null +++ b/apps/site/src/components/icons/AjHeadphones.astro @@ -0,0 +1,257 @@ +--- +interface Props { + cls?: string; +} + +const { cls = "block w-12 h-12" } = Astro.props; +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/site/src/components/icons/AjPrimaryAlt.astro b/apps/site/src/components/icons/AjPrimaryAlt.astro new file mode 100644 index 000000000..f681976c6 --- /dev/null +++ b/apps/site/src/components/icons/AjPrimaryAlt.astro @@ -0,0 +1,158 @@ +--- +interface Props { + cls?: string; +} + +const { cls = "block w-12 h-12" } = Astro.props; +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/site/src/components/portable-text/PortableTextContent.astro b/apps/site/src/components/portable-text/PortableTextContent.astro new file mode 100644 index 000000000..acdbf3ab9 --- /dev/null +++ b/apps/site/src/components/portable-text/PortableTextContent.astro @@ -0,0 +1,119 @@ +--- +import { PortableText } from "astro-portabletext"; +import type { TypedObject } from "astro-portabletext/types"; +import PtCode from "./PtCode.astro"; +import PtCodePen from "./PtCodePen.astro"; +import PtCodeSandbox from "./PtCodeSandbox.astro"; +import PtHeading5 from "./PtHeading5.astro"; +import PtHeading6 from "./PtHeading6.astro"; +import PtHtml from "./PtHtml.astro"; +import PtImage from "./PtImage.astro"; +import PtInternalLink from "./PtInternalLink.astro"; +import PtLink from "./PtLink.astro"; +import PtQuote from "./PtQuote.astro"; +import PtTable from "./PtTable.astro"; +import PtTweet from "./PtTweet.astro"; +import PtYouTube from "./PtYouTube.astro"; +import PtYouTubeShorts from "./PtYouTubeShorts.astro"; + +interface Props { + /** + * Sanity TypeGen emits `children?:` as optional on blocks, while + * `PortableTextBlock` requires it — so the generated content types are not + * assignable to it. `TypedObject` is the library's own minimum contract + * (`{_type: string}`), which the generated types do satisfy, and it is what + * PortableText actually needs to dispatch on. + */ + value?: TypedObject[] | null; + class?: string; +} + +const { value, class: className } = Astro.props; + +/** + * astro-portabletext uses SINGULAR keys — `type`, `block`, `mark` — where + * @portabletext/react uses `types` / `marks`. Getting this wrong fails + * silently: the custom components are simply never called and every custom + * block renders as nothing, which is exactly what the pre-Next Astro app did. + */ +const components = { + type: { + image: PtImage, + code: PtCode, + codepen: PtCodePen, + codesandbox: PtCodeSandbox, + youtube: PtYouTube, + youtubeShorts: PtYouTubeShorts, + twitter: PtTweet, + htmlBlock: PtHtml, + quote: PtQuote, + table: PtTable, + }, + block: { + h5: PtHeading5, + h6: PtHeading6, + }, + mark: { + link: PtLink, + internalLink: PtInternalLink, + }, +}; +--- + +{ + value && value.length > 0 && ( +
+ +
+ ) +} + +{/* One delegated listener per page rather than one island per block. */} + diff --git a/apps/site/src/components/portable-text/PtCode.astro b/apps/site/src/components/portable-text/PtCode.astro new file mode 100644 index 000000000..08de475bb --- /dev/null +++ b/apps/site/src/components/portable-text/PtCode.astro @@ -0,0 +1,68 @@ +--- +import { Code } from "astro:components"; +import { stegaClean } from "@sanity/client/stega"; +import type { Props as PtProps } from "astro-portabletext/types"; +import { normalizeLanguage } from "@/lib/code-languages"; + +type CodeNode = { + _type: "code"; + code?: string; + language?: string; + filename?: string; + highlightedLines?: number[]; +}; + +type Props = PtProps; + +const { node } = Astro.props; + +/** + * Highlighting runs through Astro's bundled Shiki at render time, replacing + * react-syntax-highlighter and the hand-maintained Prism language list — the + * page ships highlighted markup instead of a highlighter. + * + * stegaClean: the code text is fed to a tokenizer, where stega's zero-width + * characters would corrupt tokens and land in the copy-to-clipboard payload. + */ +const code = stegaClean(node.code) ?? ""; +const language = normalizeLanguage(stegaClean(node.language)); +const filename = node.filename; +--- + +{ + code && ( +
+ {filename && ( +
+ {filename} +
+ )} + + +
+ ) +} diff --git a/apps/site/src/components/portable-text/PtCodePen.astro b/apps/site/src/components/portable-text/PtCodePen.astro new file mode 100644 index 000000000..aaed8ab00 --- /dev/null +++ b/apps/site/src/components/portable-text/PtCodePen.astro @@ -0,0 +1,51 @@ +--- +import { stegaClean } from "@sanity/client/stega"; +import type { Props as PtProps } from "astro-portabletext/types"; + +type CodePenNode = { _type: "codepen"; url?: string }; + +type Props = PtProps; + +const { node } = Astro.props; + +/** + * CodePen pen URLs are `https://codepen.io/{user}/pen/{hash}`. The Next + * version destructured the split array positionally and would produce + * `undefined` segments on any other shape; this validates instead. + */ +function embedUrl(raw?: string | null) { + if (!raw) { + return undefined; + } + try { + const url = new URL(raw); + if (!url.hostname.endsWith("codepen.io")) { + return undefined; + } + const [user, , hash] = url.pathname.replace(/^\//, "").split("/"); + if (!user || !hash) { + return undefined; + } + return `https://codepen.io/${user}/embed/${hash}?height=370&theme-id=dark&default-tab=result`; + } catch { + return undefined; + } +} + +const src = embedUrl(stegaClean(node.url)); +--- + +{ + src && ( +
+