Skip to content

Migrate site to Astro + Tailwind v4 (1-to-1)#1

Closed
youhaowei wants to merge 1 commit into
masterfrom
claude/recursing-easley-58e37d
Closed

Migrate site to Astro + Tailwind v4 (1-to-1)#1
youhaowei wants to merge 1 commit into
masterfrom
claude/recursing-easley-58e37d

Conversation

@youhaowei

Copy link
Copy Markdown
Owner

Clean migration of the Tucson International Friends site from Next.js 15 (Pages Router, Bootstrap 4.6, react-bootstrap, ReactN, SCSS) to Astro 6 + Tailwind CSS v4, preserving all visual design and UX. No enhancements — a faithful 1-to-1 port. Enhancements come in follow-up PRs.

Stack

Concern Before After
Framework Next.js 15 Pages Router Astro 6 (src/pages/*.astro)
CSS Bootstrap 4.6 + SCSS + unused Tailwind v3 Tailwind v4 (@tailwindcss/vite, CSS-first @theme)
Interactivity react-bootstrap, ReactN Svelte 5 island for Signup modal only; everything else CSS-only
Deploy Next.js on Vercel @astrojs/vercel; static output + SSR on the 2 Notion pages

Dropped entirely: react, react-dom, react-bootstrap, bootstrap, reactn, reactn-devtools, classnames, moment, lodash, next, sass.

Rendering model

  • output: 'static' — 6 pages prerender to HTML at build.
  • about + newsletters use export const prerender = false (live Notion fetch via NOTION_API_KEY), bundled as a Vercel serverless function.

Interactivity — near-zero JS

  • FAQ → native <details>/<summary>
  • Guidelines tabs → CSS radio inputs
  • Navbar mobile collapse → CSS checkbox
  • Signup modal → the only JS island (Svelte 5, client:idle)

Preserved 1-to-1

  • All 8 pages, section components, brand palette, Open Sans / Poppins typography, and the original custom breakpoints (sm 460 / md 850 / lg 1024 / xl 1440)
  • GTM (GTM-WM8XF8R) + GA (UA-106394359-1) tags, vercel.json redirects
  • All external embeds: Panopto, Spotify, Google Drive, Donorbox, Google Forms

Verification

Each page diffed against the live Next.js site via per-page pixel comparison at desktop (1440) and mobile (375). Two bugs caught and fixed during verification:

  • A Tailwind v4 utility-name collision (invertfilter: invert()) that tinted the colored panels — renamed the colliding semantic classes.
  • A mobile margin-collapse in the image/text split layout — switched to relative top offset so text stacks below the image.

about + newsletters are verified structurally locally (Notion key is Vercel-only); their live data renders on deploy.

Note

NOTION_API_KEY must be set in the Vercel project for the about + newsletters pages. The 6 static pages need no env vars.

🤖 Generated with Claude Code

Faithful 1-to-1 migration of the Tucson International Friends site from
Next.js 15 (Pages Router, Bootstrap 4.6, react-bootstrap, ReactN, SCSS)
to Astro 6 + Tailwind CSS v4, preserving all visual design and UX.

Stack:
- Astro 6, file-based routing in src/pages/*.astro
- Tailwind v4 via @tailwindcss/vite (CSS-first @theme; no Bootstrap)
- Svelte 5 island for the Signup modal only; all other interactivity
  is CSS-only (FAQ = <details>, guidelines tabs = radio, navbar = checkbox)
- @astrojs/vercel adapter; output: static with about + newsletters as
  SSR (prerender = false) for live Notion data

Preserved 1-1:
- All 8 pages, section components, brand palette, Open Sans/Poppins type,
  and the original custom breakpoints (sm 460 / md 850 / lg 1024 / xl 1440)
- GTM + GA tags, vercel.json redirects, all external embeds
  (Panopto, Spotify, Google Drive, Donorbox, Google Forms)

Verified against the live Next.js site via per-page pixel diffs at
desktop (1440) and mobile (375).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Modal-based signup dialog for student and host applications.
    • Redesigned newsletter page with featured latest newsletter and archive sections.
  • Refactor

    • Site-wide visual redesign with updated styling and typography.
    • Restructured navigation and footer layouts.
    • Updated page content organization and layout components across all pages.

Walkthrough

This PR completes a comprehensive migration from Next.js + React + Bootstrap to Astro + Svelte + Tailwind CSS v4. The codebase shifts from server-side React rendering to file-based static Astro pages with selective SSR, replaces SCSS with Tailwind v4's theme system, and rebuilds all UI components using Astro and Svelte.

Changes

Framework and Build System Migration

Layer / File(s) Summary
Astro build and framework setup
astro.config.mjs, package.json, tsconfig.json
New Astro configuration with Vercel adapter, Svelte integration, and Tailwind Vite plugin; package.json updated to v2.0.0 with Astro, Svelte, Tailwind v4, and @notionhq/client; TypeScript config switched to astro/tsconfigs/strict.
Remove Next.js build artifacts
components/Action/*, pages/_app.jsx, pages/index.jsx
Deleted Next.js-specific files: next.config.js, next-env.d.ts, and all legacy React page entry points.
Project documentation and ignore rules
CLAUDE.md, .gitignore, .env.example
Updated CLAUDE.md with Astro commands and architecture; .gitignore changed to Astro/Vercel outputs; added NOTION_API_KEY requirement for SSR pages.

Styling System Migration

Layer / File(s) Summary
Tailwind v4 global stylesheet with theme and utilities
src/styles/global.css
New Tailwind v4 stylesheet with @theme block defining brand colors, typography, custom breakpoints, elevation shadows, and @layer base global styles for html/body/headings; includes @utility container-if for responsive containers.
Remove legacy SCSS infrastructure and component styles
styles/*, styles/global/*
Deleted all SCSS files: variable definitions, functions, mixins, and component-specific stylesheets (banner, button, footer, navbar, FAQ, textBlock, imageTextBlock, etc.).

Component Library Rebuild

Layer / File(s) Summary
Simple reusable UI components
src/components/Button.astro, src/components/Header.astro, src/components/Logo.astro, src/components/TextButton.astro, src/components/Testimonial.astro
New Astro components with minimal logic offering button variants (primary/important/outline-light/dark with size support), heading rendering, logo sizing, text-styled buttons, and blockquote testimonials.
Content display and layout components
src/components/Banner.astro, src/components/Contact.astro, src/components/Footer.astro, src/components/Navbar.astro
Astro components for banner with optional image overlay, contact cards with avatar fallback, footer with configurable menu items and social links, and responsive navbar with mobile toggle and signup island.
Complex content and action components with markdown and business logic
src/components/TextBlock.astro, src/components/ImageTextBlock.astro, src/components/FAQ.astro, src/components/Actions.astro
TextBlock renders markdown content with optional title alignment override and conditional actions; ImageTextBlock provides full-bleed split layout with themed backdrop and media queries; FAQ uses native <details> for zero-JS collapsible Q&A; Actions polymorphically renders signup, donate, readmore, and text-variant buttons.
Svelte client-side interactive component
src/components/Signup.svelte
Svelte component rendering a trigger button that opens a modal for Student/Host form selection; modal can be dismissed via backdrop click or close button.
Layout wrapper and utility helpers
src/layout/MainLayout.astro, src/lib/markdown.js, src/lib/page.js
MainLayout provides HTML frame with navbar, footer, GTM/GA scripts, and JSON-driven navigation item resolution; renderMarkdown exports marked-based HTML conversion with gfm/breaks disabled; getPageByRoute updated to accept route parameter.

Pages Migration

Layer / File(s) Summary
Static informational pages
src/pages/index.astro, src/pages/join.astro, src/pages/activities.astro, src/pages/by-laws.astro
Astro pages with inline content data and component composition: index displays homepage with video embed and featured sections; join shows application flow with side-by-side host/student instructions, FAQ, and signup CTA; activities renders activity blocks; by-laws embeds Google Drive preview.
Server-side rendered pages with Notion data
src/pages/about.astro, src/pages/newsletters.astro
Pages with prerender: false and runtime Notion database queries: about fetches board members, filters into current/previous lists, and renders with testimonials; newsletters queries PDF database, displays latest in iframe, and renders past entries as archive links.
Converted React pages to Astro
src/pages/guidelines.astro, src/pages/letter.astro
Pages converted from React component + getStaticProps to Astro inline data: guidelines replaces React stateful tabs with CSS-only radio/label pills and set:html={renderMarkdown(...)} for host/student content; letter inlines header/content strings and renders markdown inline.

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: migrating the site from Next.js to Astro with Tailwind v4. It is directly related to the primary objective of the changeset.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, providing context on the framework migration, stack changes, rendering model, and verification approach.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@package.json`:
- Around line 10-15: Update package.json to run "astro check" before the
production "build" script and add the `@astrojs/check` package to devDependencies:
modify the "scripts" object so "build" first runs "astro check" (e.g., chain or
a prebuild script) and ensure `@astrojs/check` is listed under "devDependencies"
so TypeScript/ASTRO type errors in .ts/.tsx/.astro files are caught before
running "astro build".

In `@src/components/Actions.astro`:
- Around line 32-40: The donate action in Actions.astro currently injects the
Donorbox installer script (src="https://donorbox.org/install-popup-button.js")
inside the action render when action.type === "donate", causing duplicate loads;
remove the <script ... src="https://donorbox.org/install-popup-button.js" />
from the donate branch and keep only the button markup here, and instead ensure
the Donorbox installer is loaded a single time in the shared layout or
Navbar.astro (where it already appears) so the popup script is initialized once
for the whole app.

In `@src/components/Button.astro`:
- Line 54: The Button component renders a plain <button> element without an
explicit type, which causes it to default to submit inside forms; update the
button element in src/components/Button.astro (the element using id and cls
props) to include an explicit type attribute (e.g., type="button" or
type="{type}" if you add a prop) so its behavior is deterministic—either
hardcode type="button" for non-submit buttons or add a prop (e.g., type) to the
component and pass that into the button element.
- Line 49: When rendering the anchor in Button.astro (<a id={id} href={href}
target={target} rel={rel} class={cls}>), ensure that when target === "_blank"
you add safe rel tokens "noopener noreferrer" to prevent tabnabbing: compute a
normalized rel value that merges existing rel (if any) with "noopener" and
"noreferrer" without duplicating tokens and fall back to those tokens when rel
is undefined; then pass that computed rel into the element instead of the raw
rel prop.

In `@src/components/Contact.astro`:
- Around line 11-14: The current use of nullish coalescing (??) keeps empty
strings for displayName and src; update the logic so blank strings are treated
as missing by checking for non-empty trimmed values instead (e.g., use truthy
checks on name/profileUrl after trimming). Replace the displayName expression
(reference: displayName, name, firstName, lastName) to prefer a non-empty
trimmed name, otherwise build from trimmed firstName/lastName fallbacks;
similarly update src (reference: src, profileUrl) to use a non-empty trimmed
profileUrl or fall back to the generated ui-avatars URL using
encodeURIComponent(displayName). Ensure you trim inputs before checking and when
building the fallback to avoid producing empty titles or broken avatar URLs.

In `@src/components/Logo.astro`:
- Line 16: The alt attribute for the logo image in the Logo.astro component is
too generic; update the <img> alt to a descriptive brand string (e.g., the
company or app name) or set it to an empty string ("") if the image is purely
decorative. Locate the logo <img> in Logo.astro and replace alt="logo" with the
appropriate descriptive alt text or alt="" according to whether the logo conveys
meaningful information.

In `@src/components/Navbar.astro`:
- Around line 15-16: The isActive helper marks sibling paths as active because
it uses startsWith; update isActive(route: string) to return true only when
current === route or when current starts with route + '/' (and keep the
special-case for route === '/' to compare equality), so that paths like '/about'
match only '/about' or '/about/...' but not '/about-us'; locate and modify the
isActive arrow function and its uses accordingly.
- Line 24: The anchor element with class "brand mr-0 flex-shrink-0" uses the
Tailwind v3 utility name; update that class to the Tailwind v4 equivalent by
replacing "flex-shrink-0" with "shrink-0" on the <a class="brand mr-0 ...">
element so the logo does not shrink on narrow widths.

In `@src/components/Signup.svelte`:
- Around line 31-75: The modal lacks keyboard dismissal and focus management:
when `open` becomes true, add logic to focus the first tabbable element inside
the dialog (e.g., the "Student" button) and save the previously focused element;
add a keydown handler on the dialog or document to close the modal on Escape
(set `open = false`) and to trap focus with Tab / Shift+Tab so focus cannot
leave the dialog; ensure the keydown listener and any focus-trap are cleaned up
and the previously-focused element is restored when `open` becomes false; update
handlers around the dialog (where `open`, `openStudent`, `openHost`, and the
close button are referenced) to integrate these lifecycle behaviors and removal
of listeners on close.
- Around line 14-20: openStudent and openHost call
window.open(STUDENT_FORM/HOST_FORM, "_blank") without preventing
reverse-tabnabbing; update these functions to open the URL
withnoopener/noreferrer protection and/or nullify the opener. Specifically, call
window.open with a feature string that includes "noopener,noreferrer" and then,
after opening, if the returned window object exists set its opener to null
(i.e., capture the returned window from window.open and set win.opener = null)
to ensure window.opener is not exposed.

In `@src/components/TextButton.astro`:
- Around line 6-7: The TextButton component defines href as optional but always
renders an <a>, causing a non-functional control when href is missing; either
make href required in the props (remove the optional mark on href) or change the
render logic in TextButton to conditionally render an anchor when href is
present and a real <button type="button"> fallback when it is not (ensure you
forward label, class, aria attributes and event handlers to the <button> so
keyboard and accessibility behavior are correct).

In `@src/lib/markdown.js`:
- Around line 8-13: renderMarkdown currently returns HTML from marked.parse
without sanitization; if future inputs may be untrusted you must harden it.
Either 1) add an HTML-sanitization step inside src/lib/markdown.js: after
marked.parse in renderMarkdown, run the output through a vetted sanitizer (e.g.,
DOMPurify or sanitize-html) and return the sanitized string (add the dependency
and import), or 2) if you intend to keep markdown trusted, add a clear
top-of-file comment documenting that all inputs to renderMarkdown are trusted
and must remain so before being passed to set:html (references: renderMarkdown,
marked.setOptions, and the Astro components using set:html such as
TextBlock.astro/ImageTextBlock.astro/FAQ.astro/letter.astro/guidelines.astro).

In `@src/pages/about.astro`:
- Around line 31-42: The mapping over notionRes.results in members currently
assumes properties exist and will throw if Name, Title, or Profile Picture are
missing; change the logic in the members = notionRes.results.map(...) block to
validate each row before returning: check that page.properties exists, that
member.Name.title is an array with at least one element (guarding
member.Name.title[0].plain_text), that member.Title.select exists and has a
name, and safely access member["Profile Picture"].files[0]?.file?.url; for rows
that fail these checks skip them (e.g., filter out invalid pages or return null
and compact) so a single bad Notion row doesn’t cause the whole page to fail and
the remaining valid members still render.
- Around line 24-30: The Notion query is only using a single
notion.databases.query call (with memberDatabaseId) and mapping
notionRes.results into currentMembers/previousMembers/BoardList, which drops
items when responses are paginated; fix by implementing pagination: call
notion.databases.query repeatedly using the response's has_more and next_cursor,
passing start_cursor on subsequent calls, accumulate all results into a single
array (e.g., allResults) before splitting into currentMembers/previousMembers
and rendering BoardList, and ensure you reference notionRes.has_more /
notionRes.next_cursor (or the variable names you use) in the loop to fully
retrieve all pages.

In `@src/pages/guidelines.astro`:
- Around line 119-129: The radio inputs with id="gl-host" and id="gl-student"
are using class="hidden" which applies display:none and removes them from
keyboard focus; replace that with a visually-hidden CSS utility (e.g.,
.visually-hidden or .sr-only) that hides visually but keeps elements focusable,
then update the inputs to use that class (input#gl-host, input#gl-student) and
add the visually-hidden CSS rule (position:absolute; width:1px; height:1px;
padding:0; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap;
border:0) so the labels (.gl-pill) still toggle the radios via their for
attributes and keyboard users can tab to and activate the controls.

In `@src/pages/join.astro`:
- Around line 28-31: In src/pages/join.astro update the visible copy string:
remove the duplicated word so “Student Application button application” becomes
“Student Application button”, remove the leading space before the FAQ question
(trim whitespace), and normalize the inconsistent heading text “An international
friends” across both occurrences (choose and apply a consistent
capitalization/grammar, e.g., “An International Friend” or “An international
friend”) in the content block and the related FAQ entries referenced around
lines 66-99 so both headings match exactly.

In `@src/pages/newsletters.astro`:
- Around line 21-30: The mapped newsLetters may include entries with missing PDF
paths (from properties.PDF.files?.[0]?.file.url); filter the collection returned
by the map to remove items missing a valid path (and optionally a title) before
computing latest and past. Update the logic around newsLetters, latest and past
so you derive latest = filteredNewsLetters[0] and past =
filteredNewsLetters.slice(1), and handle the empty filtered array case (e.g.,
skip rendering CTA/iframe when latest is undefined). Use the existing symbols
newsLetters, latest, past and the mapping that reads
properties.PDF.files?.[0]?.file.url to locate where to add the filter.
- Around line 17-20: The current call to notion.databases.query (using
newsletterDatabaseId) only fetches the first page (notionRes) and truncates
older newsletters; update the code to paginate until all pages are collected by
looping on has_more/next_cursor or by using the client helpers
iteratePaginatedAPI / collectPaginatedAPI to accumulate results into a single
array before sorting/processing; ensure you reference notion.databases.query,
newsletterDatabaseId, and replace uses of notionRes with the aggregated results.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6e6ac3a0-1b3e-4062-91d4-a8ddb17a43d7

📥 Commits

Reviewing files that changed from the base of the PR and between c2bbacb and 0a8214c.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (95)
  • .env.example
  • .eslintrc
  • .gitignore
  • CLAUDE.md
  • astro.config.mjs
  • components/Action/donate.jsx
  • components/Action/index.jsx
  • components/Action/signup.jsx
  • components/Banner/index.jsx
  • components/BoardList/contact.jsx
  • components/BoardList/index.jsx
  • components/Breadcrumb/index.jsx
  • components/Button/facebook.jsx
  • components/Button/github.jsx
  • components/Button/index.jsx
  • components/Button/instagram.jsx
  • components/Button/textButton.jsx
  • components/Events/List.jsx
  • components/Events/index.jsx
  • components/FAQ/FAQ.jsx
  • components/FAQ/List.jsx
  • components/FAQ/index.jsx
  • components/Footer/index.jsx
  • components/Header/index.jsx
  • components/ImageTextBlock/index.jsx
  • components/Navbar/index.jsx
  • components/Testimonial/index.jsx
  • components/TextBlock/index.jsx
  • components/carosal.jsx
  • components/logo.jsx
  • components/meta.jsx
  • hooks/useWindowSize.js
  • layout/main.jsx
  • lib/common/format.jsx
  • next-env.d.ts
  • next.config.js
  • package.json
  • pages/_app.jsx
  • pages/about.jsx
  • pages/activities.jsx
  • pages/by-laws.jsx
  • pages/index.jsx
  • pages/join.jsx
  • pages/newsletters.jsx
  • src/components/Actions.astro
  • src/components/Banner.astro
  • src/components/BoardList.astro
  • src/components/Button.astro
  • src/components/Contact.astro
  • src/components/FAQ.astro
  • src/components/Footer.astro
  • src/components/Header.astro
  • src/components/ImageTextBlock.astro
  • src/components/Logo.astro
  • src/components/Navbar.astro
  • src/components/Signup.svelte
  • src/components/Testimonial.astro
  • src/components/TextBlock.astro
  • src/components/TextButton.astro
  • src/data/footerMenuItems.json
  • src/data/navbarItems.json
  • src/data/pages.json
  • src/layout/MainLayout.astro
  • src/lib/image.js
  • src/lib/markdown.js
  • src/lib/notion.js
  • src/lib/page.js
  • src/pages/about.astro
  • src/pages/activities.astro
  • src/pages/by-laws.astro
  • src/pages/guidelines.astro
  • src/pages/index.astro
  • src/pages/join.astro
  • src/pages/letter.astro
  • src/pages/newsletters.astro
  • src/styles/global.css
  • styles/_functions.scss
  • styles/_global.scss
  • styles/_mixins.scss
  • styles/_variables.scss
  • styles/banner.scss
  • styles/boardList.scss
  • styles/breadcrumb.scss
  • styles/button.scss
  • styles/event.scss
  • styles/faq.scss
  • styles/footer.scss
  • styles/global.css
  • styles/global/main.scss
  • styles/imageTextBlock.scss
  • styles/navbar.scss
  • styles/testimonial.scss
  • styles/textBlock.scss
  • tailwind.config.js
  • tsconfig.json
💤 Files with no reviewable changes (57)
  • components/Button/instagram.jsx
  • pages/newsletters.jsx
  • components/TextBlock/index.jsx
  • hooks/useWindowSize.js
  • components/FAQ/index.jsx
  • components/Navbar/index.jsx
  • components/Events/List.jsx
  • styles/testimonial.scss
  • components/ImageTextBlock/index.jsx
  • layout/main.jsx
  • styles/faq.scss
  • components/Button/index.jsx
  • styles/footer.scss
  • pages/about.jsx
  • styles/breadcrumb.scss
  • components/Button/textButton.jsx
  • components/Banner/index.jsx
  • components/logo.jsx
  • components/FAQ/FAQ.jsx
  • pages/by-laws.jsx
  • lib/common/format.jsx
  • styles/_global.scss
  • components/FAQ/List.jsx
  • styles/textBlock.scss
  • components/BoardList/index.jsx
  • styles/button.scss
  • components/Header/index.jsx
  • styles/_variables.scss
  • components/Action/donate.jsx
  • components/Button/github.jsx
  • components/BoardList/contact.jsx
  • components/Breadcrumb/index.jsx
  • styles/banner.scss
  • components/Events/index.jsx
  • components/Footer/index.jsx
  • styles/_functions.scss
  • components/Action/signup.jsx
  • src/lib/image.js
  • .eslintrc
  • components/Button/facebook.jsx
  • styles/global/main.scss
  • pages/activities.jsx
  • components/Testimonial/index.jsx
  • pages/_app.jsx
  • next.config.js
  • next-env.d.ts
  • styles/navbar.scss
  • components/Action/index.jsx
  • components/meta.jsx
  • styles/global.css
  • styles/imageTextBlock.scss
  • styles/boardList.scss
  • styles/_mixins.scss
  • tailwind.config.js
  • styles/event.scss
  • pages/join.jsx
  • pages/index.jsx

Comment thread package.json
Comment on lines +10 to +15
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python - <<'PY'
import json
with open("package.json") as f:
    pkg = json.load(f)
print("scripts =", pkg.get("scripts", {}))
PY

echo
echo "Existing 'astro check' invocations in the repo:"
rg -n --hidden --glob '!node_modules' --glob '!dist' '\bastro check\b' || true

Repository: youhaowei/ifriends-next

Length of output: 317


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "package.json: scripts + check-related deps"
python - <<'PY'
import json
with open("package.json") as f: pkg=json.load(f)
print("scripts =", pkg.get("scripts", {}))
deps = {}
for k in ["dependencies","devDependencies","peerDependencies"]:
    if isinstance(pkg.get(k), dict):
        deps.update(pkg[k])
for name in ["`@astrojs/check`","astro","typescript"]:
    if name in deps:
        print(f"{name} = {deps[name]}")
PY

echo
echo "Repo-wide occurrences of astro check"
rg -n --hidden --glob '!node_modules' --glob '!dist' '\b(npx\s+)?astro\s+check\b' . || true

echo
echo "Repo-wide occurrences of `@astrojs/check`"
rg -n --hidden --glob '!node_modules' --glob '!dist' '`@astrojs/check`' . || true

Repository: youhaowei/ifriends-next

Length of output: 509


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "package.json: check-related deps (Astro check plugin)"
python - <<'PY'
import json
pkg=json.load(open("package.json"))
for section in ["dependencies","devDependencies","peerDependencies"]:
    d=pkg.get(section,{})
    for name in ["`@astrojs/check`","astro","typescript"]:
        if name in d:
            print(f"{section}.{name} =", d[name])
PY

echo
echo "CI/workflows invoking astro build/check"
rg -n --hidden --glob '!node_modules' --glob '!dist' 'astro\s+(build|check)\b' .github . || true

echo
echo "package.json full scripts block"
python - <<'PY'
import json
pkg=json.load(open("package.json"))
print(json.dumps(pkg.get("scripts",{}), indent=2, sort_keys=True))
PY

Repository: youhaowei/ifriends-next

Length of output: 633


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Top-level CI/workflow config candidates:"
ls -la .github .gitlab .circleci .azure-pipelines.yml Jenkinsfile 2>/dev/null || true
echo

echo "Search for workflow/CI yaml files:"
fd -t f -e yml -e yaml -e js -e ts . | head -n 200

echo
echo "Search for commands that run astro build/check across the repo:"
rg -n --hidden --glob '!node_modules' --glob '!dist' '\b(astro\s+(build|check)\b|npx\s+astro\s+check\b)' . || true

echo
echo "Search for `@astrojs/check` usage across the repo:"
rg -n --hidden --glob '!node_modules' --glob '!dist' '`@astrojs/check`' . || true

echo
echo "Is there an existing 'check' script in package.json?"
python - <<'PY'
import json
pkg=json.load(open("package.json"))
print("check script exists:", "check" in (pkg.get("scripts") or {}))
print("scripts keys:", list((pkg.get("scripts") or {}).keys()))
PY

Repository: youhaowei/ifriends-next

Length of output: 807


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Root files/directories (CI-related candidates):"
ls -la | sed -n '1,200p'

echo
echo "Search for CI/workflow YAML files (yml/yaml):"
fd -t f -e yml -e yaml . || true

echo
echo "Search for GitHub Actions/workflow files explicitly: "
fd -t f -e yml -e yaml .github || true

echo
echo "Search for common CI keywords in repository files:"
rg -n --hidden --glob '!node_modules' --glob '!dist' 'github\.com/actions|github\.actions|workflow|ci|build-and-test|azure-pipelines|circleci|jenkins' . || true

echo
echo "Search for npx/astro check usage outside CLAUDE.md:"
rg -n --hidden --glob '!node_modules' --glob '!dist' '\bnpx\s+astro\s+check\b|\bastro\s+check\b' . --glob '!CLAUDE.md' || true

Repository: youhaowei/ifriends-next

Length of output: 26854


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python - <<'PY'
import json
pkg=json.load(open("package.json"))
for section in ["dependencies","devDependencies"]:
    print("\n",section)
    d=pkg.get(section,{})
    for k in sorted(d.keys()):
        if "check" in k.lower() or k in ["`@astrojs/check`","astro","typescript"]:
            print(f"  {k}: {d[k]}")
PY

Repository: youhaowei/ifriends-next

Length of output: 116


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "vercel.json:"
cat vercel.json

echo
echo "astro.config.mjs:"
cat astro.config.mjs

echo
echo "README build/deploy instructions (look for astro/build/check):"
rg -n "astro(\s+check)?|npm\s+run\s+build|vercel" README.md CLAUDE.md . || true

echo
echo "tsconfig.json (to see if strict types are expected):"
cat tsconfig.json

Repository: youhaowei/ifriends-next

Length of output: 9957


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "package.json (full):"
cat package.json

echo
echo "Is `@astrojs/check` installed (package-lock scan)?"
rg -n '`@astrojs/check`' package-lock.json || true

echo
echo "Does package-lock reference 'astro check' tooling files?"
rg -n 'astro check' package-lock.json || true

Repository: youhaowei/ifriends-next

Length of output: 928


🌐 Web query:

Astro v6 "astro build" type checking does it type check "astro check" requires @astrojs/check``

💡 Result:

Astro v6 astro build does NOT run type checking by default; type checking is done by astro check (and astro dev also won’t type-check). The official docs say astro start/astro build transpile with esbuild but will not run any type checking, and suggests using astro check && astro build if you want type errors to fail the build [1]. You generally do NOT need @astrojs/check for astro build. You DO need @astrojs/check (and typescript) for astro check to work: the CLI implementation loads @astrojs/check and errors out if it (or typescript) isn’t installed [2][3]. The @astrojs/check package description also states it powers the astro check CLI command [4]. So, in your exact wording: - “astro build type checking”: No, not by default in Astro v6 [1]. - “astro check requires @astrojs/check”: Yes, astro check depends on an external @astrojs/check package (and typescript) and instructs you to install them if missing [2][3][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Check whether 'typescript' and '`@astrojs/check`' exist in package-lock.json"
rg -n '"typescript"' package-lock.json || true
echo
rg -n '`@astrojs/check`' package-lock.json || true

Repository: youhaowei/ifriends-next

Length of output: 244


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n '`@astrojs/check`' package-lock.json || true

Repository: youhaowei/ifriends-next

Length of output: 49


Add astro check before production builds (and install @astrojs/check).

  • package.json currently runs only astro build, so type errors in *.ts/ts/tsx/astro won’t be caught via the build gate.
  • Repo has no existing astro check wiring; also @astrojs/check is missing from devDependencies, so astro check won’t work unless added.
Suggested change
   "scripts": {
     "dev": "astro dev",
-    "build": "astro build",
+    "check": "astro check",
+    "build": "astro check && astro build",
     "preview": "astro preview",
     "astro": "astro"
   },

Add @astrojs/check to devDependencies (TypeScript is already present).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"scripts": {
"dev": "astro dev",
"check": "astro check",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro"
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 10 - 15, Update package.json to run "astro check"
before the production "build" script and add the `@astrojs/check` package to
devDependencies: modify the "scripts" object so "build" first runs "astro check"
(e.g., chain or a prebuild script) and ensure `@astrojs/check` is listed under
"devDependencies" so TypeScript/ASTRO type errors in .ts/.tsx/.astro files are
caught before running "astro build".

Comment on lines +32 to +40
if (action.type === "donate") {
return (
<>
<script
is:inline
type="text/javascript"
defer
src="https://donorbox.org/install-popup-button.js"
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Load the Donorbox installer once, outside this action map.

Navbar.astro already ships the same installer. Injecting it again here means every donate action can reload and reinitialize the popup script. Keep the button markup here and hoist script loading to a single shared location.

Suggested direction
     if (action.type === "donate") {
       return (
-        <>
-          <script
-            is:inline
-            type="text/javascript"
-            defer
-            src="https://donorbox.org/install-popup-button.js"
-          />
-          <Button
-            href="https://donorbox.org/ongoing-support-for-international-friends"
-            class="dbox-donation-button"
-            size={action.size}
-            label="Support"
-          />
-        </>
+        <Button
+          href="https://donorbox.org/ongoing-support-for-international-friends"
+          class="dbox-donation-button"
+          size={action.size}
+          label="Support"
+        />
       );
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Actions.astro` around lines 32 - 40, The donate action in
Actions.astro currently injects the Donorbox installer script
(src="https://donorbox.org/install-popup-button.js") inside the action render
when action.type === "donate", causing duplicate loads; remove the <script ...
src="https://donorbox.org/install-popup-button.js" /> from the donate branch and
keep only the button markup here, and instead ensure the Donorbox installer is
loaded a single time in the shared layout or Navbar.astro (where it already
appears) so the popup script is initialized once for the whole app.


{
href ? (
<a id={id} href={href} target={target} rel={rel} class={cls}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden external-link behavior when target="_blank" is used.

Line 49 passes target/rel through as-is. If callers set target="_blank" and forget rel, this can open a tabnabbing path.

Suggested fix
 const cls = `${base} ${sizeClass} ${variants[variant]} shadow-elevation-1 ${className}`;
+const safeRel =
+  target === "_blank"
+    ? [rel, "noopener", "noreferrer"].filter(Boolean).join(" ")
+    : rel;
 ---
 
 {
   href ? (
-    <a id={id} href={href} target={target} rel={rel} class={cls}>
+    <a id={id} href={href} target={target} rel={safeRel} class={cls}>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<a id={id} href={href} target={target} rel={rel} class={cls}>
const cls = `${base} ${sizeClass} ${variants[variant]} shadow-elevation-1 ${className}`;
const safeRel =
target === "_blank"
? [rel, "noopener", "noreferrer"].filter(Boolean).join(" ")
: rel;
---
{
href ? (
<a id={id} href={href} target={target} rel={safeRel} class={cls}>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Button.astro` at line 49, When rendering the anchor in
Button.astro (<a id={id} href={href} target={target} rel={rel} class={cls}>),
ensure that when target === "_blank" you add safe rel tokens "noopener
noreferrer" to prevent tabnabbing: compute a normalized rel value that merges
existing rel (if any) with "noopener" and "noreferrer" without duplicating
tokens and fall back to those tokens when rel is undefined; then pass that
computed rel into the element instead of the raw rel prop.

<slot />
</a>
) : (
<button id={id} class={cls}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Set an explicit button type to avoid unintended form submission.

Line 54 renders <button> without type, so it defaults to submit inside forms.

Suggested fix
-    <button id={id} class={cls}>
+    <button id={id} type="button" class={cls}>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button id={id} class={cls}>
<button id={id} type="button" class={cls}>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Button.astro` at line 54, The Button component renders a plain
<button> element without an explicit type, which causes it to default to submit
inside forms; update the button element in src/components/Button.astro (the
element using id and cls props) to include an explicit type attribute (e.g.,
type="button" or type="{type}" if you add a prop) so its behavior is
deterministic—either hardcode type="button" for non-submit buttons or add a prop
(e.g., type) to the component and pass that into the button element.

Comment on lines +11 to +14
const displayName = name ?? `${firstName ?? ""} ${lastName ?? ""}`.trim();
const src =
profileUrl ??
`https://ui-avatars.com/api/?name=${encodeURIComponent(displayName)}&size=200`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Treat blank strings as missing values.

?? preserves "". If the migrated data contains empty name or profileUrl fields, this renders a blank card title or broken avatar instead of falling back.

Suggested fix
-const displayName = name ?? `${firstName ?? ""} ${lastName ?? ""}`.trim();
+const displayName =
+  name?.trim() || `${firstName ?? ""} ${lastName ?? ""}`.trim();
 const src =
-  profileUrl ??
+  profileUrl?.trim() ||
   `https://ui-avatars.com/api/?name=${encodeURIComponent(displayName)}&size=200`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Contact.astro` around lines 11 - 14, The current use of
nullish coalescing (??) keeps empty strings for displayName and src; update the
logic so blank strings are treated as missing by checking for non-empty trimmed
values instead (e.g., use truthy checks on name/profileUrl after trimming).
Replace the displayName expression (reference: displayName, name, firstName,
lastName) to prefer a non-empty trimmed name, otherwise build from trimmed
firstName/lastName fallbacks; similarly update src (reference: src, profileUrl)
to use a non-empty trimmed profileUrl or fall back to the generated ui-avatars
URL using encodeURIComponent(displayName). Ensure you trim inputs before
checking and when building the fallback to avoid producing empty titles or
broken avatar URLs.

Comment thread src/pages/about.astro
Comment on lines +31 to +42
members = notionRes.results.map((page: any) => {
const member = page.properties;
const name = member.Name.title[0].plain_text;
return {
name,
profileUrl:
member["Profile Picture"].files?.[0]?.file.url ??
`https://ui-avatars.com/api/?name=${name.replace(" ", "+")}&size=200`,
title: member.Title.select.name,
current: member.Current.checkbox,
};
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard incomplete Notion rows instead of failing the whole page.

A single entry with an empty Name, missing Title, or missing Profile Picture property will throw inside this map(), and the catch then leaves both board lists empty. Skip invalid rows and keep rendering the rest.

Suggested hardening
-  members = notionRes.results.map((page: any) => {
-    const member = page.properties;
-    const name = member.Name.title[0].plain_text;
-    return {
-      name,
-      profileUrl:
-        member["Profile Picture"].files?.[0]?.file.url ??
-        `https://ui-avatars.com/api/?name=${name.replace(" ", "+")}&size=200`,
-      title: member.Title.select.name,
-      current: member.Current.checkbox,
-    };
-  });
+  members = notionRes.results.flatMap((page: any) => {
+    const member = page.properties;
+    const name = member.Name?.title?.[0]?.plain_text?.trim();
+    const title = member.Title?.select?.name;
+
+    if (!name || !title) return [];
+
+    return [
+      {
+        name,
+        profileUrl:
+          member["Profile Picture"]?.files?.[0]?.file?.url ??
+          `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&size=200`,
+        title,
+        current: Boolean(member.Current?.checkbox),
+      },
+    ];
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
members = notionRes.results.map((page: any) => {
const member = page.properties;
const name = member.Name.title[0].plain_text;
return {
name,
profileUrl:
member["Profile Picture"].files?.[0]?.file.url ??
`https://ui-avatars.com/api/?name=${name.replace(" ", "+")}&size=200`,
title: member.Title.select.name,
current: member.Current.checkbox,
};
});
members = notionRes.results.flatMap((page: any) => {
const member = page.properties;
const name = member.Name?.title?.[0]?.plain_text?.trim();
const title = member.Title?.select?.name;
if (!name || !title) return [];
return [
{
name,
profileUrl:
member["Profile Picture"]?.files?.[0]?.file?.url ??
`https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&size=200`,
title,
current: Boolean(member.Current?.checkbox),
},
];
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/about.astro` around lines 31 - 42, The mapping over
notionRes.results in members currently assumes properties exist and will throw
if Name, Title, or Profile Picture are missing; change the logic in the members
= notionRes.results.map(...) block to validate each row before returning: check
that page.properties exists, that member.Name.title is an array with at least
one element (guarding member.Name.title[0].plain_text), that member.Title.select
exists and has a name, and safely access member["Profile
Picture"].files[0]?.file?.url; for rows that fail these checks skip them (e.g.,
filter out invalid pages or return null and compact) so a single bad Notion row
doesn’t cause the whole page to fail and the remaining valid members still
render.

Comment on lines +119 to +129
<input
type="radio"
name="gl"
id="gl-host"
class="hidden"
checked
/>
<input type="radio" name="gl" id="gl-student" class="hidden" />
<nav class="flex w-full mb-2">
<label for="gl-host" class="gl-pill">For Host</label>
<label for="gl-student" class="gl-pill">For Students</label>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the tab radios visually hidden, not display:none.

Using class="hidden" removes the controls from keyboard navigation, so keyboard users cannot switch between the Host and Student panels at all. Keep the radios focusable with a visually-hidden pattern instead.

Suggested fix
       <input
         type="radio"
         name="gl"
         id="gl-host"
-        class="hidden"
+        class="sr-only"
         checked
       />
-      <input type="radio" name="gl" id="gl-student" class="hidden" />
+      <input type="radio" name="gl" id="gl-student" class="sr-only" />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<input
type="radio"
name="gl"
id="gl-host"
class="hidden"
checked
/>
<input type="radio" name="gl" id="gl-student" class="hidden" />
<nav class="flex w-full mb-2">
<label for="gl-host" class="gl-pill">For Host</label>
<label for="gl-student" class="gl-pill">For Students</label>
<input
type="radio"
name="gl"
id="gl-host"
class="sr-only"
checked
/>
<input type="radio" name="gl" id="gl-student" class="sr-only" />
<nav class="flex w-full mb-2">
<label for="gl-host" class="gl-pill">For Host</label>
<label for="gl-student" class="gl-pill">For Students</label>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/guidelines.astro` around lines 119 - 129, The radio inputs with
id="gl-host" and id="gl-student" are using class="hidden" which applies
display:none and removes them from keyboard focus; replace that with a
visually-hidden CSS utility (e.g., .visually-hidden or .sr-only) that hides
visually but keeps elements focusable, then update the inputs to use that class
(input#gl-host, input#gl-student) and add the visually-hidden CSS rule
(position:absolute; width:1px; height:1px; padding:0; margin:-1px;
overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0) so the labels
(.gl-pill) still toggle the radios via their for attributes and keyboard users
can tab to and activate the controls.

Comment thread src/pages/join.astro
Comment on lines +28 to +31
content: `
* Read and review the IF Guidelines.
* Click on the “Student Application” button application.
* Complete and submit the application, answering every question. The more information you can provide about yourself, the easier it is to match you with a host having similar interests.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clean up the visible copy in the join flow.

There’s a duplicated word in “Student Application button application,” a leading space in one FAQ question, and inconsistent “An international friends” capitalization in two FAQ headings. These ship directly to users.

Also applies to: 66-99

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/join.astro` around lines 28 - 31, In src/pages/join.astro update
the visible copy string: remove the duplicated word so “Student Application
button application” becomes “Student Application button”, remove the leading
space before the FAQ question (trim whitespace), and normalize the inconsistent
heading text “An international friends” across both occurrences (choose and
apply a consistent capitalization/grammar, e.g., “An International Friend” or
“An international friend”) in the content block and the related FAQ entries
referenced around lines 66-99 so both headings match exactly.

Comment on lines +17 to +20
const notionRes = await notion.databases.query({
database_id: newsletterDatabaseId,
sorts: [{ property: "Year", direction: "descending" }],
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In the Notion API / @notionhq/client, does databases.query paginate results by default, and should callers loop on has_more and next_cursor to fetch the full archive from a database?

💡 Result:

Yes, the Notion API's databases.query endpoint paginates results by default [1][2]. Each request is limited to a maximum of 100 results per page [3][4]. To fetch the full archive from a database, you must handle pagination [3][4]. You can do this in two primary ways: 1. Manual Pagination: You can implement a loop that checks the has_more boolean and uses the next_cursor string to request the subsequent page of results [5][6]. You pass the next_cursor value into the start_cursor parameter of your next API call [7][4]. 2. SDK Helpers: If you are using the official @notionhq/client library, it provides built-in pagination utilities that handle this logic for you, which is generally recommended for simplicity and reliability [5][8]. - iteratePaginatedAPI: Returns an async iterator, which is ideal for streaming large datasets while maintaining memory efficiency [5][8]. - collectPaginatedAPI: Automatically fetches all pages and aggregates them into a single array (ensure your dataset is small enough to fit in memory before using this) [5][8]. Note that as of the 2026-04-01 API version, pagination cursors are opaque base64-encoded strings [9]. It is critical that your code correctly distinguishes between a legitimate "no more pages" response (where has_more is false and next_cursor is null) and a potential error condition or cursor invalidation [9]. Never treat a 400 error from an invalid cursor as a signal to stop fetching data, as this will result in a silently truncated archive [9].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/pages/newsletters.astro"
ls -la "$FILE" || true
echo "----"
# show around the referenced lines
sed -n '1,120p' "$FILE" | cat -n
echo "----"
# specifically show lines 10-60 with numbers
sed -n '10,60p' "$FILE" | cat -n
echo "----"
# search for pagination-related identifiers
rg -n "has_more|next_cursor|start_cursor|iteratePaginatedAPI|collectPaginatedAPI|pagination" "$FILE" || true

Repository: youhaowei/ifriends-next

Length of output: 4456


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/lib/notion.js"
ls -la "$FILE" || true
echo "----"
cat -n "$FILE"

Repository: youhaowei/ifriends-next

Length of output: 306


Paginate notion.databases.query so the newsletter archive doesn’t truncate

File: src/pages/newsletters.astro (lines 17-20)

const notionRes = await notion.databases.query({
  database_id: newsletterDatabaseId,
  sorts: [{ property: "Year", direction: "descending" }],
});

notion.databases.query returns results page-by-page (max 100 per request). This code fetches only the first page, so older newsletters disappear once the database exceeds one page. Loop on has_more/next_cursor (or use @notionhq/client pagination helpers like iteratePaginatedAPI/collectPaginatedAPI) to build the full archive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/newsletters.astro` around lines 17 - 20, The current call to
notion.databases.query (using newsletterDatabaseId) only fetches the first page
(notionRes) and truncates older newsletters; update the code to paginate until
all pages are collected by looping on has_more/next_cursor or by using the
client helpers iteratePaginatedAPI / collectPaginatedAPI to accumulate results
into a single array before sorting/processing; ensure you reference
notion.databases.query, newsletterDatabaseId, and replace uses of notionRes with
the aggregated results.

Comment on lines +21 to +30
newsLetters = notionRes.results.map(({ properties }: any) => ({
title: properties.Name.title[0].plain_text,
path: properties.PDF.files?.[0]?.file.url,
}));
} catch (e) {
console.error("Notion newsletter fetch failed:", e);
}

const latest = newsLetters[0];
const past = newsLetters.slice(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter out incomplete newsletter rows before choosing latest.

properties.PDF.files?.[0]?.file.url can be missing, but the result is still treated as { path: string } and immediately used for the CTA link and iframe. One draft or malformed row at the top of the database gives you a broken “Latest Newsletter” section.

Suggested hardening
-  newsLetters = notionRes.results.map(({ properties }: any) => ({
-    title: properties.Name.title[0].plain_text,
-    path: properties.PDF.files?.[0]?.file.url,
-  }));
+  newsLetters = notionRes.results.flatMap(({ properties }: any) => {
+    const title = properties.Name?.title?.[0]?.plain_text?.trim();
+    const path =
+      properties.PDF?.files?.[0]?.file?.url ??
+      properties.PDF?.files?.[0]?.external?.url;
+
+    if (!title || !path) return [];
+
+    return [{ title, path }];
+  });

Also applies to: 36-54

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/newsletters.astro` around lines 21 - 30, The mapped newsLetters may
include entries with missing PDF paths (from
properties.PDF.files?.[0]?.file.url); filter the collection returned by the map
to remove items missing a valid path (and optionally a title) before computing
latest and past. Update the logic around newsLetters, latest and past so you
derive latest = filteredNewsLetters[0] and past = filteredNewsLetters.slice(1),
and handle the empty filtered array case (e.g., skip rendering CTA/iframe when
latest is undefined). Use the existing symbols newsLetters, latest, past and the
mapping that reads properties.PDF.files?.[0]?.file.url to locate where to add
the filter.

@youhaowei youhaowei closed this Jun 5, 2026
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