Skip to content
Merged
21 changes: 21 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Nakafa Context

This glossary records stable domain terms used by Nakafa code and PR review. It is not an implementation plan.

## Learning Engagement

- **Canonical asset**: A material, question, article, or Quran asset owned by the content system. Other product surfaces group or navigate over references to these assets.
- **Learning context**: The verified page, placement, and tool policy facts available for one user interaction.
- **NinaContextPack**: The immutable learning context snapshot built before one Nina turn and stored on chat messages for replay.
- **Continue Learning**: A signed-in user read model ranked from recent learning interactions. It must not be inferred for anonymous users.
- **Popularity**: Aggregate learning interest derived from view events and durable counters. Product reads use bounded read models, not raw event scans.
- **Lifetime counter**: A durable popularity count that continues after raw audit events expire.
- **Integrity Module**: Permanent operational code that proves raw event coverage, checkpoint progress, lifetime counter inclusion, and rank-index consistency.

## Nina

- **NinaHarness**: The package-owned Effect service that owns AI SDK stream response composition, ToolLoopAgent orchestration, tool policy, repair, and writer callbacks.
- **Capability**: A Nina tool Module with schema, permission policy, execution, result normalization, and tests.
- **Capability policy**: The per-turn decision that returns Allowed, Denied, or NeedsConfirmation for a capability.
- **Pinned context**: The latest stored NinaContextPack reused when a continued chat is opened away from a verified learning asset.
- **Page fetch**: The one permitted current-page Nakafa content read for a verified learning page.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { getMaterialIcon } from "@repo/contents/_lib/curriculum/material";
import {
isMaterialContentRoute,
isMaterialLessonRoute,
readMaterialPagination,
readParentMaterialRoute,
toLocalizedContentHref,
} from "@repo/contents/_types/route/content";
import { readStaticPublicContentRoutes } from "@repo/contents/_types/route/content/static";
import { readStaticPublicLearningIndex } from "@repo/contents/_types/route/learning/static";
Expand Down Expand Up @@ -139,6 +141,33 @@ export function readMaterialHeaderLink(
});
}

/**
* Builds material sibling pagination while preserving a validated source context.
*
* Canonical visits and stale context hints use plain material URLs. Contextual
* visits keep the same source card identity on previous/next lesson links only
* when the current page and target sibling both validate against the index.
*/
export function readMaterialPagePagination(
route: PublicContentRoute,
context: MaterialContextIdentity | undefined
) {
const index = readStaticPublicLearningIndex();

if (!(context && index.resolveMaterialHeaderLink({ context, route }))) {
return readMaterialPagination(route, readMaterialRoutes());
}

return readMaterialPagination(route, readMaterialRoutes(), {
toHref: (targetRoute) =>
index.toContextualMaterialHref({
context,
href: toLocalizedContentHref(targetRoute),
route: targetRoute,
}),
});
}

/**
* Selects the subject-specific icon from one projected material source path.
*
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { LearningContextInput } from "@repo/backend/convex/contents/context";
import { getHeadings } from "@repo/contents/_lib/toc";
import { formatContentDateISO } from "@repo/contents/_shared/date";
import {
isMaterialLessonRoute,
readMaterialPagination,
toLocalizedContentHref,
} from "@repo/contents/_types/route/content";
import type { MaterialContextIdentity } from "@repo/contents/_types/route/material/reference";
import type { PublicContentRoute } from "@repo/contents/_types/route/schema";
import { ArticleJsonLd } from "@repo/seo/json-ld/article";
import { BreadcrumbJsonLd } from "@repo/seo/json-ld/breadcrumb";
Expand All @@ -19,6 +20,7 @@ import {
getProjectedMaterialIcon,
listMaterialStaticParams,
readMaterialHeaderLink,
readMaterialPagePagination,
readMaterialRoutes,
requireParentMaterialRoute,
resolveMaterialRoute,
Expand All @@ -33,8 +35,10 @@ import { LayoutMaterialContent } from "@/components/shared/material/content";
import { LayoutMaterial } from "@/components/shared/material/layout";
import { LayoutMaterialToc } from "@/components/shared/material/toc";
import { PaginationContent } from "@/components/shared/pagination-content";
import { ContentViewTracker } from "@/components/tracking/tracker";
import { importContentModuleOrNull } from "@/lib/content/module";
import { fetchRuntimeCurriculumPage } from "@/lib/content/runtime/pages";
import { getRuntimeContentViewId } from "@/lib/content/views";
import { readMaterialContextQuery } from "@/lib/routing/material/query";
import { getGithubUrl } from "@/lib/utils/github";
import { getOgUrl, getSocialMetadata } from "@/lib/utils/metadata";
Expand Down Expand Up @@ -100,7 +104,7 @@ export async function generateMetadata({
/**
* Renders the canonical material lesson page.
*
* Topic rows are grouping data for curriculum card pages. They intentionally do
* Topic rows are grouping data for curriculum card pages. They intentionally
* do not render public pages, so the learner opens concrete material content
* directly from a collapsible card.
*/
Expand Down Expand Up @@ -139,31 +143,47 @@ export default async function Page({

const Content = content.default;
const parentRoute = requireParentMaterialRoute(route);
const materialContext = readMaterialContextQuery(query ?? {});
const trackerContext: LearningContextInput | undefined = materialContext
? {
mode: "placement",
nodeKey: materialContext.nodeKey,
programKey: materialContext.programKey,
}
: undefined;
const contentId = await getRuntimeContentViewId({
locale,
route: route.publicPath,
});

return (
<MaterialLessonPage
content={runtimeLesson}
footer={<DeferredComments slug={route.sourcePath} />}
headerLink={readMaterialHeaderLink(
route,
readMaterialContextQuery(query ?? {})
)}
<ContentViewTracker
contentId={contentId}
context={trackerContext}
locale={locale}
parentTitle={parentRoute.title}
route={route}
toolbar={
<DeferredAiSheetOpen
audio={{
contentType: "material",
locale,
slug: route.sourcePath,
}}
contextTitle={runtimeLesson.metadata.title}
/>
}
>
<Content />
</MaterialLessonPage>
<MaterialLessonPage
content={runtimeLesson}
footer={<DeferredComments slug={route.sourcePath} />}
headerLink={readMaterialHeaderLink(route, materialContext)}
locale={locale}
materialContext={materialContext}
parentTitle={parentRoute.title}
route={route}
toolbar={
<DeferredAiSheetOpen
audio={{
contentType: "material",
locale,
slug: route.sourcePath,
}}
contextTitle={runtimeLesson.metadata.title}
/>
}
>
<Content />
</MaterialLessonPage>
</ContentViewTracker>
);
}

Expand All @@ -179,6 +199,7 @@ async function MaterialLessonPage({
footer,
headerLink,
locale,
materialContext,
parentTitle,
route,
toolbar,
Expand All @@ -191,6 +212,7 @@ async function MaterialLessonPage({
label: string;
};
locale: Locale;
materialContext: MaterialContextIdentity | undefined;
parentTitle: string;
route: PublicContentRoute;
toolbar: ReactNode;
Expand All @@ -200,7 +222,7 @@ async function MaterialLessonPage({
const raw = content.body;
const headings = getHeadings(raw);
const metadata = content.metadata;
const pagination = readMaterialPagination(route, readMaterialRoutes());
const pagination = readMaterialPagePagination(route, materialContext);
const publishedAt = Option.getOrElse(
formatContentDateISO(metadata.date),
() => metadata.date
Expand Down
Loading
Loading