Migrate site to Astro + Tailwind v4 (1-to-1)#1
Conversation
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>
📝 WalkthroughSummary by CodeRabbit
WalkthroughThis 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. ChangesFramework and Build System Migration
Styling System Migration
Component Library Rebuild
Pages Migration
🎯 4 (Complex) | ⏱️ ~60 minutes 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ 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. Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (95)
.env.example.eslintrc.gitignoreCLAUDE.mdastro.config.mjscomponents/Action/donate.jsxcomponents/Action/index.jsxcomponents/Action/signup.jsxcomponents/Banner/index.jsxcomponents/BoardList/contact.jsxcomponents/BoardList/index.jsxcomponents/Breadcrumb/index.jsxcomponents/Button/facebook.jsxcomponents/Button/github.jsxcomponents/Button/index.jsxcomponents/Button/instagram.jsxcomponents/Button/textButton.jsxcomponents/Events/List.jsxcomponents/Events/index.jsxcomponents/FAQ/FAQ.jsxcomponents/FAQ/List.jsxcomponents/FAQ/index.jsxcomponents/Footer/index.jsxcomponents/Header/index.jsxcomponents/ImageTextBlock/index.jsxcomponents/Navbar/index.jsxcomponents/Testimonial/index.jsxcomponents/TextBlock/index.jsxcomponents/carosal.jsxcomponents/logo.jsxcomponents/meta.jsxhooks/useWindowSize.jslayout/main.jsxlib/common/format.jsxnext-env.d.tsnext.config.jspackage.jsonpages/_app.jsxpages/about.jsxpages/activities.jsxpages/by-laws.jsxpages/index.jsxpages/join.jsxpages/newsletters.jsxsrc/components/Actions.astrosrc/components/Banner.astrosrc/components/BoardList.astrosrc/components/Button.astrosrc/components/Contact.astrosrc/components/FAQ.astrosrc/components/Footer.astrosrc/components/Header.astrosrc/components/ImageTextBlock.astrosrc/components/Logo.astrosrc/components/Navbar.astrosrc/components/Signup.sveltesrc/components/Testimonial.astrosrc/components/TextBlock.astrosrc/components/TextButton.astrosrc/data/footerMenuItems.jsonsrc/data/navbarItems.jsonsrc/data/pages.jsonsrc/layout/MainLayout.astrosrc/lib/image.jssrc/lib/markdown.jssrc/lib/notion.jssrc/lib/page.jssrc/pages/about.astrosrc/pages/activities.astrosrc/pages/by-laws.astrosrc/pages/guidelines.astrosrc/pages/index.astrosrc/pages/join.astrosrc/pages/letter.astrosrc/pages/newsletters.astrosrc/styles/global.cssstyles/_functions.scssstyles/_global.scssstyles/_mixins.scssstyles/_variables.scssstyles/banner.scssstyles/boardList.scssstyles/breadcrumb.scssstyles/button.scssstyles/event.scssstyles/faq.scssstyles/footer.scssstyles/global.cssstyles/global/main.scssstyles/imageTextBlock.scssstyles/navbar.scssstyles/testimonial.scssstyles/textBlock.scsstailwind.config.jstsconfig.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
| "scripts": { | ||
| "dev": "astro dev", | ||
| "build": "astro build", | ||
| "preview": "astro preview", | ||
| "astro": "astro" | ||
| }, |
There was a problem hiding this comment.
🧩 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' || trueRepository: 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`' . || trueRepository: 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))
PYRepository: 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()))
PYRepository: 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' || trueRepository: 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]}")
PYRepository: 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.jsonRepository: 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 || trueRepository: 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:
- 1: https://docs.astro.build/en/guides/typescript/
- 2: https://github.com/withastro/astro/blob/7d72793b/packages/astro/src/cli/check/index.ts
- 3: https://github.com/withastro/astro/blob/39ff2a56/packages/astro/src/cli/check/index.ts
- 4: https://registry.npmjs.org/@astrojs/check
🏁 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 || trueRepository: youhaowei/ifriends-next
Length of output: 244
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n '`@astrojs/check`' package-lock.json || trueRepository: youhaowei/ifriends-next
Length of output: 49
Add astro check before production builds (and install @astrojs/check).
package.jsoncurrently runs onlyastro build, so type errors in*.ts/ts/tsx/astrowon’t be caught via the build gate.- Repo has no existing
astro checkwiring; also@astrojs/checkis missing fromdevDependencies, soastro checkwon’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.
| "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".
| if (action.type === "donate") { | ||
| return ( | ||
| <> | ||
| <script | ||
| is:inline | ||
| type="text/javascript" | ||
| defer | ||
| src="https://donorbox.org/install-popup-button.js" | ||
| /> |
There was a problem hiding this comment.
🛠️ 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}> |
There was a problem hiding this comment.
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.
| <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}> |
There was a problem hiding this comment.
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.
| <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.
| const displayName = name ?? `${firstName ?? ""} ${lastName ?? ""}`.trim(); | ||
| const src = | ||
| profileUrl ?? | ||
| `https://ui-avatars.com/api/?name=${encodeURIComponent(displayName)}&size=200`; |
There was a problem hiding this comment.
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.
| 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, | ||
| }; | ||
| }); |
There was a problem hiding this comment.
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.
| 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.
| <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> |
There was a problem hiding this comment.
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.
| <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.
| 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. |
There was a problem hiding this comment.
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.
| const notionRes = await notion.databases.query({ | ||
| database_id: newsletterDatabaseId, | ||
| sorts: [{ property: "Year", direction: "descending" }], | ||
| }); |
There was a problem hiding this comment.
🧩 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:
- 1: https://developers.notion.com/reference/post-database-query.md
- 2: https://developers.notion.com/reference/post-database-query
- 3: https://developers.notion.com/guides/data-apis/working-with-page-content
- 4: https://thomasfrank.notion.site/Query-a-database-with-more-than-100-rows-Pagination-1de2e1a4314c462990967ee23da865ed
- 5: https://makenotion-notion-sdk-js.mintlify.app/guides/pagination
- 6: https://www.mintlify.com/makenotion/notion-sdk-js/api/data-sources/query
- 7: https://community.powerbi.com/t5/Power-Query/API-Pagination-using-has-more-amp-next-cursor-responses-in-an/td-p/1844620
- 8: https://www.npmjs.com/package/@notionhq/client
- 9: https://dev.to/flarecanary/notions-2026-04-01-api-changed-pagination-cursors-and-the-ratelimit-reset-format-heres-what-319p
🏁 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" || trueRepository: 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.
| 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); |
There was a problem hiding this comment.
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.
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
src/pages/*.astro)@tailwindcss/vite, CSS-first@theme)@astrojs/vercel; static output + SSR on the 2 Notion pagesDropped 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+newslettersuseexport const prerender = false(live Notion fetch viaNOTION_API_KEY), bundled as a Vercel serverless function.Interactivity — near-zero JS
<details>/<summary>client:idle)Preserved 1-to-1
GTM-WM8XF8R) + GA (UA-106394359-1) tags,vercel.jsonredirectsVerification
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:
invert→filter: invert()) that tinted the colored panels — renamed the colliding semantic classes.topoffset so text stacks below the image.about+newslettersare verified structurally locally (Notion key is Vercel-only); their live data renders on deploy.Note
NOTION_API_KEYmust be set in the Vercel project for the about + newsletters pages. The 6 static pages need no env vars.🤖 Generated with Claude Code