diff --git a/.env.example b/.env.example index 6777e937..f0927629 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# Site URL (used for sitemap generation and absolute URLs) +NEXT_PUBLIC_SITE_URL=https://teachlink.app + # Starknet Configuration NEXT_PUBLIC_STARKNET_NETWORK=goerli-alpha # NEXT_PUBLIC_STARKNET_RPC_URL=https://your-rpc-endpoint.com diff --git a/package.json b/package.json index 46972eb6..73f39241 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "check-i18n": "node scripts/check-i18n.cjs", "check-locales": "node scripts/check-locales.mjs", "prebuild": "npm run check-locales && npm run check-i18n", - "validate": "npm run validate:ui && npm run validate:web3" + "validate": "npm run validate:ui && npm run validate:web3", + "generate:sitemap": "npx tsx scripts/generate-sitemap.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -39,10 +40,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", -<<<<<<< HEAD -======= "dompurify": "^3.2.4", ->>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "framer-motion": "^12.23.0", "idb": "^8.0.0", "lucide-react": "^0.462.0", @@ -57,20 +55,13 @@ "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-intersection-observer": "^10.0.3", -<<<<<<< HEAD -======= "react-virtualized-auto-sizer": "^1.0.7", "react-window": "^1.8.9", ->>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "recharts": "^2.15.4", "socket.io-client": "^4.8.3", "tailwind-merge": "^2.6.0", "web-vitals": "^4.2.4", "workbox-webpack-plugin": "^7.0.0", -<<<<<<< HEAD - "dompurify": "^3.2.4", -======= ->>>>>>> bc54ed8 (clean: remove node_modules and reset repo) "zod": "^3.25.75", "zustand": "^5.0.10" }, diff --git a/scripts/generate-sitemap.ts b/scripts/generate-sitemap.ts new file mode 100644 index 00000000..c93a7069 --- /dev/null +++ b/scripts/generate-sitemap.ts @@ -0,0 +1,94 @@ +/** + * Standalone sitemap generator — writes public/sitemap.xml at build time. + * Run with: npx tsx scripts/generate-sitemap.ts + */ +import { writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://teachlink.app'; + +interface SitemapEntry { + url: string; + lastModified?: Date; + changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'; + priority?: number; +} + +const STATIC_ROUTES: SitemapEntry[] = [ + { url: BASE_URL, changeFrequency: 'daily', priority: 1.0 }, + { url: `${BASE_URL}/search`, changeFrequency: 'weekly', priority: 0.8 }, + { url: `${BASE_URL}/study-groups`, changeFrequency: 'weekly', priority: 0.7 }, +]; + +async function fetchAllCourseIds(): Promise { + const ids: string[] = []; + let cursor: string | undefined; + + try { + do { + const url = new URL(`${BASE_URL}/api/courses`); + url.searchParams.set('limit', '100'); + if (cursor) url.searchParams.set('cursor', cursor); + + const res = await fetch(url.toString()); + if (!res.ok) break; + + const json = await res.json(); + const page: { id: string }[] = Array.isArray(json) ? json : (json.data ?? []); + ids.push(...page.map((c) => c.id)); + cursor = json.nextCursor; + } while (cursor); + } catch { + console.warn('Could not fetch courses — only static routes will be included.'); + } + + return ids; +} + +function toXml(entries: SitemapEntry[]): string { + const now = new Date().toISOString().split('T')[0]; + + const urls = entries + .map( + (entry) => ` + + ${entry.url} + ${entry.lastModified ? entry.lastModified.toISOString().split('T')[0] : now} + ${entry.changeFrequency ? `${entry.changeFrequency}` : ''} + ${entry.priority !== undefined ? `${entry.priority.toFixed(1)}` : ''} + `, + ) + .join(''); + + return ` + +${urls} +`; +} + +async function main() { + const courseIds = await fetchAllCourseIds(); + + const courseRoutes: SitemapEntry[] = courseIds.map((id) => ({ + url: `${BASE_URL}/courses/${id}`, + lastModified: new Date(), + changeFrequency: 'weekly', + priority: 0.8, + })); + + const allEntries = [...STATIC_ROUTES, ...courseRoutes]; + const xml = toXml(allEntries); + + const publicDir = join(process.cwd(), 'public'); + mkdirSync(publicDir, { recursive: true }); + + const outputPath = join(publicDir, 'sitemap.xml'); + writeFileSync(outputPath, xml, 'utf-8'); + + console.log(`Sitemap written to ${outputPath} — ${allEntries.length} URL(s) included.`); +} + +main().catch((err) => { + console.error('Sitemap generation failed:', err); + process.exit(1); +}); diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 00000000..17ca55a6 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,64 @@ +import type { MetadataRoute } from 'next'; +import type { Course, PaginatedResponse } from '@/types/api'; + +const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://teachlink.app'; + +export const revalidate = 3600; // regenerate every hour + +const STATIC_ROUTES: MetadataRoute.Sitemap = [ + { + url: BASE_URL, + lastModified: new Date(), + changeFrequency: 'daily', + priority: 1.0, + }, + { + url: `${BASE_URL}/search`, + lastModified: new Date(), + changeFrequency: 'weekly', + priority: 0.8, + }, + { + url: `${BASE_URL}/study-groups`, + lastModified: new Date(), + changeFrequency: 'weekly', + priority: 0.7, + }, +]; + +async function fetchAllCourses(): Promise { + const courses: Course[] = []; + let cursor: string | undefined; + + try { + do { + const url = new URL(`${BASE_URL}/api/courses`); + url.searchParams.set('limit', '100'); + if (cursor) url.searchParams.set('cursor', cursor); + + const res = await fetch(url.toString(), { next: { revalidate: 3600 } }); + if (!res.ok) break; + + const json: PaginatedResponse = await res.json(); + courses.push(...json.data); + cursor = json.nextCursor; + } while (cursor); + } catch { + // return whatever was collected before the failure + } + + return courses; +} + +export default async function sitemap(): Promise { + const courses = await fetchAllCourses(); + + const courseRoutes: MetadataRoute.Sitemap = courses.map((course) => ({ + url: `${BASE_URL}/courses/${course.id}`, + lastModified: new Date(), + changeFrequency: 'weekly', + priority: 0.8, + })); + + return [...STATIC_ROUTES, ...courseRoutes]; +}