diff --git a/apps/api/app/contents/[locale]/exercises/[...slug]/route.ts b/apps/api/app/contents/[locale]/exercises/[...slug]/route.ts index 9e8a7c7d0..0c5340f9e 100644 --- a/apps/api/app/contents/[locale]/exercises/[...slug]/route.ts +++ b/apps/api/app/contents/[locale]/exercises/[...slug]/route.ts @@ -1,6 +1,6 @@ import { getMDXSlugsForLocale } from "@repo/contents/_lib/cache"; -import { getExercisesContent } from "@repo/contents/_lib/exercises"; import { getExerciseContent } from "@repo/contents/_lib/exercises/content"; +import { getExercisesContent } from "@repo/contents/_lib/exercises/set"; import { hasInvalidTryOutYearSlug, isTryOutCollectionSlug, @@ -11,8 +11,6 @@ import { getExerciseSetPaths, } from "@repo/contents/_lib/static-params"; import { - ChoicesValidationError, - ExerciseLoadError, FileReadError, GitHubFetchError, InvalidPathError, @@ -199,15 +197,9 @@ export async function GET( } ); - const statusCode = - error instanceof ExerciseLoadError || - error instanceof ChoicesValidationError - ? 500 - : 500; - return NextResponse.json( { error: "Failed to fetch exercises content." }, - { status: statusCode } + { status: 500 } ); }, onSuccess: (content) => { @@ -221,10 +213,7 @@ export async function GET( const result = exerciseNumber === null ? content - : content.filter( - (exercise: { number: number }) => - exercise.number === exerciseNumber - ); + : content.filter((exercise) => exercise.number === exerciseNumber); if (exerciseNumber !== null && result.length === 0) { return NextResponse.json( diff --git a/apps/api/package.json b/apps/api/package.json index 8933a0aee..540987a9c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@repo/typescript-config": "workspace:*", - "@types/node": "25.5.2", + "@types/node": "25.6.0", "@types/react": "19.2.14", "@vitest/coverage-istanbul": "^4.1.4", "typescript": "^6.0.2", diff --git a/apps/email/package.json b/apps/email/package.json index 54c7491b6..f702e5f8d 100644 --- a/apps/email/package.json +++ b/apps/email/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@react-email/preview-server": "^5.2.10", "@repo/typescript-config": "workspace:*", - "@types/node": "25.5.2", + "@types/node": "25.6.0", "@types/react": "19.2.14", "typescript": "^6.0.2" } diff --git a/apps/email/tsconfig.json b/apps/email/tsconfig.json index 9352a57dc..a4d4cf8c5 100644 --- a/apps/email/tsconfig.json +++ b/apps/email/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "@repo/typescript-config/nextjs.json", - "include": ["**/*.ts", "**/*.tsx"], + "files": [], "exclude": ["node_modules"] } diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 79b6d3a16..5ac44f0da 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -27,7 +27,7 @@ }, "devDependencies": { "@repo/typescript-config": "workspace:*", - "@types/node": "25.5.2", + "@types/node": "25.6.0", "@types/react": "19.2.14", "typescript": "^6.0.2" } diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/chat/layout.tsx b/apps/www/app/[locale]/(app)/(dynamic)/(core)/chat/layout.tsx deleted file mode 100644 index f563aeaf2..000000000 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/chat/layout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { ErrorBoundary } from "@repo/design-system/components/ui/error-boundary"; -import { routing } from "@repo/internationalization/src/routing"; -import { notFound } from "next/navigation"; -import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { AiChatSidebar } from "@/components/ai/chat-sidebar"; - -export default function Layout(props: LayoutProps<"/[locale]/chat">) { - const { children, params } = props; - const { locale } = use(params); - if (!hasLocale(routing.locales, locale)) { - // Ensure that the incoming `locale` is valid - notFound(); - } - - // Enable static rendering - setRequestLocale(locale); - - return ( -
-
- {children} - - -
-
- ); -} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/event/try-out/[code]/page.tsx b/apps/www/app/[locale]/(app)/(dynamic)/(core)/event/try-out/[code]/page.tsx deleted file mode 100644 index dc1fb934d..000000000 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/event/try-out/[code]/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { EventAccessPage } from "@/components/event/access-page"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; - -export default function Page( - props: PageProps<"/[locale]/event/try-out/[code]"> -) { - const { params } = props; - const { code, locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - // Enable static rendering - setRequestLocale(locale); - - return ; -} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/layout.tsx b/apps/www/app/[locale]/(app)/(dynamic)/(core)/layout.tsx deleted file mode 100644 index 4ed3342f4..000000000 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/layout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { routing } from "@repo/internationalization/src/routing"; -import { notFound } from "next/navigation"; -import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { AppShell } from "@/components/sidebar/app-shell"; - -/** Renders the core signed-in application subtree inside the default app shell. */ -export default function Layout(props: LayoutProps<"/[locale]">) { - const { children, params } = props; - const { locale } = use(params); - - if (!hasLocale(routing.locales, locale)) { - notFound(); - } - - setRequestLocale(locale); - - return {children}; -} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/layout.tsx b/apps/www/app/[locale]/(app)/(dynamic)/layout.tsx deleted file mode 100644 index 42763b17a..000000000 --- a/apps/www/app/[locale]/(app)/(dynamic)/layout.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { routing } from "@repo/internationalization/src/routing"; -import { notFound } from "next/navigation"; -import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; -import { ConvexAppProviders } from "@/components/providers"; -import { getToken } from "@/lib/auth/server"; - -/** - * Mounts the auth-seeded Convex subtree for request-time application routes. - * - * Dynamic pages use authenticated SSR preloads and live Convex subscriptions, - * so this boundary seeds the first Better Auth provider in the subtree with the - * current request token. - * - * @see https://docs.convex.dev/client/nextjs/app-router/server-rendering - * @see https://labs.convex.dev/better-auth/migrations/migrate-to-0-10#pass-initial-token-to-convexbetterauthprovider - */ -export default async function Layout(props: LayoutProps<"/[locale]">) { - const { children, params } = props; - const { locale } = await params; - - if (!hasLocale(routing.locales, locale)) { - notFound(); - } - - setRequestLocale(locale); - - const token = await getToken(); - - return ( - {children} - ); -} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/forum/page.tsx b/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/forum/page.tsx deleted file mode 100644 index a2b3eff78..000000000 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/forum/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { SchoolClassesForumHeader } from "@/components/school/classes/forum/header"; -import { SchoolClassesForumList } from "@/components/school/classes/forum/list"; -import { SchoolLayoutContent } from "@/components/school/layout-content"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; - -export default function Page( - props: PageProps<"/[locale]/school/[slug]/classes/[id]/forum"> -) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - setRequestLocale(locale); - - return ( - - - - - ); -} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/auth/page.tsx b/apps/www/app/[locale]/(app)/(dynamic)/school/auth/page.tsx deleted file mode 100644 index b0b09face..000000000 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/auth/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Particles } from "@repo/design-system/components/ui/particles"; -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { ComingSoon } from "@/components/shared/coming-soon"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; - -export default function Page(props: PageProps<"/[locale]/school/auth">) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - setRequestLocale(locale); - - return ( -
- -
- -
-
- ); -} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/try-out/[product]/[slug]/part/[partKey]/page.tsx b/apps/www/app/[locale]/(app)/(dynamic)/try-out/[product]/[slug]/part/[partKey]/page.tsx deleted file mode 100644 index 9adfe3cc7..000000000 --- a/apps/www/app/[locale]/(app)/(dynamic)/try-out/[product]/[slug]/part/[partKey]/page.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { api } from "@repo/backend/convex/_generated/api"; -import { - isTryoutProduct, - type TryoutProduct, - tryoutProductPolicies, -} from "@repo/backend/convex/tryouts/products"; -import { getExercisesContent } from "@repo/contents/_lib/exercises"; -import { getMaterialIcon } from "@repo/contents/_lib/subject/material"; -import { ExercisesMaterialSchema } from "@repo/contents/_types/exercises/material"; -import { slugify } from "@repo/design-system/lib/utils"; -import { routing } from "@repo/internationalization/src/routing"; -import { fetchQuery, preloadedQueryResult, preloadQuery } from "convex/nextjs"; -import { Effect } from "effect"; -import { notFound, redirect } from "next/navigation"; -import { hasLocale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { QuestionAnalytics } from "@/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/analytics"; -import { ExerciseArticle } from "@/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/article"; -import { TryoutPartRouteShell } from "@/components/tryout/part-route-shell"; -import { TryoutPartRuntime } from "@/components/tryout/part-runtime"; -import { TryoutPartProvider } from "@/components/tryout/providers/part-provider"; -import { loadTryoutSearchParams } from "@/components/tryout/utils/attempt-search"; -import { - getTryoutHistoryHref, - getTryoutSetHref, -} from "@/components/tryout/utils/routes"; -import { getToken } from "@/lib/auth/server"; - -/** Renders one tryout part page with a native Convex preload when authenticated. */ -export default async function Page( - props: PageProps<"/[locale]/try-out/[product]/[slug]/part/[partKey]"> -) { - const { params, searchParams } = props; - const { locale, product: productParam, slug, partKey } = await params; - const initialNowMs = Date.now(); - - if (!hasLocale(routing.locales, locale)) { - notFound(); - } - - setRequestLocale(locale); - - if (!isTryoutProduct(productParam)) { - notFound(); - } - const product: TryoutProduct = productParam; - - const [tExercises, details, token] = await Promise.all([ - getTranslations({ locale, namespace: "Exercises" }), - fetchQuery(api.tryouts.queries.tryouts.getTryoutDetails, { - locale, - product, - slug, - }), - getToken(), - ]); - - if (!details) { - notFound(); - } - - const { attempt } = await loadTryoutSearchParams(searchParams); - const preloadedRuntime = token - ? await preloadQuery( - api.tryouts.queries.me.part.getUserTryoutPartAttempt, - { - attemptId: attempt ?? undefined, - locale, - partKey, - product, - tryoutSlug: slug, - }, - { token } - ) - : undefined; - const runtime = preloadedRuntime - ? preloadedQueryResult(preloadedRuntime) - : undefined; - - if (token && runtime && !runtime.part) { - redirect( - getTryoutHistoryHref( - getTryoutSetHref({ product, tryoutSlug: slug }), - attempt - ) - ); - } - - const currentPart = details.parts.find((item) => item.partKey === partKey); - const contentPart = (() => { - if (runtime?.part) { - return { - material: runtime.part.material, - partKey: runtime.part.currentPartKey, - questionCount: runtime.part.questionCount, - setSlug: runtime.part.setSlug, - }; - } - - if (currentPart) { - return { - material: currentPart.material, - partKey: currentPart.partKey, - questionCount: currentPart.questionCount, - setSlug: currentPart.setSlug, - }; - } - - return null; - })(); - - if (!contentPart) { - notFound(); - } - - const exercises = await Effect.runPromise( - Effect.match( - getExercisesContent({ locale, filePath: contentPart.setSlug }), - { - onFailure: () => [], - onSuccess: (data) => data, - } - ) - ); - - if (exercises.length === 0) { - notFound(); - } - - const tryoutLabel = details.tryout.label; - const partKeys = details.parts.map((part) => part.partKey); - - const materialLabel = ExercisesMaterialSchema.safeParse(contentPart.material); - const partLabel = materialLabel.success - ? tExercises(materialLabel.data) - : contentPart.partKey; - const timeLimitSeconds = tryoutProductPolicies[ - product - ].getPartTimeLimitSeconds(contentPart.questionCount); - const material = ExercisesMaterialSchema.safeParse(contentPart.material); - const partIcon = material.success - ? getMaterialIcon(material.data) - : undefined; - - return ( - - -
-
- - {exercises.map((exercise) => { - const id = slugify( - tExercises("number-count", { count: exercise.number }) - ); - - return ( - - - - ); - })} - -
-
-
-
- ); -} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/chat/[id]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/chat/[id]/page.tsx similarity index 63% rename from apps/www/app/[locale]/(app)/(dynamic)/(core)/chat/[id]/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(core)/chat/[id]/page.tsx index 708828918..9c9df205d 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/chat/[id]/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/chat/[id]/page.tsx @@ -2,12 +2,11 @@ import { api } from "@repo/backend/convex/_generated/api"; import type { Id } from "@repo/backend/convex/_generated/dataModel"; import { fetchQuery } from "convex/nextjs"; import type { Metadata } from "next"; -import { setRequestLocale } from "next-intl/server"; -import { cache, use } from "react"; +import { cache, Suspense, use } from "react"; import { AiChatPage } from "@/components/ai/chat-page"; import { getToken } from "@/lib/auth/server"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; +/** Loads the current chat title once per request for metadata generation. */ const getChatTitle = cache(async (id: Id<"chats">) => { const token = await getToken(); @@ -18,6 +17,7 @@ const getChatTitle = cache(async (id: Id<"chats">) => { ); }); +/** Generates the metadata for one authenticated chat route. */ export async function generateMetadata({ params, }: { @@ -41,13 +41,28 @@ export async function generateMetadata({ } } +/** Renders the chat route with a local Suspense boundary for runtime params. */ export default function Page(props: PageProps<"/[locale]/chat/[id]">) { const { params } = props; - const { locale: rawLocale, id } = use(params); - const locale = getLocaleOrThrow(rawLocale); - // Enable static rendering - setRequestLocale(locale); + return ( + + } + > + + + ); +} + +/** Resolves the runtime chat id inside the nearest Suspense boundary. */ +function ChatRouteContent({ + params, +}: { + params: PageProps<"/[locale]/chat/[id]">["params"]; +}) { + const { id } = use(params); return } />; } diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/chat/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/chat/layout.tsx new file mode 100644 index 000000000..956eff4cc --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/chat/layout.tsx @@ -0,0 +1,15 @@ +import { ErrorBoundary } from "@repo/design-system/components/ui/error-boundary"; +import { AiChatSidebar } from "@/components/ai/chat-sidebar"; + +export default function Layout(props: LayoutProps<"/[locale]/chat">) { + const { children } = props; + return ( +
+
+ {children} + + +
+
+ ); +} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/chat/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/chat/page.tsx similarity index 55% rename from apps/www/app/[locale]/(app)/(dynamic)/(core)/chat/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(core)/chat/page.tsx index 1fd4bb3ec..5d6dd9767 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/chat/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/chat/page.tsx @@ -1,27 +1,12 @@ import { Particles } from "@repo/design-system/components/ui/particles"; -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; import { ChatNew } from "@/components/ai/chat-new"; import { HomeTitle } from "@/components/ai/title"; import { Videos } from "@/components/ai/videos"; import { Weather } from "@/components/ai/weather"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; - -export const revalidate = false; - -export default function Page(props: PageProps<"/[locale]/chat">) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - // Enable static rendering - setRequestLocale(locale); +export default function Page() { return ( -
+
diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/event/try-out/[code]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/event/try-out/[code]/page.tsx new file mode 100644 index 000000000..9efe97c92 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/event/try-out/[code]/page.tsx @@ -0,0 +1,11 @@ +import { use } from "react"; +import { EventAccessPage } from "@/components/event/access-page"; + +export default function Page( + props: PageProps<"/[locale]/event/try-out/[code]"> +) { + const { params } = props; + const { code } = use(params); + + return ; +} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/page.tsx similarity index 92% rename from apps/www/app/[locale]/(app)/(dynamic)/(core)/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(core)/page.tsx index 1df527399..ba46268b1 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/page.tsx @@ -1,7 +1,7 @@ import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb"; import { redirect } from "next/navigation"; import { type Locale, useTranslations } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; + import { Suspense, use } from "react"; import { HomeContinueLearning } from "@/components/home/continue-learning"; import { HomeExplore } from "@/components/home/explore"; @@ -15,15 +15,10 @@ export default function Page(props: PageProps<"/[locale]">) { const { locale: rawLocale } = use(params); const locale = getLocaleOrThrow(rawLocale); - setRequestLocale(locale); - return ( <> -
+
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/search/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/search/page.tsx similarity index 73% rename from apps/www/app/[locale]/(app)/(dynamic)/(core)/search/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(core)/search/page.tsx index 480870c40..ffc04a42c 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/search/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/search/page.tsx @@ -1,14 +1,12 @@ import type { Metadata } from "next"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { Suspense, use } from "react"; +import { getTranslations } from "next-intl/server"; +import { Suspense } from "react"; import { HomeHeader } from "@/components/home/header"; import { InputSearch } from "@/components/search/input"; import { SearchListItems } from "@/components/search/results"; import { BackButton } from "@/components/shared/back-button"; import { getLocaleOrThrow } from "@/lib/i18n/params"; -export const revalidate = false; - export async function generateMetadata({ params, }: { @@ -26,19 +24,9 @@ export async function generateMetadata({ }; } -export default function Page(props: PageProps<"/[locale]/search">) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - // Enable static rendering - setRequestLocale(locale); - +export default function Page() { return ( -
+
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/[id]/chat/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/[id]/chat/page.tsx similarity index 69% rename from apps/www/app/[locale]/(app)/(dynamic)/(core)/user/[id]/chat/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/[id]/chat/page.tsx index 3310c9da9..730038a0f 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/[id]/chat/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/[id]/chat/page.tsx @@ -1,6 +1,6 @@ import type { Id } from "@repo/backend/convex/_generated/dataModel"; import type { Metadata } from "next"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { use } from "react"; import { UserChats } from "@/components/user/chats"; import { getLocaleOrThrow } from "@/lib/i18n/params"; @@ -23,13 +23,9 @@ export async function generateMetadata({ }; } -export default function Page(props: PageProps<"/[locale]/user/[id]/chat">) { - const { params } = props; - const { locale: rawLocale, id } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - // Enable static rendering - setRequestLocale(locale); - +export default function Page({ + params, +}: PageProps<"/[locale]/user/[id]/chat">) { + const { id } = use(params); return } />; } diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/[id]/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/[id]/layout.tsx similarity index 89% rename from apps/www/app/[locale]/(app)/(dynamic)/(core)/user/[id]/layout.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/[id]/layout.tsx index 37b1962ff..23da9f741 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/[id]/layout.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/[id]/layout.tsx @@ -3,7 +3,7 @@ import { ErrorBoundary } from "@repo/design-system/components/ui/error-boundary" import { routing } from "@repo/internationalization/src/routing"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; import { UserHeader } from "@/components/user/header"; import { UserTabs } from "@/components/user/tabs"; @@ -17,9 +17,6 @@ export default function Layout(props: LayoutProps<"/[locale]/user/[id]">) { notFound(); } - // Enable static rendering - setRequestLocale(locale); - const userId = id as Id<"users">; return ( diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/[id]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/[id]/page.tsx similarity index 70% rename from apps/www/app/[locale]/(app)/(dynamic)/(core)/user/[id]/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/[id]/page.tsx index ab471545e..26752248d 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/[id]/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/[id]/page.tsx @@ -1,6 +1,6 @@ import type { Id } from "@repo/backend/convex/_generated/dataModel"; import type { Metadata } from "next"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { use } from "react"; import { UserComments } from "@/components/user/comments"; import { getLocaleOrThrow } from "@/lib/i18n/params"; @@ -23,13 +23,7 @@ export async function generateMetadata({ }; } -export default function Page(props: PageProps<"/[locale]/user/[id]">) { - const { params } = props; - const { locale: rawLocale, id } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - // Enable static rendering - setRequestLocale(locale); - +export default function Page({ params }: PageProps<"/[locale]/user/[id]">) { + const { id } = use(params); return } />; } diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/layout.tsx similarity index 65% rename from apps/www/app/[locale]/(app)/(dynamic)/(core)/user/layout.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/layout.tsx index 9724dfdcc..7561f7ebc 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/layout.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/layout.tsx @@ -1,7 +1,7 @@ import { routing } from "@repo/internationalization/src/routing"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; export default function Layout(props: LayoutProps<"/[locale]/user">) { @@ -12,14 +12,8 @@ export default function Layout(props: LayoutProps<"/[locale]/user">) { notFound(); } - // Enable static rendering - setRequestLocale(locale); - return ( -
+
{children}
); diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/settings/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/settings/layout.tsx similarity index 87% rename from apps/www/app/[locale]/(app)/(dynamic)/(core)/user/settings/layout.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/settings/layout.tsx index 9f3db6b41..f2e685fdc 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/settings/layout.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/settings/layout.tsx @@ -1,7 +1,7 @@ import { routing } from "@repo/internationalization/src/routing"; import { notFound } from "next/navigation"; import { hasLocale, useTranslations } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; import { UserSettingsTabs } from "@/components/user/settings/tabs"; @@ -14,9 +14,6 @@ export default function Layout(props: LayoutProps<"/[locale]/user/settings">) { notFound(); } - // Enable static rendering - setRequestLocale(locale); - const t = useTranslations("Auth"); return ( diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/settings/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/settings/page.tsx similarity index 61% rename from apps/www/app/[locale]/(app)/(dynamic)/(core)/user/settings/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/settings/page.tsx index 4d2c96601..c305151ea 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/settings/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/settings/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; +import { getTranslations } from "next-intl/server"; import { UserSettingsProfilePage } from "@/components/user/settings/profile-page"; import { getLocaleOrThrow } from "@/lib/i18n/params"; @@ -18,13 +17,6 @@ export async function generateMetadata({ }; } -export default function Page(props: PageProps<"/[locale]/user/settings">) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - // Enable static rendering - setRequestLocale(locale); - +export default function Page() { return ; } diff --git a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/settings/subscriptions/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/settings/subscriptions/page.tsx similarity index 62% rename from apps/www/app/[locale]/(app)/(dynamic)/(core)/user/settings/subscriptions/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/settings/subscriptions/page.tsx index 194ffa491..0a81e135e 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/(core)/user/settings/subscriptions/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(core)/user/settings/subscriptions/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; +import { getTranslations } from "next-intl/server"; import { UserSettingsSubscriptionsPage } from "@/components/user/settings/subscriptions-page"; import { getLocaleOrThrow } from "@/lib/i18n/params"; @@ -18,15 +17,6 @@ export async function generateMetadata({ }; } -export default function Page( - props: PageProps<"/[locale]/user/settings/subscriptions"> -) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - // Enable static rendering - setRequestLocale(locale); - +export default function Page() { return ; } diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/articles/[category]/[slug]/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/articles/[category]/[slug]/layout.tsx similarity index 92% rename from apps/www/app/[locale]/(app)/(static)/(learn)/articles/[category]/[slug]/layout.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/articles/[category]/[slug]/layout.tsx index e35636dfa..2d2eaa293 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/articles/[category]/[slug]/layout.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/articles/[category]/[slug]/layout.tsx @@ -2,7 +2,6 @@ import { parseArticleCategory } from "@repo/contents/_lib/articles/category"; import { getSlugPath } from "@repo/contents/_lib/articles/slug"; import { cleanSlug } from "@repo/utilities/helper"; import { notFound } from "next/navigation"; -import { setRequestLocale } from "next-intl/server"; import { use } from "react"; import { ContentViewTracker } from "@/components/tracking/content-view-tracker"; import { getLocaleOrThrow } from "@/lib/i18n/params"; @@ -19,8 +18,6 @@ export default function Layout( notFound(); } - setRequestLocale(locale); - const filePath = getSlugPath(category, slug); const cleanedSlug = cleanSlug(filePath); diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/articles/[category]/[slug]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/articles/[category]/[slug]/page.tsx similarity index 69% rename from apps/www/app/[locale]/(app)/(static)/(learn)/articles/[category]/[slug]/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/articles/[category]/[slug]/page.tsx index e2f7baf04..994f19912 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/articles/[category]/[slug]/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/articles/[category]/[slug]/page.tsx @@ -1,5 +1,7 @@ import { parseArticleCategory } from "@repo/contents/_lib/articles/category"; +import { getArticleReferences } from "@repo/contents/_lib/articles/content"; import { getSlugPath } from "@repo/contents/_lib/articles/slug"; +import { importContentModule } from "@repo/contents/_lib/module"; import { getHeadings } from "@repo/contents/_lib/toc"; import { formatContentDateISO } from "@repo/contents/_shared/date"; import type { ArticleCategory } from "@repo/contents/_types/articles/category"; @@ -8,12 +10,13 @@ import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb"; import { LearningResourceJsonLd } from "@repo/seo/json-ld/learning-resource"; import { Effect } from "effect"; import type { Metadata } from "next"; +import { cacheLife } from "next/cache"; import { notFound } from "next/navigation"; import type { Locale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { AiSheetOpen } from "@/components/ai/sheet-open"; -import { Comments } from "@/components/comments"; +import { getTranslations } from "next-intl/server"; +import type { ReactNode } from "react"; +import { DeferredAiSheetOpen } from "@/components/ai/deferred-sheet-open"; +import { DeferredComments } from "@/components/comments/deferred"; import { ComingSoon } from "@/components/shared/coming-soon"; import { LayoutMaterial, @@ -26,16 +29,11 @@ import { import { getLocaleOrThrow } from "@/lib/i18n/params"; import { getGithubUrl } from "@/lib/utils/github"; import { getOgUrl } from "@/lib/utils/metadata"; -import { - fetchArticleContext, - fetchArticleMetadataContext, -} from "@/lib/utils/pages/article"; +import { fetchArticleMetadataContext } from "@/lib/utils/pages/article"; import { generateSEOMetadata } from "@/lib/utils/seo/generator"; import type { SEOContext } from "@/lib/utils/seo/types"; import { getStaticParams } from "@/lib/utils/system"; -export const revalidate = false; - async function getResolvedParams( params: PageProps<"/[locale]/articles/[category]/[slug]">["params"] ) { @@ -58,22 +56,18 @@ export async function generateMetadata({ const { locale, category, slug } = await getResolvedParams(params); const t = await getTranslations({ locale, namespace: "Articles" }); - const { content, FilePath } = await Effect.runPromise( - Effect.match(fetchArticleMetadataContext({ locale, category, slug }), { - onFailure: () => ({ - content: null, - FilePath: getSlugPath(category, slug), - }), - onSuccess: (data) => data, - }) - ); + const { content, filePath } = await getArticleMetadataData({ + locale, + category, + slug, + }); - const path = `/${locale}${FilePath}`; + const path = `/${locale}${filePath}`; const alternates = { canonical: path, }; const image = { - url: getOgUrl(locale, FilePath), + url: getOgUrl(locale, filePath), width: 1200, height: 630, }; @@ -128,6 +122,33 @@ export async function generateMetadata({ }; } +async function getArticleMetadataData({ + locale, + category, + slug, +}: { + locale: Locale; + category: ArticleCategory; + slug: string; +}) { + "use cache"; + + cacheLife("max"); + + return Effect.runPromise( + Effect.match(fetchArticleMetadataContext({ locale, category, slug }), { + onFailure: () => ({ + content: null, + filePath: getSlugPath(category, slug), + }), + onSuccess: ({ content, FilePath }) => ({ + content, + filePath: FilePath, + }), + }) + ); +} + // Generate bottom-up static params export function generateStaticParams() { return getStaticParams({ @@ -136,50 +157,78 @@ export function generateStaticParams() { }); } -export default function Page( - props: PageProps<"/[locale]/articles/[category]/[slug]"> -) { - const { params } = props; - const { locale: rawLocale, category: rawCategory, slug } = use(params); - const locale = getLocaleOrThrow(rawLocale); - const category = parseArticleCategory(rawCategory); - - if (!category) { - notFound(); - } - - // Enable static rendering - setRequestLocale(locale); +export default async function Page({ + params, +}: PageProps<"/[locale]/articles/[category]/[slug]">) { + const { locale, category, slug } = await getResolvedParams(params); + const filePath = getSlugPath(category, slug); + const content = await importContentModule(filePath, locale).catch(() => null); + const Content = content?.default; - return ; + return ( + } + locale={locale} + slug={slug} + toolbar={ + + } + > + {Content ? : null} + + ); } -async function PageContent({ +async function CachedArticleShell({ locale, category, slug, + children, + footer, + toolbar, }: { locale: Locale; category: ArticleCategory; slug: string; + children: ReactNode; + footer: ReactNode; + toolbar: ReactNode; }) { + "use cache"; + + cacheLife("max"); + const [tCommon, tArticles] = await Promise.all([ - getTranslations({ locale, namespace: "Common" }), - getTranslations({ locale, namespace: "Articles" }), + getTranslations("Common"), + getTranslations("Articles"), ]); const FilePath = getSlugPath(category, slug); - const result = await Effect.runPromise( - Effect.match(fetchArticleContext({ locale, category, slug }), { - onFailure: () => ({ content: null, references: null }), - onSuccess: (data) => data, - }) - ); - - const { content, references } = result; + const [content, references] = await Promise.all([ + Effect.runPromise( + Effect.match(fetchArticleMetadataContext({ locale, category, slug }), { + onFailure: () => ({ content: null, FilePath }), + onSuccess: (data) => data, + }) + ), + Effect.runPromise( + Effect.match(getArticleReferences(FilePath), { + onFailure: () => [], + onSuccess: (data) => data, + }) + ), + ]); - if (!content) { + if (!(content.content && children !== null)) { return ( @@ -191,7 +240,7 @@ async function PageContent({ ); } - const { metadata, default: Content, raw } = content; + const { metadata, raw } = content.content; const publishedAt = formatContentDateISO(metadata.date) ?? metadata.date; const headings = getHeadings(raw); @@ -244,18 +293,10 @@ async function PageContent({ /> {headings.length === 0 && } - {headings.length > 0 && Content} + {headings.length > 0 ? children : null} - - - - + {footer} + {toolbar} ["params"] ) { @@ -39,6 +38,14 @@ async function getResolvedParams( return { category, locale }; } +async function getCategoryArticles(category: ArticleCategory, locale: Locale) { + "use cache"; + + cacheLife("max"); + + return getArticleSummaries(category, locale); +} + export async function generateMetadata({ params, }: { @@ -93,9 +100,6 @@ export default function Page( notFound(); } - // Enable static rendering - setRequestLocale(locale); - const FilePath = getCategoryPath(category); return ( @@ -127,7 +131,7 @@ async function PageArticles({ FilePath: string; header: React.ReactNode; }) { - const articles = await getArticleSummaries(category, locale); + const articles = await getCategoryArticles(category, locale); const t = await getTranslations({ locale, namespace: "Articles" }); return ( diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/articles/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/articles/page.tsx new file mode 100644 index 000000000..148694851 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/articles/page.tsx @@ -0,0 +1,8 @@ +import { notFound } from "next/navigation"; + +export default function Page() { + // Return 404 for empty articles index page + // This prevents soft 404s and tells Google this page doesn't exist + // Source: https://developers.google.com/search/docs/crawling-indexing/soft-404s + notFound(); +} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-complete-button.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-complete-button.tsx similarity index 84% rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-complete-button.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-complete-button.tsx index 26b78f63a..b238ae9ec 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-complete-button.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-complete-button.tsx @@ -1,6 +1,7 @@ "use client"; import { ArrowDown01Icon, StopIcon } from "@hugeicons/core-free-icons"; +import { useDisclosure } from "@mantine/hooks"; import { api } from "@repo/backend/convex/_generated/api"; import { Button } from "@repo/design-system/components/ui/button"; import { @@ -14,17 +15,31 @@ import { Spinner } from "@repo/design-system/components/ui/spinner"; import { cn } from "@repo/design-system/lib/utils"; import { useMutation } from "convex/react"; import { useTranslations } from "next-intl"; -import { useState, useTransition } from "react"; +import { useLayoutEffect, useTransition } from "react"; import { toast } from "sonner"; import { useAttempt } from "@/lib/context/use-attempt"; import { useExercise } from "@/lib/context/use-exercise"; import { useUser } from "@/lib/context/use-user"; +/** + * Renders the completion controls for one exercise set. + * + * The completion dialog is transient UI, so it resets closed when Next hides + * the page through Cache Components state preservation. + * + * References: + * - Next.js preserving UI state with Cache Components: + * `apps/www/node_modules/next/dist/docs/01-app/02-guides/preserving-ui-state.md` + * - Mantine `useDisclosure`: + * https://mantine.dev/hooks/use-disclosure/ + */ export function CompleteExerciseButton() { const t = useTranslations("Exercises"); - const [open, setOpen] = useState(false); + const [open, { close, open: openDialog, set }] = useDisclosure(false); const [isPending, startTransition] = useTransition(); + useLayoutEffect(() => close, [close]); + const showStats = useExercise((state) => state.showStats); const setShowStats = useExercise((state) => state.setShowStats); const resetTimeSpent = useExercise((state) => state.resetTimeSpent); @@ -55,7 +70,7 @@ export function CompleteExerciseButton() { try { await completeAttempt({ attemptId: attempt._id }); - setOpen(false); + close(); resetTimeSpent(); setShowStats(true); } catch { @@ -70,7 +85,7 @@ export function CompleteExerciseButton() { } open={open} - setOpen={setOpen} + setOpen={set} title={t("complete-exercise-title")} >
diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-start-button.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-start-button.tsx similarity index 93% rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-start-button.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-start-button.tsx index 531cb4d7b..5c9d24b43 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-start-button.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt-start-button.tsx @@ -8,6 +8,7 @@ import { Tick01Icon, Timer02Icon, } from "@hugeicons/core-free-icons"; +import { useDisclosure } from "@mantine/hooks"; import { api } from "@repo/backend/convex/_generated/api"; import { Button } from "@repo/design-system/components/ui/button"; import { @@ -37,7 +38,7 @@ import { useForm } from "@tanstack/react-form"; import { useMutation } from "convex/react"; import { formatDuration } from "date-fns"; import { useLocale, useTranslations } from "next-intl"; -import { Activity, useState } from "react"; +import { Activity, useLayoutEffect } from "react"; import { toast } from "sonner"; import * as z from "zod/mini"; import { useAttempt } from "@/lib/context/use-attempt"; @@ -63,13 +64,27 @@ const defaultValues = ({ timeLimit, }); +/** + * Renders the start-attempt controls for one exercise set. + * + * The start dialog is transient UI, so it resets closed when Next hides the + * page through Cache Components state preservation. + * + * References: + * - Next.js preserving UI state with Cache Components: + * `apps/www/node_modules/next/dist/docs/01-app/02-guides/preserving-ui-state.md` + * - Mantine `useDisclosure`: + * https://mantine.dev/hooks/use-disclosure/ + */ export function StartExerciseButton({ totalExercises, }: StartExerciseButtonProps) { const t = useTranslations("Exercises"); - const [open, setOpen] = useState(false); + const [open, { close, open: openDialog, set }] = useDisclosure(false); const locale = useLocale(); + useLayoutEffect(() => close, [close]); + const router = useRouter(); const pathname = usePathname(); @@ -106,7 +121,7 @@ export function StartExerciseButton({ totalExercises, timeLimit, }); - setOpen(false); + close(); resetTimeSpent(); setShowStats(true); toast.success(t("start-exercise-success"), { @@ -129,7 +144,7 @@ export function StartExerciseButton({ }} > - @@ -174,7 +189,7 @@ export function StartExerciseButton({ } open={open} - setOpen={setOpen} + setOpen={set} title={t("start-exercise-title")} > diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt.tsx similarity index 100% rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/attempt.tsx diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/data.ts b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/data.ts new file mode 100644 index 000000000..e83760871 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/data.ts @@ -0,0 +1,170 @@ +import { parseExercisesCategory } from "@repo/contents/_lib/exercises/category"; +import { getExerciseCount } from "@repo/contents/_lib/exercises/collection"; +import { + getCurrentMaterial, + getMaterialPath, + getMaterials, + parseExercisesMaterial, +} from "@repo/contents/_lib/exercises/material"; +import { + getRenderableExerciseByNumber, + getRenderableExercisesContent, +} from "@repo/contents/_lib/exercises/renderable"; +import { + getSlugPath, + isTryOutCollectionSlug, +} from "@repo/contents/_lib/exercises/slug"; +import { parseExercisesType } from "@repo/contents/_lib/exercises/type"; +import { Effect } from "effect"; +import { cacheLife } from "next/cache"; +import { notFound } from "next/navigation"; +import { getLocaleOrThrow } from "@/lib/i18n/params"; +import { isNumber } from "@/lib/utils/number"; + +type ResolvedParams = Awaited>; + +/** Validates and normalizes one exercises route parameter object. */ +export async function getResolvedParams( + params: PageProps<"/[locale]/exercises/[category]/[type]/[material]/[...slug]">["params"] +) { + const { + locale: rawLocale, + category: rawCategory, + type: rawType, + material: rawMaterial, + slug, + } = await params; + const locale = getLocaleOrThrow(rawLocale); + const category = parseExercisesCategory(rawCategory); + const type = parseExercisesType(rawType); + const material = parseExercisesMaterial(rawMaterial); + + if (!(category && type && material)) { + notFound(); + } + + return { category, locale, material, slug, type }; +} + +/** + * Resolves the learn-exercises route into one explicit page variant. + * + * The cache key uses primitive inputs only, so the same route state can be + * reused across page rendering and `generateMetadata` within one request. + */ +export async function getExerciseRouteData( + locale: ResolvedParams["locale"], + category: ResolvedParams["category"], + type: ResolvedParams["type"], + material: ResolvedParams["material"], + slugKey: string +) { + "use cache"; + + cacheLife("max"); + + const slug = slugKey === "" ? [] : slugKey.split("/"); + const pagePath = getSlugPath(category, type, material, slug); + const materialPath = getMaterialPath(category, type, material); + const lastSlug = slug.at(-1); + const isSpecificExercise = + lastSlug !== undefined && + isNumber(lastSlug) && + !isTryOutCollectionSlug(slug); + + if (isSpecificExercise) { + const exerciseNumber = Number.parseInt(lastSlug, 10); + const baseSlug = slug.slice(0, -1); + const setPath = getSlugPath(category, type, material, baseSlug); + + const [materials, exercise, exerciseCount] = await Promise.all([ + getMaterials(materialPath, locale), + getRenderableExerciseByNumber(locale, setPath, exerciseNumber), + Effect.runPromise( + Effect.match(getExerciseCount(setPath), { + onFailure: () => 0, + onSuccess: (count) => count, + }) + ), + ]); + + const { currentMaterial, currentMaterialItem } = getCurrentMaterial( + setPath, + materials + ); + + if (!(exercise && currentMaterial && currentMaterialItem)) { + return { + kind: "missing" as const, + currentMaterial, + currentMaterialItem, + materialPath, + pagePath, + }; + } + + return { + kind: "single" as const, + currentMaterial, + currentMaterialItem, + exercise, + exerciseCount, + exerciseFilePath: pagePath, + materialPath, + pagePath, + setPath, + }; + } + + const materials = await getMaterials(materialPath, locale); + const { currentMaterial, currentMaterialItem } = getCurrentMaterial( + pagePath, + materials + ); + + if (currentMaterial && !currentMaterialItem) { + return { + kind: "year-group" as const, + currentMaterial, + currentMaterialItem, + materialPath, + pagePath, + }; + } + + if (!(currentMaterial && currentMaterialItem)) { + return { + kind: "missing" as const, + currentMaterial, + currentMaterialItem, + materialPath, + pagePath, + }; + } + + const exercises = await getRenderableExercisesContent(locale, pagePath); + + if (exercises.length === 0) { + return { + kind: "missing" as const, + currentMaterial, + currentMaterialItem, + materialPath, + pagePath, + }; + } + + return { + kind: "set" as const, + currentMaterial, + currentMaterialItem, + exercises, + materialPath, + materials, + pagePath, + }; +} + +export type ExerciseRouteData = Awaited< + ReturnType +>; diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/empty.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/empty.tsx new file mode 100644 index 000000000..a8e9a3a4e --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/empty.tsx @@ -0,0 +1,19 @@ +import { ComingSoon } from "@/components/shared/coming-soon"; +import { + LayoutMaterial, + LayoutMaterialContent, + LayoutMaterialMain, +} from "@/components/shared/layout-material"; + +/** Renders the fallback UI when an exercise route has no available content yet. */ +export function MissingExercisePage() { + return ( + + + + + + + + ); +} diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/group.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/group.tsx new file mode 100644 index 000000000..6610394a3 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/group.tsx @@ -0,0 +1,99 @@ +import type { ExercisesMaterial } from "@repo/contents/_types/exercises/material"; +import type { ExercisesType } from "@repo/contents/_types/exercises/type"; +import { slugify } from "@repo/design-system/lib/utils"; +import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb"; +import { CollectionPageJsonLd } from "@repo/seo/json-ld/collection-page"; +import type { Locale } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { CardMaterial } from "@/components/shared/card-material"; +import { ContainerList } from "@/components/shared/container-list"; +import { + LayoutMaterial, + LayoutMaterialContent, + LayoutMaterialFooter, + LayoutMaterialHeader, + LayoutMaterialMain, + LayoutMaterialToc, +} from "@/components/shared/layout-material"; +import { RefContent } from "@/components/shared/ref-content"; +import { getGithubUrl } from "@/lib/utils/github"; +import type { ExerciseRouteData } from "./data"; + +/** Renders the year-group overview variant for one exercises route. */ +export async function YearGroupPage({ + data, + locale, + material, + type, +}: { + data: Extract; + locale: Locale; + material: ExercisesMaterial; + type: ExercisesType; +}) { + const t = await getTranslations({ locale, namespace: "Exercises" }); + const headingId = slugify(data.currentMaterial.title); + + return ( + <> + ({ + "@type": "ListItem", + "@id": `https://nakafa.com/${locale}${item.href}`, + position: index + 1, + name: item.title, + item: `https://nakafa.com/${locale}${item.href}`, + }))} + /> + ({ + url: `https://nakafa.com/${locale}${item.href}`, + name: item.title, + }))} + name={`${t(material)} - ${data.currentMaterial.title}`} + url={`https://nakafa.com/${locale}${data.pagePath}`} + /> + + + + + + + + + + + + + + + + ); +} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/layout.tsx similarity index 95% rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/layout.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/layout.tsx index f39ddf7d3..5dde0ef5f 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/layout.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/layout.tsx @@ -4,7 +4,7 @@ import { getSlugPath } from "@repo/contents/_lib/exercises/slug"; import { parseExercisesType } from "@repo/contents/_lib/exercises/type"; import { cleanSlug } from "@repo/utilities/helper"; import { notFound } from "next/navigation"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; import { ContentViewTracker } from "@/components/tracking/content-view-tracker"; import { AttemptContextProvider } from "@/lib/context/use-attempt"; @@ -32,8 +32,6 @@ export default function Layout( notFound(); } - setRequestLocale(locale); - const lastSlug = slug.at(-1); const baseSlug = lastSlug && isNumber(lastSlug) ? slug.slice(0, -1) : slug; diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/page.tsx new file mode 100644 index 000000000..859daebf7 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/page.tsx @@ -0,0 +1,207 @@ +import { + getSlugPath, + hasInvalidTryOutYearSlug, + isYearlessTryOutCollectionSlug, + LEGACY_YEARLESS_TRY_OUT_REDIRECT_YEAR, +} from "@repo/contents/_lib/exercises/slug"; +import type { ExercisesCategory } from "@repo/contents/_types/exercises/category"; +import type { ExercisesMaterial } from "@repo/contents/_types/exercises/material"; +import type { ExercisesType } from "@repo/contents/_types/exercises/type"; +import type { Metadata } from "next"; +import { permanentRedirect } from "next/navigation"; +import { getOgUrl } from "@/lib/utils/metadata"; +import { generateSEOMetadata } from "@/lib/utils/seo/generator"; +import type { SEOContext } from "@/lib/utils/seo/types"; +import { getStaticParams } from "@/lib/utils/system"; +import { getExerciseRouteData, getResolvedParams } from "./data"; +import { MissingExercisePage } from "./empty"; +import { YearGroupPage } from "./group"; +import { ExerciseSetPage } from "./set"; +import { SingleExercisePage } from "./single"; + +/** Builds the fallback metadata shape when exercise route resolution fails. */ +const metadataFallback = ({ + category, + material, + pagePath, + type, +}: { + category: ExercisesCategory; + material: ExercisesMaterial; + pagePath: string; + type: ExercisesType; +}) => ({ + category, + currentMaterial: undefined, + currentMaterialItem: undefined, + kind: "missing" as const, + materialPath: getSlugPath(category, type, material, []), + pagePath, +}); + +/** Generates SEO metadata for one learn-exercises route. */ +export async function generateMetadata({ + params, +}: { + params: PageProps<"/[locale]/exercises/[category]/[type]/[material]/[...slug]">["params"]; +}): Promise { + const { locale, category, type, material, slug } = + await getResolvedParams(params); + const pagePath = getSlugPath(category, type, material, slug); + + const data = await getExerciseRouteData( + locale, + category, + type, + material, + slug.join("/") + ).catch(() => metadataFallback({ category, material, pagePath, type })); + + const urlPath = `/${locale}${data.pagePath}`; + const image = { + url: getOgUrl(locale, data.pagePath), + width: 1200, + height: 630, + }; + + const exerciseNumber = + data.kind === "single" ? data.exercise.number : undefined; + let exerciseCount = 0; + + if (data.kind === "single") { + exerciseCount = data.exerciseCount; + } + + if (data.kind === "set") { + exerciseCount = data.exercises.length; + } + + const exerciseTitle = + data.kind === "single" ? data.exercise.question.metadata.title : undefined; + + const seoContext: SEOContext = { + type: "exercise", + category, + exam: type, + material, + group: data.currentMaterial?.title, + set: data.currentMaterialItem?.title, + number: exerciseNumber, + questionCount: exerciseCount, + data: { + title: + exerciseTitle ?? + data.currentMaterialItem?.title ?? + data.currentMaterial?.title, + description: undefined, + subject: material, + }, + }; + + const { + title: finalTitle, + description, + keywords, + } = await generateSEOMetadata(seoContext, locale); + + return { + title: { + absolute: finalTitle, + }, + description, + keywords, + alternates: { + canonical: urlPath, + }, + openGraph: { + title: finalTitle, + url: urlPath, + siteName: "Nakafa", + locale, + type: "website", + images: [image], + }, + twitter: { + images: [image], + }, + }; +} + +/** Enumerates the prerenderable learn-exercises paths. */ +export function generateStaticParams() { + return getStaticParams({ + basePath: "exercises", + paramNames: ["category", "type", "material", "slug"], + slugParam: "slug", + isDeep: true, + }).filter((params) => { + const slug = params.slug; + return ( + Array.isArray(slug) && + slug.length <= 3 && + !isYearlessTryOutCollectionSlug(slug) + ); + }); +} + +/** Selects and renders the explicit learn-exercises page variant for this route. */ +export default async function Page({ + params, +}: PageProps<"/[locale]/exercises/[category]/[type]/[material]/[...slug]">) { + const { locale, category, type, material, slug } = + await getResolvedParams(params); + + if (hasInvalidTryOutYearSlug(slug)) { + const tryOutSuffixIndex = 1; + const legacyTryOutSuffix = slug.slice(tryOutSuffixIndex); + + // Legacy yearless try-out URLs were already indexed before the year segment + // migration, so keep forwarding them to their yearful 2026 successor. + permanentRedirect( + getSlugPath(category, type, material, [ + "try-out", + LEGACY_YEARLESS_TRY_OUT_REDIRECT_YEAR, + ...legacyTryOutSuffix, + ]) + ); + } + const data = await getExerciseRouteData( + locale, + category, + type, + material, + slug.join("/") + ); + + switch (data.kind) { + case "single": + return ( + + ); + case "set": + return ( + + ); + case "year-group": + return ( + + ); + default: + return ; + } +} diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/set.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/set.tsx new file mode 100644 index 000000000..6cf63bee6 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/set.tsx @@ -0,0 +1,123 @@ +import { getExercisesPagination } from "@repo/contents/_lib/exercises/slug"; +import { formatContentDateISO } from "@repo/contents/_shared/date"; +import type { ExercisesCategory } from "@repo/contents/_types/exercises/category"; +import type { ExercisesType } from "@repo/contents/_types/exercises/type"; +import type { ParsedHeading } from "@repo/contents/_types/toc"; +import { slugify } from "@repo/design-system/lib/utils"; +import { ArticleJsonLd } from "@repo/seo/json-ld/article"; +import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb"; +import { FOUNDER } from "@repo/seo/json-ld/constants"; +import { LearningResourceJsonLd } from "@repo/seo/json-ld/learning-resource"; +import type { Locale } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { DeferredAiSheetOpen } from "@/components/ai/deferred-sheet-open"; +import { DeferredComments } from "@/components/comments/deferred"; +import { ExerciseTrackedEntry } from "@/components/exercise/entry"; +import { + LayoutMaterial, + LayoutMaterialContent, + LayoutMaterialFooter, + LayoutMaterialHeader, + LayoutMaterialMain, + LayoutMaterialPagination, + LayoutMaterialToc, +} from "@/components/shared/layout-material"; +import { getOgUrl } from "@/lib/utils/metadata"; +import { ExerciseAttempt } from "./attempt"; +import type { ExerciseRouteData } from "./data"; + +/** Renders the exercise-set variant for one learn route. */ +export async function ExerciseSetPage({ + category, + data, + locale, + type, +}: { + category: ExercisesCategory; + data: Extract; + locale: Locale; + type: ExercisesType; +}) { + const t = await getTranslations({ locale, namespace: "Exercises" }); + const pagination = getExercisesPagination(data.pagePath, data.materials); + const headings: ParsedHeading[] = data.exercises.map((exercise) => ({ + label: t("number-count", { count: exercise.number }), + href: `#${slugify(t("number-count", { count: exercise.number }))}`, + children: [], + })); + const description = `${t("exercises")} - ${data.currentMaterialItem.title} - ${data.currentMaterial.title}`; + const educationalLevel = `${t(type)} - ${t(category)}`; + const publishedAt = + formatContentDateISO(data.exercises[0].question.metadata.date) ?? + data.exercises[0].question.metadata.date; + + return ( + <> + ({ + "@type": "ListItem", + "@id": `https://nakafa.com/${locale}${data.pagePath}${heading.href}`, + position: index + 1, + name: heading.label, + item: `https://nakafa.com/${locale}${data.pagePath}${heading.href}`, + }))} + /> + + + + + + + + + {data.exercises.map((exercise) => ( + + ))} + + + + + + + + + + + ); +} diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/single.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/single.tsx new file mode 100644 index 000000000..20715d730 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/[...slug]/single.tsx @@ -0,0 +1,135 @@ +import { getExerciseNumberPagination } from "@repo/contents/_lib/exercises/slug"; +import { formatContentDateISO } from "@repo/contents/_shared/date"; +import type { ExercisesCategory } from "@repo/contents/_types/exercises/category"; +import type { ExercisesType } from "@repo/contents/_types/exercises/type"; +import { slugify } from "@repo/design-system/lib/utils"; +import { ArticleJsonLd } from "@repo/seo/json-ld/article"; +import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb"; +import { FOUNDER } from "@repo/seo/json-ld/constants"; +import { LearningResourceJsonLd } from "@repo/seo/json-ld/learning-resource"; +import type { Locale } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { DeferredAiSheetOpen } from "@/components/ai/deferred-sheet-open"; +import { DeferredComments } from "@/components/comments/deferred"; +import { ExerciseEntry } from "@/components/exercise/entry"; +import { + LayoutMaterial, + LayoutMaterialContent, + LayoutMaterialFooter, + LayoutMaterialHeader, + LayoutMaterialMain, + LayoutMaterialPagination, + LayoutMaterialToc, +} from "@/components/shared/layout-material"; +import { getOgUrl } from "@/lib/utils/metadata"; +import { ExerciseAttempt } from "./attempt"; +import type { ExerciseRouteData } from "./data"; + +/** Renders the standalone single-exercise variant for one learn route. */ +export async function SingleExercisePage({ + category, + data, + locale, + type, +}: { + category: ExercisesCategory; + data: Extract; + locale: Locale; + type: ExercisesType; +}) { + const t = await getTranslations({ locale, namespace: "Exercises" }); + const exerciseLabel = t("number-count", { count: data.exercise.number }); + const exerciseId = slugify(exerciseLabel); + const description = `${t("exercises")} - ${data.exercise.question.metadata.title} - ${data.currentMaterialItem.title}`; + const educationalLevel = `${t(type)} - ${t(category)}`; + const publishedAt = + formatContentDateISO(data.exercise.question.metadata.date) ?? + data.exercise.question.metadata.date; + const pagination = getExerciseNumberPagination( + data.setPath, + data.exercise.number, + data.exerciseCount, + (number) => t("number-count", { count: number }) + ); + + return ( + <> + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/page.tsx similarity index 97% rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/page.tsx index 33497d436..42af0256c 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/[type]/[material]/page.tsx @@ -21,7 +21,7 @@ import { CollectionPageJsonLd } from "@repo/seo/json-ld/collection-page"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import type { Locale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { use } from "react"; import { CardMaterial } from "@/components/shared/card-material"; import { ComingSoon } from "@/components/shared/coming-soon"; @@ -42,8 +42,6 @@ import { createSEODescription } from "@/lib/utils/seo/descriptions"; import { createSEOTitle } from "@/lib/utils/seo/titles"; import { getStaticParams } from "@/lib/utils/system"; -export const revalidate = false; - async function getResolvedParams( params: PageProps<"/[locale]/exercises/[category]/[type]/[material]">["params"] ) { @@ -149,9 +147,6 @@ export default function Page( notFound(); } - // Enable static rendering - setRequestLocale(locale); - return ( ["params"] ) { @@ -129,9 +127,6 @@ export default function Page( notFound(); } - // Enable static rendering - setRequestLocale(locale); - return ; } diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/page.tsx similarity index 73% rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/page.tsx index 6f54a3d69..ad592f1d9 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/exercises/[category]/page.tsx @@ -1,8 +1,7 @@ import { parseExercisesCategory } from "@repo/contents/_lib/exercises/category"; import { notFound } from "next/navigation"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; import { getStaticParams } from "@/lib/utils/system"; export function generateStaticParams() { @@ -16,17 +15,13 @@ export default function Page( props: PageProps<"/[locale]/exercises/[category]"> ) { const { params } = props; - const { locale: rawLocale, category: rawCategory } = use(params); - const locale = getLocaleOrThrow(rawLocale); + const { category: rawCategory } = use(params); const category = parseExercisesCategory(rawCategory); if (!category) { notFound(); } - // Enable static rendering - setRequestLocale(locale); - // Return 404 for empty exercise category pages // This prevents soft 404s and tells Google these pages don't exist // Source: https://developers.google.com/search/docs/crawling-indexing/soft-404s diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/quran/[surah]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/quran/[surah]/page.tsx new file mode 100644 index 000000000..aa4c59970 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/quran/[surah]/page.tsx @@ -0,0 +1,342 @@ +import { AllahIcon } from "@hugeicons/core-free-icons"; +import { getSurahName } from "@repo/contents/_lib/quran"; +import { cn, slugify } from "@repo/design-system/lib/utils"; +import { BookJsonLd } from "@repo/seo/json-ld/book"; +import { Effect } from "effect"; +import type { Metadata } from "next"; +import { cacheLife } from "next/cache"; +import { notFound } from "next/navigation"; +import type { Locale } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import type { ReactNode } from "react"; +import { DeferredAiSheetOpen } from "@/components/ai/deferred-sheet-open"; +import { + LayoutMaterial, + LayoutMaterialContent, + LayoutMaterialFooter, + LayoutMaterialHeader, + LayoutMaterialMain, + LayoutMaterialPagination, + LayoutMaterialToc, +} from "@/components/shared/layout-material"; +import { QuranAudio } from "@/components/shared/quran-audio"; +import { QuranInterpretation } from "@/components/shared/quran-interpretation"; +import { QuranText } from "@/components/shared/quran-text"; +import { RefContent } from "@/components/shared/ref-content"; +import { WindowVirtualized } from "@/components/shared/window-virtualized"; +import { VirtualProvider } from "@/lib/context/use-virtual"; +import { getLocaleOrThrow } from "@/lib/i18n/params"; +import { + fetchSurahContext, + fetchSurahMetadataContext, + getQuranPagination, +} from "@/lib/utils/pages/quran"; +import { generateSEOMetadata } from "@/lib/utils/seo/generator"; +import type { SEOContext } from "@/lib/utils/seo/types"; + +export async function generateMetadata({ + params, +}: { + params: PageProps<"/[locale]/quran/[surah]">["params"]; +}): Promise { + const { locale: rawLocale, surah } = await params; + const locale = getLocaleOrThrow(rawLocale); + + const t = await getTranslations("Holy"); + + const path = `/${locale}/quran/${surah}`; + + const alternates = { + canonical: path, + }; + const image = { + url: "/quran.png", + width: 1200, + height: 630, + }; + const twitter: Metadata["twitter"] = { + images: [image], + }; + const openGraph: Metadata["openGraph"] = { + url: path, + images: [image], + type: "book", + siteName: "Nakafa", + locale, + }; + + const surahNumber = Number(surah); + + if (Number.isNaN(surahNumber)) { + return { + alternates, + }; + } + + const surahData = await getSurahMetadataData({ surah: surahNumber }); + if (!surahData) { + return { + alternates, + }; + } + + // Evidence: Use ICU-based SEO generator for type-safe, locale-aware metadata + // Source: https://developers.google.com/search/docs/appearance/title-link + // Evidence: Arabic name is universal, locale-specific for transliteration/translation + const seoContext: SEOContext = { + type: "quran", + surah: surahData, + }; + + const { title, description, keywords } = await generateSEOMetadata( + seoContext, + locale + ); + + return { + title: { absolute: title }, + alternates, + category: t("quran"), + description, + keywords, + twitter, + openGraph, + }; +} + +export function generateStaticParams() { + // surah 1-114 + return Array.from({ length: 114 }, (_, i) => ({ + surah: (i + 1).toString(), + })); +} + +export default function Page(props: PageProps<"/[locale]/quran/[surah]">) { + return ; +} + +async function ResolvedSurahPage({ + params, +}: { + params: PageProps<"/[locale]/quran/[surah]">["params"]; +}) { + const { locale: rawLocale, surah } = await params; + const locale = getLocaleOrThrow(rawLocale); + + return ( + } + locale={locale} + surah={surah} + toolbar={} + /> + ); +} + +async function getSurahMetadataData({ surah }: { surah: number }) { + "use cache"; + + cacheLife("max"); + + const surahMetadataContext = await Effect.runPromise( + Effect.match(fetchSurahMetadataContext({ surah }), { + onFailure: () => null, + onSuccess: (data) => data, + }) + ); + + return surahMetadataContext?.surahData ?? null; +} + +async function CachedSurahShell({ + locale, + surah, + footer, + toolbar, +}: { + locale: Locale; + surah: string; + footer: ReactNode; + toolbar: ReactNode; +}) { + "use cache"; + + cacheLife("max"); + + const t = await getTranslations("Holy"); + + const surahNumber = Number(surah); + + if (Number.isNaN(surahNumber)) { + notFound(); + } + + const result = await Effect.runPromise( + Effect.match(fetchSurahContext({ surah: surahNumber }), { + onFailure: () => ({ + surahData: null, + prevSurah: null, + nextSurah: null, + }), + onSuccess: (data) => data, + }) + ); + + const { surahData, prevSurah, nextSurah } = result; + + if (!surahData) { + notFound(); + } + + const translation = surahData.name.translation[locale]; + + const preBismillah = surahData.preBismillah; + + const title = getSurahName({ locale, name: surahData.name }); + + const headings = surahData.verses.map((verse, index) => ({ + label: t("verse-count", { count: verse.number.inSurah }), + index, + href: `/quran/${surah}#${slugify(t("verse-count", { count: verse.number.inSurah }))}`, + children: [], + })); + + const pagination = getQuranPagination({ + prevSurah, + nextSurah, + }); + + const prevTitle = prevSurah + ? getSurahName({ locale, name: prevSurah.name }) + : ""; + const nextTitle = nextSurah + ? getSurahName({ locale, name: nextSurah.name }) + : ""; + + const paginationWithLocalizedTitles = { + prev: { + href: pagination.prev.href, + title: prevTitle, + }, + next: { + href: pagination.next.href, + title: nextTitle, + }, + }; + + return ( + <> + + + + + + + {!!preBismillah && ( +
+ {preBismillah.text.arab} +

+ {preBismillah.translation[locale] ?? + preBismillah.translation.en} +

+
+ )} + + + {surahData.verses.map((verse, index) => { + const transliteration = verse.text.transliteration.en; + const translate = + verse.translation[locale] ?? verse.translation.en; + + const id = slugify( + t("verse-count", { count: verse.number.inSurah }) + ); + + return ( +
+ + {verse.text.arab} +
+

+ {transliteration} +

+

+ {translate} +

+
+
+ ); + })} +
+
+ + {footer} + {toolbar} +
+ +
+
+ + ); +} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/quran/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/quran/page.tsx similarity index 95% rename from apps/www/app/[locale]/(app)/(static)/(learn)/quran/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/quran/page.tsx index 4e490f34e..502e5f6db 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/quran/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/quran/page.tsx @@ -4,7 +4,7 @@ import NavigationLink from "@repo/design-system/components/ui/navigation-link"; import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb"; import type { Metadata } from "next"; import { type Locale, useTranslations } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { use } from "react"; import { FooterContent } from "@/components/shared/footer-content"; import { HeaderContent } from "@/components/shared/header-content"; @@ -12,8 +12,6 @@ import { LayoutContent } from "@/components/shared/layout-content"; import { RefContent } from "@/components/shared/ref-content"; import { getLocaleOrThrow } from "@/lib/i18n/params"; -export const revalidate = false; - export async function generateMetadata({ params, }: { @@ -62,9 +60,6 @@ export default function Page(props: PageProps<"/[locale]/quran">) { const { locale: rawLocale } = use(params); const locale = getLocaleOrThrow(rawLocale); - // Enable static rendering - setRequestLocale(locale); - return ; } diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/subject/[category]/[grade]/[material]/[...slug]/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/[category]/[grade]/[material]/[...slug]/layout.tsx similarity index 94% rename from apps/www/app/[locale]/(app)/(static)/(learn)/subject/[category]/[grade]/[material]/[...slug]/layout.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/[category]/[grade]/[material]/[...slug]/layout.tsx index c448c691f..6ffcdcf30 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/subject/[category]/[grade]/[material]/[...slug]/layout.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/[category]/[grade]/[material]/[...slug]/layout.tsx @@ -4,7 +4,7 @@ import { parseMaterial } from "@repo/contents/_lib/subject/material"; import { getSlugPath } from "@repo/contents/_lib/subject/slug"; import { cleanSlug } from "@repo/utilities/helper"; import { notFound } from "next/navigation"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; import { ContentViewTracker } from "@/components/tracking/content-view-tracker"; import { getLocaleOrThrow } from "@/lib/i18n/params"; @@ -29,8 +29,6 @@ export default function Layout( notFound(); } - setRequestLocale(locale); - const filePath = getSlugPath(category, grade, material, slug); const cleanedSlug = cleanSlug(filePath); diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/subject/[category]/[grade]/[material]/[...slug]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/[category]/[grade]/[material]/[...slug]/page.tsx similarity index 72% rename from apps/www/app/[locale]/(app)/(static)/(learn)/subject/[category]/[grade]/[material]/[...slug]/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/[category]/[grade]/[material]/[...slug]/page.tsx index 99cd91512..96466eb54 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/subject/[category]/[grade]/[material]/[...slug]/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/[category]/[grade]/[material]/[...slug]/page.tsx @@ -1,3 +1,4 @@ +import { importContentModule } from "@repo/contents/_lib/module"; import { parseSubjectCategory } from "@repo/contents/_lib/subject/category"; import { getGradeNonNumeric, @@ -25,12 +26,13 @@ import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb"; import { LearningResourceJsonLd } from "@repo/seo/json-ld/learning-resource"; import { Effect } from "effect"; import type { Metadata } from "next"; +import { cacheLife } from "next/cache"; import { notFound, redirect } from "next/navigation"; import type { Locale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { AiSheetOpen } from "@/components/ai/sheet-open"; -import { Comments } from "@/components/comments"; +import { getTranslations } from "next-intl/server"; +import type { ReactNode } from "react"; +import { DeferredAiSheetOpen } from "@/components/ai/deferred-sheet-open"; +import { DeferredComments } from "@/components/comments/deferred"; import { ComingSoon } from "@/components/shared/coming-soon"; import { LayoutMaterial, @@ -44,16 +46,11 @@ import { import { getLocaleOrThrow } from "@/lib/i18n/params"; import { getGithubUrl } from "@/lib/utils/github"; import { getOgUrl } from "@/lib/utils/metadata"; -import { - getContentContext, - getContentMetadataContext, -} from "@/lib/utils/pages/subject"; +import { getContentMetadataContext } from "@/lib/utils/pages/subject"; import { generateSEOMetadata } from "@/lib/utils/seo/generator"; import type { SEOContext } from "@/lib/utils/seo/types"; import { getStaticParams } from "@/lib/utils/system"; -export const revalidate = false; - async function getResolvedParams( params: PageProps<"/[locale]/subject/[category]/[grade]/[material]/[...slug]">["params"] ) { @@ -83,43 +80,21 @@ export async function generateMetadata({ }): Promise { const { locale, category, grade, material, slug } = await getResolvedParams(params); - const t = await getTranslations({ locale, namespace: "Subject" }); + const t = await getTranslations("Subject"); - const FilePath = getSlugPath(category, grade, material, slug); - const materialPath = getMaterialPath(category, grade, material); - - // Fetch content and materials in parallel - const [{ content }, materials] = await Promise.all([ - Effect.runPromise( - Effect.match( - getContentMetadataContext({ locale, category, grade, material, slug }), - { - onFailure: () => ({ content: null, FilePath }), - onSuccess: (data) => data, - } - ) - ), - getMaterials(materialPath, locale).catch(() => []), - ]); - - const metadata = content?.metadata ?? null; - - // Get chapter title from materials using getCurrentMaterial - let chapter: string | undefined; - if (slug.length > 0 && materials.length > 0) { - const chapterPath = getSlugPath(category, grade, material, [ - slug.at(0) ?? "", - ]); - const { currentChapter } = getCurrentMaterial(chapterPath, materials); - chapter = currentChapter?.title; - } + const { chapter, filePath, metadata, path } = await getSubjectMetadataData({ + locale, + category, + grade, + material, + slug, + }); - const path = `/${locale}${FilePath}`; const alternates = { canonical: path, }; const image = { - url: getOgUrl(locale, FilePath), + url: getOgUrl(locale, filePath), width: 1200, height: 630, }; @@ -184,41 +159,47 @@ export function generateStaticParams() { }); } -export default function Page( - props: PageProps<"/[locale]/subject/[category]/[grade]/[material]/[...slug]"> -) { - const { params } = props; - const { - locale: rawLocale, - category: rawCategory, - grade: rawGrade, - material: rawMaterial, - slug, - } = use(params); - const locale = getLocaleOrThrow(rawLocale); - const category = parseSubjectCategory(rawCategory); - const grade = parseGrade(rawGrade); - const material = parseMaterial(rawMaterial); +export default async function Page({ + params, +}: { + params: PageProps<"/[locale]/subject/[category]/[grade]/[material]/[...slug]">["params"]; +}) { + const { locale, category, grade, material, slug } = + await getResolvedParams(params); - if (!(category && grade && material)) { - notFound(); + if (slug.length === 1) { + redirect(getSlugPath(category, grade, material, [])); } - // Enable static rendering - setRequestLocale(locale); + const filePath = getSlugPath(category, grade, material, slug); + const content = await importContentModule(filePath, locale).catch(() => null); + const Content = content?.default; return ( - } grade={grade} locale={locale} material={material} slug={slug} - /> + toolbar={ + + } + > + {Content ? : null} + ); } -async function PageContent({ +async function getSubjectMetadataData({ locale, category, grade, @@ -231,40 +212,88 @@ async function PageContent({ material: Material; slug: string[]; }) { - const [tCommon, tSubject] = await Promise.all([ - getTranslations({ locale, namespace: "Common" }), - getTranslations({ locale, namespace: "Subject" }), + "use cache"; + + cacheLife("max"); + + const filePath = getSlugPath(category, grade, material, slug); + const materialPath = getMaterialPath(category, grade, material); + + const [{ content }, materials] = await Promise.all([ + Effect.runPromise( + Effect.match( + getContentMetadataContext({ locale, category, grade, material, slug }), + { + onFailure: () => ({ content: null, FilePath: filePath }), + onSuccess: (data) => data, + } + ) + ), + getMaterials(materialPath, locale).catch(() => []), ]); - if (slug.length === 1) { - // Means it only contains the chapter name, not the section name - // The slugs usually have 2 items, chapter and section - // In the future, we can add a new page specifically for the section - const materialPath = getSlugPath(category, grade, material, []); - redirect(materialPath); - } + const metadata = content?.metadata ?? null; + const chapterPath = getSlugPath(category, grade, material, [ + slug.at(0) ?? "", + ]); + const chapter = + slug.length > 0 && materials.length > 0 + ? getCurrentMaterial(chapterPath, materials).currentChapter?.title + : undefined; + + return { + metadata, + chapter, + filePath, + path: `/${locale}${filePath}`, + }; +} + +async function CachedSubjectShell({ + locale, + category, + grade, + material, + slug, + children, + footer, + toolbar, +}: { + locale: Locale; + category: SubjectCategory; + grade: Grade; + material: Material; + slug: string[]; + children: ReactNode; + footer: ReactNode; + toolbar: ReactNode; +}) { + "use cache"; + + cacheLife("max"); + + const [tCommon, tSubject] = await Promise.all([ + getTranslations("Common"), + getTranslations("Subject"), + ]); const FilePath = getSlugPath(category, grade, material, slug); const materialPath = getMaterialPath(category, grade, material); - const result = await Effect.runPromise( - Effect.match( - getContentContext({ locale, category, grade, material, slug }), - { - onFailure: () => ({ - content: null, - materials: null, - materialPath, - FilePath, - }), - onSuccess: (data) => data, - } - ) - ); - - const { content, materials } = result; + const [content, materials] = await Promise.all([ + Effect.runPromise( + Effect.match( + getContentMetadataContext({ locale, category, grade, material, slug }), + { + onFailure: () => ({ content: null, FilePath }), + onSuccess: (data) => data, + } + ) + ), + getMaterials(materialPath, locale).catch(() => null), + ]); - if (!(content && materials)) { + if (!(content.content && materials && children !== null)) { return ( @@ -276,7 +305,7 @@ async function PageContent({ ); } - const { metadata, default: Content, raw } = content; + const { metadata, raw } = content.content; const publishedAt = formatContentDateISO(metadata.date) ?? metadata.date; const pagination = getMaterialsPagination(FilePath, materials); @@ -333,19 +362,11 @@ async function PageContent({ /> {headings.length === 0 && } - {headings.length > 0 && Content} + {headings.length > 0 ? children : null} - - - - + {footer} + {toolbar} ["params"] ) { @@ -144,8 +142,6 @@ export default function Page( notFound(); } - setRequestLocale(locale); - return ( ["params"] ) { @@ -143,9 +141,6 @@ export default function Page( notFound(); } - // Enable static rendering - setRequestLocale(locale); - return ; } diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/subject/[category]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/[category]/page.tsx similarity index 73% rename from apps/www/app/[locale]/(app)/(static)/(learn)/subject/[category]/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/[category]/page.tsx index bc4caad18..8dbff0011 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/subject/[category]/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/[category]/page.tsx @@ -1,8 +1,7 @@ import { parseSubjectCategory } from "@repo/contents/_lib/subject/category"; import { notFound } from "next/navigation"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; import { getStaticParams } from "@/lib/utils/system"; export function generateStaticParams() { @@ -14,17 +13,13 @@ export function generateStaticParams() { export default function Page(props: PageProps<"/[locale]/subject/[category]">) { const { params } = props; - const { locale: rawLocale, category: rawCategory } = use(params); - const locale = getLocaleOrThrow(rawLocale); + const { category: rawCategory } = use(params); const category = parseSubjectCategory(rawCategory); if (!category) { notFound(); } - // Enable static rendering - setRequestLocale(locale); - // Return 404 for empty category pages // This prevents soft 404s and tells Google these pages don't exist // Source: https://developers.google.com/search/docs/crawling-indexing/soft-404s diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/subject/icons.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/icons.tsx similarity index 100% rename from apps/www/app/[locale]/(app)/(static)/(learn)/subject/icons.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/icons.tsx diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/subject/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/page.tsx similarity index 97% rename from apps/www/app/[locale]/(app)/(static)/(learn)/subject/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/page.tsx index bd91003a5..e23179125 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/subject/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/(learn)/subject/page.tsx @@ -9,15 +9,13 @@ import NavigationLink from "@repo/design-system/components/ui/navigation-link"; import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb"; import type { Metadata } from "next"; import type { Locale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { use } from "react"; import { HeaderContent } from "@/components/shared/header-content"; import { LayoutContent } from "@/components/shared/layout-content"; import { getLocaleOrThrow } from "@/lib/i18n/params"; import { getGradeIcon } from "./icons"; -export const revalidate = false; - const CATEGORY_ORDER = ["middle-school", "high-school", "university"] as const; export async function generateMetadata({ @@ -57,8 +55,6 @@ export default function Page(props: PageProps<"/[locale]/subject">) { const { locale: rawLocale } = use(params); const locale = getLocaleOrThrow(rawLocale); - setRequestLocale(locale); - return ; } diff --git a/apps/www/app/[locale]/(app)/(shared)/(main)/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/(main)/layout.tsx new file mode 100644 index 000000000..c1a162897 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/(main)/layout.tsx @@ -0,0 +1,12 @@ +import { AppShell } from "@/components/sidebar/app-shell"; + +/** + * Renders the shared student shell for learn pages and core signed-in routes. + * + * Keeping one shell instance for both route groups avoids shell-state + * divergence across cross-group navigations under Cache Components. + */ +export default function Layout(props: LayoutProps<"/[locale]">) { + const { children } = props; + return {children}; +} diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/privacy-policy/en.mdx b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/privacy-policy/en.mdx similarity index 100% rename from apps/www/app/[locale]/(app)/(static)/(site)/(legal)/privacy-policy/en.mdx rename to apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/privacy-policy/en.mdx diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/privacy-policy/id.mdx b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/privacy-policy/id.mdx similarity index 100% rename from apps/www/app/[locale]/(app)/(static)/(site)/(legal)/privacy-policy/id.mdx rename to apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/privacy-policy/id.mdx diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/privacy-policy/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/privacy-policy/page.tsx similarity index 80% rename from apps/www/app/[locale]/(app)/(static)/(site)/(legal)/privacy-policy/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/privacy-policy/page.tsx index a2e5af92c..55b7e19ae 100644 --- a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/privacy-policy/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/privacy-policy/page.tsx @@ -1,13 +1,10 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import type { Locale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { use } from "react"; import { getLocaleOrThrow } from "@/lib/i18n/params"; -export const dynamic = "force-static"; -export const revalidate = false; - export async function generateMetadata({ params, }: { @@ -29,9 +26,6 @@ export default function Page(props: PageProps<"/[locale]/privacy-policy">) { const { params } = props; const locale = getLocaleOrThrow(use(params).locale); - // Enable static rendering - setRequestLocale(locale); - return ; } @@ -44,7 +38,7 @@ async function PageContent({ locale }: { locale: Locale }) { } return ( -
+
); diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/security-policy/en.mdx b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/security-policy/en.mdx similarity index 100% rename from apps/www/app/[locale]/(app)/(static)/(site)/(legal)/security-policy/en.mdx rename to apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/security-policy/en.mdx diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/security-policy/id.mdx b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/security-policy/id.mdx similarity index 100% rename from apps/www/app/[locale]/(app)/(static)/(site)/(legal)/security-policy/id.mdx rename to apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/security-policy/id.mdx diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/security-policy/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/security-policy/page.tsx similarity index 80% rename from apps/www/app/[locale]/(app)/(static)/(site)/(legal)/security-policy/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/security-policy/page.tsx index b2e2fd8be..3cab20de9 100644 --- a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/security-policy/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/security-policy/page.tsx @@ -1,13 +1,10 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import type { Locale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { use } from "react"; import { getLocaleOrThrow } from "@/lib/i18n/params"; -export const dynamic = "force-static"; -export const revalidate = false; - export async function generateMetadata({ params, }: { @@ -29,9 +26,6 @@ export default function Page(props: PageProps<"/[locale]/security-policy">) { const { params } = props; const locale = getLocaleOrThrow(use(params).locale); - // Enable static rendering - setRequestLocale(locale); - return ; } @@ -44,7 +38,7 @@ async function PageContent({ locale }: { locale: Locale }) { } return ( -
+
); diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/terms-of-service/en.mdx b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/terms-of-service/en.mdx similarity index 100% rename from apps/www/app/[locale]/(app)/(static)/(site)/(legal)/terms-of-service/en.mdx rename to apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/terms-of-service/en.mdx diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/terms-of-service/id.mdx b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/terms-of-service/id.mdx similarity index 100% rename from apps/www/app/[locale]/(app)/(static)/(site)/(legal)/terms-of-service/id.mdx rename to apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/terms-of-service/id.mdx diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/terms-of-service/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/terms-of-service/page.tsx similarity index 80% rename from apps/www/app/[locale]/(app)/(static)/(site)/(legal)/terms-of-service/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/terms-of-service/page.tsx index 556d3e01a..bd61a2176 100644 --- a/apps/www/app/[locale]/(app)/(static)/(site)/(legal)/terms-of-service/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(site)/(legal)/terms-of-service/page.tsx @@ -1,13 +1,10 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import type { Locale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { use } from "react"; import { getLocaleOrThrow } from "@/lib/i18n/params"; -export const dynamic = "force-static"; -export const revalidate = false; - export async function generateMetadata({ params, }: { @@ -29,9 +26,6 @@ export default function Page(props: PageProps<"/[locale]/terms-of-service">) { const { params } = props; const locale = getLocaleOrThrow(use(params).locale); - // Enable static rendering - setRequestLocale(locale); - return ; } @@ -44,7 +38,7 @@ async function PageContent({ locale }: { locale: Locale }) { } return ( -
+
); diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/about/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(site)/about/page.tsx similarity index 95% rename from apps/www/app/[locale]/(app)/(static)/(site)/about/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(site)/about/page.tsx index df9884667..ea18e0066 100644 --- a/apps/www/app/[locale]/(app)/(static)/(site)/about/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(site)/about/page.tsx @@ -5,7 +5,7 @@ import { FAQPageJsonLd } from "@repo/seo/json-ld/faq-page"; import type { ListItem } from "@repo/seo/types"; import type { Metadata } from "next"; import type { Locale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { use } from "react"; import { Ai } from "@/components/marketing/about/ai"; import { Community } from "@/components/marketing/about/community"; @@ -20,9 +20,6 @@ import { exercisesMenu } from "@/components/sidebar/_data/exercises"; import { subjectMenu } from "@/components/sidebar/_data/subject"; import { getLocaleOrThrow } from "@/lib/i18n/params"; -export const dynamic = "force-static"; -export const revalidate = false; - export async function generateMetadata({ params, }: { @@ -78,9 +75,6 @@ export default function Page(props: PageProps<"/[locale]/about">) { const { locale: rawLocale } = use(params); const locale = getLocaleOrThrow(rawLocale); - // Enable static rendering - setRequestLocale(locale); - return ; } @@ -167,7 +161,7 @@ async function AboutPageContent({ locale }: { locale: Locale }) { }))} url={`https://nakafa.com/${locale}/about`} /> -
+
diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/contributor/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(site)/contributor/page.tsx similarity index 78% rename from apps/www/app/[locale]/(app)/(static)/(site)/contributor/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(site)/contributor/page.tsx index bfb3a4c8a..7e70503d6 100644 --- a/apps/www/app/[locale]/(app)/(static)/(site)/contributor/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(site)/contributor/page.tsx @@ -2,16 +2,12 @@ import { LoveKoreanFingerIcon } from "@hugeicons/core-free-icons"; import { Avatar } from "@repo/design-system/components/contributor/avatar"; import type { Metadata } from "next"; import { useTranslations } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; +import { getTranslations } from "next-intl/server"; import { HeaderContent } from "@/components/shared/header-content"; import { LayoutContent } from "@/components/shared/layout-content"; import { contributors } from "@/lib/data/contributor"; import { getLocaleOrThrow } from "@/lib/i18n/params"; -export const dynamic = "force-static"; -export const revalidate = false; - export async function generateMetadata({ params, }: { @@ -29,13 +25,7 @@ export async function generateMetadata({ }; } -export default function Page(props: PageProps<"/[locale]/contributor">) { - const { params } = props; - const locale = getLocaleOrThrow(use(params).locale); - - // Enable static rendering - setRequestLocale(locale); - +export default function Page() { return ( <> diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/events/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(site)/events/page.tsx similarity index 57% rename from apps/www/app/[locale]/(app)/(static)/(site)/events/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(site)/events/page.tsx index 0535cf30d..7b969b33e 100644 --- a/apps/www/app/[locale]/(app)/(static)/(site)/events/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(site)/events/page.tsx @@ -1,11 +1,7 @@ import type { Metadata } from "next"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; +import { getTranslations } from "next-intl/server"; import { getLocaleOrThrow } from "@/lib/i18n/params"; -export const dynamic = "force-static"; -export const revalidate = false; - export async function generateMetadata({ params, }: { @@ -23,12 +19,6 @@ export async function generateMetadata({ }; } -export default function Page(props: PageProps<"/[locale]/events">) { - const { params } = props; - const locale = getLocaleOrThrow(use(params).locale); - - // Enable static rendering - setRequestLocale(locale); - +export default function Page() { return null; } diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/home/page.tsx b/apps/www/app/[locale]/(app)/(shared)/(site)/home/page.tsx similarity index 78% rename from apps/www/app/[locale]/(app)/(static)/(site)/home/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/(site)/home/page.tsx index ab81c8b41..e59c0e260 100644 --- a/apps/www/app/[locale]/(app)/(static)/(site)/home/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(site)/home/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "@repo/internationalization/src/navigation"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; import { getLocaleOrThrow } from "@/lib/i18n/params"; @@ -7,9 +7,6 @@ export default function Page(props: PageProps<"/[locale]/home">) { const { params } = props; const locale = getLocaleOrThrow(use(params).locale); - // Enable static rendering - setRequestLocale(locale); - // This is empty page, redirect to home page redirect({ href: "/about", locale }); } diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/(site)/layout.tsx similarity index 90% rename from apps/www/app/[locale]/(app)/(static)/(site)/layout.tsx rename to apps/www/app/[locale]/(app)/(shared)/(site)/layout.tsx index 8444aff03..3a92b3ff4 100644 --- a/apps/www/app/[locale]/(app)/(static)/(site)/layout.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/(site)/layout.tsx @@ -1,7 +1,6 @@ import { routing } from "@repo/internationalization/src/routing"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; import { use } from "react"; import { Footer } from "@/components/marketing/shared/footer"; import { Header } from "@/components/marketing/shared/header"; @@ -14,8 +13,6 @@ export default function Layout(props: LayoutProps<"/[locale]">) { notFound(); } - setRequestLocale(locale); - return (
) { + const { children } = props; + return {children}; +} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/try-out/[product]/[slug]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/page.tsx similarity index 61% rename from apps/www/app/[locale]/(app)/(dynamic)/try-out/[product]/[slug]/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/page.tsx index 6b2bc2976..5ae22fcb2 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/try-out/[product]/[slug]/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/page.tsx @@ -8,10 +8,9 @@ import { routing } from "@repo/internationalization/src/routing"; import { fetchQuery, preloadQuery } from "convex/nextjs"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { TryoutSetProvider } from "@/components/tryout/providers/set-provider"; import { TryoutSetParts } from "@/components/tryout/set-parts"; -import { TryoutSetRouteShell } from "@/components/tryout/set-route-shell"; import { TryoutPageHeader } from "@/components/tryout/shared/page-header"; import { TryoutPageMeta } from "@/components/tryout/shared/page-meta"; import { TryoutStartCta } from "@/components/tryout/start-cta"; @@ -26,14 +25,11 @@ export default async function Page( ) { const { params, searchParams } = props; const { locale, product: productParam, slug } = await params; - const initialNowMs = Date.now(); if (!hasLocale(routing.locales, locale)) { notFound(); } - setRequestLocale(locale); - if (!isTryoutProduct(productParam)) { notFound(); } @@ -55,6 +51,8 @@ export default async function Page( notFound(); } + const initialNowMs = Date.now(); + const { attempt } = await loadTryoutSearchParams(searchParams); const preloadedSetView = token ? await preloadQuery( @@ -83,52 +81,50 @@ export default async function Page( partKeys={partKeys} preloadedSetView={preloadedSetView} > - -
-
-
- - } - title={tryoutLabel} - /> -
- - -
+
+
+
+ + } + title={tryoutLabel} + /> +
+ +
+
-
- { - const materialLabel = ExercisesMaterialSchema.safeParse( - part.material - ); +
+ { + const materialLabel = ExercisesMaterialSchema.safeParse( + part.material + ); - return { - partIndex: part.partIndex, - partKey: part.partKey, - label: materialLabel.success - ? tExercises(materialLabel.data) - : part.partKey, - material: part.material, - questionCount: part.questionCount, - }; - })} - /> -
-
+ return { + partIndex: part.partIndex, + partKey: part.partKey, + label: materialLabel.success + ? tExercises(materialLabel.data) + : part.partKey, + material: part.material, + questionCount: part.questionCount, + }; + })} + /> +
- +
); } diff --git a/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/part/[partKey]/body.tsx b/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/part/[partKey]/body.tsx new file mode 100644 index 000000000..b80872809 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/part/[partKey]/body.tsx @@ -0,0 +1,174 @@ +import { api } from "@repo/backend/convex/_generated/api"; +import { + type TryoutProduct, + tryoutProductPolicies, +} from "@repo/backend/convex/tryouts/products"; +import { getMaterialIcon } from "@repo/contents/_lib/subject/material"; +import { ExercisesMaterialSchema } from "@repo/contents/_types/exercises/material"; +import { preloadedQueryResult, preloadQuery } from "convex/nextjs"; +import { notFound, redirect } from "next/navigation"; +import type { Locale } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { ExerciseTrackedEntry } from "@/components/exercise/entry"; +import { TryoutPartRuntime } from "@/components/tryout/part-runtime"; +import { TryoutPartProvider } from "@/components/tryout/providers/part-provider"; +import { loadTryoutSearchParams } from "@/components/tryout/utils/attempt-search"; +import { + getTryoutHistoryHref, + getTryoutSetHref, +} from "@/components/tryout/utils/routes"; +import { getToken } from "@/lib/auth/server"; +import { getTryoutExercises, getTryoutPartData } from "./data"; + +/** Preloads the authenticated tryout runtime when the current request has a token. */ +async function getTryoutRuntime( + token: Awaited>, + args: { + attempt: string | null; + locale: Locale; + partKey: string; + product: TryoutProduct; + slug: string; + } +) { + if (!token) { + return { + preloadedRuntime: undefined, + runtime: undefined, + }; + } + + const preloadedRuntime = await preloadQuery( + api.tryouts.queries.me.part.getUserTryoutPartAttempt, + { + attemptId: args.attempt ?? undefined, + locale: args.locale, + partKey: args.partKey, + product: args.product, + tryoutSlug: args.slug, + }, + { token } + ); + + return { + preloadedRuntime, + runtime: preloadedQueryResult(preloadedRuntime), + }; +} + +/** Resolves the full tryout part route after the local Suspense boundary opens. */ +export async function TryoutPartBody({ + locale, + partKey, + product, + searchParams, + slug, +}: { + locale: Locale; + partKey: string; + product: TryoutProduct; + searchParams: PageProps<"/[locale]/try-out/[product]/[slug]/part/[partKey]">["searchParams"]; + slug: string; +}) { + const [partData, tExercises] = await Promise.all([ + getTryoutPartData(locale, product, slug, partKey), + getTranslations({ locale, namespace: "Exercises" }), + ]); + + if (!partData) { + notFound(); + } + + const [token, { attempt }] = await Promise.all([ + getToken(), + loadTryoutSearchParams(searchParams), + ]); + const initialNowMs = Date.now(); + const { preloadedRuntime, runtime } = await getTryoutRuntime(token, { + attempt, + locale, + partKey, + product, + slug, + }); + + if (token && runtime && !runtime.part) { + redirect( + getTryoutHistoryHref( + getTryoutSetHref({ product, tryoutSlug: slug }), + attempt + ) + ); + } + + const contentPart = runtime?.part + ? { + material: runtime.part.material, + partKey: runtime.part.currentPartKey, + questionCount: runtime.part.questionCount, + setSlug: runtime.part.setSlug, + } + : { + material: partData.currentPart.material, + partKey: partData.currentPart.partKey, + questionCount: partData.currentPart.questionCount, + setSlug: partData.currentPart.setSlug, + }; + + const exercises = await getTryoutExercises(locale, contentPart.setSlug); + + if (exercises.length === 0) { + notFound(); + } + + const material = ExercisesMaterialSchema.safeParse(contentPart.material); + const partIcon = material.success + ? getMaterialIcon(material.data) + : undefined; + const partLabel = material.success + ? tExercises(material.data) + : contentPart.partKey; + const timeLimitSeconds = tryoutProductPolicies[ + product + ].getPartTimeLimitSeconds(contentPart.questionCount); + + return ( + +
+
+ + {exercises.map((exercise) => ( + + ))} + +
+
+
+ ); +} diff --git a/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/part/[partKey]/data.ts b/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/part/[partKey]/data.ts new file mode 100644 index 000000000..e255aa328 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/part/[partKey]/data.ts @@ -0,0 +1,52 @@ +import { api } from "@repo/backend/convex/_generated/api"; +import type { TryoutProduct } from "@repo/backend/convex/tryouts/products"; +import { getRenderableExercisesContent } from "@repo/contents/_lib/exercises/renderable"; +import { fetchQuery } from "convex/nextjs"; +import { cacheLife } from "next/cache"; +import type { Locale } from "next-intl"; + +/** Loads the public tryout details for one part route inside a cacheable scope. */ +export async function getTryoutPartData( + locale: Locale, + product: TryoutProduct, + slug: string, + partKey: string +) { + "use cache"; + + cacheLife("max"); + + const details = await fetchQuery( + api.tryouts.queries.tryouts.getTryoutDetails, + { + locale, + product, + slug, + } + ); + + if (!details) { + return null; + } + + const currentPart = details.parts.find((part) => part.partKey === partKey); + + if (!currentPart) { + return null; + } + + return { + currentPart, + details, + partKeys: details.parts.map((part) => part.partKey), + }; +} + +/** Loads one tryout exercise set as serializable exercise rows. */ +export async function getTryoutExercises(locale: Locale, setSlug: string) { + "use cache"; + + cacheLife("max"); + + return await getRenderableExercisesContent(locale, setSlug); +} diff --git a/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/part/[partKey]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/part/[partKey]/page.tsx new file mode 100644 index 000000000..467d6ee5d --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/[slug]/part/[partKey]/page.tsx @@ -0,0 +1,31 @@ +import { isTryoutProduct } from "@repo/backend/convex/tryouts/products"; +import { routing } from "@repo/internationalization/src/routing"; +import { notFound } from "next/navigation"; +import { hasLocale } from "next-intl"; +import { TryoutPartBody } from "./body"; + +/** Renders one tryout part page after the session layout has mounted the shell. */ +export default async function Page( + props: PageProps<"/[locale]/try-out/[product]/[slug]/part/[partKey]"> +) { + const { params, searchParams } = props; + const { locale, partKey, product, slug } = await params; + + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + if (!isTryoutProduct(product)) { + notFound(); + } + + return ( + + ); +} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/try-out/[product]/page.tsx b/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/page.tsx similarity index 54% rename from apps/www/app/[locale]/(app)/(dynamic)/try-out/[product]/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/try-out/[product]/page.tsx index ccf239272..81a19a08d 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/try-out/[product]/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/try-out/[product]/page.tsx @@ -11,8 +11,7 @@ import { routing } from "@repo/internationalization/src/routing"; import { fetchQuery } from "convex/nextjs"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { AppShell } from "@/components/sidebar/app-shell"; +import { getTranslations } from "next-intl/server"; import { TryoutCatalogCard } from "@/components/tryout/catalog-card"; import { TryoutCatalogList } from "@/components/tryout/catalog-list"; import { SnbtTryoutIcon } from "@/components/tryout/product-icon"; @@ -31,14 +30,11 @@ export default async function Page( ) { const { params } = props; const { locale, product: productParam } = await params; - const initialNowMs = Date.now(); if (!hasLocale(routing.locales, locale)) { notFound(); } - setRequestLocale(locale); - if (!isTryoutProduct(productParam)) { notFound(); } @@ -49,6 +45,9 @@ export default async function Page( getTranslations({ locale, namespace: "Tryouts" }), getToken(), ]); + + const initialNowMs = Date.now(); + const catalogSnapshot = await fetchQuery( api.tryouts.queries.tryouts.getActiveTryoutCatalogSnapshot, { @@ -60,50 +59,48 @@ export default async function Page( ); return ( - -
-
-
- - {tCommon("back")} - - -
- -

- {tTryouts("products.snbt.title")} -

-
-

- {tTryouts("product-page-description")} -

-
- - } - description={tTryouts("products.snbt.description")} - title={tTryouts("products.snbt.title")} +
+
+
+ - + +
+ - -
+

+ {tTryouts("products.snbt.title")} +

+
+

+ {tTryouts("product-page-description")} +

+ + + } + description={tTryouts("products.snbt.description")} + title={tTryouts("products.snbt.title")} + > + +
- +
); } diff --git a/apps/www/app/[locale]/(app)/(shared)/try-out/layout.tsx b/apps/www/app/[locale]/(app)/(shared)/try-out/layout.tsx new file mode 100644 index 000000000..5e61a6629 --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/try-out/layout.tsx @@ -0,0 +1,6 @@ +import { TryoutShell } from "@/components/tryout/shell"; + +/** Renders the shared tryout shell for every route in the tryout subtree. */ +export default function Layout({ children }: LayoutProps<"/[locale]/try-out">) { + return {children}; +} diff --git a/apps/www/app/[locale]/(app)/(shared)/try-out/loading.tsx b/apps/www/app/[locale]/(app)/(shared)/try-out/loading.tsx new file mode 100644 index 000000000..877aab12d --- /dev/null +++ b/apps/www/app/[locale]/(app)/(shared)/try-out/loading.tsx @@ -0,0 +1,4 @@ +/** Renders the tryout route fallback inside the shared tryout shell. */ +export default function Loading() { + return null; +} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/try-out/page.tsx b/apps/www/app/[locale]/(app)/(shared)/try-out/page.tsx similarity index 86% rename from apps/www/app/[locale]/(app)/(dynamic)/try-out/page.tsx rename to apps/www/app/[locale]/(app)/(shared)/try-out/page.tsx index 0aedeb3f4..0b3fead5a 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/try-out/page.tsx +++ b/apps/www/app/[locale]/(app)/(shared)/try-out/page.tsx @@ -3,9 +3,8 @@ import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { hasLocale, useTranslations } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { use } from "react"; -import { AppShell } from "@/components/sidebar/app-shell"; import { TryoutHubPage } from "@/components/tryout/hub-page"; export async function generateMetadata({ @@ -53,15 +52,11 @@ export default function Page(props: PageProps<"/[locale]/try-out">) { notFound(); } - setRequestLocale(locale); - return ( - + <> -
- -
-
+ + ); } diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/articles/page.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/articles/page.tsx deleted file mode 100644 index 729d6e72e..000000000 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/articles/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { notFound } from "next/navigation"; -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; - -export default function Page(props: PageProps<"/[locale]/articles">) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - // Enable static rendering - setRequestLocale(locale); - - // Return 404 for empty articles index page - // This prevents soft 404s and tells Google this page doesn't exist - // Source: https://developers.google.com/search/docs/crawling-indexing/soft-404s - notFound(); -} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/analytics.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/analytics.tsx deleted file mode 100644 index b4358931b..000000000 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/analytics.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { useIntersection, useInterval } from "@mantine/hooks"; -import type { ReactNode } from "react"; -import { useEffect, useEffectEvent, useRef } from "react"; -import { useAttempt } from "@/lib/context/use-attempt"; -import { useExercise } from "@/lib/context/use-exercise"; - -export function QuestionAnalytics({ - exerciseNumber, - children, -}: { - exerciseNumber: number; - children: ReactNode; -}) { - const attempt = useAttempt((state) => state.attempt); - const isInputLocked = useAttempt((state) => state.isInputLocked); - - const ref = useIntersection({ threshold: 0.75 }); - const isActive = ref.entry?.isIntersecting ?? false; - const timeSpent = useExercise( - (state) => state.timeSpent[exerciseNumber] ?? 0 - ); - const timeCounterRef = useRef(timeSpent); - - const setTimeSpent = useExercise((state) => state.setTimeSpent); - - const hasActiveAttempt = attempt?.status === "in-progress" && !isInputLocked; - - const handleTick = useEffectEvent(() => { - if (isActive && hasActiveAttempt) { - timeCounterRef.current += 1; - setTimeSpent(exerciseNumber, timeCounterRef.current); - } - }); - - const interval = useInterval(() => { - handleTick(); - }, 1000); - - useEffect(() => { - if (isActive && hasActiveAttempt) { - interval.start(); - } else { - interval.stop(); - } - }, [isActive, hasActiveAttempt, interval]); - - return
{children}
; -} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/article.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/article.tsx deleted file mode 100644 index bb158803f..000000000 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/article.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { Exercise } from "@repo/contents/_types/exercises/shared"; -import type { Locale } from "next-intl"; -import { ExerciseAnswerAction } from "./actions"; -import { ExerciseAnswer } from "./answer"; -import { ExerciseChoices } from "./choices"; - -interface Props { - exercise: Exercise; - id: string; - locale: Locale; - srLabel: string; -} - -export function ExerciseArticle({ exercise, locale, id, srLabel }: Props) { - return ( - - ); -} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/page.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/page.tsx deleted file mode 100644 index 7460c5347..000000000 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/page.tsx +++ /dev/null @@ -1,665 +0,0 @@ -import { - getExerciseByNumber, - getExercisesContent, -} from "@repo/contents/_lib/exercises"; -import { parseExercisesCategory } from "@repo/contents/_lib/exercises/category"; -import { - getCurrentMaterial, - getMaterialPath, - getMaterials, - parseExercisesMaterial, -} from "@repo/contents/_lib/exercises/material"; -import { - getExerciseNumberPagination, - getExercisesPagination, - getSlugPath, - hasInvalidTryOutYearSlug, - isTryOutCollectionSlug, - isYearlessTryOutCollectionSlug, - LEGACY_YEARLESS_TRY_OUT_REDIRECT_YEAR, -} from "@repo/contents/_lib/exercises/slug"; -import { parseExercisesType } from "@repo/contents/_lib/exercises/type"; -import { formatContentDateISO } from "@repo/contents/_shared/date"; -import type { ExercisesCategory } from "@repo/contents/_types/exercises/category"; -import type { - ExercisesMaterial, - ExercisesMaterialList, -} from "@repo/contents/_types/exercises/material"; -import type { Exercise } from "@repo/contents/_types/exercises/shared"; -import type { ExercisesType } from "@repo/contents/_types/exercises/type"; -import type { ParsedHeading } from "@repo/contents/_types/toc"; -import { slugify } from "@repo/design-system/lib/utils"; -import { ArticleJsonLd } from "@repo/seo/json-ld/article"; -import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb"; -import { CollectionPageJsonLd } from "@repo/seo/json-ld/collection-page"; -import { FOUNDER } from "@repo/seo/json-ld/constants"; -import { LearningResourceJsonLd } from "@repo/seo/json-ld/learning-resource"; -import { Effect, Option } from "effect"; -import type { Metadata } from "next"; -import { notFound, permanentRedirect } from "next/navigation"; -import type { Locale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { AiSheetOpen } from "@/components/ai/sheet-open"; -import { Comments } from "@/components/comments"; -import { CardMaterial } from "@/components/shared/card-material"; -import { ComingSoon } from "@/components/shared/coming-soon"; -import { ContainerList } from "@/components/shared/container-list"; -import { - LayoutMaterial, - LayoutMaterialContent, - LayoutMaterialFooter, - LayoutMaterialHeader, - LayoutMaterialMain, - LayoutMaterialPagination, - LayoutMaterialToc, -} from "@/components/shared/layout-material"; -import { RefContent } from "@/components/shared/ref-content"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; -import { getGithubUrl } from "@/lib/utils/github"; -import { getOgUrl } from "@/lib/utils/metadata"; -import { isNumber } from "@/lib/utils/number"; -import { - fetchExerciseContext, - fetchExerciseMetadataContext, -} from "@/lib/utils/pages/exercises"; -import { generateSEOMetadata } from "@/lib/utils/seo/generator"; -import type { SEOContext } from "@/lib/utils/seo/types"; -import { getStaticParams } from "@/lib/utils/system"; -import { QuestionAnalytics } from "./analytics"; -import { ExerciseArticle } from "./article"; -import { ExerciseAttempt } from "./attempt"; - -export const revalidate = false; - -async function getResolvedParams( - params: PageProps<"/[locale]/exercises/[category]/[type]/[material]/[...slug]">["params"] -) { - const { - locale: rawLocale, - category: rawCategory, - type: rawType, - material: rawMaterial, - slug, - } = await params; - const locale = getLocaleOrThrow(rawLocale); - const category = parseExercisesCategory(rawCategory); - const type = parseExercisesType(rawType); - const material = parseExercisesMaterial(rawMaterial); - - if (!(category && type && material)) { - notFound(); - } - - return { category, locale, material, slug, type }; -} - -export async function generateMetadata({ - params, -}: { - params: PageProps<"/[locale]/exercises/[category]/[type]/[material]/[...slug]">["params"]; -}): Promise { - const { locale, category, type, material, slug } = - await getResolvedParams(params); - - const { - isSpecificExercise, - exerciseTitle, - exerciseCount, - FilePath, - currentMaterial, - currentMaterialItem, - } = await Effect.runPromise( - Effect.match( - fetchExerciseMetadataContext({ locale, category, type, material, slug }), - { - onFailure: () => ({ - isSpecificExercise: false, - exerciseTitle: undefined, - exerciseCount: 0, - FilePath: getSlugPath(category, type, material, slug), - currentMaterial: undefined, - currentMaterialItem: undefined, - }), - onSuccess: (data) => data, - } - ) - ); - - const urlPath = `/${locale}${FilePath}`; - const image = { - url: getOgUrl(locale, FilePath), - width: 1200, - height: 630, - }; - - // Evidence: Use ICU-based SEO generator for type-safe, locale-aware metadata - // Source: https://developers.google.com/search/docs/appearance/title-link - // Get exercise type and set names from material data (e.g., "Try Out", "Set 1") - const group = currentMaterial?.title; - const set = currentMaterialItem?.title; - - // Extract exercise number from slug for specific exercises (e.g., /set-2/1) - const lastSlug = slug.at(-1); - const exerciseNumber = - isSpecificExercise && lastSlug && isNumber(lastSlug) - ? Number.parseInt(lastSlug, 10) - : undefined; - - const seoContext: SEOContext = { - type: "exercise", - category, - exam: type, - material, - group, - set, - number: exerciseNumber, - questionCount: exerciseCount, // Total count from filesystem - data: { - title: - exerciseTitle ?? currentMaterialItem?.title ?? currentMaterial?.title, - description: undefined, - subject: material, - }, - }; - - const { - title: finalTitle, - description, - keywords, - } = await generateSEOMetadata(seoContext, locale); - - return { - title: { - absolute: finalTitle, - }, - description, - keywords, - alternates: { - canonical: urlPath, - }, - openGraph: { - title: finalTitle, - url: urlPath, - siteName: "Nakafa", - locale, - type: "website", - images: [image], - }, - twitter: { - images: [image], - }, - }; -} - -export function generateStaticParams() { - return getStaticParams({ - basePath: "exercises", - paramNames: ["category", "type", "material", "slug"], - slugParam: "slug", - isDeep: true, - }).filter((params) => { - const slug = params.slug; - return ( - Array.isArray(slug) && - slug.length <= 3 && - !isYearlessTryOutCollectionSlug(slug) - ); - }); -} - -export default function Page( - props: PageProps<"/[locale]/exercises/[category]/[type]/[material]/[...slug]"> -) { - const { params } = props; - const { - locale: rawLocale, - category: rawCategory, - type: rawType, - material: rawMaterial, - slug, - } = use(params); - const locale = getLocaleOrThrow(rawLocale); - const category = parseExercisesCategory(rawCategory); - const type = parseExercisesType(rawType); - const material = parseExercisesMaterial(rawMaterial); - - if (!(category && type && material)) { - notFound(); - } - - // Enable static rendering - setRequestLocale(locale); - - if (hasInvalidTryOutYearSlug(slug)) { - const tryOutSuffixIndex = 1; - const legacyTryOutSuffix = slug.slice(tryOutSuffixIndex); - - // Legacy yearless try-out URLs were already indexed before the year segment - // migration, so keep forwarding them to their yearful 2026 successor. - permanentRedirect( - getSlugPath(category, type, material, [ - "try-out", - LEGACY_YEARLESS_TRY_OUT_REDIRECT_YEAR, - ...legacyTryOutSuffix, - ]) - ); - } - - const lastSlug = slug.at(-1); - // Try-out collection routes like `try-out/2026` end in a number but should - // still render the collection page, not a single exercise page. - if (!isTryOutCollectionSlug(slug) && lastSlug && isNumber(lastSlug)) { - const exerciseNumber = Number.parseInt(lastSlug, 10); - const baseSlug = slug.slice(0, -1); - return ( - - ); - } - - return ( - - ); -} - -async function YearGroupContent({ - currentMaterial, - FilePath, - locale, - material, - materialPath, - type, -}: { - currentMaterial: ExercisesMaterialList[number]; - FilePath: string; - locale: Locale; - material: ExercisesMaterial; - materialPath: string; - type: ExercisesType; -}) { - const t = await getTranslations({ locale, namespace: "Exercises" }); - const headingId = slugify(currentMaterial.title); - - return ( - <> - ({ - "@type": "ListItem", - "@id": `https://nakafa.com/${locale}${item.href}`, - position: index + 1, - name: item.title, - item: `https://nakafa.com/${locale}${item.href}`, - }))} - /> - ({ - url: `https://nakafa.com/${locale}${item.href}`, - name: item.title, - }))} - name={`${t(material)} - ${currentMaterial.title}`} - url={`https://nakafa.com/${locale}${FilePath}`} - /> - - - - - - - - - - - - - - - - ); -} - -async function PageContent({ - locale, - category, - type, - material, - slug, -}: { - locale: Locale; - category: ExercisesCategory; - type: ExercisesType; - material: ExercisesMaterial; - slug: string[]; -}) { - const t = await getTranslations({ locale, namespace: "Exercises" }); - - const materialPath = getMaterialPath(category, type, material); - const FilePath = getSlugPath(category, type, material, slug); - const materialGroups = await getMaterials(materialPath, locale); - const { currentMaterial: matchedMaterial, currentMaterialItem: matchedItem } = - getCurrentMaterial(FilePath, materialGroups); - - if (matchedMaterial && !matchedItem) { - return ( - - ); - } - - const exercises = await Effect.runPromise( - Effect.match(getExercisesContent({ locale, filePath: FilePath }), { - onFailure: () => [], - onSuccess: (data) => data, - }) - ); - - if (exercises.length === 0 || !matchedMaterial || !matchedItem) { - return ( - - - - - - - - ); - } - - const currentMaterial = matchedMaterial; - const currentMaterialItem = matchedItem; - const materials = materialGroups; - - const pagination = getExercisesPagination(FilePath, materials); - - const headings: ParsedHeading[] = exercises.map((exercise) => ({ - label: t("number-count", { count: exercise.number }), - href: `#${slugify(t("number-count", { count: exercise.number }))}`, - children: [], - })); - - const description = `${t("exercises")} - ${currentMaterialItem.title} - ${currentMaterial.title}`; - const educationalLevel = `${t(type)} - ${t(category)}`; - const publishedAt = - formatContentDateISO(exercises[0].question.metadata.date) ?? - exercises[0].question.metadata.date; - - return ( - <> - ({ - "@type": "ListItem", - "@id": `https://nakafa.com/${locale}${FilePath}${heading.href}`, - position: index + 1, - name: heading.label, - item: `https://nakafa.com/${locale}${FilePath}${heading.href}`, - }))} - /> - - - - - - - - - - {exercises.map((exercise) => { - const id = slugify(t("number-count", { count: exercise.number })); - - return ( - - - - ); - })} - - - - - - - - - - - ); -} - -async function SingleExerciseContent({ - locale, - category, - type, - material, - slug, - exerciseNumber, -}: { - locale: Locale; - category: ExercisesCategory; - type: ExercisesType; - material: ExercisesMaterial; - slug: string[]; - exerciseNumber: number; -}) { - const t = await getTranslations({ locale, namespace: "Exercises" }); - - const FilePath = getSlugPath(category, type, material, slug); - const exerciseFilePath = `${FilePath}/${exerciseNumber}`; - - const singleExerciseEffect = Effect.all([ - getExerciseByNumber(locale, FilePath, exerciseNumber), - fetchExerciseContext({ locale, category, type, material, slug }), - getExercisesContent({ locale, filePath: FilePath }), - ]); - - const [exerciseOption, exerciseContext, exercises] = await Effect.runPromise( - Effect.match(singleExerciseEffect, { - onFailure: () => { - const emptyResult: [Option.Option, null, null] = [ - Option.none(), - null, - null, - ]; - return emptyResult; - }, - onSuccess: (data) => data, - }) - ); - - if (Option.isNone(exerciseOption) || !exerciseContext) { - return ( - - - - - - - - ); - } - - const exercise = exerciseOption.value; - - const { currentMaterialItem } = exerciseContext; - - const totalExercises = exercises?.length ?? 0; - const pagination = getExerciseNumberPagination( - FilePath, - exerciseNumber, - totalExercises, - (number) => t("number-count", { count: number }) - ); - - const id = slugify(t("number-count", { count: exercise.number })); - - const description = `${t("exercises")} - ${exercise.question.metadata.title} - ${currentMaterialItem.title}`; - const educationalLevel = `${t(type)} - ${t(category)}`; - const publishedAt = - formatContentDateISO(exercise.question.metadata.date) ?? - exercise.question.metadata.date; - - return ( - <> - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/layout.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/layout.tsx deleted file mode 100644 index c6f527a28..000000000 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/layout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { routing } from "@repo/internationalization/src/routing"; -import { notFound } from "next/navigation"; -import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { AppShell } from "@/components/sidebar/app-shell"; - -/** Renders the static learning subtree inside the default app shell. */ -export default function Layout(props: LayoutProps<"/[locale]">) { - const { children, params } = props; - const { locale } = use(params); - - if (!hasLocale(routing.locales, locale)) { - notFound(); - } - - setRequestLocale(locale); - - return {children}; -} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/quran/[surah]/page.tsx b/apps/www/app/[locale]/(app)/(static)/(learn)/quran/[surah]/page.tsx deleted file mode 100644 index 554306e68..000000000 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/quran/[surah]/page.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import { AllahIcon } from "@hugeicons/core-free-icons"; -import { getSurahName } from "@repo/contents/_lib/quran"; -import { cn, slugify } from "@repo/design-system/lib/utils"; -import { BookJsonLd } from "@repo/seo/json-ld/book"; -import { Effect } from "effect"; -import type { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { type Locale, useTranslations } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { AiSheetOpen } from "@/components/ai/sheet-open"; -import { - LayoutMaterial, - LayoutMaterialContent, - LayoutMaterialFooter, - LayoutMaterialHeader, - LayoutMaterialMain, - LayoutMaterialPagination, - LayoutMaterialToc, -} from "@/components/shared/layout-material"; -import { QuranAudio } from "@/components/shared/quran-audio"; -import { QuranInterpretation } from "@/components/shared/quran-interpretation"; -import { QuranText } from "@/components/shared/quran-text"; -import { RefContent } from "@/components/shared/ref-content"; -import { WindowVirtualized } from "@/components/shared/window-virtualized"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; -import { - fetchSurahContext, - fetchSurahMetadataContext, - getQuranPagination, -} from "@/lib/utils/pages/quran"; -import { generateSEOMetadata } from "@/lib/utils/seo/generator"; -import type { SEOContext } from "@/lib/utils/seo/types"; - -export const revalidate = false; - -export async function generateMetadata({ - params, -}: { - params: PageProps<"/[locale]/quran/[surah]">["params"]; -}): Promise { - const { locale: rawLocale, surah } = await params; - const locale = getLocaleOrThrow(rawLocale); - - const t = await getTranslations({ locale, namespace: "Holy" }); - - const path = `/${locale}/quran/${surah}`; - - const alternates = { - canonical: path, - }; - const image = { - url: "/quran.png", - width: 1200, - height: 630, - }; - const twitter: Metadata["twitter"] = { - images: [image], - }; - const openGraph: Metadata["openGraph"] = { - url: path, - images: [image], - type: "book", - siteName: "Nakafa", - locale, - }; - - const surahNumber = Number(surah); - - if (Number.isNaN(surahNumber)) { - return { - alternates, - }; - } - - const surahMetadataContext = await Effect.runPromise( - Effect.match(fetchSurahMetadataContext({ surah: surahNumber }), { - onFailure: () => null, - onSuccess: (data) => data, - }) - ); - - const surahData = surahMetadataContext?.surahData ?? null; - if (!surahData) { - return { - alternates, - }; - } - - // Evidence: Use ICU-based SEO generator for type-safe, locale-aware metadata - // Source: https://developers.google.com/search/docs/appearance/title-link - // Evidence: Arabic name is universal, locale-specific for transliteration/translation - const seoContext: SEOContext = { - type: "quran", - surah: surahData, - }; - - const { title, description, keywords } = await generateSEOMetadata( - seoContext, - locale - ); - - return { - title: { absolute: title }, - alternates, - category: t("quran"), - description, - keywords, - twitter, - openGraph, - }; -} - -export function generateStaticParams() { - // surah 1-114 - return Array.from({ length: 114 }, (_, i) => ({ - surah: (i + 1).toString(), - })); -} - -export default function Page(props: PageProps<"/[locale]/quran/[surah]">) { - const { params } = props; - const { locale: rawLocale, surah } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - // Enable static rendering - setRequestLocale(locale); - - return ; -} - -function PageContent({ locale, surah }: { locale: Locale; surah: string }) { - const t = useTranslations("Holy"); - - const surahNumber = Number(surah); - - if (Number.isNaN(surahNumber)) { - notFound(); - } - - const result = use( - Effect.runPromise( - Effect.match(fetchSurahContext({ surah: surahNumber }), { - onFailure: () => ({ - surahData: null, - prevSurah: null, - nextSurah: null, - }), - onSuccess: (data) => data, - }) - ) - ); - - const { surahData, prevSurah, nextSurah } = result; - - if (!surahData) { - notFound(); - } - - const translation = surahData.name.translation[locale]; - - const preBismillah = surahData.preBismillah; - - const title = getSurahName({ locale, name: surahData.name }); - - const headings = surahData.verses.map((verse, index) => ({ - label: t("verse-count", { count: verse.number.inSurah }), - index, - href: `/quran/${surah}#${slugify(t("verse-count", { count: verse.number.inSurah }))}`, - children: [], - })); - - const pagination = getQuranPagination({ - prevSurah, - nextSurah, - }); - - const prevTitle = prevSurah - ? getSurahName({ locale, name: prevSurah.name }) - : ""; - const nextTitle = nextSurah - ? getSurahName({ locale, name: nextSurah.name }) - : ""; - - const paginationWithLocalizedTitles = { - prev: { - href: pagination.prev.href, - title: prevTitle, - }, - next: { - href: pagination.next.href, - title: nextTitle, - }, - }; - - return ( - <> - - - - - - {!!preBismillah && ( -
- {preBismillah.text.arab} -

- {preBismillah.translation[locale] ?? - preBismillah.translation.en} -

-
- )} - - - {surahData.verses.map((verse, index) => { - const transliteration = verse.text.transliteration.en; - const translate = - verse.translation[locale] ?? verse.translation.en; - - const id = slugify( - t("verse-count", { count: verse.number.inSurah }) - ); - - return ( -
-
- -
- - {verse.number.inSurah} - -

- {t("verse-count", { count: verse.number.inSurah })} -

-
-
- -
- - {locale === "id" && ( - // Only available in Indonesian - - )} -
-
- {verse.text.arab} -
-

- {transliteration} -

-

{translate}

-
-
- ); - })} -
-
- - - - - -
- -
- - ); -} diff --git a/apps/www/app/[locale]/(app)/(static)/(site)/ask/[slug]/page.tsx b/apps/www/app/[locale]/(app)/(static)/(site)/ask/[slug]/page.tsx deleted file mode 100644 index 8ed724295..000000000 --- a/apps/www/app/[locale]/(app)/(static)/(site)/ask/[slug]/page.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { askSeo } from "@repo/seo/ask"; -import { FAQPageJsonLd } from "@repo/seo/json-ld/faq-page"; -import type { Metadata } from "next"; -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { AskCta } from "@/components/ask/cta"; -import { AskListItems } from "@/components/ask/results"; -import { - LayoutMaterial, - LayoutMaterialContent, - LayoutMaterialMain, -} from "@/components/shared/layout-material"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; -import { convertSlugToTitle } from "@/lib/utils/helper"; - -export const dynamic = "force-static"; -export const revalidate = false; - -const askData = askSeo(); - -export async function generateMetadata({ - params, -}: { - params: PageProps<"/[locale]/ask/[slug]">["params"]; -}): Promise { - const { locale: rawLocale, slug } = await params; - const locale = getLocaleOrThrow(rawLocale); - - const seoData = askData.find((data) => data.slug === slug); - - if (!seoData) { - return {}; - } - - const title = seoData.locales[locale].title; - const description = seoData.locales[locale].description; - - return { - title: { - absolute: title, - }, - description, - alternates: { - canonical: `/${locale}/ask/${slug}`, - }, - keywords: title - .split(" ") - .concat(description.split(" ")) - .filter((keyword) => keyword.length > 0), - }; -} - -export function generateStaticParams() { - return askData.map((data) => ({ - slug: data.slug, - })); -} - -export default function Page(props: PageProps<"/[locale]/ask/[slug]">) { - const { params } = props; - const { locale: rawLocale, slug } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - // Enable static rendering - setRequestLocale(locale); - - const seoData = askData.find((data) => data.slug === slug); - - const title = seoData?.locales[locale].title ?? convertSlugToTitle(slug); - const description = seoData?.locales[locale].description ?? ""; - - return ( - <> - -
- - -
-
-

- {title} -

- - {!!description && ( -

- {description} -

- )} - - -
-
- - - - -
-
-
- - ); -} diff --git a/apps/www/app/[locale]/(app)/(static)/layout.tsx b/apps/www/app/[locale]/(app)/(static)/layout.tsx deleted file mode 100644 index f02a3b66e..000000000 --- a/apps/www/app/[locale]/(app)/(static)/layout.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { routing } from "@repo/internationalization/src/routing"; -import { notFound } from "next/navigation"; -import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; -import { ConvexAppProviders } from "@/components/providers"; - -/** - * Mounts the non-seeded Convex subtree for routes that can stay statically - * rendered. - * - * Static pages still benefit from the ambient user context after hydration, but - * avoid request-time token reads at the layout boundary. - * - * @see https://nextjs.org/docs/app/guides/streaming - * @see https://labs.convex.dev/better-auth/migrations/migrate-to-0-10#pass-initial-token-to-convexbetterauthprovider - */ -export default async function Layout(props: LayoutProps<"/[locale]">) { - const { children, params } = props; - const { locale } = await params; - - if (!hasLocale(routing.locales, locale)) { - notFound(); - } - - setRequestLocale(locale); - - return {children}; -} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/auth/page.tsx b/apps/www/app/[locale]/(app)/auth/page.tsx similarity index 83% rename from apps/www/app/[locale]/(app)/(dynamic)/auth/page.tsx rename to apps/www/app/[locale]/(app)/auth/page.tsx index 2427b6908..108bb2491 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/auth/page.tsx +++ b/apps/www/app/[locale]/(app)/auth/page.tsx @@ -1,25 +1,16 @@ import { Button } from "@repo/design-system/components/ui/button"; import { type Locale, useTranslations } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; +import { getLocale } from "next-intl/server"; import { Auth } from "@/components/auth"; import { FeaturesDithering } from "@/components/marketing/about/features.client"; import { Theme } from "@/components/marketing/shared/footer-action"; import { BackButton } from "@/components/shared/back-button"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; -export const revalidate = false; - -export default function Page(props: PageProps<"/[locale]/auth">) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - // Enable static rendering - setRequestLocale(locale); +export default async function Page() { + const locale = await getLocale(); return ( -
+
diff --git a/apps/www/app/[locale]/(app)/error.tsx b/apps/www/app/[locale]/(app)/error.tsx index 056120ad0..545c58fd1 100644 --- a/apps/www/app/[locale]/(app)/error.tsx +++ b/apps/www/app/[locale]/(app)/error.tsx @@ -22,10 +22,7 @@ export default function ErrorPage({ }, [error]); return ( -
+
diff --git a/apps/www/app/[locale]/(app)/layout.tsx b/apps/www/app/[locale]/(app)/layout.tsx index 47905b189..40608855c 100644 --- a/apps/www/app/[locale]/(app)/layout.tsx +++ b/apps/www/app/[locale]/(app)/layout.tsx @@ -1,14 +1,24 @@ import { routing } from "@repo/internationalization/src/routing"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; +import { Suspense } from "react"; +import { AppProviders } from "@/components/providers/app"; +import { getToken } from "@/lib/auth/server"; /** - * Binds the validated locale to the full authenticated app subtree. + * Binds the validated locale to the shared authenticated app subtree. * - * Keeping this locale setup at the shared `(app)` segment lets nested route - * groups focus on shell and provider ownership instead of repeating the same - * request setup in every child layout. + * Every route in the `(app)` group relies on `useUser()` through the shared app + * shell, so this layout resolves the Better Auth token once and mounts a single + * provider tree behind a real Suspense boundary. The fallback stays intentionally + * small so we do not duplicate the full app subtree while the request token + * resolves. + * + * References: + * - Convex App Router SSR: + * https://docs.convex.dev/client/nextjs/app-router/server-rendering + * - Next.js Cache Components / mixed static-dynamic routes: + * @.agents/skills/next-cache-components/SKILL.md */ export default async function Layout(props: LayoutProps<"/[locale]">) { const { children, params } = props; @@ -18,7 +28,20 @@ export default async function Layout(props: LayoutProps<"/[locale]">) { notFound(); } - setRequestLocale(locale); + return ( + }> + {children} + + ); +} + +/** Resolves the request token before mounting the authenticated app providers. */ +async function AuthenticatedAppProviders({ + children, +}: { + children: React.ReactNode; +}) { + const token = await getToken(); - return children; + return {children}; } diff --git a/apps/www/app/[locale]/(app)/loading.tsx b/apps/www/app/[locale]/(app)/loading.tsx new file mode 100644 index 000000000..ce6fa3c53 --- /dev/null +++ b/apps/www/app/[locale]/(app)/loading.tsx @@ -0,0 +1,4 @@ +/** Renders the route-group fallback while the shared app auth token resolves. */ +export default function Loading() { + return
; +} diff --git a/apps/www/app/[locale]/(app)/not-found.tsx b/apps/www/app/[locale]/(app)/not-found.tsx index b2e49b2e0..3ae80da73 100644 --- a/apps/www/app/[locale]/(app)/not-found.tsx +++ b/apps/www/app/[locale]/(app)/not-found.tsx @@ -9,10 +9,7 @@ export default async function NotFound() { const t = await getTranslations("NotFound"); return ( -
+
diff --git a/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/forum/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/forum/page.tsx new file mode 100644 index 000000000..e688b1a7c --- /dev/null +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/forum/page.tsx @@ -0,0 +1,12 @@ +import { SchoolClassesForumHeader } from "@/components/school/classes/forum/header"; +import { SchoolClassesForumList } from "@/components/school/classes/forum/list"; +import { SchoolLayoutContent } from "@/components/school/layout-content"; + +export default function Page() { + return ( + + + + + ); +} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx similarity index 93% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx index b0646e20a..1282407b5 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/layout.tsx @@ -3,7 +3,7 @@ import { ErrorBoundary } from "@repo/design-system/components/ui/error-boundary" import { routing } from "@repo/internationalization/src/routing"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; import { SchoolClassesForumPostSheet } from "@/components/school/classes/forum/post-sheet"; import { SchoolClassesHeaderInfo } from "@/components/school/classes/info"; @@ -23,9 +23,6 @@ export default function Layout( notFound(); } - // Enable static rendering - setRequestLocale(locale); - const classId = id as Id<"schoolClasses">; return ( diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx similarity index 51% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx index fdac1fe4d..b79eec939 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/materials/page.tsx @@ -1,19 +1,8 @@ -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; import { SchoolClassesMaterialsHeader } from "@/components/school/classes/materials/header"; import { SchoolClassesMaterialsList } from "@/components/school/classes/materials/list"; import { SchoolLayoutContent } from "@/components/school/layout-content"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; - -export default function Page( - props: PageProps<"/[locale]/school/[slug]/classes/[id]/materials"> -) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - setRequestLocale(locale); +export default function Page() { return ( diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx similarity index 84% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx index 21477f429..b6d2296ed 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/page.tsx @@ -1,7 +1,6 @@ import { routing } from "@repo/internationalization/src/routing"; import { notFound, redirect } from "next/navigation"; import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; export default async function Page( props: PageProps<"/[locale]/school/[slug]/classes/[id]"> @@ -13,7 +12,5 @@ export default async function Page( notFound(); } - setRequestLocale(locale); - redirect(`/${locale}/school/${slug}/classes/${id}/forum`); } diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx similarity index 50% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx index c4830ccfb..b9070ba65 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/[id]/people/page.tsx @@ -1,19 +1,8 @@ -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; import { SchoolClassesPeopleHeader } from "@/components/school/classes/people/header"; import { SchoolClassesPeopleList } from "@/components/school/classes/people/list"; import { SchoolLayoutContent } from "@/components/school/layout-content"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; - -export default function Page( - props: PageProps<"/[locale]/school/[slug]/classes/[id]/people"> -) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - setRequestLocale(locale); +export default function Page() { return ( diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/page.tsx similarity index 70% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/page.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/page.tsx index 29b3f2a0b..409566dca 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/classes/page.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/classes/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; +import { getTranslations } from "next-intl/server"; import { SchoolClassesHeader } from "@/components/school/classes/header"; import { SchoolClassesList } from "@/components/school/classes/list"; import { SchoolLayoutContent } from "@/components/school/layout-content"; @@ -19,15 +18,7 @@ export async function generateMetadata({ }; } -export default function Page( - props: PageProps<"/[locale]/school/[slug]/classes"> -) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - setRequestLocale(locale); - +export default function Page() { return (
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/home/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/home/page.tsx similarity index 61% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/home/page.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/home/page.tsx index 52ea97cb1..734bc8211 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/home/page.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/home/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; +import { getTranslations } from "next-intl/server"; import { SchoolLayoutContent } from "@/components/school/layout-content"; import { getLocaleOrThrow } from "@/lib/i18n/params"; @@ -17,12 +16,6 @@ export async function generateMetadata({ }; } -export default function Page(props: PageProps<"/[locale]/school/[slug]/home">) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - setRequestLocale(locale); - +export default function Page() { return ; } diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/layout.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/layout.tsx similarity index 86% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/layout.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/layout.tsx index c9253ea1f..b62fa1ba2 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/layout.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/layout.tsx @@ -5,7 +5,7 @@ import { import { routing } from "@repo/internationalization/src/routing"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; import { SchoolSidebar } from "@/components/school/sidebar"; @@ -17,9 +17,6 @@ export default function Layout(props: LayoutProps<"/[locale]/school/[slug]">) { notFound(); } - // Enable static rendering - setRequestLocale(locale); - return ( diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/notifications/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/notifications/page.tsx similarity index 61% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/notifications/page.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/notifications/page.tsx index ccbcf0404..856ef7d2b 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/(main)/notifications/page.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/(main)/notifications/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; +import { getTranslations } from "next-intl/server"; import { SchoolLayoutContent } from "@/components/school/layout-content"; import { getLocaleOrThrow } from "@/lib/i18n/params"; @@ -17,14 +16,6 @@ export async function generateMetadata({ }; } -export default function Page( - props: PageProps<"/[locale]/school/[slug]/notifications"> -) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - setRequestLocale(locale); - +export default function Page() { return ; } diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/layout.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/layout.tsx similarity index 93% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/layout.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/layout.tsx index 8a3601ee3..9c7160a57 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/layout.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/layout.tsx @@ -5,7 +5,7 @@ import { fetchQuery } from "convex/nextjs"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; + import { cache, use } from "react"; import { SchoolNotFound } from "@/components/school/not-found"; import { SchoolContextProvider } from "@/lib/context/use-school"; @@ -45,9 +45,6 @@ export default function Layout(props: LayoutProps<"/[locale]/school/[slug]">) { notFound(); } - // Enable static rendering - setRequestLocale(locale); - return ( }> diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/page.tsx similarity index 83% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/page.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/page.tsx index a3233873c..9f7c48ed9 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/[slug]/page.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/[slug]/page.tsx @@ -1,7 +1,6 @@ import { routing } from "@repo/internationalization/src/routing"; import { notFound, redirect } from "next/navigation"; import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; export default async function Page( props: PageProps<"/[locale]/school/[slug]"> @@ -13,7 +12,5 @@ export default async function Page( notFound(); } - setRequestLocale(locale); - redirect(`/${locale}/school/${slug}/home`); } diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/auth.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/auth.tsx similarity index 100% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/auth.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/auth.tsx diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/layout.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/layout.tsx similarity index 82% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/layout.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/layout.tsx index a978e62a9..416e2555a 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/layout.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/layout.tsx @@ -1,7 +1,7 @@ import { routing } from "@repo/internationalization/src/routing"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; import { LayoutAuth } from "./auth"; @@ -13,8 +13,5 @@ export default function Layout(props: LayoutProps<"/[locale]/school">) { notFound(); } - // Enable static rendering - setRequestLocale(locale); - return {children}; } diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/create/form.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/create/form.tsx similarity index 100% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/create/form.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/create/form.tsx diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/create/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/create/page.tsx similarity index 72% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/create/page.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/create/page.tsx index 299b6f5a3..be21922f5 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/create/page.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/create/page.tsx @@ -2,20 +2,9 @@ import { ArrowLeft02Icon } from "@hugeicons/core-free-icons"; import { HugeIcons } from "@repo/design-system/components/ui/huge-icons"; import NavigationLink from "@repo/design-system/components/ui/navigation-link"; import { useTranslations } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; import { SchoolOnboardingCreateForm } from "./form"; -export default function Page( - props: PageProps<"/[locale]/school/onboarding/create"> -) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - setRequestLocale(locale); - +export default function Page() { const t = useTranslations("School.Onboarding"); return ( diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/join/form.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/join/form.tsx similarity index 100% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/join/form.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/join/form.tsx diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/join/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/join/page.tsx similarity index 72% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/join/page.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/join/page.tsx index 072ebb6e4..eed54babd 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/join/page.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/join/page.tsx @@ -2,20 +2,9 @@ import { ArrowLeft02Icon } from "@hugeicons/core-free-icons"; import { HugeIcons } from "@repo/design-system/components/ui/huge-icons"; import NavigationLink from "@repo/design-system/components/ui/navigation-link"; import { useTranslations } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; import { SchoolOnboardingJoinForm } from "./form"; -export default function Page( - props: PageProps<"/[locale]/school/onboarding/join"> -) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - setRequestLocale(locale); - +export default function Page() { const t = useTranslations("School.Onboarding"); return ( diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/layout.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/layout.tsx similarity index 86% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/layout.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/layout.tsx index ed920c434..a25e5e028 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/layout.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/layout.tsx @@ -2,7 +2,7 @@ import { Particles } from "@repo/design-system/components/ui/particles"; import { routing } from "@repo/internationalization/src/routing"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; + import { use } from "react"; export default function Layout( @@ -15,9 +15,6 @@ export default function Layout( notFound(); } - // Enable static rendering - setRequestLocale(locale); - return (
diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/page.tsx b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/page.tsx similarity index 89% rename from apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/page.tsx rename to apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/page.tsx index 076d32912..188e1c59e 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/(authenticated)/onboarding/page.tsx +++ b/apps/www/app/[locale]/(app)/school/(authenticated)/onboarding/page.tsx @@ -1,22 +1,13 @@ import NavigationLink from "@repo/design-system/components/ui/navigation-link"; import Image from "next/image"; import { useTranslations } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; import SchoolCreateImage from "@/public/school-create.png"; import SchoolJoinImage from "@/public/school-join.png"; const IMAGE_WIDTH = 238; const IMAGE_HEIGHT = 134; -export default function Page(props: PageProps<"/[locale]/school/onboarding">) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - setRequestLocale(locale); - +export default function Page() { const t = useTranslations("School.Onboarding"); return ( diff --git a/apps/www/app/[locale]/(app)/school/auth/page.tsx b/apps/www/app/[locale]/(app)/school/auth/page.tsx new file mode 100644 index 000000000..56efe2e08 --- /dev/null +++ b/apps/www/app/[locale]/(app)/school/auth/page.tsx @@ -0,0 +1,13 @@ +import { Particles } from "@repo/design-system/components/ui/particles"; +import { ComingSoon } from "@/components/shared/coming-soon"; + +export default function Page() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/layout.tsx b/apps/www/app/[locale]/(app)/school/layout.tsx similarity index 93% rename from apps/www/app/[locale]/(app)/(dynamic)/school/layout.tsx rename to apps/www/app/[locale]/(app)/school/layout.tsx index 0cd2f9266..46d305910 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/layout.tsx +++ b/apps/www/app/[locale]/(app)/school/layout.tsx @@ -2,7 +2,7 @@ import { routing } from "@repo/internationalization/src/routing"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { getTranslations } from "next-intl/server"; import { use } from "react"; export async function generateMetadata({ @@ -65,7 +65,5 @@ export default function Layout(props: LayoutProps<"/[locale]/school">) { notFound(); } - setRequestLocale(locale); - return children; } diff --git a/apps/www/app/[locale]/(app)/(dynamic)/school/page.tsx b/apps/www/app/[locale]/(app)/school/page.tsx similarity index 63% rename from apps/www/app/[locale]/(app)/(dynamic)/school/page.tsx rename to apps/www/app/[locale]/(app)/school/page.tsx index 0fe1e83d5..e08c295f0 100644 --- a/apps/www/app/[locale]/(app)/(dynamic)/school/page.tsx +++ b/apps/www/app/[locale]/(app)/school/page.tsx @@ -1,18 +1,11 @@ import { Particles } from "@repo/design-system/components/ui/particles"; -import { setRequestLocale } from "next-intl/server"; -import { Suspense, use } from "react"; + +import { Suspense } from "react"; import { School } from "@/components/school"; import { SchoolLoader } from "@/components/school/loader"; import { ComingSoon } from "@/components/shared/coming-soon"; -import { getLocaleOrThrow } from "@/lib/i18n/params"; - -export default function Page(props: PageProps<"/[locale]/school">) { - const { params } = props; - const { locale: rawLocale } = use(params); - const locale = getLocaleOrThrow(rawLocale); - - setRequestLocale(locale); +export default function Page() { return ( }> diff --git a/apps/www/app/[locale]/[...rest]/page.tsx b/apps/www/app/[locale]/[...rest]/page.tsx index 064276c26..3b1d244b6 100644 --- a/apps/www/app/[locale]/[...rest]/page.tsx +++ b/apps/www/app/[locale]/[...rest]/page.tsx @@ -1,17 +1,4 @@ -import { routing } from "@repo/internationalization/src/routing"; import { notFound } from "next/navigation"; -import { hasLocale } from "next-intl"; -import { setRequestLocale } from "next-intl/server"; - -export default async function Page(props: PageProps<"/[locale]/[...rest]">) { - const { params } = props; - const { locale } = await params; - - if (!hasLocale(routing.locales, locale)) { - notFound(); - } - - setRequestLocale(locale); - +export default function Page() { notFound(); } diff --git a/apps/www/app/[locale]/layout.tsx b/apps/www/app/[locale]/layout.tsx index 2cc7db747..0afc5a2ef 100644 --- a/apps/www/app/[locale]/layout.tsx +++ b/apps/www/app/[locale]/layout.tsx @@ -11,25 +11,16 @@ import { WebsiteJsonLd } from "@repo/seo/json-ld/website"; import type { Metadata, Viewport } from "next"; import { notFound } from "next/navigation"; import { hasLocale, NextIntlClientProvider } from "next-intl"; -import { getTranslations, setRequestLocale } from "next-intl/server"; -import { use } from "react"; -import { AppProviders } from "@/components/providers"; +import { getLocale, getMessages, getTranslations } from "next-intl/server"; -export async function generateMetadata({ - params, -}: { - params: LayoutProps<"/[locale]">["params"]; -}): Promise { - const { locale } = await params; +export async function generateMetadata(): Promise { + const locale = await getLocale(); if (!hasLocale(routing.locales, locale)) { notFound(); } - const t = await getTranslations({ - locale, - namespace: "Metadata", - }); + const t = await getTranslations("Metadata"); return { title: { @@ -146,20 +137,18 @@ export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })); } -export default function Layout(props: LayoutProps<"/[locale]">) { - const { children, params } = props; - const { locale } = use(params); - // Ensure that the incoming `locale` is valid +export default async function Layout({ children }: LayoutProps<"/[locale]">) { + const locale = await getLocale(); + if (!hasLocale(routing.locales, locale)) { notFound(); } - // Enable static rendering - setRequestLocale(locale); + const messages = await getMessages(); return ( - + {/* Add JSON-LD structured data using the JsonLd component */} @@ -168,9 +157,7 @@ export default function Layout(props: LayoutProps<"/[locale]">) {
- - {children} - + {children}
diff --git a/apps/www/app/[locale]/og/[...slug]/route.tsx b/apps/www/app/[locale]/og/[...slug]/route.tsx index d9f40c2c2..88e8a9c2a 100644 --- a/apps/www/app/[locale]/og/[...slug]/route.tsx +++ b/apps/www/app/[locale]/og/[...slug]/route.tsx @@ -1,16 +1,8 @@ import { routing } from "@repo/internationalization/src/routing"; -import { Effect } from "effect"; import type { NextRequest } from "next/server"; import { hasLocale, type Locale } from "next-intl"; -import { getMetadataFromSlug } from "@/lib/utils/system"; -import { generateOGImage } from "./og"; - -export const dynamic = "force-static"; -export const revalidate = false; - -export function generateStaticParams() { - return []; -} +import { generateOGImage } from "@/lib/og"; +import { getCachedMetadataFromSlug } from "@/lib/utils/system"; export async function GET( _req: NextRequest, @@ -24,11 +16,12 @@ export async function GET( const contentSlug = slug.at(-1) === "image.png" ? slug.slice(0, -1) : slug; - const { title, description } = await Effect.runPromise( - getMetadataFromSlug(cleanedLocale, contentSlug) + const { title, description } = await getCachedMetadataFromSlug( + cleanedLocale, + contentSlug ); - return generateOGImage({ + return await generateOGImage({ title, description, }); diff --git a/apps/www/app/api/chat/route.ts b/apps/www/app/api/chat/route.ts index d738ca2c0..2232228f9 100644 --- a/apps/www/app/api/chat/route.ts +++ b/apps/www/app/api/chat/route.ts @@ -196,7 +196,7 @@ export async function POST(req: Request) { sessionLogger.info("Chat session started"); - const t = await getTranslations("Ai"); + const t = await getTranslations({ locale, namespace: "Ai" }); const stream = createUIMessageStream({ onError: (error) => { diff --git a/apps/www/app/global-error.tsx b/apps/www/app/global-error.tsx index bf7c16dad..a7595a377 100644 --- a/apps/www/app/global-error.tsx +++ b/apps/www/app/global-error.tsx @@ -28,10 +28,7 @@ export default function GlobalError({
-
+
diff --git a/apps/www/app/not-found.tsx b/apps/www/app/global-not-found.tsx similarity index 88% rename from apps/www/app/not-found.tsx rename to apps/www/app/global-not-found.tsx index 0a9754e6a..c34af917f 100644 --- a/apps/www/app/not-found.tsx +++ b/apps/www/app/global-not-found.tsx @@ -6,9 +6,15 @@ import { Particles } from "@repo/design-system/components/ui/particles"; import { buttonVariants } from "@repo/design-system/lib/button"; import { fonts } from "@repo/design-system/lib/fonts"; import { cn } from "@repo/design-system/lib/utils"; +import type { Metadata } from "next"; import Link from "next/link"; -export default function NotFound() { +export const metadata: Metadata = { + title: "404 - Page Not Found", + description: "The page you are looking for does not exist.", +}; + +export default function GlobalNotFound() { return ( @@ -18,10 +24,7 @@ export default function NotFound() { disableTransitionOnChange enableSystem > -
+
diff --git a/apps/www/app/layout.tsx b/apps/www/app/layout.tsx deleted file mode 100644 index ca8429e4f..000000000 --- a/apps/www/app/layout.tsx +++ /dev/null @@ -1,8 +0,0 @@ -// Since we have a root `not-found.tsx` page, a layout file - -import type { ReactNode } from "react"; - -// is required, even if it's just passing children through. -export default function RootLayout({ children }: { children: ReactNode }) { - return children; -} diff --git a/apps/www/app/llms.mdx/[...slug]/route.ts b/apps/www/app/llms.mdx/[...slug]/route.ts index 1d41fe3fb..8654c5a1c 100644 --- a/apps/www/app/llms.mdx/[...slug]/route.ts +++ b/apps/www/app/llms.mdx/[...slug]/route.ts @@ -1,390 +1,48 @@ -import { getExercisesContent } from "@repo/contents/_lib/exercises"; -import { - getCurrentMaterial, - getMaterialPath, - getMaterials, -} from "@repo/contents/_lib/exercises/material"; -import { getContentMetadataWithRaw } from "@repo/contents/_lib/metadata"; -import { getAllSurah, getSurah, getSurahName } from "@repo/contents/_lib/quran"; -import { ExercisesCategorySchema } from "@repo/contents/_types/exercises/category"; -import { ExercisesMaterialSchema } from "@repo/contents/_types/exercises/material"; -import { ExercisesTypeSchema } from "@repo/contents/_types/exercises/type"; +import { generateSlugOnlyParams } from "@repo/contents/_lib/static-params"; import { routing } from "@repo/internationalization/src/routing"; -import { Effect, Option } from "effect"; -import ky from "ky"; import type { NextRequest } from "next/server"; import { hasLocale, type Locale } from "next-intl"; -import { getRawGithubUrl } from "@/lib/utils/github"; - -const BASE_URL = "https://nakafa.com"; +import { + getCachedLlmsExerciseText, + getCachedLlmsIndexText, + getCachedLlmsMdxText, + getQuranLlmsText, +} from "@/lib/llms"; -export const dynamic = "force-static"; -export const revalidate = false; +export function generateStaticParams() { + return generateSlugOnlyParams({ + includeExerciseNumbers: true, + includeExerciseSets: true, + includeQuran: true, + }); +} export async function GET( _req: NextRequest, - { params }: { params: Promise<{ slug: string[] }> } + ctx: RouteContext<"/llms.mdx/[...slug]"> ) { - const slug = (await params).slug; - - // Parse locale from slug + const { slug } = await ctx.params; const locale: Locale = hasLocale(routing.locales, slug[0]) ? slug[0] : routing.defaultLocale; - let cleanSlug = hasLocale(routing.locales, slug[0]) + const cleanSlug = hasLocale(routing.locales, slug[0]) ? slug.slice(1).join("/") : slug.join("/"); - // if last element is /llms.txt, we must remove it - if (cleanSlug.endsWith("/llms.txt")) { - cleanSlug = cleanSlug.slice(0, -"/llms.txt".length); - } - - // Handle Quran content - if (cleanSlug.startsWith("quran")) { - const quranResponse = handleQuranContent({ - cleanSlug, - locale, - }); - if (quranResponse) { - return quranResponse; - } - } - - // Handle Exercises content - if (cleanSlug.startsWith("exercises")) { - const exercisesResponse = await handleExercisesContent({ - cleanSlug, - locale, - }); - if (exercisesResponse) { - return exercisesResponse; - } - } - - // Handle MDX content - const content = await Effect.runPromise( - Effect.match(getContentMetadataWithRaw(locale, cleanSlug), { - onFailure: () => null, - onSuccess: (data) => data, - }) - ); - if (content) { - return buildMdxResponse({ content, locale, cleanSlug }); - } - - // Fallback to /llms.txt for everything not found - const fallbackResponse = await ky - .get(`${BASE_URL}/llms.txt`, { - cache: "force-cache", - }) - .text() - .catch(() => ""); - return new Response(fallbackResponse); -} - -function buildHeader({ - url, - description, - source, -}: { - url: string; - description: string; - source?: string; -}): string[] { - const header = ["# Nakafa Framework: LLM", "", `URL: ${url}`]; - - if (source) { - header.push(`Source: ${source}`); - } - - header.push("", description, "", "---", ""); - - return header; -} - -function getTranslation( - translations: Record, - locale: Locale -): string { - return translations[locale] || translations.en; -} - -function handleQuranContent({ - cleanSlug, - locale, -}: { - cleanSlug: string; - locale: Locale; -}): Response | null { - const parts = cleanSlug.split("/"); - const scanned: string[] = []; - - // List all surahs - if (parts.length === 1) { - const surahs = getAllSurah(); - const url = `${BASE_URL}/${locale}/quran`; - scanned.push( - ...buildHeader({ - url, - description: "Al-Quran - List of all 114 Surahs in the Holy Quran.", - }) - ); - - for (const surah of surahs) { - const title = getSurahName({ locale, name: surah.name }); - const translation = getTranslation(surah.name.translation, locale); - scanned.push(`## ${surah.number}. ${title}`); - scanned.push(""); - scanned.push(`**Translation:** ${translation}`); - scanned.push(""); - scanned.push(`**Revelation:** ${surah.revelation.en}`); - scanned.push(""); - scanned.push(`**Number of Verses:** ${surah.numberOfVerses}`); - scanned.push(""); - } - - return new Response(scanned.join("\n")); - } - - // Show specific surah - if (parts.length === 2) { - const surahNumber = Number(parts[1]); - const surahData = Option.fromNullable( - Effect.runSync( - Effect.match(getSurah(surahNumber), { - onFailure: () => null, - onSuccess: (data) => data, - }) - ) - ); - - if (Option.isSome(surahData)) { - const surahValue = Option.getOrThrow(surahData); - const title = getSurahName({ locale, name: surahValue.name }); - const translation = getTranslation(surahValue.name.translation, locale); - const url = `${BASE_URL}/${locale}/quran/${surahNumber}`; - - scanned.push( - ...buildHeader({ - url, - description: `Al-Quran - Surah ${title} (${translation})`, - }) - ); - - scanned.push(`## ${title}`); - scanned.push(""); - scanned.push(`**Translation:** ${translation}`); - scanned.push(`**Revelation:** ${surahValue.revelation.en}`); - scanned.push(`**Number of Verses:** ${surahValue.numberOfVerses}`); - scanned.push(""); - - // Add pre-bismillah if exists - if (surahValue.preBismillah) { - scanned.push("### Pre-Bismillah"); - scanned.push(""); - scanned.push(surahValue.preBismillah.text.arab); - scanned.push(""); - const preBismillahTranslation = getTranslation( - surahValue.preBismillah.translation, - locale - ); - scanned.push(`*${preBismillahTranslation}*`); - scanned.push(""); - } - - // Add all verses - scanned.push("### Verses"); - scanned.push(""); - - for (const verse of surahValue.verses) { - scanned.push(`#### Verse ${verse.number.inSurah}`); - scanned.push(""); - scanned.push(verse.text.arab); - scanned.push(""); - scanned.push(`**Transliteration:** ${verse.text.transliteration.en}`); - scanned.push(""); - scanned.push( - `**Translation:** ${getTranslation(verse.translation, locale)}` - ); - scanned.push(""); - } - - return new Response(scanned.join("\n")); - } - } - - return null; -} - -async function handleExercisesContent({ - cleanSlug, - locale, -}: { - cleanSlug: string; - locale: Locale; -}): Promise { - const parts = cleanSlug.split("/"); - let exerciseNumber: number | null = null; - let path = cleanSlug; - - // Check if last part is a number (specific exercise request) - const lastPart = parts.at(-1); - if (lastPart) { - const parsedNumber = Number.parseInt(lastPart, 10); - if (!Number.isNaN(parsedNumber)) { - exerciseNumber = parsedNumber; - path = parts.slice(0, -1).join("/"); - } - } - - // Fetch exercises without MDX components (raw content only for LLM output) - const exercises = await Effect.runPromise( - Effect.match( - getExercisesContent({ locale, filePath: path, includeMDX: false }), - { - onFailure: () => [], - onSuccess: (data) => data, - } - ) - ); - - if (exercises.length === 0) { - return null; - } - - // If specific exercise requested, filter it - let targetExercises = exercises; - if (exerciseNumber !== null) { - targetExercises = exercises.filter((ex) => ex.number === exerciseNumber); - if (targetExercises.length === 0) { - return null; - } - } - - const url = `${BASE_URL}/${locale}/${cleanSlug}`; - - let description = "Exercises Content"; - const pathParts = path.split("/"); - const category = pathParts.at(1); - const type = pathParts.at(2); - const material = pathParts.at(3); - - const parsedCategory = ExercisesCategorySchema.safeParse(category); - const parsedType = ExercisesTypeSchema.safeParse(type); - const parsedMaterial = ExercisesMaterialSchema.safeParse(material); - - if (parsedCategory.success && parsedType.success && parsedMaterial.success) { - const materialPath = getMaterialPath( - parsedCategory.data, - parsedType.data, - parsedMaterial.data - ); - const materialsList = await getMaterials(materialPath, locale); - const { currentMaterial, currentMaterialItem } = getCurrentMaterial( - `/${path}`, - materialsList - ); - - if (currentMaterial && currentMaterialItem) { - description = currentMaterial.description - ? `Exercises: ${currentMaterial.title} - ${currentMaterialItem.title}: ${currentMaterial.description}` - : `Exercises: ${currentMaterial.title} - ${currentMaterialItem.title}`; - } + const quranText = getQuranLlmsText({ cleanSlug, locale }); + if (quranText) { + return new Response(quranText); } - // If specific exercise, append its info - if (exerciseNumber !== null) { - const exerciseTitle = targetExercises[0]?.question.metadata.title; - if (exerciseTitle) { - description = `${description} - ${exerciseTitle}`; - } else { - description = `${description} - Question ${exerciseNumber}`; - } + const exerciseText = await getCachedLlmsExerciseText({ cleanSlug, locale }); + if (exerciseText) { + return new Response(exerciseText); } - const scanned: string[] = []; - scanned.push( - ...buildHeader({ - url, - description, - }) - ); - - for (const exercise of targetExercises) { - scanned.push(`## Exercise ${exercise.number}`); - scanned.push(""); - - // Question - scanned.push("### Question"); - scanned.push(""); - scanned.push(exercise.question.raw); - scanned.push(""); - - // Choices - scanned.push("### Choices"); - scanned.push(""); - // Fallback to English if locale specific choices are missing (though schema requires them) - const choices = - (locale === "id" ? exercise.choices.id : exercise.choices.en) || - exercise.choices.en; - - if (choices) { - for (const choice of choices) { - const mark = choice.value ? "x" : " "; - scanned.push(`- [${mark}] ${choice.label}`); - } - } - scanned.push(""); - - // Answer (Explanation) - scanned.push("### Answer & Explanation"); - scanned.push(""); - scanned.push(exercise.answer.raw); - scanned.push(""); - scanned.push("---"); - scanned.push(""); + const mdxText = await getCachedLlmsMdxText({ cleanSlug, locale }); + if (mdxText) { + return new Response(mdxText); } - return new Response(scanned.join("\n")); -} - -function buildMdxResponse({ - content, - locale, - cleanSlug, -}: { - content: { - metadata: { - title: string; - authors: { name: string }[]; - date: string; - description?: string; - subject?: string; - }; - default?: React.ReactElement; - raw: string; - }; - locale: Locale; - cleanSlug: string; -}): Response { - const url = `${BASE_URL}/${locale}/${cleanSlug}`; - const source = getRawGithubUrl( - `/packages/contents/${cleanSlug}/${locale}.mdx` - ); - - const scanned = [ - ...buildHeader({ - url, - description: "Output docs content for large language models.", - source, - }), - content.raw, - ]; - - return new Response(scanned.join("\n")); -} - -export function generateStaticParams() { - return []; + return new Response(await getCachedLlmsIndexText()); } diff --git a/apps/www/app/llms.txt/route.ts b/apps/www/app/llms.txt/route.ts index 998dab3c6..7f12eb4ab 100644 --- a/apps/www/app/llms.txt/route.ts +++ b/apps/www/app/llms.txt/route.ts @@ -1,182 +1,5 @@ -import { - getMaterialPath, - getMaterials, -} from "@repo/contents/_lib/exercises/material"; -import { getFolderChildNames } from "@repo/contents/_lib/fs"; -import { getContentsMetadata } from "@repo/contents/_lib/metadata"; -import { getAllSurah, getSurahName } from "@repo/contents/_lib/quran"; -import { - type ExercisesCategory, - ExercisesCategorySchema, -} from "@repo/contents/_types/exercises/category"; -import { - type ExercisesMaterial, - ExercisesMaterialSchema, -} from "@repo/contents/_types/exercises/material"; -import { - type ExercisesType, - ExercisesTypeSchema, -} from "@repo/contents/_types/exercises/type"; -import { routing } from "@repo/internationalization/src/routing"; -import { Effect } from "effect"; - -const BASE_URL = "https://nakafa.com"; - -export const revalidate = false; +import { getCachedLlmsIndexText } from "@/lib/llms"; export async function GET() { - const locales = routing.locales; - const scanned: string[] = []; - - scanned.push("# Nakafa Framework: LLM"); - scanned.push(`URL: ${BASE_URL}/llms.txt`); - scanned.push("Complete list of all content available on Nakafa."); - scanned.push("---"); - - // Fetch all articles and subjects for all locales in parallel - const contentPromises = locales.flatMap((locale) => [ - Effect.runPromise( - getContentsMetadata({ locale, basePath: "articles" }) - ).then((contents) => ({ - section: "Articles", - locale, - contents, - })), - Effect.runPromise( - getContentsMetadata({ locale, basePath: "subject" }) - ).then((contents) => ({ - section: "Subjects", - locale, - contents, - })), - ]); - - const results = await Promise.all(contentPromises); - - // Group results by section - const map = new Map(); - - for (const result of results) { - for (const content of result.contents) { - if (!content) { - continue; - } - - const entry = `- [${content.metadata.title}](${content.url}): ${ - content.metadata.description ?? content.metadata.title - }`; - - const list = map.get(result.section) ?? []; - list.push(entry); - map.set(result.section, list); - } - } - - // Handle Exercises - const exercisesList: string[] = []; - const exerciseMaterials = getAllExerciseMaterials(); - - for (const locale of locales) { - for (const { category, type, material } of exerciseMaterials) { - const materialPath = getMaterialPath(category, type, material); - const materialsList = await getMaterials(materialPath, locale); - - const context = `${category}/${type}/${material}`; - - for (const group of materialsList) { - for (const item of group.items) { - const href = item.href.startsWith("/") ? item.href : `/${item.href}`; - const url = `${BASE_URL}/${locale}${href}`; - const title = `${group.title} - ${item.title} (${context})`; - const description = group.description ?? group.title; - exercisesList.push(`- [${title}](${url}): ${description}`); - } - } - } - } - - if (exercisesList.length > 0) { - map.set("Exercises", exercisesList); - } - - // Build final output - for (const [key, value] of map) { - scanned.push(`## ${key}`); - scanned.push(value.join("\n")); - } - - // Add Quran section - scanned.push("## Quran"); - const surahs = getAllSurah(); - const quranEntries: string[] = []; - - for (const locale of locales) { - for (const surah of surahs) { - const title = getSurahName({ locale, name: surah.name }); - const translation = - surah.name.translation[locale] || surah.name.translation.en; - quranEntries.push( - `- [${surah.number}. ${title}](${BASE_URL}/${locale}/quran/${surah.number}): ${translation}` - ); - } - } - - scanned.push(quranEntries.join("\n")); - - return new Response(scanned.join("\n\n")); -} - -function getAllExerciseMaterials(): { - category: ExercisesCategory; - type: ExercisesType; - material: ExercisesMaterial; -}[] { - const root = "exercises"; - const categories = Effect.runSync( - Effect.match(getFolderChildNames(root), { - onFailure: () => [], - onSuccess: (names) => names, - }) - ); - const result: { - category: ExercisesCategory; - type: ExercisesType; - material: ExercisesMaterial; - }[] = []; - - for (const category of categories) { - const types = Effect.runSync( - Effect.match(getFolderChildNames(`${root}/${category}`), { - onFailure: () => [], - onSuccess: (names) => names, - }) - ); - for (const type of types) { - const materials = Effect.runSync( - Effect.match(getFolderChildNames(`${root}/${category}/${type}`), { - onFailure: () => [], - onSuccess: (names) => names, - }) - ); - for (const material of materials) { - const parsedCategory = ExercisesCategorySchema.safeParse(category); - const parsedType = ExercisesTypeSchema.safeParse(type); - const parsedMaterial = ExercisesMaterialSchema.safeParse(material); - - if ( - parsedCategory.success && - parsedType.success && - parsedMaterial.success - ) { - result.push({ - category: parsedCategory.data, - type: parsedType.data, - material: parsedMaterial.data, - }); - } - } - } - } - - return result; + return new Response(await getCachedLlmsIndexText()); } diff --git a/apps/www/app/og/[...slug]/route.tsx b/apps/www/app/og/[...slug]/route.tsx index bcba2ba6d..97e027b91 100644 --- a/apps/www/app/og/[...slug]/route.tsx +++ b/apps/www/app/og/[...slug]/route.tsx @@ -1,16 +1,8 @@ import { routing } from "@repo/internationalization/src/routing"; -import { Effect } from "effect"; import type { NextRequest } from "next/server"; import { hasLocale, type Locale } from "next-intl"; -import { generateOGImage } from "@/app/[locale]/og/[...slug]/og"; -import { getMetadataFromSlug } from "@/lib/utils/system"; - -export const dynamic = "force-static"; -export const revalidate = false; - -export function generateStaticParams() { - return []; -} +import { generateOGImage } from "@/lib/og"; +import { getCachedMetadataFromSlug } from "@/lib/utils/system"; export async function GET( _req: NextRequest, @@ -28,11 +20,12 @@ export async function GET( const contentSlug = cleanSlug.at(-1) === "image.png" ? cleanSlug.slice(0, -1) : cleanSlug; - const { title, description } = await Effect.runPromise( - getMetadataFromSlug(locale, contentSlug) + const { title, description } = await getCachedMetadataFromSlug( + locale, + contentSlug ); - return generateOGImage({ + return await generateOGImage({ title, description, }); diff --git a/apps/www/app/rss.xml/route.ts b/apps/www/app/rss.xml/route.ts index 80798a06b..9cfd0f2dc 100644 --- a/apps/www/app/rss.xml/route.ts +++ b/apps/www/app/rss.xml/route.ts @@ -7,8 +7,6 @@ import { Feed, type Item } from "feed"; import { NextResponse } from "next/server"; import { getTranslations } from "next-intl/server"; -export const revalidate = false; - const baseUrl = "https://nakafa.com"; export async function GET() { diff --git a/apps/www/app/sitemap.ts b/apps/www/app/sitemap.ts index c410d9467..4e0507f0a 100644 --- a/apps/www/app/sitemap.ts +++ b/apps/www/app/sitemap.ts @@ -5,7 +5,6 @@ import { parseContentDate } from "@repo/contents/_shared/date"; import { getPathname } from "@repo/internationalization/src/navigation"; import { routing } from "@repo/internationalization/src/routing"; import { MAIN_DOMAIN } from "@repo/next-config/domains"; -import { askSeo } from "@repo/seo/ask"; import { Effect } from "effect"; import type { MetadataRoute } from "next"; import type { Locale } from "next-intl"; @@ -119,10 +118,6 @@ export function getQuranRoutes(): string[] { return Array.from({ length: 114 }, (_, index) => `/quran/${index + 1}`); } -export function getAskRoutes(): string[] { - return askSeo().map((data) => `/ask/${data.slug}`); -} - // Function to recursively get all directories export function getContentRoutes(currentPath = ""): string[] { const children = Effect.runSync( @@ -159,8 +154,7 @@ export async function getEntries( if ( routeString !== "/" && !baseRoutes.includes(routeString) && - !routeString.startsWith("/quran") && - !routeString.startsWith("/ask/") + !routeString.startsWith("/quran") ) { try { // This is likely educational content, get actual modification date @@ -180,9 +174,6 @@ export async function getEntries( } else if (routeString.startsWith("/quran")) { // Quran content is very stable, set to founding date lastModified = new Date("2025-01-01"); - } else if (routeString.startsWith("/ask/")) { - // Ask content is very stable, set to founding date - lastModified = new Date("2025-01-01"); } else if (routeString.startsWith("/about")) { // About page is very stable, set to founding date lastModified = new Date("2025-01-01"); @@ -245,14 +236,12 @@ export default async function sitemap(): Promise { return !isInvalidYearlessTryOutRoute; }); const quranRoutes = getQuranRoutes(); - const askRoutes = getAskRoutes(); // Deduplicate all base routes (contentRoutes might include "/" which is also in baseRoutes) const allBaseRoutesSet = new Set([ ...baseRoutes, ...contentRoutes, ...quranRoutes, - ...askRoutes, ]); const allBaseRoutes = Array.from(allBaseRoutesSet); diff --git a/apps/www/components/ai/chat-sidebar.tsx b/apps/www/components/ai/chat-sidebar.tsx index 916539ec5..5660bc35f 100644 --- a/apps/www/components/ai/chat-sidebar.tsx +++ b/apps/www/components/ai/chat-sidebar.tsx @@ -38,7 +38,7 @@ type Props = ComponentProps; export function AiChatSidebar({ ...props }: Props) { return ( -
+
+ import("@/components/ai/sheet-open").then((module) => module.AiSheetOpen), + { + ssr: false, + loading: () => null, + } +); diff --git a/apps/www/components/ask/cta.tsx b/apps/www/components/ask/cta.tsx deleted file mode 100644 index 4ce53989a..000000000 --- a/apps/www/components/ask/cta.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import { StarsIcon } from "@hugeicons/core-free-icons"; -import { Button } from "@repo/design-system/components/ui/button"; -import { HugeIcons } from "@repo/design-system/components/ui/huge-icons"; -import { useRouter } from "@repo/internationalization/src/navigation"; -import { useTranslations } from "next-intl"; -import { authClient } from "@/lib/auth/client"; -import { useAi } from "@/lib/context/use-ai"; -import { useUser } from "@/lib/context/use-user"; - -interface Props { - title: string; -} - -export function AskCta({ title }: Props) { - const t = useTranslations("Ai"); - const user = useUser((s) => s.user); - const router = useRouter(); - - const setText = useAi((state) => state.setText); - - const handleGoogleSignIn = () => { - authClient.signIn.social({ - provider: "google", - }); - }; - - const handleAsk = () => { - setText(title); - - if (!user) { - handleGoogleSignIn(); - return; - } - - router.push("/chat"); - }; - - return ( - - ); -} diff --git a/apps/www/components/ask/results.tsx b/apps/www/components/ask/results.tsx deleted file mode 100644 index 9fc4a240b..000000000 --- a/apps/www/components/ask/results.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { getErrorMessage, usePagefind } from "@/lib/context/use-pagefind"; -import { useSearchQuery } from "@/lib/react-query/use-search"; -import { SearchResults } from "../shared/search-results"; - -interface Props { - query: string; -} - -export function AskListItems({ query }: Props) { - const pagefindError = usePagefind((context) => context.error); - - const { - data: results = [], - isError, - error, - isLoading, - isPlaceholderData, - } = useSearchQuery({ - query, - enabled: Boolean(query), - }); - - const hasError = isError || Boolean(pagefindError); - const displayError = pagefindError || (error ? getErrorMessage(error) : ""); - const queryLoading = isLoading && !hasError && !isPlaceholderData; - - return ( - - ); -} diff --git a/apps/www/components/comments/deferred.tsx b/apps/www/components/comments/deferred.tsx new file mode 100644 index 000000000..a18f16b57 --- /dev/null +++ b/apps/www/components/comments/deferred.tsx @@ -0,0 +1,11 @@ +"use client"; + +import dynamic from "next/dynamic"; + +export const DeferredComments = dynamic( + () => import("@/components/comments").then((module) => module.Comments), + { + ssr: false, + loading: () => null, + } +); diff --git a/apps/www/components/exercise/entry.tsx b/apps/www/components/exercise/entry.tsx new file mode 100644 index 000000000..72295e9b0 --- /dev/null +++ b/apps/www/components/exercise/entry.tsx @@ -0,0 +1,141 @@ +import { importContentModule } from "@repo/contents/_lib/module"; +import type { ExerciseWithoutDefaults } from "@repo/contents/_types/exercises/shared"; +import { slugify } from "@repo/design-system/lib/utils"; +import type { Locale } from "next-intl"; +import { Suspense } from "react"; +import { QuestionAnalytics } from "@/components/exercise/item/analytics"; +import { ExerciseArticle } from "@/components/exercise/item/article"; + +/** Loads the compiled question module for one exercise entry. */ +async function QuestionContent({ + exerciseNumber, + locale, + setPath, +}: { + exerciseNumber: number; + locale: Locale; + setPath: string; +}) { + const question = await importContentModule( + `${setPath}/${exerciseNumber}/_question`, + locale + ).catch(() => null); + const Question = question?.default; + + return Question ? : null; +} + +/** Loads the compiled answer module for one exercise entry. */ +async function AnswerContent({ + exerciseNumber, + locale, + setPath, +}: { + exerciseNumber: number; + locale: Locale; + setPath: string; +}) { + const answer = await importContentModule( + `${setPath}/${exerciseNumber}/_answer`, + locale + ).catch(() => null); + const Answer = answer?.default; + + return Answer ? : null; +} + +/** Builds the shared exercise article body used by learn and try-out routes. */ +function ExerciseEntryBody({ + exercise, + id, + locale, + setPath, + srLabel, +}: { + exercise: ExerciseWithoutDefaults; + id: string; + locale: Locale; + setPath: string; + srLabel: string; +}) { + return ( + + + + } + choices={exercise.choices[locale]} + exerciseNumber={exercise.number} + id={id} + questionContent={ + + } + srLabel={srLabel} + /> + ); +} + +/** Renders one exercise entry without analytics tracking. */ +export function ExerciseEntry({ + exercise, + locale, + setPath, + srLabel, +}: { + exercise: ExerciseWithoutDefaults; + locale: Locale; + setPath: string; + srLabel: string; +}) { + const id = slugify(srLabel); + + return ( + + ); +} + +/** Renders one exercise entry inside viewport analytics tracking. */ +export function ExerciseTrackedEntry({ + exercise, + locale, + setPath, + srLabel, +}: { + exercise: ExerciseWithoutDefaults; + locale: Locale; + setPath: string; + srLabel: string; +}) { + const id = slugify(srLabel); + + return ( + <> + + + + ); +} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/actions.tsx b/apps/www/components/exercise/item/action.tsx similarity index 86% rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/actions.tsx rename to apps/www/components/exercise/item/action.tsx index 474df684b..74b59115b 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/actions.tsx +++ b/apps/www/components/exercise/item/action.tsx @@ -8,22 +8,19 @@ import { useTranslations } from "next-intl"; import { useAttempt } from "@/lib/context/use-attempt"; import { useExercise } from "@/lib/context/use-exercise"; -interface Props { +/** Toggles one exercise explanation and keeps the hash anchored to the active section. */ +export function ExerciseAnswerAction({ + exerciseNumber, +}: { exerciseNumber: number; -} - -export function ExerciseAnswerAction({ exerciseNumber }: Props) { +}) { const t = useTranslations("Exercises"); const toggleAnswer = useExercise((state) => state.toggleAnswer); const showAnswer = useExercise( (state) => state.visibleExplanations[exerciseNumber] ?? false ); - const mustDisable = useAttempt( - (state) => - state.attempt?.status === "in-progress" && - state.attempt?.mode === "simulation" - ); + const mustDisable = useAttempt((state) => state.isSimulationInProgress); return (
diff --git a/apps/www/components/exercise/item/analytics.tsx b/apps/www/components/exercise/item/analytics.tsx new file mode 100644 index 000000000..32de707e6 --- /dev/null +++ b/apps/www/components/exercise/item/analytics.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useInterval } from "@mantine/hooks"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useAttempt } from "@/lib/context/use-attempt"; +import { useExercise } from "@/lib/context/use-exercise"; + +/** Tracks visible time for one rendered exercise while the active attempt is running. */ +export function QuestionAnalytics({ + articleId, + exerciseNumber, +}: { + articleId: string; + exerciseNumber: number; +}) { + const isInputLocked = useAttempt((state) => state.isInputLocked); + const isAttemptInProgress = useAttempt((state) => state.isAttemptInProgress); + const [isActive, setIsActive] = useState(false); + const timeSpent = useExercise( + (state) => state.timeSpent[exerciseNumber] ?? 0 + ); + const timeCounterRef = useRef(timeSpent); + const setTimeSpent = useExercise((state) => state.setTimeSpent); + const hasActiveAttempt = isAttemptInProgress && !isInputLocked; + + useEffect(() => { + timeCounterRef.current = timeSpent; + }, [timeSpent]); + + useEffect(() => { + const target = document.getElementById(articleId); + + if (!target) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + setIsActive(entry?.isIntersecting ?? false); + }, + { threshold: 0.75 } + ); + + observer.observe(target); + + return () => { + observer.disconnect(); + }; + }, [articleId]); + + const handleTick = useEffectEvent(() => { + if (isActive && hasActiveAttempt) { + timeCounterRef.current += 1; + setTimeSpent(exerciseNumber, timeCounterRef.current); + } + }); + + const interval = useInterval(() => { + handleTick(); + }, 1000); + + useEffect(() => { + if (isActive && hasActiveAttempt) { + interval.start(); + return () => { + interval.stop(); + }; + } + + interval.stop(); + }, [hasActiveAttempt, interval, isActive]); + + return null; +} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/answer.tsx b/apps/www/components/exercise/item/answer.tsx similarity index 75% rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/answer.tsx rename to apps/www/components/exercise/item/answer.tsx index 67a573186..769a6859c 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/answer.tsx +++ b/apps/www/components/exercise/item/answer.tsx @@ -3,38 +3,35 @@ import { Link05Icon } from "@hugeicons/core-free-icons"; import { HugeIcons } from "@repo/design-system/components/ui/huge-icons"; import { Separator } from "@repo/design-system/components/ui/separator"; -import { cn, slugify } from "@repo/design-system/lib/utils"; +import { slugify } from "@repo/design-system/lib/utils"; import { useTranslations } from "next-intl"; +import type { ReactNode } from "react"; import { useAttempt } from "@/lib/context/use-attempt"; import { useExercise } from "@/lib/context/use-exercise"; -interface Props { - children: React.ReactNode; +/** Renders the explanation section for one exercise when the active attempt allows it. */ +export function ExerciseAnswer({ + children, + exerciseNumber, +}: { + children: ReactNode; exerciseNumber: number; -} - -export function ExerciseAnswer({ children, exerciseNumber }: Props) { +}) { const t = useTranslations("Exercises"); const showAnswer = useExercise( (state) => state.visibleExplanations[exerciseNumber] ?? false ); - const mustHide = useAttempt( - (state) => - state.attempt?.status === "in-progress" && - state.attempt?.mode === "simulation" - ); + const mustHide = useAttempt((state) => state.isSimulationInProgress); + + if (mustHide || !showAnswer) { + return null; + } const id = slugify(`${t("explanation")}-${exerciseNumber}`); return ( -
+

diff --git a/apps/www/components/exercise/item/article.tsx b/apps/www/components/exercise/item/article.tsx new file mode 100644 index 000000000..d120957aa --- /dev/null +++ b/apps/www/components/exercise/item/article.tsx @@ -0,0 +1,61 @@ +import type { ExercisesChoices } from "@repo/contents/_types/exercises/choices"; +import type { Locale } from "next-intl"; +import type { ReactNode } from "react"; +import { ExerciseAnswerAction } from "@/components/exercise/item/action"; +import { ExerciseAnswer } from "@/components/exercise/item/answer"; +import { ExerciseChoices } from "@/components/exercise/item/choices"; + +/** Renders one exercise article with question, answer controls, choices, and explanation. */ +export function ExerciseArticle({ + answerContent, + choices, + exerciseNumber, + id, + questionContent, + srLabel, +}: { + answerContent: ReactNode; + choices: ExercisesChoices[Locale]; + exerciseNumber: number; + id: string; + questionContent: ReactNode; + srLabel: string; +}) { + const articleId = `exercise-${id}`; + + return ( + + ); +} diff --git a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/choices.tsx b/apps/www/components/exercise/item/choices.tsx similarity index 84% rename from apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/choices.tsx rename to apps/www/components/exercise/item/choices.tsx index 553cc59b1..9455da6c1 100644 --- a/apps/www/app/[locale]/(app)/(static)/(learn)/exercises/[category]/[type]/[material]/[...slug]/choices.tsx +++ b/apps/www/components/exercise/item/choices.tsx @@ -16,37 +16,38 @@ import { toast } from "sonner"; import { useAttempt } from "@/lib/context/use-attempt"; import { useExercise } from "@/lib/context/use-exercise"; -interface Props { +/** Renders the selectable choices for one exercise and submits answers to Convex. */ +export function ExerciseChoices({ + choices, + exerciseNumber, + id, +}: { choices: ExercisesChoices[keyof ExercisesChoices]; exerciseNumber: number; id: string; -} - -export function ExerciseChoices({ id, exerciseNumber, choices }: Props) { +}) { const t = useTranslations("Exercises"); - const [isPending, startTransition] = useTransition(); - const attempt = useAttempt((state) => state.attempt); - const answers = useAttempt((state) => state.answers); - const answerSheet = useAttempt((state) => state.answerSheet); + const attemptId = useAttempt((state) => state.attemptId); + const attemptMode = useAttempt((state) => state.attemptMode); + const attemptStatus = useAttempt((state) => state.attemptStatus); + const currentAnswer = useAttempt( + (state) => state.answerByExercise.get(exerciseNumber) ?? null + ); + const answerSheetEntry = useAttempt( + (state) => state.answerSheetByExercise.get(exerciseNumber) ?? null + ); const isInputLocked = useAttempt((state) => state.isInputLocked); const isReviewMode = useAttempt((state) => state.isReviewMode); - const submitAttempt = useMutation(api.exercises.mutations.submitAnswer); const timeSpent = useExercise( (state) => state.timeSpent[exerciseNumber] ?? 0 ); - const currentAnswer = answers.find( - (a) => a.exerciseNumber === exerciseNumber - ); - const answerSheetEntry = answerSheet.find( - (entry) => entry.exerciseNumber === exerciseNumber - ); - - function handleSubmit({ index }: { index: number }) { - if (!attempt) { + /** Submits one selected option for the current exercise. */ + function handleSubmit(index: number) { + if (!attemptId) { toast.info(t("attempt-not-found"), { position: "bottom-center" }); return; } @@ -58,8 +59,7 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) { return; } - // If the attempt is not in progress, tell user to start new attempt - if (attempt.status !== "in-progress") { + if (attemptStatus !== "in-progress") { toast.info(t("attempt-not-in-progress"), { position: "bottom-center" }); return; } @@ -76,7 +76,7 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) { startTransition(async () => { try { await submitAttempt({ - attemptId: attempt._id, + attemptId, exerciseNumber, questionId: answerSheetEntry.questionId, selectedOptionId: option.optionKey, @@ -139,9 +139,9 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) { const shouldShowReviewState = isReviewMode || - attempt?.mode === "practice" || - attempt?.status === "completed" || - attempt?.status === "expired"; + attemptMode === "practice" || + attemptStatus === "completed" || + attemptStatus === "expired"; if (shouldShowReviewState) { if (checked && currentAnswer && !currentAnswer.isCorrect) { @@ -167,7 +167,7 @@ export function ExerciseChoices({ id, exerciseNumber, choices }: Props) { disabled={isInputLocked || isPending} onCheckedChange={(checked) => { if (checked) { - handleSubmit({ index }); + handleSubmit(index); } }} /> diff --git a/apps/www/components/providers/app.tsx b/apps/www/components/providers/app.tsx new file mode 100644 index 000000000..338746e8f --- /dev/null +++ b/apps/www/components/providers/app.tsx @@ -0,0 +1,34 @@ +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import type { ComponentProps, ReactNode } from "react"; +import { UserContextProvider } from "@/lib/context/use-user"; +import { ConvexProvider } from "./convex"; +import { ReactQueryProviders } from "./react-query"; + +/** + * Mounts the app-wide client runtime providers for the localized app subtree. + * + * `NuqsAdapter` and `ReactQueryProviders` are global router/query config, while + * the Convex and current-user contexts are seeded once per request at the + * shared `(app)` boundary. + * + * @see https://github.com/47ng/nuqs#readme + * @see https://docs.convex.dev/client/nextjs/app-router/server-rendering + * @see https://labs.convex.dev/better-auth/migrations/migrate-to-0-10#pass-initial-token-to-convexbetterauthprovider + */ +export function AppProviders({ + children, + initialToken, +}: { + children: ReactNode; + initialToken?: ComponentProps["initialToken"]; +}) { + return ( + + + + {children} + + + + ); +} diff --git a/apps/www/components/providers/index.tsx b/apps/www/components/providers/index.tsx deleted file mode 100644 index 294526cb9..000000000 --- a/apps/www/components/providers/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { NuqsAdapter } from "nuqs/adapters/next/app"; -import type { ComponentProps, ReactNode } from "react"; -import { AiContextProvider } from "@/lib/context/use-ai"; -import { ContentViewsProvider } from "@/lib/context/use-content-views"; -import { PagefindProvider } from "@/lib/context/use-pagefind"; -import { SearchContextProvider } from "@/lib/context/use-search"; -import { UserContextProvider } from "@/lib/context/use-user"; -import { ConvexProvider } from "./convex"; -import { ReactQueryProviders } from "./react-query"; - -/** - * Mounts request-agnostic client providers at the root layout. - * - * This layer stays free of request-time auth so static routes, including the - * contents subtree, keep their existing prerendering behavior. - * - * @see https://nextjs.org/docs/app/api-reference/file-conventions/layout - * @see https://nextjs.org/docs/app/guides/streaming - */ -export function AppProviders({ children }: { children: ReactNode }) { - return ( - - - - - - {children} - - - - - - ); -} - -/** - * Mounts the authenticated Convex and current-user contexts for one route - * subtree. - * - * Keeping this boundary route-scoped lets us seed auth only where SSR preloads - * need it, without broadening dynamic rendering for static layouts. - * - * @see https://docs.convex.dev/client/nextjs/app-router/server-rendering - * @see https://labs.convex.dev/better-auth/migrations/migrate-to-0-10#pass-initial-token-to-convexbetterauthprovider - */ -export function ConvexAppProviders({ - children, - initialToken, -}: { - children: ReactNode; - initialToken?: ComponentProps["initialToken"]; -}) { - return ( - - {children} - - ); -} diff --git a/apps/www/components/providers/shared.tsx b/apps/www/components/providers/shared.tsx new file mode 100644 index 000000000..ccbe8adfd --- /dev/null +++ b/apps/www/components/providers/shared.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; +import { AiContextProvider } from "@/lib/context/use-ai"; +import { ContentViewsProvider } from "@/lib/context/use-content-views"; +import { PagefindProvider } from "@/lib/context/use-pagefind"; +import { SearchContextProvider } from "@/lib/context/use-search"; + +/** + * Mounts shared feature-state providers for the marketing, main, and tryout + * route groups. + * + * These stores are intentionally scoped below the app-wide runtime providers so + * `auth` and `school` stay free of unrelated search, Pagefind, AI, and content + * view state. + * + * @see https://nextjs.org/docs/app/api-reference/file-conventions/layout + * @see https://nextjs.org/docs/app/guides/streaming + */ +export function SharedProviders({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/apps/www/components/shared/card-material.tsx b/apps/www/components/shared/card-material.tsx index aca71569d..a1e0db061 100644 --- a/apps/www/components/shared/card-material.tsx +++ b/apps/www/components/shared/card-material.tsx @@ -5,6 +5,7 @@ import { ArrowRight02Icon, Link04Icon, } from "@hugeicons/core-free-icons"; +import { useDisclosure } from "@mantine/hooks"; import type { MaterialList } from "@repo/contents/_types/subject/material"; import { Button } from "@repo/design-system/components/ui/button"; import { @@ -21,14 +22,43 @@ import { import { HugeIcons } from "@repo/design-system/components/ui/huge-icons"; import { cn, slugify } from "@repo/design-system/lib/utils"; import { Link } from "@repo/internationalization/src/navigation"; -import { useState } from "react"; +import { useLayoutEffect, useState } from "react"; interface Props { material: MaterialList[number]; } +/** + * Renders one expandable material card on the subject material index page. + * + * The card defaults to open, and resets back to open when Next.js hides the + * page through Cache Components state preservation. This keeps the material list + * behaving like a transient disclosure instead of persisting a collapsed state + * across route transitions. + * + * We also remount the Base UI collapsible root on hide. Its panel measures and + * caches `scrollHeight` for height transitions, and under preserved hidden DOM a + * stale zero-height measurement can survive the route transition even after + * `open` is reset to `true`. + * + * References: + * - Next.js preserving UI state with Cache Components: + * `apps/www/node_modules/next/dist/docs/01-app/02-guides/preserving-ui-state.md` + * - Base UI collapsible component docs: + * https://base-ui.com/react/components/collapsible + * - Installed Base UI panel measurement logic: + * `packages/design-system/node_modules/@base-ui/react/esm/collapsible/panel/useCollapsiblePanel.js` + */ export function CardMaterial({ material }: Props) { - const [isOpen, setIsOpen] = useState(true); + const [isOpen, { open, set, toggle }] = useDisclosure(true); + const [panelKey, setPanelKey] = useState(0); + + useLayoutEffect(() => { + return () => { + open(); + setPanelKey((key) => key + 1); + }; + }, [open]); const id = slugify(material.title); @@ -63,7 +93,7 @@ export function CardMaterial({ material }: Props) {

- +
    diff --git a/apps/www/components/shared/coming-soon.tsx b/apps/www/components/shared/coming-soon.tsx index b887bc93a..77221fc88 100644 --- a/apps/www/components/shared/coming-soon.tsx +++ b/apps/www/components/shared/coming-soon.tsx @@ -11,7 +11,7 @@ export function ComingSoon({ className }: { className?: string }) { const t = useTranslations("ComingSoon"); return ( -
    +
    {t("title")} diff --git a/apps/www/components/shared/footer-content.tsx b/apps/www/components/shared/footer-content.tsx index 5a85003f7..fe3f2dc69 100644 --- a/apps/www/components/shared/footer-content.tsx +++ b/apps/www/components/shared/footer-content.tsx @@ -13,7 +13,7 @@ export function FooterContent({ childrenClassName, }: Props) { return ( -
    +
    {children}
    diff --git a/apps/www/components/shared/header-content.tsx b/apps/www/components/shared/header-content.tsx index 5e7a51153..1d908baff 100644 --- a/apps/www/components/shared/header-content.tsx +++ b/apps/www/components/shared/header-content.tsx @@ -47,7 +47,7 @@ export function HeaderContent({ }: Props) { const showFooter = authors || date; return ( -
    +
    {!!link && ( diff --git a/apps/www/components/shared/layout-material.tsx b/apps/www/components/shared/layout-material.tsx index d31b2be6a..b52bc7722 100644 --- a/apps/www/components/shared/layout-material.tsx +++ b/apps/www/components/shared/layout-material.tsx @@ -1,7 +1,6 @@ import type { ParsedHeading } from "@repo/contents/_types/toc"; import { cn } from "@repo/design-system/lib/utils"; import type { ComponentProps, ReactNode } from "react"; -import { VirtualProvider } from "@/lib/context/use-virtual"; import { FooterContent } from "./footer-content"; import { HeaderContent } from "./header-content"; import { LayoutContent } from "./layout-content"; @@ -81,9 +80,5 @@ export function LayoutMaterialToc({ } export function LayoutMaterial({ className, ...props }: ComponentProps<"div">) { - return ( - -
    - - ); + return
    ; } diff --git a/apps/www/components/shared/open-content.tsx b/apps/www/components/shared/open-content.tsx index 360486102..72de75e28 100644 --- a/apps/www/components/shared/open-content.tsx +++ b/apps/www/components/shared/open-content.tsx @@ -7,7 +7,7 @@ import { Tick01Icon, } from "@hugeicons/core-free-icons"; import { Claude, Gemini, Github, OpenAI } from "@lobehub/icons"; -import { useClipboard } from "@mantine/hooks"; +import { useClipboard, useDisclosure } from "@mantine/hooks"; import { Button } from "@repo/design-system/components/ui/button"; import { DropdownMenu, @@ -19,10 +19,22 @@ import { HugeIcons } from "@repo/design-system/components/ui/huge-icons"; import { cn } from "@repo/design-system/lib/utils"; import { Link } from "@repo/internationalization/src/navigation"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useLayoutEffect } from "react"; import { toast } from "sonner"; import { getGithubUrl } from "@/lib/utils/github"; +/** + * Renders open/share actions for one content page. + * + * The dropdown is transient UI, so it resets closed when Next hides the page + * through Cache Components state preservation. + * + * References: + * - Next.js preserving UI state with Cache Components: + * `apps/www/node_modules/next/dist/docs/01-app/02-guides/preserving-ui-state.md` + * - Mantine `useDisclosure`: + * https://mantine.dev/hooks/use-disclosure/ + */ export function OpenContent({ slug, content, @@ -32,7 +44,9 @@ export function OpenContent({ }) { const t = useTranslations("Common"); const clipboard = useClipboard({ timeout: 500 }); - const [open, setOpen] = useState(false); + const [open, { close, set }] = useDisclosure(false); + + useLayoutEffect(() => close, [close]); const handleCopy = () => { if (!content) { @@ -83,7 +97,7 @@ export function OpenContent({ {t("copy-content")} - +
- {!!showSheet && ( - + {showSheet ? ( +
- {references.length} {t("references")} + {referenceList.length} {t("references")} {title} @@ -194,7 +208,7 @@ export function RefContent({ title, references, githubUrl, className }: Props) {
- {references.map((reference) => { + {referenceList.map((reference) => { const url = reference.url ? formatUrl(reference.url) : t("no-website"); @@ -281,7 +295,7 @@ export function RefContent({ title, references, githubUrl, className }: Props) {
- )} + ) : null} ); } diff --git a/apps/www/components/shared/search-command.tsx b/apps/www/components/shared/search-command.tsx index 92760ec19..c0690f2f1 100644 --- a/apps/www/components/shared/search-command.tsx +++ b/apps/www/components/shared/search-command.tsx @@ -24,13 +24,17 @@ import { cn } from "@repo/design-system/lib/utils"; import { useRouter } from "@repo/internationalization/src/navigation"; import { useTranslations } from "next-intl"; import type { ReactElement, ReactNode } from "react"; -import { Fragment, useTransition } from "react"; +import { Fragment, useLayoutEffect, useTransition } from "react"; import { articlesMenu } from "@/components/sidebar/_data/articles"; import { holyMenu } from "@/components/sidebar/_data/holy"; import { subjectMenu } from "@/components/sidebar/_data/subject"; import { getErrorMessage, usePagefind } from "@/lib/context/use-pagefind"; import { useSearch } from "@/lib/context/use-search"; import { useSearchQuery } from "@/lib/react-query/use-search"; +import { + getPagefindSectionResults, + hasPagefindExcerpt, +} from "@/lib/utils/pagefind"; import type { PagefindResult } from "@/types/pagefind"; const DEBOUNCE_TIME = 500; @@ -44,6 +48,12 @@ export function SearchCommand() { setOpen: state.setOpen, })); + useLayoutEffect(() => { + return () => { + setOpen(false); + }; + }, [setOpen]); + useHotkeys([ ["/", () => setOpen(true)], ["mod+K", () => setOpen(true)], @@ -174,33 +184,16 @@ function SearchListItems({ return results.map((result, index) => ( - - {result.sub_results.map((subResult, index) => ( - { - startTransition(() => { - setOpen(false); - router.push(subResult.url); - }); - }} - value={`${result.meta.title} ${subResult.title} ${subResult.url}`} - > -
- - {subResult.title} -
-

- - ))} - + { + startTransition(() => { + setOpen(false); + router.push(url); + }); + }} + result={result} + /> {results.length > 1 && index !== results.length - 1 && ( )} @@ -208,6 +201,47 @@ function SearchListItems({ )); } +/** Renders one grouped search result inside the command palette. */ +function SearchResultGroup({ + result, + onSelect, + isPending, +}: { + result: PagefindResult; + onSelect: (url: string) => void; + isPending: boolean; +}) { + const sectionResults = getPagefindSectionResults(result); + + return ( + + {sectionResults.map((subResult, subIndex) => ( + onSelect(subResult.url)} + value={`${result.meta.title} ${subResult.title} ${subResult.url}`} + > +

+ + {subResult.title} +
+

+ + ))} + + ); +} + function DefaultItems() { const t = useTranslations("Subject"); const tArticles = useTranslations("Articles"); diff --git a/apps/www/components/shared/search-results.tsx b/apps/www/components/shared/search-results.tsx index 5ae8c1683..c17e662a8 100644 --- a/apps/www/components/shared/search-results.tsx +++ b/apps/www/components/shared/search-results.tsx @@ -11,6 +11,10 @@ import { Spinner } from "@repo/design-system/components/ui/spinner"; import { cn } from "@repo/design-system/lib/utils"; import { useTranslations } from "next-intl"; import { Fragment, type ReactElement } from "react"; +import { + getPagefindSectionResults, + hasPagefindExcerpt, +} from "@/lib/utils/pagefind"; import type { PagefindResult } from "@/types/pagefind"; interface Props { @@ -73,39 +77,51 @@ export function SearchResults({

{results.map((result, index) => ( -
-

- {result.meta.title} -

-
- {result.sub_results.map((subResult, subIndex) => ( - -
- - {subResult.title} -
-

- - ))} -

-
+ {index !== results.length - 1 && }
))}
); } + +/** Renders one grouped Pagefind result with optional summary copy. */ +function ResultGroup({ result }: { result: PagefindResult }) { + const sectionResults = getPagefindSectionResults(result); + + return ( +
+

+ {result.meta.title} +

+
+ {sectionResults.map((subResult, subIndex) => ( + +
+ + {subResult.title} +
+

+ + ))} +

+
+ ); +} diff --git a/apps/www/components/shared/sidebar-right.tsx b/apps/www/components/shared/sidebar-right.tsx index d6165a728..e8fc00963 100644 --- a/apps/www/components/shared/sidebar-right.tsx +++ b/apps/www/components/shared/sidebar-right.tsx @@ -117,7 +117,7 @@ export function SidebarRight({ ...props }: SidebarRightProps) { return ( -
); } + +/** Generates one OG image response with cached persistent assets. */ +export async function generateOGImage( + options: GenerateProps & { width?: number; height?: number } +) { + const { title, description, width = 1200, height = 630 } = options; + const logoDataUrl = await getLogoDataUrl(); + + return new ImageResponse( + + } + title={title} + />, + { + width, + height, + fetchedResources: [], + } + ); +} diff --git a/apps/www/lib/utils/__tests__/pagefind.test.ts b/apps/www/lib/utils/__tests__/pagefind.test.ts index 3848adce2..77758d593 100644 --- a/apps/www/lib/utils/__tests__/pagefind.test.ts +++ b/apps/www/lib/utils/__tests__/pagefind.test.ts @@ -1,6 +1,11 @@ import { normalizeLocalizedInternalHref } from "@repo/internationalization/src/href"; import { describe, expect, it } from "vitest"; -import { getPagefindSubResultHref, normalizePagefindResult } from "../pagefind"; +import { + getPagefindSectionResults, + getPagefindSubResultHref, + hasPagefindExcerpt, + normalizePagefindResult, +} from "../pagefind"; describe("normalizeLocalizedInternalHref", () => { it("strips the leading locale from internal hrefs", () => { @@ -101,4 +106,167 @@ describe("normalizePagefindResult", () => { "/subject/high-school/10/mathematics/exponential-logarithm/logarithm-definition#pengertian-logaritma" ); }); + + it("removes repeated section titles from sub-result excerpts", () => { + expect( + normalizePagefindResult({ + excerpt: "...", + meta: { + title: "Matematika", + }, + raw_url: + "/id/subject/high-school/10/mathematics/exponential-logarithm/logarithm-definition.html", + sub_results: [ + { + anchor: { + element: "h2", + id: "pengertian-logaritma", + location: 1, + text: "Pengertian Logaritma", + }, + excerpt: + "Pengertian Logaritma. Logaritma adalah operasi matematika...", + title: "Pengertian Logaritma", + url: "/id/subject/high-school/10/mathematics/exponential-logarithm/logarithm-definition.html#pengertian-logaritma", + }, + ], + url: "/id/subject/high-school/10/mathematics/exponential-logarithm/logarithm-definition.html", + }).sub_results[0]?.excerpt + ).toBe("Logaritma adalah operasi matematika..."); + }); + + it("keeps empty html excerpts unchanged", () => { + expect( + normalizePagefindResult({ + excerpt: "...", + meta: { + title: "Matematika", + }, + raw_url: "/id/subject/logarithm-definition.html", + sub_results: [ + { + excerpt: "", + title: "Pengertian Logaritma", + url: "/id/subject/logarithm-definition.html#pengertian-logaritma", + }, + ], + url: "/id/subject/logarithm-definition.html", + }).sub_results[0]?.excerpt + ).toBe(""); + }); + + it("keeps excerpts intact when the title is not repeated at the start", () => { + expect( + normalizePagefindResult({ + excerpt: "...", + meta: { + title: "Matematika", + }, + raw_url: "/id/subject/logarithm-definition.html", + sub_results: [ + { + excerpt: "Materi ini menjelaskan logaritma dasar.", + title: "Pengertian Logaritma", + url: "/id/subject/logarithm-definition.html#pengertian-logaritma", + }, + ], + url: "/id/subject/logarithm-definition.html", + }).sub_results[0]?.excerpt + ).toBe("Materi ini menjelaskan logaritma dasar."); + }); + + it("trims repeated title prefixes that end inside a text token", () => { + expect( + normalizePagefindResult({ + excerpt: "...", + meta: { + title: "Matematika", + }, + raw_url: "/id/subject/logarithm-definition.html", + sub_results: [ + { + excerpt: "PengertianLogaritma adalah operasi matematika.", + title: "Pengertian", + url: "/id/subject/logarithm-definition.html#pengertian-logaritma", + }, + ], + url: "/id/subject/logarithm-definition.html", + }).sub_results[0]?.excerpt + ).toBe("Logaritma adalah operasi matematika."); + }); +}); + +describe("getPagefindSectionResults", () => { + it("drops a duplicate title row when anchored section hits exist", () => { + const result = getPagefindSectionResults({ + excerpt: "...", + meta: { title: "Definisi Logaritma" }, + raw_url: "/id/subject/logarithm-definition.html", + sub_results: [ + { + excerpt: "Ringkasan...", + title: "Definisi Logaritma", + url: "/id/subject/logarithm-definition.html", + }, + { + anchor: { + element: "h2", + id: "pengertian-logaritma", + location: 1, + text: "Pengertian Logaritma", + }, + excerpt: "Isi...", + title: "Pengertian Logaritma", + url: "/id/subject/logarithm-definition.html#pengertian-logaritma", + }, + ], + url: "/id/subject/logarithm-definition.html", + }); + + expect(result).toHaveLength(1); + expect(result[0]?.title).toBe("Pengertian Logaritma"); + }); + + it("keeps the first item clickable when no section hits exist", () => { + const result = getPagefindSectionResults({ + excerpt: "...", + meta: { title: "Set 1" }, + raw_url: "/id/exercises/set-1.html", + sub_results: [ + { + excerpt: "Ringkasan...", + title: "Set 1", + url: "/id/exercises/set-1.html", + }, + ], + url: "/id/exercises/set-1.html", + }); + + expect(result).toHaveLength(1); + expect(result[0]?.title).toBe("Set 1"); + }); + + it("returns an empty array when there are no sub results", () => { + const result = getPagefindSectionResults({ + excerpt: "...", + meta: { title: "Set 1" }, + raw_url: "/id/exercises/set-1.html", + sub_results: [], + url: "/id/exercises/set-1.html", + }); + + expect(result).toStrictEqual([]); + }); +}); + +describe("hasPagefindExcerpt", () => { + it("returns false for empty excerpt markup", () => { + expect(hasPagefindExcerpt("")).toBe(false); + }); + + it("returns true when excerpt contains visible text", () => { + expect(hasPagefindExcerpt("Logaritma adalah operasi")).toBe( + true + ); + }); }); diff --git a/apps/www/lib/utils/__tests__/system.test.ts b/apps/www/lib/utils/__tests__/system.test.ts index eb9a5077e..dd1e31c6c 100644 --- a/apps/www/lib/utils/__tests__/system.test.ts +++ b/apps/www/lib/utils/__tests__/system.test.ts @@ -1,13 +1,26 @@ import { Effect } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getMetadataFromSlug, getStaticParams } from "../system"; +import { + getCachedMetadataFromSlug, + getMetadataFromSlug, + getStaticParams, +} from "../system"; + +const { + mockCacheLife, + mockGetFolderChildNames, + mockGetNestedSlugs, + mockGetContentMetadata, +} = vi.hoisted(() => ({ + mockCacheLife: vi.fn(), + mockGetFolderChildNames: vi.fn(), + mockGetNestedSlugs: vi.fn(), + mockGetContentMetadata: vi.fn(), +})); -const { mockGetFolderChildNames, mockGetNestedSlugs, mockGetContentMetadata } = - vi.hoisted(() => ({ - mockGetFolderChildNames: vi.fn(), - mockGetNestedSlugs: vi.fn(), - mockGetContentMetadata: vi.fn(), - })); +vi.mock("next/cache", () => ({ + cacheLife: mockCacheLife, +})); vi.mock("@repo/contents/_lib/fs", () => ({ getFolderChildNames: mockGetFolderChildNames, @@ -74,6 +87,7 @@ beforeEach(() => { afterEach(() => { vi.clearAllMocks(); + mockCacheLife.mockReset(); mockGetFolderChildNames.mockReset(); mockGetNestedSlugs.mockReset(); mockGetContentMetadata.mockReset(); @@ -1064,3 +1078,30 @@ describe("getMetadataFromSlug", () => { }); }); }); + +describe("getCachedMetadataFromSlug", () => { + it("sets the max cache profile and resolves metadata", async () => { + mockGetContentMetadata.mockReturnValue( + Effect.succeed({ + title: "Cached Title", + description: "Cached Description", + authors: [{ name: "Nakafa" }], + date: "04/12/2026", + }) + ); + + const result = await getCachedMetadataFromSlug("en", [ + "articles", + "politics", + "cached", + ]); + + expect(mockCacheLife).toHaveBeenCalledWith("max"); + expect(result).toEqual({ + title: "Cached Title", + description: "Cached Description", + authors: [{ name: "Nakafa" }], + date: "04/12/2026", + }); + }); +}); diff --git a/apps/www/lib/utils/pagefind.ts b/apps/www/lib/utils/pagefind.ts index 8c514ff6d..6846a2351 100644 --- a/apps/www/lib/utils/pagefind.ts +++ b/apps/www/lib/utils/pagefind.ts @@ -3,6 +3,9 @@ import type { PagefindResult } from "@/types/pagefind"; const HTML_ANCHOR_REGEX = /\.html#/; const HTML_EXT_REGEX = /\.html$/; +const EMPTY_HTML_TAG_REGEX = /<([a-z]+)(?:\s[^>]*)?>\s*<\/\1>/gi; +const HTML_TAG_REGEX = /<[^>]+>/g; +const HTML_TOKEN_REGEX = /(<[^>]+>)/g; /** Strip the static `.html` suffix that Pagefind stores for app pages. */ function normalizePagefindPath(path: string) { @@ -22,6 +25,49 @@ function stripHash(href: string) { return href.slice(0, hashIndex); } +/** + * Removes a repeated leading title from one Pagefind HTML excerpt while keeping + * the remaining markup intact. + */ +function trimExcerptPrefix(title: string, excerpt: string) { + const plainExcerpt = excerpt.replace(HTML_TAG_REGEX, "").trim(); + + if (!plainExcerpt) { + return excerpt; + } + + const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = plainExcerpt.match( + new RegExp(`^${escapedTitle}(?:[\\s.:-]+)?`, "i") + ); + + if (!match?.[0]) { + return excerpt; + } + + let remaining = match[0].length; + const tokens = excerpt.split(HTML_TOKEN_REGEX); + + return tokens + .map((token) => { + if (remaining === 0 || token.startsWith("<")) { + return token; + } + + if (token.length <= remaining) { + remaining -= token.length; + return ""; + } + + const trimmed = token.slice(remaining); + remaining = 0; + return trimmed; + }) + .join("") + .replace(EMPTY_HTML_TAG_REGEX, "") + .trim(); +} + /** Build the final app-internal href for one Pagefind sub-result. */ export function getPagefindSubResultHref( subResult: PagefindResult["sub_results"][number] @@ -35,6 +81,37 @@ export function getPagefindSubResultHref( return `${stripHash(href)}#${subResult.anchor.id}`; } +/** + * Returns the sub-results that should be rendered as clickable items. + * + * Pagefind can emit a first sub-result that repeats the page title with no + * anchor. When richer section results also exist, rendering that first item as a + * clickable row creates a duplicate title in the UI. In that case we drop the + * duplicate row and only render anchored section hits as list items. + */ +export function getPagefindSectionResults(result: PagefindResult) { + const [firstSubResult, ...restSubResults] = result.sub_results; + + if (!firstSubResult) { + return result.sub_results.slice(0, 0); + } + + const hasSectionResults = restSubResults.length > 0; + const isPageSummary = + firstSubResult.title === result.meta.title && !firstSubResult.anchor?.id; + + if (!(isPageSummary && hasSectionResults)) { + return result.sub_results; + } + + return restSubResults; +} + +/** Returns whether one HTML excerpt still contains visible text. */ +export function hasPagefindExcerpt(excerpt: string) { + return excerpt.replace(HTML_TAG_REGEX, "").trim().length > 0; +} + /** Normalize one Pagefind result payload for locale-aware app navigation. */ export function normalizePagefindResult( result: PagefindResult @@ -44,6 +121,7 @@ export function normalizePagefindResult( url: normalizePagefindPath(result.url), sub_results: result.sub_results.map((subResult) => ({ ...subResult, + excerpt: trimExcerptPrefix(subResult.title, subResult.excerpt), url: getPagefindSubResultHref(subResult), })), }; diff --git a/apps/www/lib/utils/pages/article.ts b/apps/www/lib/utils/pages/article.ts index be7b01e0f..e54679e71 100644 --- a/apps/www/lib/utils/pages/article.ts +++ b/apps/www/lib/utils/pages/article.ts @@ -1,24 +1,17 @@ -import { - getArticleContent, - getArticleReferences, -} from "@repo/contents/_lib/articles/content"; import { getSlugPath } from "@repo/contents/_lib/articles/slug"; import { getContentMetadataWithRaw } from "@repo/contents/_lib/metadata"; -import { - type FileReadError, - type GitHubFetchError, +import type { + FileReadError, + GitHubFetchError, InvalidPathError, - type MetadataParseError, - type ModuleLoadError, + MetadataParseError, + ModuleLoadError, } from "@repo/contents/_shared/error"; import type { ArticleCategory } from "@repo/contents/_types/articles/category"; -import type { ContentWithMDX, Reference } from "@repo/contents/_types/content"; +import type { ContentWithMDX } from "@repo/contents/_types/content"; import { Effect } from "effect"; import type { Locale } from "next-intl"; -/** - * Input parameters for fetching article context. - */ export interface FetchArticleContextInput { /** The article category */ category: ArticleCategory; @@ -28,16 +21,6 @@ export interface FetchArticleContextInput { slug: string; } -/** - * Output data containing fetched article context. - */ -export interface FetchArticleContextOutput { - /** The content data with MDX component */ - content: ContentWithMDX; - /** The list of references for the article */ - references: Reference[]; -} - /** * Output data containing fetched article metadata context. */ @@ -48,48 +31,6 @@ export interface FetchArticleMetadataContextOutput { FilePath: ReturnType; } -/** - * Fetches the article context including content data and references. - * Returns an error if the content is not found. - * - * @param input - The input parameters for fetching article context - * @param input.locale - The locale for localized content - * @param input.category - The article category - * @param input.slug - The article slug - * @returns An Effect that resolves to the article context or fails with an Error - */ -export function fetchArticleContext({ - locale, - category, - slug, -}: FetchArticleContextInput): Effect.Effect< - FetchArticleContextOutput, - | InvalidPathError - | FileReadError - | GitHubFetchError - | MetadataParseError - | ModuleLoadError -> { - const FilePath = getSlugPath(category, slug); - - return Effect.gen(function* () { - const content = yield* getArticleContent(locale, FilePath); - - if (content === null) { - return yield* Effect.fail( - new InvalidPathError({ - path: FilePath, - reason: "Article content not found", - }) - ); - } - - const references = yield* getArticleReferences(FilePath); - - return { content, references }; - }); -} - /** * Fetches the article metadata context for generating page metadata. * Returns null for content if not found, allowing for graceful handling. diff --git a/apps/www/lib/utils/pages/exercises.ts b/apps/www/lib/utils/pages/exercises.ts deleted file mode 100644 index de30bcaa5..000000000 --- a/apps/www/lib/utils/pages/exercises.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - getExerciseByNumber, - getExerciseCount, -} from "@repo/contents/_lib/exercises"; -import { - getCurrentMaterial, - getMaterialPath, - getMaterials, -} from "@repo/contents/_lib/exercises/material"; -import { - getSlugPath, - isTryOutCollectionSlug, -} from "@repo/contents/_lib/exercises/slug"; -import type { ExercisesCategory } from "@repo/contents/_types/exercises/category"; -import type { ExercisesMaterial } from "@repo/contents/_types/exercises/material"; -import type { ExercisesType } from "@repo/contents/_types/exercises/type"; -import { Effect, Option } from "effect"; -import type { Locale } from "next-intl"; -import { isNumber } from "@/lib/utils/number"; - -/** - * Input parameters for fetching exercise context. - */ -interface FetchExerciseContextInput { - /** The exercise category */ - category: ExercisesCategory; - /** The locale for localized content */ - locale: Locale; - /** The exercise material */ - material: ExercisesMaterial; - /** The slug path segments for the specific exercise */ - slug: string[]; - /** The exercise type */ - type: ExercisesType; -} - -/** - * Output data containing fetched exercise context. - */ -interface FetchExerciseContextOutput { - /** The current material being accessed (guaranteed to be defined) */ - currentMaterial: NonNullable< - ReturnType["currentMaterial"] - >; - /** The specific item within the current material (guaranteed to be defined) */ - currentMaterialItem: NonNullable< - ReturnType["currentMaterialItem"] - >; - /** All available materials for the given category/type/material */ - materials: Awaited>; -} - -/** - * Output data containing fetched exercise metadata context. - */ -export interface FetchExerciseMetadataContextOutput { - /** The current material being accessed (can be undefined for metadata generation) */ - currentMaterial: ReturnType["currentMaterial"]; - /** The specific item within the current material (can be undefined for metadata generation) */ - currentMaterialItem: ReturnType< - typeof getCurrentMaterial - >["currentMaterialItem"]; - /** The total count of exercises in the current set */ - exerciseCount: number; - /** The exercise title if it's a specific exercise, undefined otherwise */ - exerciseTitle: string | undefined; - /** The full file path to the content file */ - FilePath: ReturnType; - /** Whether this is a specific exercise page (numeric slug) */ - isSpecificExercise: boolean; - /** All available materials for the given category/type/material */ - materials: Awaited>; -} - -/** - * Fetches the exercise context including materials, current material, and current material item. - * - * @param input - The input parameters for fetching exercise context - * @param input.locale - The locale for localized content - * @param input.category - The exercise category - * @param input.type - The exercise type - * @param input.material - The exercise material - * @param input.slug - The slug path segments for the specific exercise - * @returns An Effect that resolves to the exercise context or fails with an Error - */ -export function fetchExerciseContext({ - locale, - category, - type, - material, - slug, -}: FetchExerciseContextInput): Effect.Effect< - FetchExerciseContextOutput, - Error -> { - const materialPath = getMaterialPath(category, type, material); - const FilePath = getSlugPath(category, type, material, slug); - - return Effect.all({ - materials: Effect.tryPromise({ - try: () => getMaterials(materialPath, locale), - catch: () => new Error("Failed to fetch materials"), - }), - }).pipe( - Effect.flatMap(({ materials }) => { - const { currentMaterial, currentMaterialItem } = getCurrentMaterial( - FilePath, - materials - ); - - if (currentMaterial === undefined || currentMaterialItem === undefined) { - return Effect.fail(new Error("Exercise material not found")); - } - - return Effect.succeed({ - materials, - currentMaterial, - currentMaterialItem, - }); - }) - ); -} - -/** - * Fetches the exercise metadata context for generating page metadata. - * - * @param input - The input parameters for fetching exercise metadata context - * @param input.locale - The locale for localized content - * @param input.category - The exercise category - * @param input.type - The exercise type - * @param input.material - The exercise material - * @param input.slug - The slug path segments for the specific exercise - * @returns An Effect that resolves to the exercise metadata context or fails with an Error - */ -export function fetchExerciseMetadataContext({ - locale, - category, - type, - material, - slug, -}: FetchExerciseContextInput): Effect.Effect< - FetchExerciseMetadataContextOutput, - Error -> { - return Effect.gen(function* () { - const materialPath = getMaterialPath(category, type, material); - const lastSlug = slug.at(-1); - const isSpecificExercise = - lastSlug !== undefined && - isNumber(lastSlug) && - !isTryOutCollectionSlug(slug); - - const baseSlug = isSpecificExercise ? slug.slice(0, -1) : slug; - const FilePath = getSlugPath(category, type, material, slug); - const exerciseSetPath = getSlugPath(category, type, material, baseSlug); - - const [materials, exerciseOption, exerciseCount] = yield* Effect.all([ - Effect.tryPromise({ - try: () => getMaterials(materialPath, locale), - catch: () => new Error("Failed to fetch materials"), - }), - isSpecificExercise - ? getExerciseByNumber( - locale, - exerciseSetPath, - Number.parseInt(lastSlug, 10) - ) - : Effect.succeed(Option.none()), - getExerciseCount(exerciseSetPath), - ]); - - const exerciseTitle = Option.isSome(exerciseOption) - ? exerciseOption.value.question.metadata.title - : undefined; - - // Use exerciseSetPath (without exercise number) for material lookup - const { currentMaterial, currentMaterialItem } = getCurrentMaterial( - exerciseSetPath, - materials - ); - - return { - isSpecificExercise: Boolean(isSpecificExercise), - exerciseTitle, - exerciseCount, - FilePath, - materials, - currentMaterial, - currentMaterialItem, - }; - }); -} diff --git a/apps/www/lib/utils/pages/subject.ts b/apps/www/lib/utils/pages/subject.ts index 093a32989..7a581d743 100644 --- a/apps/www/lib/utils/pages/subject.ts +++ b/apps/www/lib/utils/pages/subject.ts @@ -1,9 +1,4 @@ import { getContentMetadataWithRaw } from "@repo/contents/_lib/metadata"; -import { getSubjectContent } from "@repo/contents/_lib/subject/content"; -import { - getMaterialPath, - getMaterials, -} from "@repo/contents/_lib/subject/material"; import { getSlugPath } from "@repo/contents/_lib/subject/slug"; import type { ContentWithMDX } from "@repo/contents/_types/content"; import type { SubjectCategory } from "@repo/contents/_types/subject/category"; @@ -28,20 +23,6 @@ interface GetContentContextInput { slug: string[]; } -/** - * Output data containing fetched subject content context. - */ -interface GetContentContextOutput { - /** The content data with MDX component (guaranteed to be defined) */ - content: ContentWithMDX; - /** The full file path to the content file */ - FilePath: ReturnType; - /** The file path to the material directory */ - materialPath: string; - /** All available materials for the given category/grade/material */ - materials: Awaited>; -} - /** * Output data containing fetched subject metadata context. */ @@ -52,51 +33,6 @@ interface GetContentMetadataContextOutput { FilePath: ReturnType; } -/** - * Fetches the subject content context including content data, materials, and paths. - * - * @param input - The input parameters for fetching subject content context - * @param input.locale - The locale for localized content - * @param input.category - The subject category - * @param input.grade - The grade level - * @param input.material - The material type - * @param input.slug - The slug path segments for the specific content - * @returns An Effect that resolves to the subject content context or fails with an Error - */ -export function getContentContext({ - locale, - category, - grade, - material, - slug, -}: GetContentContextInput): Effect.Effect { - return Effect.gen(function* () { - const materialPath = getMaterialPath(category, grade, material); - const FilePath = getSlugPath(category, grade, material, slug); - - const [content, materials] = yield* Effect.all([ - Effect.orElse(getSubjectContent(locale, FilePath), () => - Effect.succeed(null) - ), - Effect.tryPromise({ - try: () => getMaterials(materialPath, locale), - catch: () => new Error("Failed to fetch materials"), - }), - ]); - - if (content === null) { - return yield* Effect.fail(new Error("Subject content not found")); - } - - return { - content, - materials, - materialPath, - FilePath, - }; - }); -} - /** * Fetches the subject metadata context for generating page metadata. * Returns null for content if not found, allowing for graceful handling. diff --git a/apps/www/lib/utils/seo/generator.ts b/apps/www/lib/utils/seo/generator.ts index 49e8cdb1d..d6aa6a68a 100644 --- a/apps/www/lib/utils/seo/generator.ts +++ b/apps/www/lib/utils/seo/generator.ts @@ -5,6 +5,7 @@ import type { ExercisesType } from "@repo/contents/_types/exercises/type"; import type { Grade } from "@repo/contents/_types/subject/grade"; import type { Material } from "@repo/contents/_types/subject/material"; import { Effect } from "effect"; +import { cacheLife } from "next/cache"; import type { Locale } from "next-intl"; import { getTranslations } from "next-intl/server"; import { createSEODescription } from "./descriptions"; @@ -322,6 +323,10 @@ export async function generateSEOMetadata( context: SEOContext, locale: Locale ): Promise { + "use cache"; + + cacheLife("max"); + const { type } = context; const effect = Effect.gen(function* () { diff --git a/apps/www/lib/utils/system.ts b/apps/www/lib/utils/system.ts index 8ef452c26..929c350d9 100644 --- a/apps/www/lib/utils/system.ts +++ b/apps/www/lib/utils/system.ts @@ -2,6 +2,7 @@ import { getFolderChildNames, getNestedSlugs } from "@repo/contents/_lib/fs"; import { getContentMetadata } from "@repo/contents/_lib/metadata"; import type { ContentMetadata } from "@repo/contents/_types/content"; import { Data, Effect, Option } from "effect"; +import { cacheLife } from "next/cache"; import type { Locale } from "next-intl"; import { getTranslations } from "next-intl/server"; @@ -186,3 +187,18 @@ export function getMetadataFromSlug( return { ...metadataValue, title, description }; }); } + +/** + * Resolves one content metadata payload inside a Cache Components-safe helper for + * route handlers that need static image generation. + */ +export async function getCachedMetadataFromSlug( + locale: Locale, + slug: string[] +) { + "use cache"; + + cacheLife("max"); + + return await Effect.runPromise(getMetadataFromSlug(locale, slug)); +} diff --git a/apps/www/mdx-components.tsx b/apps/www/mdx-components.tsx index e9da9d46f..10044da5e 100644 --- a/apps/www/mdx-components.tsx +++ b/apps/www/mdx-components.tsx @@ -1,9 +1,7 @@ import { components } from "@repo/design-system/components/markdown/mdx"; import type { MDXComponents } from "@repo/design-system/types/markdown"; -export function useMDXComponents(inherited: MDXComponents): MDXComponents { - return { - ...inherited, - ...components, - }; +/** Returns the shared MDX component map for App Router MDX rendering. */ +export function useMDXComponents(): MDXComponents { + return components; } diff --git a/apps/www/next.config.ts b/apps/www/next.config.ts index ac0b52c4f..9dccf8cc3 100644 --- a/apps/www/next.config.ts +++ b/apps/www/next.config.ts @@ -10,6 +10,7 @@ const withNextIntl = createNextIntlPlugin( let nextConfig: NextConfig = { ...config, + cacheComponents: true, // Next.js recommends outputFileTracingRoot in monorepos so files outside the // app folder are included in the production trace. // Docs: https://nextjs.org/docs/app/api-reference/config/next-config-js/output @@ -35,7 +36,7 @@ let nextConfig: NextConfig = { }, serverExternalPackages: [ ...(config.serverExternalPackages ?? []), - "@takumi-rs/image-response", + "@takumi-rs/core", ], async rewrites() { const llmSource = [ @@ -104,6 +105,11 @@ let nextConfig: NextConfig = { ]; }); }, + experimental: { + ...config.experimental, + globalNotFound: true, + rootParams: true, + }, }; if (env.ANALYZE === "true") { diff --git a/apps/www/package.json b/apps/www/package.json index 25f473f3c..16987d328 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start -p 3000", "analyze": "ANALYZE=true next build", - "postbuild": "pagefind --site .next/server/app --output-path public/_pagefind", + "postbuild": "tsx scripts/pagefind/index.ts", "test": "NODE_ENV=test vitest run", "test:watch": "NODE_ENV=test vitest watch", "test:ui": "NODE_ENV=test vitest --ui", @@ -19,7 +19,7 @@ }, "dependencies": { "@ai-sdk/devtools": "^0.0.15", - "@ai-sdk/react": "^3.0.156", + "@ai-sdk/react": "^3.0.160", "@convex-dev/better-auth": "^0.11.4", "@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/react": "^1.1.6", @@ -28,7 +28,7 @@ "@mantine/hooks": "^9.0.1", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", - "@paper-design/shaders-react": "^0.0.72", + "@paper-design/shaders-react": "^0.0.73", "@repo/ai": "workspace:*", "@repo/analytics": "workspace:*", "@repo/backend": "workspace:*", @@ -43,19 +43,17 @@ "@t3-oss/env-nextjs": "^0.13.11", "@tabler/icons-react": "^3.41.1", "@tailwindcss/postcss": "^4.2.2", - "@takumi-rs/core": "^0.73.1", - "@takumi-rs/image-response": "^0.73.1", - "@tanstack/react-form": "^1.28.6", - "@tanstack/react-query": "^5.97.0", - "@tanstack/react-query-devtools": "^5.97.0", + "@tanstack/react-form": "^1.29.0", + "@tanstack/react-query": "^5.99.0", + "@tanstack/react-query-devtools": "^5.99.0", "@vercel/functions": "^3.4.3", - "ai": "^6.0.154", + "ai": "^6.0.158", "any-ascii": "^0.3.3", "babel-plugin-react-compiler": "^1.0.0", "better-auth": "1.5.3", "class-variance-authority": "^0.7.1", - "convex": "^1.34.1", - "country-flag-icons": "^1.6.15", + "convex": "^1.35.1", + "country-flag-icons": "^1.6.16", "date-fns": "^4.1.0", "effect": "^3.21.0", "feed": "^5.2.0", @@ -66,7 +64,7 @@ "motion": "^12.38.0", "nanoid": "^5.1.7", "next": "16.2.3", - "next-intl": "^4.9.0", + "next-intl": "^4.9.1", "next-themes": "^0.4.6", "nuqs": "^2.8.9", "pino": "^10.3.1", @@ -74,22 +72,25 @@ "react-dom": "19.2.5", "shiki": "^4.0.2", "sonner": "^2.0.7", + "takumi-js": "^1.0.5", "use-context-selector": "^2.0.0", "use-sound": "^5.0.0", - "virtua": "^0.49.0", + "virtua": "^0.49.1", "zod": "^4.3.6", "zustand": "^5.0.12" }, "devDependencies": { "@repo/testing": "workspace:*", "@repo/typescript-config": "workspace:*", - "@types/node": "25.5.2", + "@types/node": "25.6.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@vitest/coverage-istanbul": "^4.1.4", "@vitest/ui": "^4.1.4", "jsdom": "^29.0.2", - "pagefind": "^1.5.0", + "pagefind": "^1.5.2", + "remark": "^15.0.1", + "remark-mdx": "^3.1.1", "tailwindcss": "^4.2.2", "tsx": "^4.21.0", "typescript": "^6.0.2", diff --git a/apps/www/scripts/google-index.ts b/apps/www/scripts/google-index.ts index ab20eb85d..ecb5632d7 100644 --- a/apps/www/scripts/google-index.ts +++ b/apps/www/scripts/google-index.ts @@ -33,7 +33,6 @@ import { fileURLToPath } from "node:url"; import { JWT } from "google-auth-library"; import { baseRoutes, - getAskRoutes, getContentRoutes, getEntries, getQuranRoutes, @@ -147,15 +146,9 @@ async function getUnsubmittedUrls(): Promise<{ // Get all URLs from sitemap const routes = getContentRoutes(); const quranRoutes = getQuranRoutes(); - const askRoutes = getAskRoutes(); // Deduplicate all base routes (contentRoutes might include "/" which is also in baseRoutes) - const allBaseRoutesSet = new Set([ - ...baseRoutes, - ...routes, - ...quranRoutes, - ...askRoutes, - ]); + const allBaseRoutesSet = new Set([...baseRoutes, ...routes, ...quranRoutes]); const allBaseRoutes = Array.from(allBaseRoutesSet); // Get all entries asynchronously - simplified to only locale-prefixed routes diff --git a/apps/www/scripts/index-now.ts b/apps/www/scripts/index-now.ts index e79fc8dfe..a09b232fe 100644 --- a/apps/www/scripts/index-now.ts +++ b/apps/www/scripts/index-now.ts @@ -4,7 +4,6 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { baseRoutes, - getAskRoutes, getContentRoutes, getEntries, getQuranRoutes, @@ -101,15 +100,9 @@ async function getUnsubmittedUrls(service: "indexNow" | "bing"): Promise<{ // Get all URLs from sitemap const routes = getContentRoutes(); const quranRoutes = getQuranRoutes(); - const askRoutes = getAskRoutes(); // Deduplicate all base routes (contentRoutes might include "/" which is also in baseRoutes) - const allBaseRoutesSet = new Set([ - ...baseRoutes, - ...routes, - ...quranRoutes, - ...askRoutes, - ]); + const allBaseRoutesSet = new Set([...baseRoutes, ...routes, ...quranRoutes]); const allBaseRoutes = Array.from(allBaseRoutesSet); // Get all entries asynchronously - simplified to only locale-prefixed routes diff --git a/apps/www/scripts/pagefind/build.ts b/apps/www/scripts/pagefind/build.ts new file mode 100644 index 000000000..faf375451 --- /dev/null +++ b/apps/www/scripts/pagefind/build.ts @@ -0,0 +1,81 @@ +import { rm } from "node:fs/promises"; +import { createServiceLogger } from "@repo/utilities/logging"; +import { + addArticleRecords, + addExerciseRecords, + addQuranRecords, + addSubjectRecords, +} from "./records"; + +const OUTPUT_PATH = "public/_pagefind"; +const logger = createServiceLogger("pagefind"); + +/** + * Builds the production Pagefind bundle from source-of-truth content instead of + * scanning Next.js build artifacts. + * + * Why this exists: + * - With Next.js Cache Components / PPR, some `.next/server/app` artifacts do + * not contain fully rendered learn-page DOM. + * - Pagefind's HTML scan mode indexes HTML files, so shell-oriented artifacts + * can under-index content or index inconsistent payloads. + * - Pagefind exposes official custom-record and HTML-file APIs, so we can index + * the content we own rather than scrape implementation-specific build output. + * + * References: + * - Next.js Cache Components guide: + * https://nextjs.org/docs/app/getting-started/cache-components + * - Next.js `use cache` directive: + * https://nextjs.org/docs/app/api-reference/directives/use-cache + * - Pagefind indexing docs: + * https://pagefind.app/docs/indexing/ + * - Installed Pagefind API surface: + * `apps/www/node_modules/pagefind/types/index.d.ts` + */ +export async function buildPagefindIndex() { + const { createIndex } = await import("pagefind"); + + await rm(OUTPUT_PATH, { force: true, recursive: true }); + + const response = await createIndex(); + + if (!response.index) { + throw new Error( + response.errors.join("\n") || "Failed to create Pagefind index" + ); + } + + const { index } = response; + let count = 0; + let words = 0; + + for (const add of [ + addArticleRecords, + addSubjectRecords, + addExerciseRecords, + addQuranRecords, + ]) { + const result = await add(index); + count += result.count; + words += result.words; + } + + const write = await index.writeFiles({ outputPath: OUTPUT_PATH }); + + if (write.errors.length > 0) { + throw new Error(write.errors.join("\n")); + } + + logger.info( + { count, outputPath: write.outputPath, words }, + "Indexed Pagefind source records" + ); +} + +/** + * Logs the Pagefind failure and exits with a non-zero code. + */ +export function handlePagefindError(error: unknown) { + logger.error({ error }, "Pagefind build failed"); + process.exitCode = 1; +} diff --git a/apps/www/scripts/pagefind/index.ts b/apps/www/scripts/pagefind/index.ts new file mode 100644 index 000000000..2f919fd73 --- /dev/null +++ b/apps/www/scripts/pagefind/index.ts @@ -0,0 +1,3 @@ +import { buildPagefindIndex, handlePagefindError } from "./build"; + +buildPagefindIndex().catch(handlePagefindError); diff --git a/apps/www/scripts/pagefind/mdx.ts b/apps/www/scripts/pagefind/mdx.ts new file mode 100644 index 000000000..0bdd46b4c --- /dev/null +++ b/apps/www/scripts/pagefind/mdx.ts @@ -0,0 +1,272 @@ +import { createHeadingId } from "@repo/design-system/lib/utils"; +import { remark } from "remark"; +import remarkMdx from "remark-mdx"; + +const WORD_SEPARATOR = /\s+/; +const mdxParser = remark().use(remarkMdx); + +/** + * Converts MDX source into semantic HTML while preserving markdown headings. + * + * References: + * - remark parser: + * https://github.com/remarkjs/remark/tree/main/packages/remark + * - remark-mdx parser extension: + * https://mdxjs.com/packages/remark-mdx/ + */ +export function renderMdxHtml(source: string) { + const root = readRecord(mdxParser.parse(source)); + + if (!root) { + return ""; + } + + if (!Array.isArray(root.children)) { + return ""; + } + + return root.children.map(renderBlockNode).join(""); +} + +/** + * Extracts plain searchable text from markdown or MDX source. + */ +export function extractMdxText(source: string) { + return normalizeText(readNode(mdxParser.parse(source))); +} + +/** + * Normalizes arbitrary text into a compact Pagefind body string. + */ +export function normalizeText(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +/** + * Counts words using the same whitespace normalization as the index corpus. + */ +export function countWords(value: string) { + const normalized = normalizeText(value); + return normalized ? normalized.split(WORD_SEPARATOR).length : 0; +} + +/** + * Escapes arbitrary text for safe inclusion in synthetic HTML records. + */ +export function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** + * Serializes block-level markdown / MDX nodes into simplified semantic HTML. + */ +function renderBlockNode(node: unknown): string { + const current = readRecord(node); + + if (!current) { + return ""; + } + + if (!("type" in current)) { + return ""; + } + + if ( + current.type === "mdxjsEsm" || + current.type === "mdxFlowExpression" || + current.type === "mdxTextExpression" + ) { + return ""; + } + + if (current.type === "heading" && typeof current.depth === "number") { + const text = normalizeText(readNode(current)); + + if (!text) { + return ""; + } + + const level = Math.min(Math.max(current.depth, 2), 6); + const id = createHeadingId(text); + + return `${escapeHtml(text)}`; + } + + if (current.type === "paragraph") { + const html = renderInlineNodes(current.children); + return html ? `

${html}

` : ""; + } + + if (current.type === "list") { + const tag = current.ordered ? "ol" : "ul"; + const items = Array.isArray(current.children) + ? current.children.map(renderBlockNode).join("") + : ""; + + return items ? `<${tag}>${items}` : ""; + } + + if (current.type === "listItem") { + const html = Array.isArray(current.children) + ? current.children.map(renderBlockNode).join("") + : ""; + + return html ? `
  • ${html}
  • ` : ""; + } + + if (current.type === "code" && typeof current.value === "string") { + return `
    ${escapeHtml(current.value)}
    `; + } + + if (current.type === "blockquote") { + const html = Array.isArray(current.children) + ? current.children.map(renderBlockNode).join("") + : ""; + + return html ? `
    ${html}
    ` : ""; + } + + if (current.type === "thematicBreak") { + return "
    "; + } + + const text = normalizeText(readNode(current)); + return text ? `

    ${escapeHtml(text)}

    ` : ""; +} + +/** + * Serializes inline markdown nodes into HTML while preserving visible text. + */ +function renderInlineNodes(nodes: unknown) { + if (!Array.isArray(nodes)) { + return ""; + } + + return nodes.map(renderInlineNode).join(""); +} + +/** + * Serializes one inline markdown or MDX node into simplified semantic HTML. + */ +function renderInlineNode(node: unknown): string { + const current = readRecord(node); + + if (!current) { + return ""; + } + + if (!("type" in current)) { + return ""; + } + + if (current.type === "text" && typeof current.value === "string") { + return escapeHtml(current.value); + } + + if (current.type === "inlineCode" && typeof current.value === "string") { + return `${escapeHtml(current.value)}`; + } + + if (current.type === "strong") { + return `${renderInlineNodes(current.children)}`; + } + + if (current.type === "emphasis") { + return `${renderInlineNodes(current.children)}`; + } + + if (current.type === "delete") { + return `${renderInlineNodes(current.children)}`; + } + + if (current.type === "link") { + const html = renderInlineNodes(current.children); + + if (html) { + return html; + } + + if (typeof current.url === "string") { + return escapeHtml(current.url); + } + + return ""; + } + + if (current.type === "break") { + return "
    "; + } + + return escapeHtml(normalizeText(readNode(current))); +} + +/** + * Reads text content from a markdown or MDX AST node. + * + * We intentionally skip MDX ESM / expression nodes so imports, metadata exports, + * and JSX expressions don't leak into search records. + */ +function readNode(node: unknown): string { + const current = readRecord(node); + + if (!current) { + return ""; + } + + if ("type" in current) { + if ( + current.type === "mdxjsEsm" || + current.type === "mdxFlowExpression" || + current.type === "mdxTextExpression" + ) { + return ""; + } + + if ("value" in current && typeof current.value === "string") { + return current.value; + } + + if ("alt" in current && typeof current.alt === "string") { + return current.alt; + } + } + + const values = [ + ...(Array.isArray(current.attributes) + ? current.attributes + .map(readRecord) + .filter((attribute) => attribute !== null) + .flatMap((attribute) => + "value" in attribute && typeof attribute.value === "string" + ? [attribute.value] + : [] + ) + : []), + ...(Array.isArray(current.children) ? current.children.map(readNode) : []), + ]; + + return values.join("\n"); +} + +/** + * Narrows an unknown value to a plain record. + */ +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +/** + * Returns a record value when the input is an object. + */ +function readRecord(value: unknown) { + if (!isRecord(value)) { + return null; + } + + return value; +} diff --git a/apps/www/scripts/pagefind/records.ts b/apps/www/scripts/pagefind/records.ts new file mode 100644 index 000000000..3a8266d80 --- /dev/null +++ b/apps/www/scripts/pagefind/records.ts @@ -0,0 +1,316 @@ +import { getMDXSlugsForLocale } from "@repo/contents/_lib/cache"; +import { getContent } from "@repo/contents/_lib/content"; +import { getExerciseSetPaths } from "@repo/contents/_lib/exercises/collection"; +import { + getCurrentMaterial, + getMaterials as getExerciseMaterials, +} from "@repo/contents/_lib/exercises/material"; +import { getExercisesContent } from "@repo/contents/_lib/exercises/set"; +import { getSurah, getSurahName } from "@repo/contents/_lib/quran"; +import { ContentRootSchema } from "@repo/contents/_types/content"; +import { routing } from "@repo/internationalization/src/routing"; +import { Effect } from "effect"; +import type { CustomRecord, HTMLFile, PagefindIndex } from "pagefind"; +import { countWords, escapeHtml, extractMdxText, renderMdxHtml } from "./mdx"; + +/** + * Indexes article leaf pages from MDX source while preserving heading structure. + */ +export async function addArticleRecords(index: PagefindIndex) { + const entries = ( + await Promise.all( + routing.locales.map((locale) => { + const slugs = getMDXSlugsForLocale(locale).filter((slug) => { + const parts = slug.split("/"); + + return ( + isIndexedMdxRoot(parts) && + parts[0] === ContentRootSchema.enum.articles + ); + }); + + return Promise.all( + slugs.map(async (slug) => { + const content = await Effect.runPromise( + getContent(locale, slug, { includeMDX: false }) + ); + const recordContent = [ + content.metadata.title, + content.metadata.description ?? "", + extractMdxText(content.raw), + ].join("\n\n"); + + return { + file: buildHtmlFile({ + url: `/${locale}/${slug}`, + locale, + title: content.metadata.title, + description: content.metadata.description, + body: renderMdxHtml(content.raw), + }), + words: countWords(recordContent), + }; + }) + ); + }) + ) + ).flat(2); + + return addHtmlFiles(index, entries); +} + +/** + * Indexes subject leaf pages from MDX source while preserving heading structure. + */ +export async function addSubjectRecords(index: PagefindIndex) { + const entries = ( + await Promise.all( + routing.locales.map((locale) => { + const slugs = getMDXSlugsForLocale(locale).filter((slug) => { + const parts = slug.split("/"); + + return ( + isIndexedMdxRoot(parts) && + parts[0] === ContentRootSchema.enum.subject + ); + }); + + return Promise.all( + slugs.map(async (slug) => { + const content = await Effect.runPromise( + getContent(locale, slug, { includeMDX: false }) + ); + const recordContent = [ + content.metadata.title, + content.metadata.subject ?? "", + content.metadata.description ?? "", + extractMdxText(content.raw), + ].join("\n\n"); + + return { + file: buildHtmlFile({ + url: `/${locale}/${slug}`, + locale, + title: content.metadata.title, + description: + content.metadata.description ?? content.metadata.subject, + body: renderMdxHtml(content.raw), + }), + words: countWords(recordContent), + }; + }) + ); + }) + ) + ).flat(2); + + return addHtmlFiles(index, entries); +} + +/** + * Indexes exercise set pages from assembled source records. + * + * The exercise search corpus intentionally keeps: + * - question titles + * - question bodies + * - answer explanations + * + * We intentionally exclude choice labels because they are low-signal text for + * search relevance and tend to add noise without adding much retrieval value. + */ +export async function addExerciseRecords(index: PagefindIndex) { + const records = ( + await Promise.all( + routing.locales.map((locale) => + Promise.all( + getExerciseSetPaths(locale).map(async (setPath) => { + const exercises = await Effect.runPromise( + getExercisesContent({ + filePath: setPath, + includeMDX: false, + locale, + }) + ); + + if (exercises.length === 0) { + return null; + } + + const segments = setPath.split("/"); + const materialPath = `/${segments.slice(0, 4).join("/")}`; + const materials = await getExerciseMaterials(materialPath, locale); + const { currentMaterial, currentMaterialItem } = getCurrentMaterial( + `/${setPath}`, + materials + ); + const title = + currentMaterialItem?.title ?? + currentMaterial?.title ?? + segments.at(-1); + + if (!title) { + return null; + } + + return { + url: `/${locale}/${setPath}`, + language: locale, + meta: { title }, + content: [ + title, + ...exercises.flatMap((exercise) => [ + exercise.question.metadata.title, + extractMdxText(exercise.question.raw), + extractMdxText(exercise.answer.raw), + ]), + ].join("\n\n"), + }; + }) + ) + ) + ) + ) + .flat(2) + .filter((record) => record !== null); + + return addRecords(index, records); +} + +/** + * Indexes Quran surah pages from the Quran dataset. + */ +export async function addQuranRecords(index: PagefindIndex) { + const records = ( + await Promise.all( + routing.locales.map((locale) => + Promise.all( + Array.from({ length: 114 }, (_, index) => index + 1).map( + async (number) => { + const surah = await Effect.runPromise(getSurah(number)); + const title = getSurahName({ locale, name: surah.name }); + const translation = + surah.name.translation[locale] ?? surah.name.translation.en; + const body = surah.verses + .map( + (verse) => verse.translation[locale] ?? verse.translation.en + ) + .join("\n"); + + return { + url: `/${locale}/quran/${surah.number}`, + language: locale, + meta: { title }, + content: `${title}\n\n${translation}\n\n${body}`, + }; + } + ) + ) + ) + ) + ).flat(2); + + return addRecords(index, records); +} + +/** + * Adds custom records to Pagefind and returns aggregate stats. + */ +async function addRecords(index: PagefindIndex, records: CustomRecord[]) { + let count = 0; + let words = 0; + + for (const record of records) { + if (!record.content) { + continue; + } + + const result = await index.addCustomRecord(record); + + if (result.errors.length > 0) { + throw new Error(result.errors.join("\n")); + } + + count += 1; + words += countWords(record.content); + } + + return { count, words }; +} + +/** + * Adds semantic HTML files to Pagefind and returns aggregate stats. + */ +async function addHtmlFiles( + index: PagefindIndex, + entries: Array<{ file: HTMLFile; words: number }> +) { + let count = 0; + let words = 0; + + for (const entry of entries) { + if (!entry.file.content) { + continue; + } + + const result = await index.addHTMLFile(entry.file); + + if (result.errors.length > 0) { + throw new Error(result.errors.join("\n")); + } + + count += 1; + words += entry.words; + } + + return { count, words }; +} + +/** + * Builds one semantic HTML file for Pagefind from source content. + */ +function buildHtmlFile({ + url, + locale, + title, + description, + body, +}: { + url: string; + locale: (typeof routing.locales)[number]; + title: string; + description?: string; + body: string; +}) { + const escapedTitle = escapeHtml(title); + + return { + url, + content: [ + "", + ``, + "", + `${escapedTitle}`, + "", + "", + "
    ", + `

    ${escapedTitle}

    `, + description ? `

    ${escapeHtml(description)}

    ` : "", + body, + "
    ", + "", + "", + ].join(""), + }; +} + +/** + * Checks whether a slug belongs to one of the MDX roots we index directly. + */ +function isIndexedMdxRoot(parts: string[]) { + const [root] = parts; + + return ( + root === ContentRootSchema.enum.articles || + root === ContentRootSchema.enum.subject + ); +} diff --git a/apps/www/vitest.config.ts b/apps/www/vitest.config.ts index 9997add39..e23461eac 100644 --- a/apps/www/vitest.config.ts +++ b/apps/www/vitest.config.ts @@ -1 +1,13 @@ -export { default } from "@repo/testing"; +import config from "@repo/testing"; +import { mergeConfig } from "vitest/config"; + +export default mergeConfig(config, { + test: { + coverage: { + thresholds: { + 100: true, + perFile: true, + }, + }, + }, +}); diff --git a/package.json b/package.json index e1da17e16..83687aa17 100644 --- a/package.json +++ b/package.json @@ -41,15 +41,15 @@ "@biomejs/biome": "2.4.11", "@changesets/changelog-github": "^0.6.0", "@changesets/cli": "^2.30.0", - "@effect/language-service": "^0.85.0", + "@effect/language-service": "^0.85.1", "@repo/typescript-config": "workspace:*", - "@turbo/gen": "^2.9.5", - "@types/node": "^25.5.2", + "@turbo/gen": "^2.9.6", + "@types/node": "^25.6.0", "depcheck": "^1.4.7", "tsup": "^8.5.1", - "turbo": "^2.9.5", + "turbo": "^2.9.6", "typescript": "^6.0.2", - "ultracite": "^7.4.4" + "ultracite": "^7.5.6" }, "engines": { "node": ">=22" diff --git a/packages/ai/package.json b/packages/ai/package.json index 48ec29998..6b9a2fcd9 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -9,16 +9,16 @@ "dependencies": { "@ai-sdk/devtools": "^0.0.15", "@ai-sdk/elevenlabs": "^2.0.29", - "@ai-sdk/gateway": "^3.0.94", - "@ai-sdk/google": "^3.0.61", - "@ai-sdk/google-vertex": "^4.0.106", + "@ai-sdk/gateway": "^3.0.95", + "@ai-sdk/google": "^3.0.62", + "@ai-sdk/google-vertex": "^4.0.108", "@ai-sdk/openai": "^3.0.52", - "@ai-sdk/react": "^3.0.156", - "@mendable/firecrawl-js": "^4.18.1", + "@ai-sdk/react": "^3.0.160", + "@mendable/firecrawl-js": "^4.18.2", "@repo/utilities": "workspace:*", "@t3-oss/env-nextjs": "^0.13.11", - "ai": "^6.0.154", - "convex": "^1.34.1", + "ai": "^6.0.158", + "convex": "^1.35.1", "dedent": "^1.7.2", "gpt-tokenizer": "^3.4.0", "ky": "^2.0.0", @@ -28,7 +28,7 @@ }, "devDependencies": { "@repo/typescript-config": "workspace:*", - "@types/node": "25.5.2", + "@types/node": "25.6.0", "@types/react": "19.2.14", "@types/react-dom": "^19.2.3" } diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 36e17846c..66e33380b 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -9,7 +9,7 @@ "dependencies": { "@t3-oss/env-nextjs": "^0.13.11", "@vercel/analytics": "^2.0.1", - "posthog-js": "^1.366.1", + "posthog-js": "^1.367.0", "posthog-node": "^5.29.2", "react": "^19.2.5", "server-only": "^0.0.1", @@ -17,7 +17,7 @@ }, "devDependencies": { "@repo/typescript-config": "workspace:*", - "@types/node": "25.5.2", + "@types/node": "25.6.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3" } diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 6bb0640d8..6899191a7 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -224,6 +224,7 @@ import type * as tryouts_queries_me_helpers_context from "../tryouts/queries/me/ import type * as tryouts_queries_me_helpers_history from "../tryouts/queries/me/helpers/history.js"; import type * as tryouts_queries_me_history from "../tryouts/queries/me/history.js"; import type * as tryouts_queries_me_part from "../tryouts/queries/me/part.js"; +import type * as tryouts_queries_me_session from "../tryouts/queries/me/session.js"; import type * as tryouts_queries_me_setView from "../tryouts/queries/me/setView.js"; import type * as tryouts_queries_me_validators from "../tryouts/queries/me/validators.js"; import type * as tryouts_queries_tryouts from "../tryouts/queries/tryouts.js"; @@ -468,6 +469,7 @@ declare const fullApi: ApiFromModules<{ "tryouts/queries/me/helpers/history": typeof tryouts_queries_me_helpers_history; "tryouts/queries/me/history": typeof tryouts_queries_me_history; "tryouts/queries/me/part": typeof tryouts_queries_me_part; + "tryouts/queries/me/session": typeof tryouts_queries_me_session; "tryouts/queries/me/setView": typeof tryouts_queries_me_setView; "tryouts/queries/me/validators": typeof tryouts_queries_me_validators; "tryouts/queries/tryouts": typeof tryouts_queries_tryouts; @@ -517,2837 +519,12 @@ export declare const internal: FilterApi< >; export declare const components: { - betterAuth: { - adapter: { - create: FunctionReference< - "mutation", - "internal", - { - input: - | { - data: { - createdAt: number; - displayUsername?: null | string; - email: string; - emailVerified: boolean; - image?: null | string; - isAnonymous?: null | boolean; - name: string; - updatedAt: number; - userId?: null | string; - username?: null | string; - }; - model: "user"; - } - | { - data: { - activeOrganizationId?: null | string; - createdAt: number; - expiresAt: number; - ipAddress?: null | string; - token: string; - updatedAt: number; - userAgent?: null | string; - userId: string; - }; - model: "session"; - } - | { - data: { - accessToken?: null | string; - accessTokenExpiresAt?: null | number; - accountId: string; - createdAt: number; - idToken?: null | string; - password?: null | string; - providerId: string; - refreshToken?: null | string; - refreshTokenExpiresAt?: null | number; - scope?: null | string; - updatedAt: number; - userId: string; - }; - model: "account"; - } - | { - data: { - createdAt: number; - expiresAt: number; - identifier: string; - updatedAt: number; - value: string; - }; - model: "verification"; - } - | { - data: { - createdAt: number; - logo?: null | string; - metadata?: null | string; - name: string; - slug: string; - }; - model: "organization"; - } - | { - data: { - createdAt: number; - organizationId: string; - role: string; - userId: string; - }; - model: "member"; - } - | { - data: { - createdAt: number; - email: string; - expiresAt: number; - inviterId: string; - organizationId: string; - role?: null | string; - status: string; - }; - model: "invitation"; - } - | { - data: { - createdAt: number; - expiresAt?: null | number; - privateKey: string; - publicKey: string; - }; - model: "jwks"; - }; - onCreateHandle?: string; - select?: Array; - }, - any - >; - deleteMany: FunctionReference< - "mutation", - "internal", - { - input: - | { - model: "user"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "email" - | "emailVerified" - | "image" - | "createdAt" - | "updatedAt" - | "isAnonymous" - | "username" - | "displayUsername" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "session"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "expiresAt" - | "token" - | "createdAt" - | "updatedAt" - | "ipAddress" - | "userAgent" - | "userId" - | "activeOrganizationId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "account"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "accountId" - | "providerId" - | "userId" - | "accessToken" - | "refreshToken" - | "idToken" - | "accessTokenExpiresAt" - | "refreshTokenExpiresAt" - | "scope" - | "password" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "verification"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "identifier" - | "value" - | "expiresAt" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "organization"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "slug" - | "logo" - | "createdAt" - | "metadata" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "member"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "organizationId" - | "userId" - | "role" - | "createdAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "invitation"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "organizationId" - | "email" - | "role" - | "status" - | "expiresAt" - | "createdAt" - | "inviterId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "jwks"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "publicKey" - | "privateKey" - | "createdAt" - | "expiresAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }; - onDeleteHandle?: string; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - any - >; - deleteOne: FunctionReference< - "mutation", - "internal", - { - input: - | { - model: "user"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "email" - | "emailVerified" - | "image" - | "createdAt" - | "updatedAt" - | "isAnonymous" - | "username" - | "displayUsername" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "session"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "expiresAt" - | "token" - | "createdAt" - | "updatedAt" - | "ipAddress" - | "userAgent" - | "userId" - | "activeOrganizationId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "account"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "accountId" - | "providerId" - | "userId" - | "accessToken" - | "refreshToken" - | "idToken" - | "accessTokenExpiresAt" - | "refreshTokenExpiresAt" - | "scope" - | "password" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "verification"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "identifier" - | "value" - | "expiresAt" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "organization"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "slug" - | "logo" - | "createdAt" - | "metadata" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "member"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "organizationId" - | "userId" - | "role" - | "createdAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "invitation"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "organizationId" - | "email" - | "role" - | "status" - | "expiresAt" - | "createdAt" - | "inviterId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "jwks"; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "publicKey" - | "privateKey" - | "createdAt" - | "expiresAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }; - onDeleteHandle?: string; - }, - any - >; - findMany: FunctionReference< - "query", - "internal", - { - join?: any; - limit?: number; - model: - | "user" - | "session" - | "account" - | "verification" - | "organization" - | "member" - | "invitation" - | "jwks"; - offset?: number; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - select?: Array; - sortBy?: { direction: "asc" | "desc"; field: string }; - where?: Array<{ - connector?: "AND" | "OR"; - field: string; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }, - any - >; - findOne: FunctionReference< - "query", - "internal", - { - join?: any; - model: - | "user" - | "session" - | "account" - | "verification" - | "organization" - | "member" - | "invitation" - | "jwks"; - select?: Array; - where?: Array<{ - connector?: "AND" | "OR"; - field: string; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }, - any - >; - updateMany: FunctionReference< - "mutation", - "internal", - { - input: - | { - model: "user"; - update: { - createdAt?: number; - displayUsername?: null | string; - email?: string; - emailVerified?: boolean; - image?: null | string; - isAnonymous?: null | boolean; - name?: string; - updatedAt?: number; - userId?: null | string; - username?: null | string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "email" - | "emailVerified" - | "image" - | "createdAt" - | "updatedAt" - | "isAnonymous" - | "username" - | "displayUsername" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "session"; - update: { - activeOrganizationId?: null | string; - createdAt?: number; - expiresAt?: number; - ipAddress?: null | string; - token?: string; - updatedAt?: number; - userAgent?: null | string; - userId?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "expiresAt" - | "token" - | "createdAt" - | "updatedAt" - | "ipAddress" - | "userAgent" - | "userId" - | "activeOrganizationId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "account"; - update: { - accessToken?: null | string; - accessTokenExpiresAt?: null | number; - accountId?: string; - createdAt?: number; - idToken?: null | string; - password?: null | string; - providerId?: string; - refreshToken?: null | string; - refreshTokenExpiresAt?: null | number; - scope?: null | string; - updatedAt?: number; - userId?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "accountId" - | "providerId" - | "userId" - | "accessToken" - | "refreshToken" - | "idToken" - | "accessTokenExpiresAt" - | "refreshTokenExpiresAt" - | "scope" - | "password" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "verification"; - update: { - createdAt?: number; - expiresAt?: number; - identifier?: string; - updatedAt?: number; - value?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "identifier" - | "value" - | "expiresAt" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "organization"; - update: { - createdAt?: number; - logo?: null | string; - metadata?: null | string; - name?: string; - slug?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "slug" - | "logo" - | "createdAt" - | "metadata" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "member"; - update: { - createdAt?: number; - organizationId?: string; - role?: string; - userId?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "organizationId" - | "userId" - | "role" - | "createdAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "invitation"; - update: { - createdAt?: number; - email?: string; - expiresAt?: number; - inviterId?: string; - organizationId?: string; - role?: null | string; - status?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "organizationId" - | "email" - | "role" - | "status" - | "expiresAt" - | "createdAt" - | "inviterId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "jwks"; - update: { - createdAt?: number; - expiresAt?: null | number; - privateKey?: string; - publicKey?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "publicKey" - | "privateKey" - | "createdAt" - | "expiresAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }; - onUpdateHandle?: string; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - any - >; - updateOne: FunctionReference< - "mutation", - "internal", - { - input: - | { - model: "user"; - update: { - createdAt?: number; - displayUsername?: null | string; - email?: string; - emailVerified?: boolean; - image?: null | string; - isAnonymous?: null | boolean; - name?: string; - updatedAt?: number; - userId?: null | string; - username?: null | string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "email" - | "emailVerified" - | "image" - | "createdAt" - | "updatedAt" - | "isAnonymous" - | "username" - | "displayUsername" - | "userId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "session"; - update: { - activeOrganizationId?: null | string; - createdAt?: number; - expiresAt?: number; - ipAddress?: null | string; - token?: string; - updatedAt?: number; - userAgent?: null | string; - userId?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "expiresAt" - | "token" - | "createdAt" - | "updatedAt" - | "ipAddress" - | "userAgent" - | "userId" - | "activeOrganizationId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "account"; - update: { - accessToken?: null | string; - accessTokenExpiresAt?: null | number; - accountId?: string; - createdAt?: number; - idToken?: null | string; - password?: null | string; - providerId?: string; - refreshToken?: null | string; - refreshTokenExpiresAt?: null | number; - scope?: null | string; - updatedAt?: number; - userId?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "accountId" - | "providerId" - | "userId" - | "accessToken" - | "refreshToken" - | "idToken" - | "accessTokenExpiresAt" - | "refreshTokenExpiresAt" - | "scope" - | "password" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "verification"; - update: { - createdAt?: number; - expiresAt?: number; - identifier?: string; - updatedAt?: number; - value?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "identifier" - | "value" - | "expiresAt" - | "createdAt" - | "updatedAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "organization"; - update: { - createdAt?: number; - logo?: null | string; - metadata?: null | string; - name?: string; - slug?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "name" - | "slug" - | "logo" - | "createdAt" - | "metadata" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "member"; - update: { - createdAt?: number; - organizationId?: string; - role?: string; - userId?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "organizationId" - | "userId" - | "role" - | "createdAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "invitation"; - update: { - createdAt?: number; - email?: string; - expiresAt?: number; - inviterId?: string; - organizationId?: string; - role?: null | string; - status?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "organizationId" - | "email" - | "role" - | "status" - | "expiresAt" - | "createdAt" - | "inviterId" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - } - | { - model: "jwks"; - update: { - createdAt?: number; - expiresAt?: null | number; - privateKey?: string; - publicKey?: string; - }; - where?: Array<{ - connector?: "AND" | "OR"; - field: - | "publicKey" - | "privateKey" - | "createdAt" - | "expiresAt" - | "_id"; - operator?: - | "lt" - | "lte" - | "gt" - | "gte" - | "eq" - | "in" - | "not_in" - | "ne" - | "contains" - | "starts_with" - | "ends_with"; - value: - | string - | number - | boolean - | Array - | Array - | null; - }>; - }; - onUpdateHandle?: string; - }, - any - >; - }; - mutations: { - setUserId: FunctionReference< - "mutation", - "internal", - { authId: string; userId: string }, - null - >; - updateUserName: FunctionReference< - "mutation", - "internal", - { authId: string; name: string }, - null - >; - }; - queries: { - getUserByEmail: FunctionReference< - "query", - "internal", - { email: string }, - null | { _id: string } - >; - }; - }; - workflow: { - event: { - create: FunctionReference< - "mutation", - "internal", - { name: string; workflowId: string }, - string - >; - send: FunctionReference< - "mutation", - "internal", - { - eventId?: string; - name?: string; - result: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - workflowId?: string; - workpoolOptions?: { - defaultRetryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - retryActionsByDefault?: boolean; - }; - }, - string - >; - }; - journal: { - load: FunctionReference< - "query", - "internal", - { shortCircuit?: boolean; workflowId: string }, - { - blocked?: boolean; - journalEntries: Array<{ - _creationTime: number; - _id: string; - step: - | { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - kind?: "function"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - } - | { - args: any; - argsSize: number; - completedAt?: number; - handle: string; - inProgress: boolean; - kind: "workflow"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workflowId?: string; - } - | { - args: { eventId?: string }; - argsSize: number; - completedAt?: number; - eventId?: string; - inProgress: boolean; - kind: "event"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - } - | { - args: any; - argsSize: number; - completedAt?: number; - inProgress: boolean; - kind: "sleep"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - stepNumber: number; - workflowId: string; - }>; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - ok: boolean; - workflow: { - _creationTime: number; - _id: string; - args: any; - generationNumber: number; - logLevel?: any; - name?: string; - onComplete?: { context?: any; fnHandle: string }; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt?: any; - state?: any; - workflowHandle: string; - }; - } - >; - startSteps: FunctionReference< - "mutation", - "internal", - { - generationNumber: number; - steps: Array<{ - retry?: - | boolean - | { base: number; initialBackoffMs: number; maxAttempts: number }; - schedulerOptions?: { runAt?: number } | { runAfter?: number }; - step: - | { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - kind?: "function"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - } - | { - args: any; - argsSize: number; - completedAt?: number; - handle: string; - inProgress: boolean; - kind: "workflow"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workflowId?: string; - } - | { - args: { eventId?: string }; - argsSize: number; - completedAt?: number; - eventId?: string; - inProgress: boolean; - kind: "event"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - } - | { - args: any; - argsSize: number; - completedAt?: number; - inProgress: boolean; - kind: "sleep"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - }>; - workflowId: string; - workpoolOptions?: { - defaultRetryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - retryActionsByDefault?: boolean; - }; - }, - Array<{ - _creationTime: number; - _id: string; - step: - | { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - kind?: "function"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - } - | { - args: any; - argsSize: number; - completedAt?: number; - handle: string; - inProgress: boolean; - kind: "workflow"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workflowId?: string; - } - | { - args: { eventId?: string }; - argsSize: number; - completedAt?: number; - eventId?: string; - inProgress: boolean; - kind: "event"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - } - | { - args: any; - argsSize: number; - completedAt?: number; - inProgress: boolean; - kind: "sleep"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - stepNumber: number; - workflowId: string; - }> - >; - }; - workflow: { - cancel: FunctionReference< - "mutation", - "internal", - { workflowId: string }, - null - >; - cleanup: FunctionReference< - "mutation", - "internal", - { force?: boolean; workflowId: string }, - boolean - >; - complete: FunctionReference< - "mutation", - "internal", - { - generationNumber: number; - runResult: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - workflowId: string; - }, - null - >; - create: FunctionReference< - "mutation", - "internal", - { - maxParallelism?: number; - onComplete?: { context?: any; fnHandle: string }; - startAsync?: boolean; - workflowArgs: any; - workflowHandle: string; - workflowName: string; - }, - string - >; - getStatus: FunctionReference< - "query", - "internal", - { workflowId: string }, - { - inProgress: Array<{ - _creationTime: number; - _id: string; - step: - | { - args: any; - argsSize: number; - completedAt?: number; - functionType: "query" | "mutation" | "action"; - handle: string; - inProgress: boolean; - kind?: "function"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - } - | { - args: any; - argsSize: number; - completedAt?: number; - handle: string; - inProgress: boolean; - kind: "workflow"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workflowId?: string; - } - | { - args: { eventId?: string }; - argsSize: number; - completedAt?: number; - eventId?: string; - inProgress: boolean; - kind: "event"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - } - | { - args: any; - argsSize: number; - completedAt?: number; - inProgress: boolean; - kind: "sleep"; - name: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - workId?: string; - }; - stepNumber: number; - workflowId: string; - }>; - logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - workflow: { - _creationTime: number; - _id: string; - args: any; - generationNumber: number; - logLevel?: any; - name?: string; - onComplete?: { context?: any; fnHandle: string }; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt?: any; - state?: any; - workflowHandle: string; - }; - } - >; - list: FunctionReference< - "query", - "internal", - { - order: "asc" | "desc"; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - args: any; - context?: any; - name?: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - workflowId: string; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - listByName: FunctionReference< - "query", - "internal", - { - name: string; - order: "asc" | "desc"; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - args: any; - context?: any; - name?: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - workflowId: string; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - listSteps: FunctionReference< - "query", - "internal", - { - order: "asc" | "desc"; - paginationOpts: { - cursor: string | null; - endCursor?: string | null; - id?: number; - maximumBytesRead?: number; - maximumRowsRead?: number; - numItems: number; - }; - workflowId: string; - }, - { - continueCursor: string; - isDone: boolean; - page: Array<{ - args: any; - completedAt?: number; - eventId?: string; - kind: "function" | "workflow" | "event" | "sleep"; - name: string; - nestedWorkflowId?: string; - runResult?: - | { kind: "success"; returnValue: any } - | { error: string; kind: "failed" } - | { kind: "canceled" }; - startedAt: number; - stepId: string; - stepNumber: number; - workId?: string; - workflowId: string; - }>; - pageStatus?: "SplitRecommended" | "SplitRequired" | null; - splitCursor?: string | null; - } - >; - restart: FunctionReference< - "mutation", - "internal", - { from?: number | string; startAsync?: boolean; workflowId: string }, - null - >; - }; - }; - irtCalibrationSyncWorkpool: { - config: { - update: FunctionReference< - "mutation", - "internal", - { - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - }, - any - >; - }; - lib: { - cancel: FunctionReference< - "mutation", - "internal", - { - id: string; - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - cancelAll: FunctionReference< - "mutation", - "internal", - { - before?: number; - limit?: number; - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - enqueue: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - }; - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }, - string - >; - enqueueBatch: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - }; - items: Array<{ - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }>; - }, - Array - >; - status: FunctionReference< - "query", - "internal", - { id: string }, - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - >; - statusBatch: FunctionReference< - "query", - "internal", - { ids: Array }, - Array< - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - > - >; - }; - }; - irtScalePublicationQueueWorkpool: { - config: { - update: FunctionReference< - "mutation", - "internal", - { - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - }, - any - >; - }; - lib: { - cancel: FunctionReference< - "mutation", - "internal", - { - id: string; - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - cancelAll: FunctionReference< - "mutation", - "internal", - { - before?: number; - limit?: number; - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - enqueue: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - }; - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }, - string - >; - enqueueBatch: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - }; - items: Array<{ - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }>; - }, - Array - >; - status: FunctionReference< - "query", - "internal", - { id: string }, - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - >; - statusBatch: FunctionReference< - "query", - "internal", - { ids: Array }, - Array< - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - > - >; - }; - }; - tryoutLeaderboardWorkpool: { - config: { - update: FunctionReference< - "mutation", - "internal", - { - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - }, - any - >; - }; - lib: { - cancel: FunctionReference< - "mutation", - "internal", - { - id: string; - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - cancelAll: FunctionReference< - "mutation", - "internal", - { - before?: number; - limit?: number; - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - }, - any - >; - enqueue: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - }; - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }, - string - >; - enqueueBatch: FunctionReference< - "mutation", - "internal", - { - config: { - logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; - maxParallelism?: number; - }; - items: Array<{ - fnArgs: any; - fnHandle: string; - fnName: string; - fnType: "action" | "mutation" | "query"; - onComplete?: { context?: any; fnHandle: string }; - retryBehavior?: { - base: number; - initialBackoffMs: number; - maxAttempts: number; - }; - runAt: number; - }>; - }, - Array - >; - status: FunctionReference< - "query", - "internal", - { id: string }, - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - >; - statusBatch: FunctionReference< - "query", - "internal", - { ids: Array }, - Array< - | { previousAttempts: number; state: "pending" } - | { previousAttempts: number; state: "running" } - | { state: "finished" } - > - >; - }; - }; - resend: { - lib: { - cancelEmail: FunctionReference< - "mutation", - "internal", - { emailId: string }, - null - >; - cleanupAbandonedEmails: FunctionReference< - "mutation", - "internal", - { olderThan?: number }, - null - >; - cleanupOldEmails: FunctionReference< - "mutation", - "internal", - { olderThan?: number }, - null - >; - createManualEmail: FunctionReference< - "mutation", - "internal", - { - from: string; - headers?: Array<{ name: string; value: string }>; - replyTo?: Array; - subject: string; - to: Array | string; - }, - string - >; - get: FunctionReference< - "query", - "internal", - { emailId: string }, - { - bcc?: Array; - bounced?: boolean; - cc?: Array; - clicked?: boolean; - complained: boolean; - createdAt: number; - deliveryDelayed?: boolean; - errorMessage?: string; - failed?: boolean; - finalizedAt: number; - from: string; - headers?: Array<{ name: string; value: string }>; - html?: string; - opened: boolean; - replyTo: Array; - resendId?: string; - segment: number; - status: - | "waiting" - | "queued" - | "cancelled" - | "sent" - | "delivered" - | "delivery_delayed" - | "bounced" - | "failed"; - subject?: string; - template?: { - id: string; - variables?: Record; - }; - text?: string; - to: Array; - } | null - >; - getStatus: FunctionReference< - "query", - "internal", - { emailId: string }, - { - bounced: boolean; - clicked: boolean; - complained: boolean; - deliveryDelayed: boolean; - errorMessage: string | null; - failed: boolean; - opened: boolean; - status: - | "waiting" - | "queued" - | "cancelled" - | "sent" - | "delivered" - | "delivery_delayed" - | "bounced" - | "failed"; - } | null - >; - handleEmailEvent: FunctionReference< - "mutation", - "internal", - { event: any }, - null - >; - sendEmail: FunctionReference< - "mutation", - "internal", - { - bcc?: Array; - cc?: Array; - from: string; - headers?: Array<{ name: string; value: string }>; - html?: string; - options: { - apiKey: string; - initialBackoffMs: number; - onEmailEvent?: { fnHandle: string }; - retryAttempts: number; - testMode: boolean; - }; - replyTo?: Array; - subject?: string; - template?: { - id: string; - variables?: Record; - }; - text?: string; - to: Array; - }, - string - >; - updateManualEmail: FunctionReference< - "mutation", - "internal", - { - emailId: string; - errorMessage?: string; - resendId?: string; - status: - | "waiting" - | "queued" - | "cancelled" - | "sent" - | "delivered" - | "delivery_delayed" - | "bounced" - | "failed"; - }, - null - >; - }; - }; - tryoutLeaderboard: { - btree: { - aggregateBetween: FunctionReference< - "query", - "internal", - { k1?: any; k2?: any; namespace?: any }, - { count: number; sum: number } - >; - aggregateBetweenBatch: FunctionReference< - "query", - "internal", - { queries: Array<{ k1?: any; k2?: any; namespace?: any }> }, - Array<{ count: number; sum: number }> - >; - atNegativeOffset: FunctionReference< - "query", - "internal", - { k1?: any; k2?: any; namespace?: any; offset: number }, - { k: any; s: number; v: any } - >; - atOffset: FunctionReference< - "query", - "internal", - { k1?: any; k2?: any; namespace?: any; offset: number }, - { k: any; s: number; v: any } - >; - atOffsetBatch: FunctionReference< - "query", - "internal", - { - queries: Array<{ - k1?: any; - k2?: any; - namespace?: any; - offset: number; - }>; - }, - Array<{ k: any; s: number; v: any }> - >; - get: FunctionReference< - "query", - "internal", - { key: any; namespace?: any }, - null | { k: any; s: number; v: any } - >; - offset: FunctionReference< - "query", - "internal", - { k1?: any; key: any; namespace?: any }, - number - >; - offsetUntil: FunctionReference< - "query", - "internal", - { k2?: any; key: any; namespace?: any }, - number - >; - paginate: FunctionReference< - "query", - "internal", - { - cursor?: string; - k1?: any; - k2?: any; - limit: number; - namespace?: any; - order: "asc" | "desc"; - }, - { - cursor: string; - isDone: boolean; - page: Array<{ k: any; s: number; v: any }>; - } - >; - paginateNamespaces: FunctionReference< - "query", - "internal", - { cursor?: string; limit: number }, - { cursor: string; isDone: boolean; page: Array } - >; - validate: FunctionReference< - "query", - "internal", - { namespace?: any }, - any - >; - }; - inspect: { - display: FunctionReference<"query", "internal", { namespace?: any }, any>; - dump: FunctionReference<"query", "internal", { namespace?: any }, string>; - inspectNode: FunctionReference< - "query", - "internal", - { namespace?: any; node?: string }, - null - >; - listTreeNodes: FunctionReference< - "query", - "internal", - { take?: number }, - Array<{ - _creationTime: number; - _id: string; - aggregate?: { count: number; sum: number }; - items: Array<{ k: any; s: number; v: any }>; - subtrees: Array; - }> - >; - listTrees: FunctionReference< - "query", - "internal", - { take?: number }, - Array<{ - _creationTime: number; - _id: string; - maxNodeSize: number; - namespace?: any; - root: string; - }> - >; - }; - public: { - clear: FunctionReference< - "mutation", - "internal", - { maxNodeSize?: number; namespace?: any; rootLazy?: boolean }, - null - >; - delete_: FunctionReference< - "mutation", - "internal", - { key: any; namespace?: any }, - null - >; - deleteIfExists: FunctionReference< - "mutation", - "internal", - { key: any; namespace?: any }, - any - >; - init: FunctionReference< - "mutation", - "internal", - { maxNodeSize?: number; namespace?: any; rootLazy?: boolean }, - null - >; - insert: FunctionReference< - "mutation", - "internal", - { key: any; namespace?: any; summand?: number; value: any }, - null - >; - makeRootLazy: FunctionReference< - "mutation", - "internal", - { namespace?: any }, - null - >; - replace: FunctionReference< - "mutation", - "internal", - { - currentKey: any; - namespace?: any; - newKey: any; - newNamespace?: any; - summand?: number; - value: any; - }, - null - >; - replaceOrInsert: FunctionReference< - "mutation", - "internal", - { - currentKey: any; - namespace?: any; - newKey: any; - newNamespace?: any; - summand?: number; - value: any; - }, - any - >; - }; - }; - globalLeaderboard: { - btree: { - aggregateBetween: FunctionReference< - "query", - "internal", - { k1?: any; k2?: any; namespace?: any }, - { count: number; sum: number } - >; - aggregateBetweenBatch: FunctionReference< - "query", - "internal", - { queries: Array<{ k1?: any; k2?: any; namespace?: any }> }, - Array<{ count: number; sum: number }> - >; - atNegativeOffset: FunctionReference< - "query", - "internal", - { k1?: any; k2?: any; namespace?: any; offset: number }, - { k: any; s: number; v: any } - >; - atOffset: FunctionReference< - "query", - "internal", - { k1?: any; k2?: any; namespace?: any; offset: number }, - { k: any; s: number; v: any } - >; - atOffsetBatch: FunctionReference< - "query", - "internal", - { - queries: Array<{ - k1?: any; - k2?: any; - namespace?: any; - offset: number; - }>; - }, - Array<{ k: any; s: number; v: any }> - >; - get: FunctionReference< - "query", - "internal", - { key: any; namespace?: any }, - null | { k: any; s: number; v: any } - >; - offset: FunctionReference< - "query", - "internal", - { k1?: any; key: any; namespace?: any }, - number - >; - offsetUntil: FunctionReference< - "query", - "internal", - { k2?: any; key: any; namespace?: any }, - number - >; - paginate: FunctionReference< - "query", - "internal", - { - cursor?: string; - k1?: any; - k2?: any; - limit: number; - namespace?: any; - order: "asc" | "desc"; - }, - { - cursor: string; - isDone: boolean; - page: Array<{ k: any; s: number; v: any }>; - } - >; - paginateNamespaces: FunctionReference< - "query", - "internal", - { cursor?: string; limit: number }, - { cursor: string; isDone: boolean; page: Array } - >; - validate: FunctionReference< - "query", - "internal", - { namespace?: any }, - any - >; - }; - inspect: { - display: FunctionReference<"query", "internal", { namespace?: any }, any>; - dump: FunctionReference<"query", "internal", { namespace?: any }, string>; - inspectNode: FunctionReference< - "query", - "internal", - { namespace?: any; node?: string }, - null - >; - listTreeNodes: FunctionReference< - "query", - "internal", - { take?: number }, - Array<{ - _creationTime: number; - _id: string; - aggregate?: { count: number; sum: number }; - items: Array<{ k: any; s: number; v: any }>; - subtrees: Array; - }> - >; - listTrees: FunctionReference< - "query", - "internal", - { take?: number }, - Array<{ - _creationTime: number; - _id: string; - maxNodeSize: number; - namespace?: any; - root: string; - }> - >; - }; - public: { - clear: FunctionReference< - "mutation", - "internal", - { maxNodeSize?: number; namespace?: any; rootLazy?: boolean }, - null - >; - delete_: FunctionReference< - "mutation", - "internal", - { key: any; namespace?: any }, - null - >; - deleteIfExists: FunctionReference< - "mutation", - "internal", - { key: any; namespace?: any }, - any - >; - init: FunctionReference< - "mutation", - "internal", - { maxNodeSize?: number; namespace?: any; rootLazy?: boolean }, - null - >; - insert: FunctionReference< - "mutation", - "internal", - { key: any; namespace?: any; summand?: number; value: any }, - null - >; - makeRootLazy: FunctionReference< - "mutation", - "internal", - { namespace?: any }, - null - >; - replace: FunctionReference< - "mutation", - "internal", - { - currentKey: any; - namespace?: any; - newKey: any; - newNamespace?: any; - summand?: number; - value: any; - }, - null - >; - replaceOrInsert: FunctionReference< - "mutation", - "internal", - { - currentKey: any; - namespace?: any; - newKey: any; - newNamespace?: any; - summand?: number; - value: any; - }, - any - >; - }; - }; + betterAuth: import("@repo/backend/convex/betterAuth/_generated/component.js").ComponentApi<"betterAuth">; + workflow: import("@convex-dev/workflow/_generated/component.js").ComponentApi<"workflow">; + irtCalibrationSyncWorkpool: import("@convex-dev/workpool/_generated/component.js").ComponentApi<"irtCalibrationSyncWorkpool">; + irtScalePublicationQueueWorkpool: import("@convex-dev/workpool/_generated/component.js").ComponentApi<"irtScalePublicationQueueWorkpool">; + tryoutLeaderboardWorkpool: import("@convex-dev/workpool/_generated/component.js").ComponentApi<"tryoutLeaderboardWorkpool">; + resend: import("@convex-dev/resend/_generated/component.js").ComponentApi<"resend">; + tryoutLeaderboard: import("@convex-dev/aggregate/_generated/component.js").ComponentApi<"tryoutLeaderboard">; + globalLeaderboard: import("@convex-dev/aggregate/_generated/component.js").ComponentApi<"globalLeaderboard">; }; diff --git a/packages/backend/convex/tryouts/queries/me/session.test.ts b/packages/backend/convex/tryouts/queries/me/session.test.ts new file mode 100644 index 000000000..3bdddd8b9 --- /dev/null +++ b/packages/backend/convex/tryouts/queries/me/session.test.ts @@ -0,0 +1,148 @@ +import { api } from "@repo/backend/convex/_generated/api"; +import { seedAuthenticatedUser } from "@repo/backend/convex/test.helpers"; +import { + createTryoutTestConvex, + insertCompletedTryoutAttempt, + insertTryoutSkeleton, + NOW, +} from "@repo/backend/convex/tryouts/test.helpers"; +import { describe, expect, it } from "vitest"; + +describe("tryouts/queries/me/session", () => { + it("returns null when the user has no resolved tryout attempt", async () => { + const t = createTryoutTestConvex(); + const identity = await t.mutation(async (ctx) => { + return await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "session-missing", + }); + }); + + const result = await t + .withIdentity({ + subject: identity.authUserId, + sessionId: identity.sessionId, + }) + .query(api.tryouts.queries.me.session.getUserTryoutSession, { + product: "snbt", + locale: "id", + tryoutSlug: "missing-tryout", + }); + + expect(result).toBeNull(); + }); + + it("returns the explicitly selected historical attempt when it is valid", async () => { + const t = createTryoutTestConvex(); + const state = await t.mutation(async (ctx) => { + const identity = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "session-selected", + }); + const tryout = await insertTryoutSkeleton(ctx, "session-selected"); + const olderAttempt = await insertCompletedTryoutAttempt(ctx, { + scaleVersionId: tryout.scaleVersionId, + setId: tryout.setId, + slug: "session-selected-older", + tryoutId: tryout.tryoutId, + userId: identity.userId, + }); + const latestAttempt = await insertCompletedTryoutAttempt(ctx, { + scaleVersionId: tryout.scaleVersionId, + setId: tryout.setId, + slug: "session-selected-latest", + tryoutId: tryout.tryoutId, + userId: identity.userId, + }); + + await ctx.db.patch("tryoutAttempts", olderAttempt.tryoutAttemptId, { + expiresAt: NOW + 10, + startedAt: NOW, + status: "completed", + }); + await ctx.db.patch("tryoutAttempts", latestAttempt.tryoutAttemptId, { + expiresAt: NOW + 20, + startedAt: NOW + 1, + status: "completed", + }); + + return { + identity, + olderAttemptId: olderAttempt.tryoutAttemptId, + }; + }); + + const result = await t + .withIdentity({ + subject: state.identity.authUserId, + sessionId: state.identity.sessionId, + }) + .query(api.tryouts.queries.me.session.getUserTryoutSession, { + attemptId: state.olderAttemptId, + product: "snbt", + locale: "id", + tryoutSlug: "session-selected", + }); + + expect(result).toEqual({ + attemptId: state.olderAttemptId, + expiresAtMs: NOW + 10, + status: "completed", + }); + }); + + it("falls back to the latest attempt when the requested attempt id is invalid", async () => { + const t = createTryoutTestConvex(); + const state = await t.mutation(async (ctx) => { + const identity = await seedAuthenticatedUser(ctx, { + now: NOW, + suffix: "session-latest", + }); + const tryout = await insertTryoutSkeleton(ctx, "session-latest"); + + await insertCompletedTryoutAttempt(ctx, { + scaleVersionId: tryout.scaleVersionId, + setId: tryout.setId, + slug: "session-latest-older", + tryoutId: tryout.tryoutId, + userId: identity.userId, + }); + const latestAttempt = await insertCompletedTryoutAttempt(ctx, { + scaleVersionId: tryout.scaleVersionId, + setId: tryout.setId, + slug: "session-latest-newest", + tryoutId: tryout.tryoutId, + userId: identity.userId, + }); + + await ctx.db.patch("tryoutAttempts", latestAttempt.tryoutAttemptId, { + expiresAt: NOW + 30, + startedAt: NOW + 2, + status: "in-progress", + }); + + return { + identity, + latestAttemptId: latestAttempt.tryoutAttemptId, + }; + }); + + const result = await t + .withIdentity({ + subject: state.identity.authUserId, + sessionId: state.identity.sessionId, + }) + .query(api.tryouts.queries.me.session.getUserTryoutSession, { + attemptId: "not-a-valid-tryout-attempt-id", + product: "snbt", + locale: "id", + tryoutSlug: "session-latest", + }); + + expect(result).toEqual({ + attemptId: state.latestAttemptId, + expiresAtMs: NOW + 30, + status: "in-progress", + }); + }); +}); diff --git a/packages/backend/convex/tryouts/queries/me/session.ts b/packages/backend/convex/tryouts/queries/me/session.ts new file mode 100644 index 000000000..7659ff7c5 --- /dev/null +++ b/packages/backend/convex/tryouts/queries/me/session.ts @@ -0,0 +1,34 @@ +import { query } from "@repo/backend/convex/_generated/server"; +import { requireAuth } from "@repo/backend/convex/lib/helpers/auth"; +import { loadResolvedUserTryoutContext } from "@repo/backend/convex/tryouts/queries/me/helpers/context"; +import { + userTryoutLookupArgs, + userTryoutSessionResultValidator, +} from "@repo/backend/convex/tryouts/queries/me/validators"; +import { nullable } from "convex-helpers/validators"; + +/** + * Returns the selected tryout attempt shell state used to lock the shared + * session sidebar while one tryout is actively in progress. + */ +export const getUserTryoutSession = query({ + args: userTryoutLookupArgs, + returns: nullable(userTryoutSessionResultValidator), + handler: async (ctx, args) => { + const { appUser } = await requireAuth(ctx); + const context = await loadResolvedUserTryoutContext(ctx, { + ...args, + userId: appUser._id, + }); + + if (!context) { + return null; + } + + return { + attemptId: context.attempt._id, + expiresAtMs: context.attempt.expiresAt, + status: context.attempt.status, + }; + }, +}); diff --git a/packages/backend/convex/tryouts/queries/me/validators.ts b/packages/backend/convex/tryouts/queries/me/validators.ts index bd12e76c3..cff8b131b 100644 --- a/packages/backend/convex/tryouts/queries/me/validators.ts +++ b/packages/backend/convex/tryouts/queries/me/validators.ts @@ -90,6 +90,12 @@ export const userTryoutSetViewResultValidator = v.object({ initialHistory: userTryoutAttemptHistoryResultValidator, }); +export const userTryoutSessionResultValidator = v.object({ + attemptId: vv.id("tryoutAttempts"), + expiresAtMs: vv.doc("tryoutAttempts").fields.expiresAt, + status: vv.doc("tryoutAttempts").fields.status, +}); + export const tryoutPartAttemptRuntimeValidator = v.object({ partIndex: v.number(), partKey: tryoutPartKeyValidator, diff --git a/packages/backend/package.json b/packages/backend/package.json index 1da4b2a58..e650006d2 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -54,10 +54,10 @@ "@repo/ai": "workspace:*", "@repo/utilities": "workspace:*", "@t3-oss/env-nextjs": "^0.13.11", - "ai": "^6.0.154", + "ai": "^6.0.158", "better-auth": "1.5.3", "buffer": "^6.0.3", - "convex": "^1.34.1", + "convex": "^1.35.1", "convex-helpers": "^0.1.114", "hono": "^4.12.12", "jsonrepair": "^3.13.3", diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index dc8a50f9b..61ade6ba3 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -18,5 +18,5 @@ } }, "include": ["./**/*"], - "exclude": ["./convex/_generated", "./coverage"] + "exclude": ["./convex/_generated", "./coverage", "./vitest.config.ts"] } diff --git a/packages/contents/_lib/__tests__/exercises.test.ts b/packages/contents/_lib/__tests__/exercises.test.ts index c38f2f0f0..6ef36e69b 100644 --- a/packages/contents/_lib/__tests__/exercises.test.ts +++ b/packages/contents/_lib/__tests__/exercises.test.ts @@ -56,11 +56,18 @@ vi.mock("ky", () => ({ })); import { - getExerciseByNumber, getExerciseCount, getExerciseQuestionNumbers, + getExerciseSetPaths, +} from "@repo/contents/_lib/exercises/collection"; +import { + getRenderableExerciseByNumber, + getRenderableExercisesContent, +} from "@repo/contents/_lib/exercises/renderable"; +import { + getExerciseByNumber, getExercisesContent, -} from "@repo/contents/_lib/exercises"; +} from "@repo/contents/_lib/exercises/set"; const exerciseBasePath = "exercises/high-school/snbt/quantitative-knowledge/try-out/2026/set-1"; @@ -203,6 +210,48 @@ describe("getExerciseQuestionNumbers", () => { }); }); +describe("getExerciseSetPaths", () => { + it("collects unique exercise set paths from question and answer slugs", () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + `${exerciseBasePath}/1/_question`, + `${exerciseBasePath}/1/_answer`, + `${exerciseBasePath}/2/_question`, + "exercises/high-school/snbt/general-knowledge/try-out/2026/set-2/1/_answer", + ]); + + const result = getExerciseSetPaths("id"); + + expect(result).toStrictEqual([ + "exercises/high-school/snbt/general-knowledge/try-out/2026/set-2", + exerciseBasePath, + ]); + }); + + it("ignores exercise collection folders and unrelated slugs", () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + "exercises/high-school/snbt/quantitative-knowledge/try-out/2026", + `${exerciseBasePath}/1/choices`, + "articles/politics/dynastic-politics-asian-values", + ]); + + const result = getExerciseSetPaths("en"); + + expect(result).toStrictEqual([]); + }); + + it("ignores malformed exercise slugs without trailing segments", () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + "exercises/high-school/snbt/quantitative-knowledge/try-out/2026/set-1/_question", + "exercises/high-school/snbt/quantitative-knowledge/try-out/2026/set-1", + "exercises", + ]); + + const result = getExerciseSetPaths("en"); + + expect(result).toStrictEqual([]); + }); +}); + describe("getExercisesContent", () => { it("returns an empty array when no exercise slugs exist", async () => { const result = await Effect.runPromise( @@ -670,3 +719,309 @@ describe("getExerciseByNumber", () => { expect(Option.getOrUndefined(result)?.question.default).toBeDefined(); }); }); + +describe("getRenderableExercisesContent", () => { + it("loads plain exercise rows from local mdx files and choices", async () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + `${exerciseBasePath}/1/_question`, + `${exerciseBasePath}/1/_answer`, + `${exerciseBasePath}/2/_question`, + `${exerciseBasePath}/2/_answer`, + ]); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.endsWith("1/_question/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Question 1", description: "Q1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Question 1' + ); + } + + if (filePath.endsWith("1/_answer/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Answer 1", description: "A1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Answer 1' + ); + } + + if (filePath.endsWith("2/_question/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Question 2", description: "Q2", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Question 2' + ); + } + + if (filePath.endsWith("2/_answer/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Answer 2", description: "A2", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Answer 2' + ); + } + + if (filePath.endsWith("1/choices.ts")) { + return Promise.resolve(createChoicesSource("One")); + } + + return Promise.resolve(createChoicesSource("Two")); + }); + + const result = await getRenderableExercisesContent("id", exerciseBasePath); + + expect(result).toHaveLength(2); + expect(result.map((exercise) => exercise.number)).toStrictEqual([1, 2]); + expect(result[0]?.question.metadata.title).toBe("Question 1"); + expect(result[1]?.choices.id[0]?.label).toBe("Two ID"); + }); + + it("falls back to GitHub raw choices when the local choices file is missing", async () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + `${exerciseBasePath}/1/_question`, + `${exerciseBasePath}/1/_answer`, + ]); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.endsWith("choices.ts")) { + return Promise.reject(new Error("missing choices file")); + } + + if (filePath.endsWith("_question/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Question 1", description: "Q1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Question 1' + ); + } + + return Promise.resolve( + 'export const metadata = { title: "Answer 1", description: "A1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Answer 1' + ); + }); + mockKyGet.mockReturnValue({ + text: () => Promise.resolve(createChoicesSource("Remote")), + }); + + const result = await getRenderableExercisesContent("en", exerciseBasePath); + + expect(result).toHaveLength(1); + expect(result[0]?.choices.en[0]?.label).toBe("Remote EN"); + expect(mockKyGet).toHaveBeenCalledTimes(1); + }); + + it("skips incomplete exercises when metadata or choices are missing", async () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + `${exerciseBasePath}/1/_question`, + `${exerciseBasePath}/1/_answer`, + `${exerciseBasePath}/2/_question`, + `${exerciseBasePath}/2/_answer`, + ]); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.endsWith("1/_question/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Question 1", description: "Q1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Question 1' + ); + } + + if (filePath.endsWith("1/_answer/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Answer 1", description: "A1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Answer 1' + ); + } + + if (filePath.endsWith("1/choices.ts")) { + return Promise.resolve(createChoicesSource("One")); + } + + if (filePath.endsWith("2/choices.ts")) { + return Promise.resolve("const choices = invalid;"); + } + + return Promise.resolve("## Missing metadata"); + }); + + const result = await getRenderableExercisesContent("id", exerciseBasePath); + + expect(result).toHaveLength(1); + expect(result[0]?.number).toBe(1); + }); +}); + +describe("getRenderableExerciseByNumber", () => { + it("loads one matching exercise row by number", async () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + `${exerciseBasePath}/03/_question`, + `${exerciseBasePath}/03/_answer`, + ]); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.endsWith("03/_question/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Question 03", description: "Q3", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Question 03' + ); + } + + if (filePath.endsWith("03/_answer/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Answer 03", description: "A3", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Answer 03' + ); + } + + return Promise.resolve(createChoicesSource("Three")); + }); + + const result = await getRenderableExerciseByNumber( + "id", + exerciseBasePath, + 3 + ); + + expect(result?.number).toBe(3); + expect(result?.choices.id[0]?.label).toBe("Three ID"); + }); + + it("returns null when the requested number is not present", async () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + `${exerciseBasePath}/1/_question`, + `${exerciseBasePath}/1/_answer`, + ]); + + const result = await getRenderableExerciseByNumber( + "en", + exerciseBasePath, + 9 + ); + + expect(result).toBeNull(); + }); + + it("returns null when the choices source cannot be evaluated", async () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + `${exerciseBasePath}/1/_question`, + `${exerciseBasePath}/1/_answer`, + ]); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.endsWith("1/_question/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Question 1", description: "Q1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Question 1' + ); + } + + if (filePath.endsWith("1/_answer/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Answer 1", description: "A1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Answer 1' + ); + } + + return Promise.resolve("const choices = { broken:"); + }); + + const result = await getRenderableExerciseByNumber( + "en", + exerciseBasePath, + 1 + ); + + expect(result).toBeNull(); + }); + + it("returns null when the choices expression throws during evaluation", async () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + `${exerciseBasePath}/1/_question`, + `${exerciseBasePath}/1/_answer`, + ]); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.endsWith("1/_question/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Question 1", description: "Q1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Question 1' + ); + } + + if (filePath.endsWith("1/_answer/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Answer 1", description: "A1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Answer 1' + ); + } + + return Promise.resolve("const choices = { broken: foo };"); + }); + + const result = await getRenderableExerciseByNumber( + "en", + exerciseBasePath, + 1 + ); + + expect(result).toBeNull(); + }); + + it("returns null when the choices expression shape fails schema validation", async () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + `${exerciseBasePath}/1/_question`, + `${exerciseBasePath}/1/_answer`, + ]); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.endsWith("1/_question/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Question 1", description: "Q1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Question 1' + ); + } + + if (filePath.endsWith("1/_answer/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Answer 1", description: "A1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Answer 1' + ); + } + + return Promise.resolve("const choices = { id: 'wrong', en: [] };"); + }); + + const result = await getRenderableExerciseByNumber( + "en", + exerciseBasePath, + 1 + ); + + expect(result).toBeNull(); + }); + + it("returns null when a renderable exercise path escapes the contents root", async () => { + const unsafePath = "../outside"; + mockGetMDXSlugsForLocale.mockReturnValue([ + `${unsafePath}/1/_question`, + `${unsafePath}/1/_answer`, + ]); + + const result = await getRenderableExerciseByNumber("en", unsafePath, 1); + + expect(result).toBeNull(); + expect(mockReadFile).not.toHaveBeenCalled(); + }); + + it("returns null when a renderable exercise mdx file cannot be read", async () => { + mockGetMDXSlugsForLocale.mockReturnValue([ + `${exerciseBasePath}/1/_question`, + `${exerciseBasePath}/1/_answer`, + ]); + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.endsWith("1/_question/id.mdx")) { + return Promise.resolve( + 'export const metadata = { title: "Question 1", description: "Q1", authors: [{ name: "Author" }], date: "01/01/2024" };\n\n## Question 1' + ); + } + + if (filePath.endsWith("1/_answer/id.mdx")) { + return Promise.reject(new Error("missing answer mdx")); + } + + return Promise.resolve(createChoicesSource("One")); + }); + + const result = await getRenderableExerciseByNumber( + "id", + exerciseBasePath, + 1 + ); + + expect(result).toBeNull(); + }); +}); + +describe("getRenderableExercisesContent edge cases", () => { + it("returns an empty array when no renderable exercise numbers exist", async () => { + mockGetMDXSlugsForLocale.mockReturnValue([]); + + const result = await getRenderableExercisesContent("en", exerciseBasePath); + + expect(result).toStrictEqual([]); + }); +}); diff --git a/packages/contents/_lib/__tests__/scoped.test.ts b/packages/contents/_lib/__tests__/scoped.test.ts index e5487ac82..6f1d02118 100644 --- a/packages/contents/_lib/__tests__/scoped.test.ts +++ b/packages/contents/_lib/__tests__/scoped.test.ts @@ -12,13 +12,17 @@ import { import { Effect } from "effect"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { mockGetMDXSlugsForLocale, mockKyGet, mockReadFile } = vi.hoisted( - () => ({ - mockGetMDXSlugsForLocale: vi.fn(), - mockKyGet: vi.fn(), - mockReadFile: vi.fn(), - }) -); +const { + mockGetMDXSlugsForLocale, + mockImportContentModule, + mockKyGet, + mockReadFile, +} = vi.hoisted(() => ({ + mockGetMDXSlugsForLocale: vi.fn(), + mockImportContentModule: vi.fn(), + mockKyGet: vi.fn(), + mockReadFile: vi.fn(), +})); vi.mock("@repo/contents/_lib/cache", () => ({ getMDXSlugsForLocale: mockGetMDXSlugsForLocale, @@ -41,6 +45,10 @@ vi.mock("ky", () => ({ }, })); +vi.mock("@repo/contents/_lib/module", () => ({ + importContentModule: mockImportContentModule, +})); + const rawMetadataSource = ` export const metadata = { title: "Raw Title", @@ -53,9 +61,11 @@ export const metadata = { describe("scoped content helpers", () => { beforeEach(() => { mockGetMDXSlugsForLocale.mockReset(); + mockImportContentModule.mockReset(); mockReadFile.mockReset(); mockKyGet.mockReset(); mockGetMDXSlugsForLocale.mockReturnValue([]); + mockImportContentModule.mockRejectedValue(new Error("Module not found")); mockReadFile.mockResolvedValue(rawMetadataSource); mockKyGet.mockImplementation(() => { throw new Error("Unexpected GitHub fetch"); @@ -63,24 +73,14 @@ describe("scoped content helpers", () => { }); it("loads raw metadata without calling the scoped importer", async () => { - const importer = vi.fn(() => - Promise.reject(new Error("should not import")) - ); - const result = await Effect.runPromise( - getScopedContent( - "articles", - importer, - "en", - "articles/politics/test-article", - { - includeMDX: false, - } - ) + getScopedContent("articles", "en", "articles/politics/test-article", { + includeMDX: false, + }) ); expect(result.metadata.title).toBe("Raw Title"); - expect(importer).not.toHaveBeenCalled(); + expect(mockImportContentModule).not.toHaveBeenCalled(); }); it("falls back to GitHub when the local raw content read fails", async () => { @@ -92,7 +92,6 @@ describe("scoped content helpers", () => { const result = await Effect.runPromise( getScopedContent( "subject", - () => Promise.reject(new Error("should not import")), "en", "subject/high-school/10/mathematics/algebra/basic-concept", { includeMDX: false } @@ -113,7 +112,6 @@ describe("scoped content helpers", () => { Effect.flip( getScopedContent( "subject", - () => Promise.reject(new Error("should not import")), "en", "subject/high-school/10/mathematics/algebra/basic-concept", { includeMDX: false } @@ -129,7 +127,6 @@ describe("scoped content helpers", () => { Effect.flip( getScopedContent( "articles", - () => Promise.reject(new Error("should not import")), "en", "subject/high-school/10/mathematics/algebra/basic-concept", { includeMDX: false } @@ -143,13 +140,9 @@ describe("scoped content helpers", () => { it("fails when a scoped file path attempts path traversal", async () => { const failure = await Effect.runPromise( Effect.flip( - getScopedContent( - "articles", - () => Promise.reject(new Error("should not import")), - "en", - "articles/../../secret", - { includeMDX: false } - ) + getScopedContent("articles", "en", "articles/../../secret", { + includeMDX: false, + }) ) ); @@ -163,7 +156,6 @@ describe("scoped content helpers", () => { Effect.flip( getScopedContent( "exercises", - () => Promise.reject(new Error("should not import")), "en", "exercises/high-school/snbt/quantitative-knowledge/try-out/2026/set-1/1/_question", { includeMDX: false } @@ -175,28 +167,24 @@ describe("scoped content helpers", () => { }); it("loads scoped MDX content with the provided importer", async () => { - const importer = vi.fn(() => - Promise.resolve({ - metadata: { - title: "Scoped Title", - description: "Scoped Description", - authors: [{ name: "Author" }], - date: "01/01/2024", - }, - default: () => "Scoped MDX", - }) - ); + mockImportContentModule.mockResolvedValue({ + metadata: { + title: "Scoped Title", + description: "Scoped Description", + authors: [{ name: "Author" }], + date: "01/01/2024", + }, + default: () => "Scoped MDX", + }); const result = await Effect.runPromise( - getScopedContent( - "articles", - importer, - "en", - "articles/politics/test-article" - ) + getScopedContent("articles", "en", "articles/politics/test-article") ); - expect(importer).toHaveBeenCalledWith("politics/test-article", "en"); + expect(mockImportContentModule).toHaveBeenCalledWith( + "articles/politics/test-article", + "en" + ); expect(result.metadata.title).toBe("Scoped Title"); expect(result.default).toBeTruthy(); }); @@ -207,10 +195,10 @@ describe("scoped content helpers", () => { "articles/science/another-article", ]); - const importer = vi.fn((relativePath: string) => + mockImportContentModule.mockImplementation((cleanPath: string) => Promise.resolve({ metadata: { - title: relativePath, + title: cleanPath, description: "Scoped Description", authors: [{ name: "Author" }], date: "01/01/2024", @@ -220,7 +208,7 @@ describe("scoped content helpers", () => { ); const result = await Effect.runPromise( - getScopedContents("articles", importer, { + getScopedContents("articles", { basePath: "articles/politics", locale: "en", }) @@ -231,7 +219,10 @@ describe("scoped content helpers", () => { expect(result[0]?.url).toBe( "https://nakafa.com/en/articles/politics/test-article" ); - expect(importer).toHaveBeenCalledWith("politics/test-article", "en"); + expect(mockImportContentModule).toHaveBeenCalledWith( + "articles/politics/test-article", + "en" + ); }); it("omits scoped list entries that fail to load", async () => { @@ -240,14 +231,14 @@ describe("scoped content helpers", () => { "subject/high-school/10/math/geometry", ]); - const importer = vi.fn((relativePath: string) => { - if (relativePath === "high-school/10/math/geometry") { + mockImportContentModule.mockImplementation((cleanPath: string) => { + if (cleanPath === "subject/high-school/10/math/geometry") { return Promise.reject(new Error("broken module")); } return Promise.resolve({ metadata: { - title: relativePath, + title: cleanPath, description: "Scoped Description", authors: [{ name: "Author" }], date: "01/01/2024", @@ -257,7 +248,7 @@ describe("scoped content helpers", () => { }); const result = await Effect.runPromise( - getScopedContents("subject", importer, { + getScopedContents("subject", { locale: "en", }) ); @@ -272,21 +263,17 @@ describe("scoped content helpers", () => { "subject/high-school/10/math/algebra", ]); - const importer = vi.fn(() => - Promise.resolve({ - metadata: { - title: "Scoped Title", - description: "Scoped Description", - authors: [{ name: "Author" }], - date: "01/01/2024", - }, - default: () => "Scoped MDX", - }) - ); + mockImportContentModule.mockResolvedValue({ + metadata: { + title: "Scoped Title", + description: "Scoped Description", + authors: [{ name: "Author" }], + date: "01/01/2024", + }, + default: () => "Scoped MDX", + }); - const result = await Effect.runPromise( - getScopedContents("articles", importer) - ); + const result = await Effect.runPromise(getScopedContents("articles")); expect(result).toHaveLength(1); expect(result[0]?.slug).toBe("articles/politics/test-article"); @@ -295,12 +282,7 @@ describe("scoped content helpers", () => { it("fails with ModuleLoadError when the scoped importer throws", async () => { const failure = await Effect.runPromise( Effect.flip( - getScopedContent( - "articles", - () => Promise.reject(new Error("boom")), - "en", - "articles/politics/test-article" - ) + getScopedContent("articles", "en", "articles/politics/test-article") ) ); @@ -308,18 +290,14 @@ describe("scoped content helpers", () => { }); it("fails with MetadataParseError when the scoped MDX module metadata is invalid", async () => { + mockImportContentModule.mockResolvedValue({ + metadata: { title: "Broken" }, + default: () => "Broken MDX", + }); + const failure = await Effect.runPromise( Effect.flip( - getScopedContent( - "articles", - () => - Promise.resolve({ - metadata: { title: "Broken" }, - default: () => "Broken MDX", - }), - "en", - "articles/politics/test-article" - ) + getScopedContent("articles", "en", "articles/politics/test-article") ) ); @@ -327,17 +305,13 @@ describe("scoped content helpers", () => { }); it("fails with MetadataParseError when the scoped MDX module has no metadata export", async () => { + mockImportContentModule.mockResolvedValue({ + default: () => "Broken MDX", + }); + const failure = await Effect.runPromise( Effect.flip( - getScopedContent( - "articles", - () => - Promise.resolve({ - default: () => "Broken MDX", - }), - "en", - "articles/politics/test-article" - ) + getScopedContent("articles", "en", "articles/politics/test-article") ) ); @@ -345,23 +319,29 @@ describe("scoped content helpers", () => { }); it("parses scoped references successfully", async () => { + const importReferencesModule = vi.fn(() => + Promise.resolve({ + references: [ + { + title: "Scoped Reference", + authors: "Reference Author", + year: 2024, + }, + ], + }) + ); + const result = await Effect.runPromise( getScopedReferences( "articles", - () => - Promise.resolve({ - references: [ - { - title: "Scoped Reference", - authors: "Reference Author", - year: 2024, - }, - ], - }), + importReferencesModule, "articles/politics/test-article" ) ); + expect(importReferencesModule).toHaveBeenCalledWith( + "politics/test-article" + ); expect(result).toStrictEqual([ { title: "Scoped Reference", diff --git a/packages/contents/_lib/__tests__/static-params.test.ts b/packages/contents/_lib/__tests__/static-params.test.ts index ed082e5a4..ef91e0898 100644 --- a/packages/contents/_lib/__tests__/static-params.test.ts +++ b/packages/contents/_lib/__tests__/static-params.test.ts @@ -329,21 +329,6 @@ describe("generateSlugOnlyParams", () => { expect(slugPaths).toContain("en/exercises/math/set-1/2"); }); - it("should include OG variants when includeOGVariants is true", () => { - vi.mocked(getFolderChildNames).mockReturnValue(Effect.succeed(["subject"])); - vi.mocked(getNestedSlugs).mockReturnValue([]); - vi.mocked(getMDXSlugsForLocale).mockReturnValue(["subject"]); - - const result = generateSlugOnlyParams({ - includeOGVariants: true, - locales: ["en"], - }); - const slugPaths = result.map((r) => r.slug.join("/")); - - expect(slugPaths).toContain("en/image.png"); - expect(slugPaths).toContain("en/subject/image.png"); - }); - it("should handle all options together (llms.mdx use case)", () => { vi.mocked(getFolderChildNames).mockReturnValue(Effect.succeed(["subject"])); vi.mocked(getNestedSlugs).mockReturnValue([]); @@ -513,21 +498,6 @@ describe("generateLocaleParams", () => { expect(result[0].locale).toBe("en"); }); - it("should include OG variants when includeOGVariants is true", () => { - vi.mocked(getFolderChildNames).mockReturnValue(Effect.succeed(["subject"])); - vi.mocked(getNestedSlugs).mockReturnValue([]); - vi.mocked(getMDXSlugsForLocale).mockReturnValue(["subject"]); - - const result = generateLocaleParams({ - includeOGVariants: true, - locales: ["en"], - }); - const slugPaths = result.map((r) => r.slug.join("/")); - - expect(slugPaths).toContain("image.png"); - expect(slugPaths).toContain("subject/image.png"); - }); - it("should handle multiple locales", () => { vi.mocked(getFolderChildNames).mockReturnValue(Effect.succeed(["subject"])); vi.mocked(getNestedSlugs).mockReturnValue([]); @@ -618,26 +588,6 @@ describe("generateLocaleParams", () => { expect(slugPaths).not.toContain("subject/biology"); }); - it("should include nested paths with OG variants", () => { - vi.mocked(getFolderChildNames).mockReturnValue(Effect.succeed(["subject"])); - vi.mocked(getNestedSlugs).mockReturnValue([["math"]]); - vi.mocked(getMDXSlugsForLocale).mockReturnValue([ - "subject", - "subject/math", - ]); - - const result = generateLocaleParams({ - locales: ["en"], - includeOGVariants: true, - }); - const slugPaths = result.map((r) => r.slug.join("/")); - - expect(slugPaths).toContain("subject"); - expect(slugPaths).toContain("subject/image.png"); - expect(slugPaths).toContain("subject/math"); - expect(slugPaths).toContain("subject/math/image.png"); - }); - it("should handle error gracefully", () => { vi.mocked(getFolderChildNames).mockReturnValue( Effect.fail(new DirectoryReadError({ path: ".", cause: "error" })) diff --git a/packages/contents/_lib/articles/content.test.ts b/packages/contents/_lib/articles/content.test.ts index 6a414a5f8..de1572e89 100644 --- a/packages/contents/_lib/articles/content.test.ts +++ b/packages/contents/_lib/articles/content.test.ts @@ -31,7 +31,6 @@ describe("getArticleContent", () => { expect(mockGetScopedContent).toHaveBeenCalledWith( "articles", - expect.any(Function), "en", "articles/politics/test-article", { includeMDX: false } @@ -43,7 +42,6 @@ describe("getArticleContent", () => { expect(mockGetScopedContent).toHaveBeenCalledWith( "articles", - expect.any(Function), "en", "articles/politics/test-article", {} @@ -63,25 +61,17 @@ describe("getArticleContents", () => { locale: "en", }); - expect(mockGetScopedContents).toHaveBeenCalledWith( - "articles", - expect.any(Function), - { - basePath: "articles/politics", - includeMDX: false, - locale: "en", - } - ); + expect(mockGetScopedContents).toHaveBeenCalledWith("articles", { + basePath: "articles/politics", + includeMDX: false, + locale: "en", + }); }); it("delegates article list loading with default options", () => { getArticleContents(); - expect(mockGetScopedContents).toHaveBeenCalledWith( - "articles", - expect.any(Function), - {} - ); + expect(mockGetScopedContents).toHaveBeenCalledWith("articles", {}); }); }); diff --git a/packages/contents/_lib/articles/content.ts b/packages/contents/_lib/articles/content.ts index 2dcffb29d..d8547b613 100644 --- a/packages/contents/_lib/articles/content.ts +++ b/packages/contents/_lib/articles/content.ts @@ -13,15 +13,7 @@ export function getArticleContent( filePath: string, options: { includeMDX?: boolean } = {} ) { - return getScopedContent( - "articles", - /* istanbul ignore next: Vitest/Vite cannot execute nested variable dynamic imports here. */ - async (relativePath, contentLocale) => - await import(`../../articles/${relativePath}/${contentLocale}.mdx`), - locale, - filePath, - options - ); + return getScopedContent("articles", locale, filePath, options); } /** @@ -31,13 +23,7 @@ export function getArticleContent( export function getArticleContents( options: { basePath?: string; includeMDX?: boolean; locale?: Locale } = {} ) { - return getScopedContents( - "articles", - /* istanbul ignore next: Vitest/Vite cannot execute nested variable dynamic imports here. */ - async (relativePath, contentLocale) => - await import(`../../articles/${relativePath}/${contentLocale}.mdx`), - options - ); + return getScopedContents("articles", options); } /** diff --git a/packages/contents/_lib/content.ts b/packages/contents/_lib/content.ts index 2824ba642..b685a1b55 100644 --- a/packages/contents/_lib/content.ts +++ b/packages/contents/_lib/content.ts @@ -22,7 +22,6 @@ import type { } from "@repo/contents/_types/content"; import { cleanSlug } from "@repo/utilities/helper"; import { Effect, Either, Option } from "effect"; -import { createElement } from "react"; const contentsDir = resolveContentsDir(import.meta.url); @@ -65,7 +64,7 @@ function loadRenderableContentModule( return { metadata, - default: createElement(contentModule.default), + default: contentModule.default, }; }); } diff --git a/packages/contents/_lib/exercises.ts b/packages/contents/_lib/exercises.ts deleted file mode 100644 index cdb4d42af..000000000 --- a/packages/contents/_lib/exercises.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { promises as fsPromises } from "node:fs"; -import nodePath from "node:path"; -import { getMDXSlugsForLocale } from "@repo/contents/_lib/cache"; -import { getExerciseContent } from "@repo/contents/_lib/exercises/content"; -import { getFolderChildNames } from "@repo/contents/_lib/fs"; -import { resolveContentsDir } from "@repo/contents/_lib/root"; -import { - type ChoicesValidationError, - ExerciseLoadError, - type FileReadError, - type GitHubFetchError, - type InvalidPathError, - type MetadataParseError, - type ModuleLoadError, -} from "@repo/contents/_shared/error"; -import type { ContentMetadata, Locale } from "@repo/contents/_types/content"; -import { - type ExercisesChoices, - ExercisesChoicesSchema, -} from "@repo/contents/_types/exercises/choices"; -import type { Exercise } from "@repo/contents/_types/exercises/shared"; -import { cleanSlug } from "@repo/utilities/helper"; -import { Effect, Option } from "effect"; -import ky from "ky"; -import type React from "react"; - -const contentsDir = resolveContentsDir(import.meta.url); - -const CHOICES_REGEX = - /const\s+choices\s*(?::\s*ExercisesChoices\s*)?=\s*({[\s\S]*?});/; - -const NUMBER_REGEX = /^\d+$/; -const EXERCISE_CONTENT_SEGMENTS = new Set(["_question", "_answer"]); - -/** - * Loads one exercise content fragment, either as raw metadata or full MDX. - * - * @param locale - Locale used to resolve the exercise content file - * @param filePath - Exercise question or answer path relative to `packages/contents` - * @param includeMDX - Whether to return the compiled MDX element as well - * @returns Effect resolving to content metadata, raw source, and optional MDX - */ -function loadExerciseContent( - locale: Locale, - filePath: string, - includeMDX: boolean -): Effect.Effect< - { - default?: React.ReactElement; - metadata: ContentMetadata; - raw: string; - }, - | InvalidPathError - | FileReadError - | GitHubFetchError - | MetadataParseError - | ModuleLoadError -> { - return getExerciseContent(locale, filePath, { includeMDX }); -} - -/** - * Counts the number of exercises in a given path by scanning for numbered directories. - * This is a lightweight operation that does not load any MDX content. - * Evidence: Reuses getFolderChildNames from fs.ts for clean, maintainable code - * - * @param filePath - Path to the exercise set (e.g., "exercises/high-school/tka/mathematics/try-out/2026/set-1") - * @returns Effect that produces the count of exercises, or 0 if path doesn't exist - * - * @example - * ```ts - * const count = await Effect.runPromise( - * getExerciseCount("exercises/high-school/tka/mathematics/try-out/2026/set-1") - * ); - * // Returns: 40 - * ``` - */ -export function getExerciseCount(filePath: string): Effect.Effect { - return Effect.gen(function* () { - const cleanPath = cleanSlug(filePath); - - // Reuse existing getFolderChildNames - returns directory names as strings - const childNames = yield* getFolderChildNames(cleanPath).pipe( - Effect.orElse(() => Effect.succeed([])) - ); - - // Filter for numeric folder names (1, 2, 3, etc.) - const exerciseCount = childNames.filter((name) => - NUMBER_REGEX.test(name) - ).length; - - return exerciseCount; - }); -} - -/** - * Options for loading an exercise set from the contents package. - */ -export interface ExerciseContentOptions { - filePath: string; - includeMDX?: boolean; - locale: Locale; -} - -/** - * Extracts direct exercise numbers for a specific exercise set path. - * - * Exercise content is stored under numbered folders such as - * `set-1/1/_question` and `set-1/1/_answer`. This helper only accepts direct - * number segments followed by `_question` or `_answer`, so collection routes - * like `try-out/2026` are not misclassified as exercise pages. - * - * @param slugs - Cached MDX slugs for a locale - * @param filePath - Exercise set path relative to `packages/contents` - * @returns Sorted list of unique exercise numbers found under the set path - */ -export function getExerciseQuestionNumbers( - slugs: readonly string[], - filePath: string -): string[] { - const cleanPath = cleanSlug(filePath); - const exercisePathPrefix = cleanPath === "" ? "" : `${cleanPath}/`; - const questionNumbers = new Set(); - - for (const slug of slugs) { - if (!slug.startsWith(exercisePathPrefix)) { - continue; - } - - const remainingPath = slug.slice(exercisePathPrefix.length); - const pathParts = remainingPath.split("/"); - - if ( - pathParts.length >= 2 && - NUMBER_REGEX.test(pathParts[0]) && - EXERCISE_CONTENT_SEGMENTS.has(pathParts[1]) - ) { - questionNumbers.add(pathParts[0]); - } - } - - return Array.from(questionNumbers).sort( - (a: string, b: string) => Number.parseInt(a, 10) - Number.parseInt(b, 10) - ); -} - -/** - * Retrieves all exercises for a given path, handling _question and _answer subdirectories. - * Exercise sets are structured with numbered folders containing question/answer pairs. - * - * @param options - Exercise retrieval options - * @param options.includeMDX - Whether to load MDX components (default: true) - * @param options.locale - Target locale - * @param options.filePath - Base path to exercise set (e.g., "exercises/high-school/tka/mathematics/try-out/2026/set-1") - * @returns Effect that produces array of exercises sorted by number, or Option.none() if no exercises found - * - * @example - * ```ts - * const exercises = await Effect.runPromise( - * getExercisesContent({ - * locale: "en", - * filePath: "exercises/high-school/tka/mathematics/try-out/2026/set-1", - * includeMDX: true - * }) - * ); - * ``` - */ -export function getExercisesContent( - options: ExerciseContentOptions -): Effect.Effect { - return Effect.gen(function* () { - const { includeMDX = true, locale, filePath } = options; - const cleanPath = cleanSlug(filePath); - - const allSlugs = getMDXSlugsForLocale(locale); - - const sortedQuestionNumbers = getExerciseQuestionNumbers( - allSlugs, - cleanPath - ); - - if (sortedQuestionNumbers.length === 0) { - return []; - } - - const exercises = yield* Effect.all( - sortedQuestionNumbers.map((numberSegment: string) => - loadExercise(numberSegment, cleanPath, locale, includeMDX) - ) - ); - - return exercises - .filter(Option.isSome) - .map((option) => option.value) - .sort((a: Exercise, b: Exercise) => a.number - b.number); - }); -} - -/** - * Fetches and parses choices from a choices.ts file without dynamic imports. - * Reads from filesystem first, falls back to GitHub raw content. - */ -function getRawChoices( - choicesPath: string -): Effect.Effect { - const fullPath = nodePath.join(contentsDir, choicesPath); - - const readFromFile = Effect.tryPromise({ - try: () => fsPromises.readFile(fullPath, "utf8"), - catch: () => - new ExerciseLoadError({ - path: choicesPath, - reason: "Failed to read choices file", - }), - }); - - const fetchFromGitHub = Effect.tryPromise({ - try: () => - ky - .get( - `https://raw.githubusercontent.com/nakafaai/nakafa.com/refs/heads/main/packages/contents/${choicesPath}`, - { cache: "force-cache" } - ) - .text(), - catch: () => - new ExerciseLoadError({ - path: choicesPath, - reason: "Failed to fetch choices from GitHub", - }), - }); - - const getRawContent = Effect.catchAll(readFromFile, () => fetchFromGitHub); - - return Effect.map(getRawContent, (raw) => { - const match = raw.match(CHOICES_REGEX); - if (!match?.[1]) { - return null; - } - - try { - const choicesObject: unknown = new Function(`return ${match[1]}`)(); - const parsed = ExercisesChoicesSchema.safeParse(choicesObject); - return parsed.success ? parsed.data : null; - } catch { - return null; - } - }); -} - -/** - * Loads a single exercise entry from its numbered folder. - * - * @param exerciseNumberSegment - Folder name for the exercise within the set - * @param cleanPath - Normalized exercise-set path relative to `packages/contents` - * @param locale - Locale used to load the question and answer content - * @param includeMDX - Whether to include compiled MDX elements in the result - * @returns Effect that resolves to an exercise or `Option.none()` when incomplete - */ -function loadExercise( - exerciseNumberSegment: string, - cleanPath: string, - locale: Locale, - includeMDX: boolean -): Effect.Effect< - Option.Option, - ExerciseLoadError | ChoicesValidationError -> { - return Effect.gen(function* () { - const exerciseNumber = Number.parseInt(exerciseNumberSegment, 10); - - const questionPath = `${cleanPath}/${exerciseNumberSegment}/_question`; - const answerPath = `${cleanPath}/${exerciseNumberSegment}/_answer`; - const choicesPath = `${cleanPath}/${exerciseNumberSegment}/choices.ts`; - - const [questionContent, answerContent, choicesData] = yield* Effect.all( - [ - loadExerciseContent(locale, questionPath, includeMDX).pipe( - Effect.mapError( - () => - new ExerciseLoadError({ - path: questionPath, - reason: "Failed to load question", - }) - ) - ), - loadExerciseContent(locale, answerPath, includeMDX).pipe( - Effect.mapError( - () => - new ExerciseLoadError({ - path: answerPath, - reason: "Failed to load answer", - }) - ) - ), - getRawChoices(choicesPath).pipe( - Effect.mapError( - () => - new ExerciseLoadError({ - path: choicesPath, - reason: "Failed to load choices", - }) - ) - ), - ], - { concurrency: "unbounded" } - ); - - if (!(questionContent && answerContent && choicesData)) { - return Option.none(); - } - - return Option.some({ - number: exerciseNumber, - choices: choicesData, - question: { - metadata: questionContent.metadata, - default: - "default" in questionContent ? questionContent.default : undefined, - raw: questionContent.raw, - }, - answer: { - metadata: answerContent.metadata, - default: "default" in answerContent ? answerContent.default : undefined, - raw: answerContent.raw, - }, - }); - }); -} - -/** - * Retrieves a single exercise by its number from an exercise set. - * Convenience wrapper around getExercisesContent that finds a specific exercise. - * - * @param locale - Target locale - * @param filePath - Base path to exercise set - * @param exerciseNumber - The exercise number to retrieve - * @param includeMDX - Whether to load MDX components (default: true) - * @returns Effect that produces Option of exercise, Option.none() if not found - * - * @example - * ```ts - * const exercise = await Effect.runPromise( - * getExerciseByNumber("en", "exercises/high-school/tka/mathematics/try-out/2026/set-1", 5) - * ); - * ``` - */ -export function getExerciseByNumber( - locale: Locale, - filePath: string, - exerciseNumber: number, - includeMDX = true -): Effect.Effect< - Option.Option, - ExerciseLoadError | ChoicesValidationError -> { - return Effect.gen(function* () { - const cleanPath = cleanSlug(filePath); - const allSlugs = getMDXSlugsForLocale(locale); - const exerciseNumberSegment = getExerciseQuestionNumbers( - allSlugs, - cleanPath - ).find( - (numberSegment) => Number.parseInt(numberSegment, 10) === exerciseNumber - ); - - if (!exerciseNumberSegment) { - return Option.none(); - } - - return yield* loadExercise( - exerciseNumberSegment, - cleanPath, - locale, - includeMDX - ); - }); -} diff --git a/packages/contents/_lib/exercises/collection.ts b/packages/contents/_lib/exercises/collection.ts new file mode 100644 index 000000000..7931a8aa0 --- /dev/null +++ b/packages/contents/_lib/exercises/collection.ts @@ -0,0 +1,113 @@ +import { getMDXSlugsForLocale } from "@repo/contents/_lib/cache"; +import { getFolderChildNames } from "@repo/contents/_lib/fs"; +import type { Locale } from "@repo/contents/_types/content"; +import { cleanSlug } from "@repo/utilities/helper"; +import { Effect } from "effect"; + +const NUMBER_REGEX = /^\d+$/; +const EXERCISE_CONTENT_SEGMENTS = new Set(["_question", "_answer"]); + +/** + * Returns whether one path segment is a supported exercise content directory. + * + * @param value - Path segment to validate + * @returns True when the segment is `_question` or `_answer` + */ +function isExerciseContentSegment(value: string | undefined) { + return value !== undefined && EXERCISE_CONTENT_SEGMENTS.has(value); +} + +/** + * Counts the numbered exercise folders that exist under one exercise set path. + * + * @param filePath - Exercise set path relative to `packages/contents` + * @returns Effect that resolves to the number of exercise folders in the set + */ +export function getExerciseCount(filePath: string) { + return Effect.gen(function* () { + const cleanPath = cleanSlug(filePath); + const childNames = yield* getFolderChildNames(cleanPath).pipe( + Effect.orElse(() => Effect.succeed([])) + ); + + return childNames.filter((name) => NUMBER_REGEX.test(name)).length; + }); +} + +/** + * Extracts direct exercise numbers for one exercise set path. + * + * Exercise content is stored under numbered folders such as + * `set-1/1/_question` and `set-1/1/_answer`. This helper only accepts direct + * number segments followed by `_question` or `_answer`, so collection routes + * like `try-out/2026` are not misclassified as exercise pages. + * + * @param slugs - Cached MDX slugs for one locale + * @param filePath - Exercise set path relative to `packages/contents` + * @returns Sorted list of unique exercise numbers found under the set path + */ +export function getExerciseQuestionNumbers( + slugs: readonly string[], + filePath: string +) { + const cleanPath = cleanSlug(filePath); + const exercisePathPrefix = cleanPath === "" ? "" : `${cleanPath}/`; + const questionNumbers = new Set(); + + for (const slug of slugs) { + if (!slug.startsWith(exercisePathPrefix)) { + continue; + } + + const remainingPath = slug.slice(exercisePathPrefix.length); + const pathParts = remainingPath.split("/"); + + if ( + pathParts.length >= 2 && + NUMBER_REGEX.test(pathParts[0]) && + isExerciseContentSegment(pathParts[1]) + ) { + questionNumbers.add(pathParts[0]); + } + } + + return Array.from(questionNumbers).sort( + (a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10) + ); +} + +/** + * Extracts unique exercise-set paths for one locale from cached exercise slugs. + * + * Exercise content is stored as numbered folders containing `_question` or + * `_answer` entries. This helper strips the trailing number/content segments so + * callers can work at the set level without re-encoding path rules. + * + * @param locale - Locale whose cached exercise slugs should be scanned + * @returns Sorted list of unique exercise set paths + */ +export function getExerciseSetPaths(locale: Locale) { + const setPaths = new Set(); + + for (const slug of getMDXSlugsForLocale(locale)) { + const pathParts = cleanSlug(slug).split("/"); + + if (pathParts.length < 2) { + continue; + } + + const [numberSegment, contentSegment] = pathParts.slice(-2); + + if (!NUMBER_REGEX.test(numberSegment)) { + continue; + } + + if (!isExerciseContentSegment(contentSegment)) { + continue; + } + + setPaths.add(pathParts.slice(0, -2).join("/")); + } + + return [...setPaths].sort(); +} diff --git a/packages/contents/_lib/exercises/content.test.ts b/packages/contents/_lib/exercises/content.test.ts index 3e9202a13..75e2c2876 100644 --- a/packages/contents/_lib/exercises/content.test.ts +++ b/packages/contents/_lib/exercises/content.test.ts @@ -24,7 +24,6 @@ describe("getExerciseContent", () => { expect(mockGetScopedContent).toHaveBeenCalledWith( "exercises", - expect.any(Function), "en", "exercises/high-school/snbt/quantitative-knowledge/try-out/2026/set-1/1/_question", { includeMDX: false } @@ -39,7 +38,6 @@ describe("getExerciseContent", () => { expect(mockGetScopedContent).toHaveBeenCalledWith( "exercises", - expect.any(Function), "en", "exercises/high-school/snbt/quantitative-knowledge/try-out/2026/set-1/1/_question", {} diff --git a/packages/contents/_lib/exercises/content.ts b/packages/contents/_lib/exercises/content.ts index ad4b96586..9a8ed7ab7 100644 --- a/packages/contents/_lib/exercises/content.ts +++ b/packages/contents/_lib/exercises/content.ts @@ -9,13 +9,5 @@ export function getExerciseContent( filePath: string, options: { includeMDX?: boolean } = {} ) { - return getScopedContent( - "exercises", - /* istanbul ignore next: Vitest/Vite cannot execute nested variable dynamic imports here. */ - async (relativePath, contentLocale) => - await import(`../../exercises/${relativePath}/${contentLocale}.mdx`), - locale, - filePath, - options - ); + return getScopedContent("exercises", locale, filePath, options); } diff --git a/packages/contents/_lib/exercises/renderable.ts b/packages/contents/_lib/exercises/renderable.ts new file mode 100644 index 000000000..f79c8022b --- /dev/null +++ b/packages/contents/_lib/exercises/renderable.ts @@ -0,0 +1,98 @@ +import { getMDXSlugsForLocale } from "@repo/contents/_lib/cache"; +import { getExerciseQuestionNumbers } from "@repo/contents/_lib/exercises/collection"; +import { + loadExerciseEntry, + readExerciseChoices, + readExerciseContentData, +} from "@repo/contents/_lib/exercises/source"; +import type { Locale } from "@repo/contents/_types/content"; +import { cleanSlug } from "@repo/utilities/helper"; +import { Effect, Option } from "effect"; + +/** + * Loads one plain exercise row from raw MDX metadata and parsed choices. + * + * @param exerciseNumberSegment - Numbered folder name inside the exercise set + * @param cleanPath - Normalized exercise-set path relative to `packages/contents` + * @param locale - Locale used to resolve the exercise content files + * @returns Plain exercise row, or `null` when any required source is missing + */ +function loadRenderableExercise( + exerciseNumberSegment: string, + cleanPath: string, + locale: Locale +) { + return Effect.runPromise( + loadExerciseEntry(cleanPath, exerciseNumberSegment, { + loadQuestion: (questionPath) => + Effect.promise(() => readExerciseContentData(locale, questionPath)), + loadAnswer: (answerPath) => + Effect.promise(() => readExerciseContentData(locale, answerPath)), + loadChoices: (choicesPath) => + Effect.promise(() => + readExerciseChoices(choicesPath).catch(() => null) + ), + }).pipe( + Effect.map((exercise) => + Option.isSome(exercise) ? exercise.value : null + ) + ) + ); +} + +/** + * Loads the plain renderable exercise rows for one exercise set without + * importing compiled MDX modules. + * + * @param locale - Locale whose exercise set should be loaded + * @param filePath - Exercise-set path relative to `packages/contents` + * @returns Plain exercise rows ordered by exercise number + */ +export async function getRenderableExercisesContent( + locale: Locale, + filePath: string +) { + const cleanPath = cleanSlug(filePath); + const exerciseNumbers = getExerciseQuestionNumbers( + getMDXSlugsForLocale(locale), + cleanPath + ); + + if (exerciseNumbers.length === 0) { + return []; + } + + const exercises = await Promise.all( + exerciseNumbers.map((exerciseNumber) => + loadRenderableExercise(exerciseNumber, cleanPath, locale) + ) + ); + + return exercises.filter((exercise) => exercise !== null); +} + +/** + * Loads one plain renderable exercise row by its number within an exercise set. + * + * @param locale - Locale whose exercise set should be loaded + * @param filePath - Exercise-set path relative to `packages/contents` + * @param exerciseNumber - Exercise number to look up + * @returns Matching plain exercise row, or `null` when it is unavailable + */ +export function getRenderableExerciseByNumber( + locale: Locale, + filePath: string, + exerciseNumber: number +) { + const cleanPath = cleanSlug(filePath); + const numberSegment = getExerciseQuestionNumbers( + getMDXSlugsForLocale(locale), + cleanPath + ).find((segment) => Number.parseInt(segment, 10) === exerciseNumber); + + if (!numberSegment) { + return null; + } + + return loadRenderableExercise(numberSegment, cleanPath, locale); +} diff --git a/packages/contents/_lib/exercises/set.ts b/packages/contents/_lib/exercises/set.ts new file mode 100644 index 000000000..2cabbc0c2 --- /dev/null +++ b/packages/contents/_lib/exercises/set.ts @@ -0,0 +1,160 @@ +import { getMDXSlugsForLocale } from "@repo/contents/_lib/cache"; +import { getExerciseQuestionNumbers } from "@repo/contents/_lib/exercises/collection"; +import { getExerciseContent } from "@repo/contents/_lib/exercises/content"; +import { + loadExerciseEntry, + readExerciseChoices, +} from "@repo/contents/_lib/exercises/source"; +import { ExerciseLoadError } from "@repo/contents/_shared/error"; +import type { Locale } from "@repo/contents/_types/content"; +import { cleanSlug } from "@repo/utilities/helper"; +import { Effect, Option } from "effect"; + +/** + * Options for loading one exercise set from the contents package. + */ +export interface ExerciseContentOptions { + filePath: string; + includeMDX?: boolean; + locale: Locale; +} + +/** + * Loads one exercise row from its numbered directory inside a set. + * + * @param exerciseNumberSegment - Numbered folder name inside the exercise set + * @param cleanPath - Normalized exercise-set path relative to `packages/contents` + * @param locale - Locale used to load question and answer content + * @param includeMDX - Whether to include compiled MDX components in the result + * @returns Effect that resolves to an exercise or `Option.none()` when incomplete + */ +function loadExercise( + exerciseNumberSegment: string, + cleanPath: string, + locale: Locale, + includeMDX: boolean +) { + return loadExerciseEntry(cleanPath, exerciseNumberSegment, { + loadQuestion: (questionPath) => + getExerciseContent(locale, questionPath, { includeMDX }).pipe( + Effect.mapError( + () => + new ExerciseLoadError({ + path: questionPath, + reason: "Failed to load question", + }) + ) + ), + loadAnswer: (answerPath) => + getExerciseContent(locale, answerPath, { includeMDX }).pipe( + Effect.mapError( + () => + new ExerciseLoadError({ + path: answerPath, + reason: "Failed to load answer", + }) + ) + ), + loadChoices: (choicesPath) => + Effect.tryPromise({ + try: () => readExerciseChoices(choicesPath), + catch: () => + new ExerciseLoadError({ + path: choicesPath, + reason: "Failed to load choices", + }), + }), + }).pipe( + Effect.map((exercise) => { + if (Option.isNone(exercise)) { + return Option.none(); + } + + const { answer, choices, number, question } = exercise.value; + + return Option.some({ + answer: { + metadata: answer.metadata, + default: "default" in answer ? answer.default : undefined, + raw: answer.raw, + }, + choices, + number, + question: { + metadata: question.metadata, + default: "default" in question ? question.default : undefined, + raw: question.raw, + }, + }); + }) + ); +} + +/** + * Retrieves all exercises for one exercise set. + * + * @param options - Exercise-set retrieval options + * @returns Effect that resolves to exercises sorted by number + */ +export function getExercisesContent(options: ExerciseContentOptions) { + return Effect.gen(function* () { + const { includeMDX = true, locale, filePath } = options; + const cleanPath = cleanSlug(filePath); + const sortedQuestionNumbers = getExerciseQuestionNumbers( + getMDXSlugsForLocale(locale), + cleanPath + ); + + if (sortedQuestionNumbers.length === 0) { + return []; + } + + const exercises = yield* Effect.all( + sortedQuestionNumbers.map((numberSegment) => + loadExercise(numberSegment, cleanPath, locale, includeMDX) + ) + ); + + return exercises + .filter(Option.isSome) + .map((option) => option.value) + .sort((a, b) => a.number - b.number); + }); +} + +/** + * Retrieves one exercise by number from an exercise set. + * + * @param locale - Locale used to resolve the exercise set + * @param filePath - Exercise-set path relative to `packages/contents` + * @param exerciseNumber - Exercise number to look up + * @param includeMDX - Whether to include compiled MDX components + * @returns Effect that resolves to the matching exercise or `Option.none()` + */ +export function getExerciseByNumber( + locale: Locale, + filePath: string, + exerciseNumber: number, + includeMDX = true +) { + return Effect.gen(function* () { + const cleanPath = cleanSlug(filePath); + const exerciseNumberSegment = getExerciseQuestionNumbers( + getMDXSlugsForLocale(locale), + cleanPath + ).find( + (numberSegment) => Number.parseInt(numberSegment, 10) === exerciseNumber + ); + + if (!exerciseNumberSegment) { + return Option.none(); + } + + return yield* loadExercise( + exerciseNumberSegment, + cleanPath, + locale, + includeMDX + ); + }); +} diff --git a/packages/contents/_lib/exercises/source.ts b/packages/contents/_lib/exercises/source.ts new file mode 100644 index 000000000..79e0c28e6 --- /dev/null +++ b/packages/contents/_lib/exercises/source.ts @@ -0,0 +1,167 @@ +import { promises as fsPromises } from "node:fs"; +import nodePath from "node:path"; +import { extractMetadata } from "@repo/contents/_lib/metadata"; +import { resolveContentsDir } from "@repo/contents/_lib/root"; +import type { Locale } from "@repo/contents/_types/content"; +import { ExercisesChoicesSchema } from "@repo/contents/_types/exercises/choices"; +import { cleanSlug } from "@repo/utilities/helper"; +import { Effect, Either, Option } from "effect"; +import ky from "ky"; + +const contentsDir = resolveContentsDir(import.meta.url); +const CHOICES_REGEX = + /const\s+choices\s*(?::\s*ExercisesChoices\s*)?=\s*({[\s\S]*?});/; + +/** + * Builds the content and choices file paths for one exercise inside a set. + * + * @param cleanPath - Normalized exercise-set path relative to `packages/contents` + * @param exerciseNumberSegment - Numbered exercise directory inside the set + * @returns Root-relative question, answer, and choices paths for the exercise + */ +function getExerciseEntryPaths( + cleanPath: string, + exerciseNumberSegment: string +) { + return { + questionPath: `${cleanPath}/${exerciseNumberSegment}/_question`, + answerPath: `${cleanPath}/${exerciseNumberSegment}/_answer`, + choicesPath: `${cleanPath}/${exerciseNumberSegment}/choices.ts`, + }; +} + +/** + * Loads one exercise entry by composing question, answer, and choices loaders + * for the numbered folder inside an exercise set. + */ +export function loadExerciseEntry( + cleanPath: string, + exerciseNumberSegment: string, + options: { + loadAnswer: (filePath: string) => Effect.Effect; + loadChoices: (filePath: string) => Effect.Effect; + loadQuestion: (filePath: string) => Effect.Effect; + } +) { + return Effect.gen(function* () { + const exerciseNumber = Number.parseInt(exerciseNumberSegment, 10); + const { answerPath, choicesPath, questionPath } = getExerciseEntryPaths( + cleanPath, + exerciseNumberSegment + ); + + const [question, answer, choices] = yield* Effect.all( + [ + options.loadQuestion(questionPath), + options.loadAnswer(answerPath), + options.loadChoices(choicesPath), + ], + { concurrency: "unbounded" } + ); + + if (!(question && answer && choices)) { + return Option.none(); + } + + return Option.some({ + answer, + choices, + number: exerciseNumber, + question, + }); + }); +} + +/** + * Reads one text file from the contents workspace and falls back to the + * canonical GitHub raw copy when the local file is unavailable. + * + * @param filePath - Relative file path inside `packages/contents` + * @returns Raw file contents from disk or GitHub + */ +async function readContentsTextWithGitHubFallback(filePath: string) { + const fullPath = nodePath.join(contentsDir, filePath); + + if (!fullPath.startsWith(contentsDir)) { + throw new Error("Path escapes contents root"); + } + + return await fsPromises + .readFile(fullPath, "utf8") + .catch(() => + ky + .get( + `https://raw.githubusercontent.com/nakafaai/nakafa.com/refs/heads/main/packages/contents/${filePath}`, + { cache: "force-cache" } + ) + .text() + ); +} + +/** + * Reads and parses one `choices.ts` file for an exercise. + * + * Read failures reject so callers can decide whether missing source is fatal or + * should be treated as an incomplete exercise. Invalid or missing `choices` + * exports return `null` because the file exists but its payload is unusable. + * + * @param choicesPath - Root-relative path to the `choices.ts` file + * @returns Parsed choices payload, or `null` when the file is invalid + */ +export async function readExerciseChoices(choicesPath: string) { + const raw = await readContentsTextWithGitHubFallback(choicesPath); + const match = raw.match(CHOICES_REGEX); + + if (!match?.[1]) { + return null; + } + + const choicesObject = Either.try({ + try: () => new Function(`return ${match[1]}`)(), + catch: () => null, + }); + + if (Either.isLeft(choicesObject)) { + return null; + } + + const parsed = ExercisesChoicesSchema.safeParse(choicesObject.right); + return parsed.success ? parsed.data : null; +} + +/** + * Reads one exercise MDX file as raw text and parses its metadata without + * importing the compiled MDX module. + * + * @param locale - Locale used to resolve the localized MDX file + * @param filePath - Exercise question or answer path relative to `packages/contents` + * @returns Metadata plus raw MDX, or `null` when the file is missing or invalid + */ +export async function readExerciseContentData( + locale: Locale, + filePath: string +) { + const cleanPath = cleanSlug(filePath); + const fullPath = nodePath.join(contentsDir, `${cleanPath}/${locale}.mdx`); + + if (!fullPath.startsWith(contentsDir)) { + return null; + } + + const raw = await fsPromises.readFile(fullPath, "utf8").catch(() => null); + + if (raw === null) { + return null; + } + + const metadata = extractMetadata(raw); + + if (Option.isNone(metadata)) { + return null; + } + + return { + metadata: metadata.value, + raw, + }; +} diff --git a/packages/contents/_lib/module.ts b/packages/contents/_lib/module.ts index fcf9ba549..c168249e2 100644 --- a/packages/contents/_lib/module.ts +++ b/packages/contents/_lib/module.ts @@ -1,81 +1,22 @@ -import { - type ContentRoot, - ContentRootSchema, - type Locale, -} from "@repo/contents/_types/content"; +import type { Locale } from "@repo/contents/_types/content"; +import { cleanSlug } from "@repo/utilities/helper"; import type { ComponentType } from "react"; -interface ContentModule { +export interface ContentModule { default: ComponentType; metadata?: unknown; } -const contentModuleImporters: Record< - ContentRoot, - (relativePath: string, locale: Locale) => Promise -> = { - articles: async (relativePath, locale) => - await import(`../articles/${relativePath}/${locale}.mdx`), - exercises: async (relativePath, locale) => - await import(`../exercises/${relativePath}/${locale}.mdx`), - subject: async (relativePath, locale) => - await import(`../subject/${relativePath}/${locale}.mdx`), -}; - -/** - * Checks whether a slug root belongs to the supported content roots. - * - * @param root - First path segment from a normalized content slug - * @returns True when the root is one of the supported content roots - */ -function isContentRoot(root: string): root is ContentRoot { - return ContentRootSchema.safeParse(root).success; -} - -/** - * Splits a normalized content slug into its top-level content root and the - * remaining relative path. - * - * Restricting dynamic imports to the real content roots keeps Turbopack's - * import contexts narrower than a single catch-all template path. - * - * @param cleanPath - Content slug relative to `packages/contents` - * @returns Root/path pair for supported content roots, else null - */ -function getContentModuleTarget( - cleanPath: string -): { relativePath: string; root: ContentRoot } | null { - const [root, ...segments] = cleanPath.split("/"); - - if (segments.length === 0 || !isContentRoot(root)) { - return null; - } - - return { - root, - relativePath: segments.join("/"), - }; -} - /** * Dynamically imports a localized MDX content module. * - * Keep this import relative. Turbopack expands alias-based template imports too - * broadly here and can pull test-only files like `vitest.config.ts` into app - * builds. + * This uses the exact MDX dynamic-import pattern documented by Next.js so the + * bundler resolves the final content file path directly. * * @param cleanPath - Content slug relative to `packages/contents` * @param locale - Locale used to resolve the MDX file * @returns Imported MDX module namespace */ export function importContentModule(cleanPath: string, locale: Locale) { - const target = getContentModuleTarget(cleanPath); - - if (!target) { - throw new Error(`Unsupported content module path: ${cleanPath}`); - } - - const importer = contentModuleImporters[target.root]; - - return importer(target.relativePath, locale); + return import(`@repo/contents/${cleanSlug(cleanPath)}/${locale}.mdx`); } diff --git a/packages/contents/_lib/scoped.ts b/packages/contents/_lib/scoped.ts index 89dec19c4..0695360c5 100644 --- a/packages/contents/_lib/scoped.ts +++ b/packages/contents/_lib/scoped.ts @@ -2,6 +2,7 @@ import { promises as fsPromises } from "node:fs"; import path from "node:path"; import { getMDXSlugsForLocale } from "@repo/contents/_lib/cache"; import { extractMetadata } from "@repo/contents/_lib/metadata"; +import { importContentModule } from "@repo/contents/_lib/module"; import { resolveContentsDir } from "@repo/contents/_lib/root"; import { FileReadError, @@ -23,29 +24,21 @@ import { import { cleanSlug } from "@repo/utilities/helper"; import { Effect, Option } from "effect"; import ky from "ky"; -import { type ComponentType, createElement } from "react"; const contentsDir = resolveContentsDir(import.meta.url); -interface ScopedContentModule { - default: ComponentType; - metadata?: unknown; -} - interface ScopedReferencesModule { references?: unknown; } -interface ScopedContentTarget { +interface ScopedPathTarget { cleanPath: string; - contentPath: string; relativePath: string; } -type ScopedContentImporter = ( - relativePath: string, - locale: Locale -) => Promise; +interface ScopedContentTarget extends ScopedPathTarget { + contentPath: string; +} type ScopedReferencesImporter = ( relativePath: string @@ -115,14 +108,15 @@ export function getRawContent( } /** - * Normalizes one content path for a specific root and locale so root-scoped - * loaders can share the same path validation rules. + * Normalizes one content path for a specific top-level root. + * + * This is the shared root-membership guard for both localized MDX loaders and + * locale-agnostic siblings like article `ref.ts` modules. */ -function getScopedContentTarget( +function getScopedPathTarget( root: ContentRoot, - filePath: string, - locale: Locale -): Effect.Effect { + filePath: string +): Effect.Effect { const cleanPath = cleanSlug(filePath); const rootPrefix = `${root}/`; @@ -139,11 +133,31 @@ function getScopedContentTarget( return Effect.succeed({ cleanPath, - contentPath: `${cleanPath}/${locale}.mdx`, relativePath, }); } +/** + * Builds the localized MDX target for one root-scoped content entry. + * + * @param root - Top-level content root that owns the path + * @param filePath - Content slug relative to `packages/contents` + * @param locale - Locale used to resolve the MDX file + * @returns Root-validated content target including the localized MDX path + */ +function getScopedContentTarget( + root: ContentRoot, + filePath: string, + locale: Locale +): Effect.Effect { + return getScopedPathTarget(root, filePath).pipe( + Effect.map((target) => ({ + ...target, + contentPath: `${target.cleanPath}/${locale}.mdx`, + })) + ); +} + /** * Validates the `metadata` export from one dynamically imported MDX module. * @@ -213,7 +227,6 @@ export function parseReferences( */ export function getScopedContent( root: ContentRoot, - importContentModule: ScopedContentImporter, locale: Locale, filePath: string, options: { includeMDX?: boolean } = {} @@ -235,7 +248,7 @@ export function getScopedContent( [ getRawContent(target.contentPath), Effect.tryPromise({ - try: () => importContentModule(target.relativePath, locale), + try: () => importContentModule(target.cleanPath, locale), catch: (error: unknown) => new ModuleLoadError({ path: `@repo/contents/${target.contentPath}`, @@ -253,7 +266,7 @@ export function getScopedContent( return { metadata, - default: createElement(contentModule.default), + default: contentModule.default, raw, }; } @@ -283,7 +296,6 @@ export function getScopedContent( */ export function getScopedContents( root: ContentRoot, - importContentModule: ScopedContentImporter, options: { basePath?: string; includeMDX?: boolean; @@ -302,7 +314,7 @@ export function getScopedContents( function processSlug(slug: string, contentLocale: Locale) { return Effect.gen(function* () { const content = yield* Effect.either( - getScopedContent(root, importContentModule, contentLocale, slug, { + getScopedContent(root, contentLocale, slug, { includeMDX, }) ); @@ -333,6 +345,10 @@ export function getScopedContents( /** * Loads references from one fixed top-level root and gracefully falls back to an * empty list when the references module is missing or invalid. + * + * Article references live in one shared `ref.ts` file per article directory, so + * this loader only needs the root-relative directory path and does not depend on + * locale-specific MDX resolution. */ export function getScopedReferences( root: Extract, @@ -340,7 +356,7 @@ export function getScopedReferences( filePath: string ): Effect.Effect { return Effect.gen(function* () { - const target = yield* getScopedContentTarget(root, filePath, "en"); + const target = yield* getScopedPathTarget(root, filePath); const referencesModule = yield* Effect.tryPromise({ try: () => importReferencesModule(target.relativePath), diff --git a/packages/contents/_lib/static-params.ts b/packages/contents/_lib/static-params.ts index 7ac499f66..444421d56 100644 --- a/packages/contents/_lib/static-params.ts +++ b/packages/contents/_lib/static-params.ts @@ -33,13 +33,10 @@ interface ContentPathsConfig extends BaseConfig { interface SlugOnlyConfig extends BaseConfig { includeExerciseNumbers?: boolean; includeExerciseSets?: boolean; - includeOGVariants?: boolean; includeQuran?: boolean; } -interface LocaleParamsConfig extends BaseConfig { - includeOGVariants?: boolean; -} +interface LocaleParamsConfig extends BaseConfig {} /** * Extracts unique exercise set paths from MDX cache entries. @@ -228,12 +225,6 @@ export function generateContentParams( * }); * } * - * // For /og route (locale in slug with image.png variants) - * export function generateStaticParams() { - * return generateSlugOnlyParams({ - * includeOGVariants: true, - * }); - * } * ``` */ export function generateSlugOnlyParams( @@ -244,7 +235,6 @@ export function generateSlugOnlyParams( includeQuran = false, includeExerciseSets = false, includeExerciseNumbers = false, - includeOGVariants = false, } = config; const contentPathCandidates = getContentPathCandidates(); @@ -252,16 +242,9 @@ export function generateSlugOnlyParams( const addPath = (locale: Locale, slugParts: string[]) => { result.push({ slug: [locale, ...slugParts] }); - if (includeOGVariants && slugParts.length > 0) { - result.push({ slug: [locale, ...slugParts, "image.png"] }); - } }; for (const locale of locales) { - if (includeOGVariants) { - result.push({ slug: [locale, "image.png"] }); - } - const slugs = getMDXSlugsForLocale(locale); const localeCache = new Set(slugs); @@ -303,33 +286,20 @@ export function generateSlugOnlyParams( * * @example * ```ts - * // For /[locale]/og route - * export function generateStaticParams() { - * return generateLocaleParams({ - * includeOGVariants: true, - * }); - * } * ``` */ export function generateLocaleParams( config: LocaleParamsConfig = {} ): StaticParamsWithLocale[] { - const { locales = routing.locales, includeOGVariants = false } = config; + const { locales = routing.locales } = config; const contentPathCandidates = getContentPathCandidates(); const result: StaticParamsWithLocale[] = []; const addPath = (locale: Locale, slugParts: string[]) => { result.push({ locale, slug: slugParts }); - if (includeOGVariants && slugParts.length > 0) { - result.push({ locale, slug: [...slugParts, "image.png"] }); - } }; for (const locale of locales) { - if (includeOGVariants) { - result.push({ locale, slug: ["image.png"] }); - } - const slugs = getMDXSlugsForLocale(locale); const localeCache = new Set(slugs); diff --git a/packages/contents/_lib/subject/content.test.ts b/packages/contents/_lib/subject/content.test.ts index cb30681be..29f6b8149 100644 --- a/packages/contents/_lib/subject/content.test.ts +++ b/packages/contents/_lib/subject/content.test.ts @@ -29,7 +29,6 @@ describe("getSubjectContent", () => { expect(mockGetScopedContent).toHaveBeenCalledWith( "subject", - expect.any(Function), "en", "subject/high-school/10/mathematics/algebra/basic-concept", { includeMDX: false } @@ -44,7 +43,6 @@ describe("getSubjectContent", () => { expect(mockGetScopedContent).toHaveBeenCalledWith( "subject", - expect.any(Function), "en", "subject/high-school/10/mathematics/algebra/basic-concept", {} @@ -64,24 +62,16 @@ describe("getSubjectContents", () => { locale: "en", }); - expect(mockGetScopedContents).toHaveBeenCalledWith( - "subject", - expect.any(Function), - { - basePath: "subject/high-school", - includeMDX: false, - locale: "en", - } - ); + expect(mockGetScopedContents).toHaveBeenCalledWith("subject", { + basePath: "subject/high-school", + includeMDX: false, + locale: "en", + }); }); it("delegates subject list loading with default options", () => { getSubjectContents(); - expect(mockGetScopedContents).toHaveBeenCalledWith( - "subject", - expect.any(Function), - {} - ); + expect(mockGetScopedContents).toHaveBeenCalledWith("subject", {}); }); }); diff --git a/packages/contents/_lib/subject/content.ts b/packages/contents/_lib/subject/content.ts index 6e7dee253..483d6ff42 100644 --- a/packages/contents/_lib/subject/content.ts +++ b/packages/contents/_lib/subject/content.ts @@ -12,15 +12,7 @@ export function getSubjectContent( filePath: string, options: { includeMDX?: boolean } = {} ) { - return getScopedContent( - "subject", - /* istanbul ignore next: Vitest/Vite cannot execute nested variable dynamic imports here. */ - async (relativePath, contentLocale) => - await import(`../../subject/${relativePath}/${contentLocale}.mdx`), - locale, - filePath, - options - ); + return getScopedContent("subject", locale, filePath, options); } /** @@ -30,11 +22,5 @@ export function getSubjectContent( export function getSubjectContents( options: { basePath?: string; includeMDX?: boolean; locale?: Locale } = {} ) { - return getScopedContents( - "subject", - /* istanbul ignore next: Vitest/Vite cannot execute nested variable dynamic imports here. */ - async (relativePath, contentLocale) => - await import(`../../subject/${relativePath}/${contentLocale}.mdx`), - options - ); + return getScopedContents("subject", options); } diff --git a/packages/contents/_shared/error.ts b/packages/contents/_shared/error.ts index e12067e66..69e9dd11c 100644 --- a/packages/contents/_shared/error.ts +++ b/packages/contents/_shared/error.ts @@ -35,10 +35,6 @@ export class ExerciseLoadError extends Data.TaggedError("ExerciseLoadError")<{ readonly reason: string; }> {} -export class ChoicesValidationError extends Data.TaggedError( - "ChoicesValidationError" -)<{ readonly path: string; readonly errors: unknown }> {} - export class SurahNotFoundError extends Data.TaggedError("SurahNotFoundError")<{ readonly surahNumber: number; }> {} diff --git a/packages/contents/_types/content.ts b/packages/contents/_types/content.ts index b60317ffd..3bf74d413 100644 --- a/packages/contents/_types/content.ts +++ b/packages/contents/_types/content.ts @@ -72,7 +72,7 @@ export const ContentSchema = z.object({ export type Content = z.infer; export type ContentWithMDX = Omit & { - default?: React.ReactElement; + default?: React.ComponentType; }; /** @@ -86,5 +86,5 @@ export type ContentWithMDX = Omit & { export type RenderableContent = Omit; export type ContentListWithMDX = Content & { - default?: React.ReactElement; + default?: React.ComponentType; }; diff --git a/packages/contents/exercises/high-school/snbt/quantitative-knowledge/try-out/2026/set-10/8/graph.tsx b/packages/contents/exercises/high-school/snbt/quantitative-knowledge/try-out/2026/set-10/8/graph.tsx index de09a4bcb..8a6e50325 100644 --- a/packages/contents/exercises/high-school/snbt/quantitative-knowledge/try-out/2026/set-10/8/graph.tsx +++ b/packages/contents/exercises/high-school/snbt/quantitative-knowledge/try-out/2026/set-10/8/graph.tsx @@ -1,5 +1,3 @@ -"use client"; - import { NumberLine } from "@repo/design-system/components/contents/number-line"; import { InlineMath } from "@repo/design-system/components/markdown/math"; import type { ComponentProps } from "react"; diff --git a/packages/contents/package.json b/packages/contents/package.json index c61708e48..f100675d0 100644 --- a/packages/contents/package.json +++ b/packages/contents/package.json @@ -19,7 +19,7 @@ "effect": "^3.21.0", "ky": "^2.0.0", "motion": "^12.38.0", - "next-intl": "^4.9.0", + "next-intl": "^4.9.1", "react": "19.2.5", "react-aria-components": "^1.16.0", "recharts": "^2.15.4", @@ -27,7 +27,7 @@ }, "devDependencies": { "@repo/typescript-config": "workspace:*", - "@types/node": "25.5.2", + "@types/node": "25.6.0", "@types/react": "^19.2.14", "@vitest/coverage-istanbul": "^4.1.4", "vitest": "^4.1.4" diff --git a/packages/contents/subject/high-school/10/mathematics/linear-equation-inequality/system-linear-inequality/en.mdx b/packages/contents/subject/high-school/10/mathematics/linear-equation-inequality/system-linear-inequality/en.mdx index 91469dbed..28c9fb7ff 100644 --- a/packages/contents/subject/high-school/10/mathematics/linear-equation-inequality/system-linear-inequality/en.mdx +++ b/packages/contents/subject/high-school/10/mathematics/linear-equation-inequality/system-linear-inequality/en.mdx @@ -1,5 +1,4 @@ import { Inequality } from "@repo/design-system/components/contents/inequality"; -import { condition1, condition2 } from "./inequality-functions"; import { getColor } from "@repo/design-system/lib/color"; export const metadata = { @@ -176,7 +175,6 @@ The purple region (the intersection of the blue and red areas) is the solution t { is2D: true, boundaryLine2D: [1, 1, -10], - condition: condition1, xRange: [-15, 15], yRange: [-15, 15], zRange: [-0.05, 0.05], @@ -190,7 +188,6 @@ The purple region (the intersection of the blue and red areas) is the solution t { is2D: true, boundaryLine2D: [-15, -9, 120], - condition: condition2, xRange: [-15, 15], yRange: [-15, 15], zRange: [-0.05, 0.05], diff --git a/packages/contents/subject/high-school/10/mathematics/linear-equation-inequality/system-linear-inequality/id.mdx b/packages/contents/subject/high-school/10/mathematics/linear-equation-inequality/system-linear-inequality/id.mdx index 690ed8493..452830e4e 100644 --- a/packages/contents/subject/high-school/10/mathematics/linear-equation-inequality/system-linear-inequality/id.mdx +++ b/packages/contents/subject/high-school/10/mathematics/linear-equation-inequality/system-linear-inequality/id.mdx @@ -1,5 +1,4 @@ import { Inequality } from "@repo/design-system/components/contents/inequality"; -import { condition1, condition2 } from "./inequality-functions"; import { getColor } from "@repo/design-system/lib/color"; export const metadata = { @@ -175,7 +174,6 @@ Daerah yang berwarna ungu (perpotongan area biru dan merah) adalah solusi dari s { is2D: true, boundaryLine2D: [1, 1, -10], - condition: condition1, xRange: [-15, 15], yRange: [-15, 15], zRange: [-0.05, 0.05], @@ -189,7 +187,6 @@ Daerah yang berwarna ungu (perpotongan area biru dan merah) adalah solusi dari s { is2D: true, boundaryLine2D: [-15, -9, 120], - condition: condition2, xRange: [-15, 15], yRange: [-15, 15], zRange: [-0.05, 0.05], diff --git a/packages/contents/subject/high-school/10/mathematics/linear-equation-inequality/system-linear-inequality/inequality-functions.ts b/packages/contents/subject/high-school/10/mathematics/linear-equation-inequality/system-linear-inequality/inequality-functions.ts deleted file mode 100644 index c864d6316..000000000 --- a/packages/contents/subject/high-school/10/mathematics/linear-equation-inequality/system-linear-inequality/inequality-functions.ts +++ /dev/null @@ -1,15 +0,0 @@ -"use server"; - -const COEFFICIENT_X = 15; -const COEFFICIENT_Y = 9; -const CONSTANT = 120; - -export async function condition1(x: number, y: number): Promise { - await Promise.resolve(); - return x + y <= 10; -} - -export async function condition2(x: number, y: number): Promise { - await Promise.resolve(); - return COEFFICIENT_X * x + COEFFICIENT_Y * y >= CONSTANT; -} diff --git a/packages/contents/subject/high-school/11/mathematics/function-composition-inverse-function/inverse-function/illustration.tsx b/packages/contents/subject/high-school/11/mathematics/function-composition-inverse-function/inverse-function/illustration.tsx index b4f7707e7..5c3ed37e0 100644 --- a/packages/contents/subject/high-school/11/mathematics/function-composition-inverse-function/inverse-function/illustration.tsx +++ b/packages/contents/subject/high-school/11/mathematics/function-composition-inverse-function/inverse-function/illustration.tsx @@ -1,5 +1,3 @@ -"use client"; - import { ArrowDown02Icon, ArrowRight02Icon } from "@hugeicons/core-free-icons"; import { Button } from "@repo/design-system/components/ui/button"; import { diff --git a/packages/design-system/components/contents/inequality.tsx b/packages/design-system/components/contents/inequality.tsx index b332af9b7..233245d21 100644 --- a/packages/design-system/components/contents/inequality.tsx +++ b/packages/design-system/components/contents/inequality.tsx @@ -14,7 +14,10 @@ interface Props { description: ReactNode; title: ReactNode; } - +/** + * Renders one card-wrapped inequality visualization with a shared coordinate + * system shell. + */ export function Inequality({ title, description, data }: Props) { return ( diff --git a/packages/design-system/components/contents/line-equation.tsx b/packages/design-system/components/contents/line-equation.tsx index 475a7dbda..c2da97677 100644 --- a/packages/design-system/components/contents/line-equation.tsx +++ b/packages/design-system/components/contents/line-equation.tsx @@ -21,6 +21,9 @@ interface Props { title: ReactNode; } +/** + * Renders one interactive line-equation card with the shared coordinate system. + */ export function LineEquation({ title, description, diff --git a/packages/design-system/components/contents/vector-3d.tsx b/packages/design-system/components/contents/vector-3d.tsx index ac9aa883b..93d46e4e4 100644 --- a/packages/design-system/components/contents/vector-3d.tsx +++ b/packages/design-system/components/contents/vector-3d.tsx @@ -20,6 +20,9 @@ interface Props { vectors: ComponentProps[]; } +/** + * Renders one interactive 3D vector card with the shared coordinate system. + */ export function Vector3d({ title, description, diff --git a/packages/design-system/components/markdown/math.tsx b/packages/design-system/components/markdown/math.tsx index 585267fcb..5301bcc1b 100644 --- a/packages/design-system/components/markdown/math.tsx +++ b/packages/design-system/components/markdown/math.tsx @@ -2,13 +2,17 @@ import { ScrollArea, ScrollBar, } from "@repo/design-system/components/ui/scroll-area"; +import { cn } from "@repo/design-system/lib/utils"; import type { HTMLAttributes } from "react"; import { BlockMath as BlockMathReactKatex, InlineMath as InlineMathReactKatex, type MathComponentProps, } from "react-katex"; -import { cn } from "../../lib/utils"; + +/** + * Renders one KaTeX block without the surrounding card shell. + */ export function BlockMathKatex(props: MathComponentProps) { return ( @@ -19,6 +23,9 @@ export function BlockMathKatex(props: MathComponentProps) { ); } +/** + * Groups consecutive math blocks with the shared vertical spacing. + */ export function MathContainer({ className, ...props @@ -28,6 +35,10 @@ export function MathContainer({ ); } +/** + * Renders one block-math card with native horizontal scrolling for wide + * formulas. + */ export function BlockMath({ className, ...props @@ -39,7 +50,7 @@ export function BlockMath({ className )} > - +