DocsDeveloper

Lesson Content Blocks

The structured block schema that powers lesson bodies on both web and mobile

Phase 7 replaced the old free-form TipTap HTML blob in Lesson.content with a structured block array stored in Lesson.contentBlocks. This doc explains the shape, the renderer, the editor, and how to add new block types.

TL;DR

  • Every lesson body is an array of discriminated-union blocks: { type: "heading" | "paragraph" | ..., ...fields }
  • The schema lives at src/types/lesson-blocks.ts, validated by a Zod discriminatedUnion
  • The renderer lives at src/components/lesson-blocks/LessonRenderer.tsx and dispatches by block.type
  • The authoring UI lives at src/components/lesson-blocks/BlockEditor.tsx with one editor per block type
  • The legacy Lesson.content HTML path is still wired in the student player — older lessons render via dangerouslySetInnerHTML, new lessons render via LessonRenderer
  • An instructor-facing "Convert to blocks" button migrates an old lesson's HTML to a block array via src/lib/lesson-blocks/migrate.ts

Why blocks instead of TipTap JSON

TipTap's output is deeply-nested ProseMirror JSON tied to the web editor's internals. Rendering it on mobile would require a Dart port of ProseMirror — months of work and always slightly wrong.

Blocks are flat, typed, and trivially renderable on any platform:

  • Web renderer maps block.type to a React component
  • Flutter renderer (when we get there) maps block.type to a Dart widget
  • Both platforms walk the same array with the same semantics

The schema

export type LessonBlock =
  | HeadingBlock
  | ParagraphBlock
  | ImageBlock
  | VideoBlock
  | CalloutBlock
  | DividerBlock
  | EmbedBlock
  | ListBlock
  | QuoteBlock
  | CodeBlock;

export interface LessonContentBlocks {
  version: 1;
  blocks: LessonBlock[]; // capped at 500
}

Each block variant has a type discriminator and optional id (required only for interactive blocks that track state — those ship in Phase 8). All variants are defined in src/types/lesson-blocks.ts with corresponding Zod schemas.

Inline formatting inside paragraphs

Paragraph, callout, quote, and list-item content uses a structured InlineSpan[] format instead of HTML or ProseMirror:

export interface InlineSpan {
  text: string;
  bold?: boolean;
  italic?: boolean;
  underline?: boolean;
  strike?: boolean;
  code?: boolean;
  link?: string;
}

Converting to/from HTML (for the TipTap authoring UI) lives in src/lib/lesson-blocks/spans.ts. The Flutter renderer builds a TextSpan with matching TextStyle for each span.

The renderer

<LessonRenderer content={contentBlocks} /> walks the block array and dispatches each block to a per-type component:

src/components/lesson-blocks/
├── LessonRenderer.tsx          # top-level dispatcher
├── renderers/
│   ├── InlineSpans.tsx         # shared inline text renderer
│   ├── BlockHeading.tsx
│   ├── BlockParagraph.tsx
│   ├── BlockImage.tsx
│   ├── BlockVideo.tsx
│   ├── BlockCallout.tsx
│   ├── BlockDivider.tsx
│   ├── BlockEmbed.tsx
│   ├── BlockList.tsx
│   ├── BlockQuote.tsx
│   └── BlockCode.tsx

The dispatcher uses an exhaustive switch(block.type) — TypeScript errors if you add a new block type without a renderer.

The editor

<BlockEditor value={...} onChange={...} /> provides the authoring UI:

  • Drag-to-reorder via @dnd-kit/sortable (same pattern as the analytics Outline tab)
  • "Add block" dropdown listing every registered type
  • Per-block collapse/expand header
  • Delete button per block
  • Each block type has its own editor in src/components/lesson-blocks/editors/

Inline rich text for paragraph/callout/quote uses InlineRichTextEditor (a restricted TipTap with no block-level schema). Its output HTML is parsed into InlineSpan[] via htmlToSpans() before being stored.

The lesson editor page

src/app/(app)/courses/[id]/lessons/[lessonId]/page.tsx shows a mode toggle for TEXT lessons:

  • Blocks — default for new lessons and lessons with existing contentBlocks
  • Legacy editor — default for lessons with only content HTML (authored before Phase 7). Offers a "Convert to blocks" button that runs the migrator.

Saving a lesson in blocks mode clears Lesson.content and writes Lesson.contentBlocks. Saving in legacy mode writes Lesson.content and nulls Lesson.contentBlocks. A lesson never carries both at once.

The student player

src/app/(app)/learn/[courseId]/[lessonId]/page.tsx prefers contentBlocks when present:

lesson.contentBlocks && lesson.contentBlocks.blocks.length > 0
  ? <LessonRenderer content={lesson.contentBlocks} />
  : lesson.content
    ? <div dangerouslySetInnerHTML={{ __html: lesson.content }} />
    : <EmptyState />

HTML → blocks migration

src/lib/lesson-blocks/migrate.ts converts a legacy TipTap HTML string into a block array. It handles the block-level elements TipTap StarterKit produces:

HTML tagBlock type
<h1> / <h2>heading (level 1 / 2)
<h3><h6>heading (level 3 — collapsed)
<p>paragraph (with inline spans)
<ul> / <ol>list (unordered / ordered)
<blockquote>quote
<pre><code>code (language extracted from class)
<img>image
<hr>divider

Unknown tags degrade to a paragraph with the text content and a warning in the MigrationResult.warnings array. The editor shows a toast so instructors know to review the result.

The migrator has two code paths: a DOMParser-based version for browsers and a regex-based fallback for Node/SSR. Both produce the same block output for the subset TipTap emits.

Adding a new block type

Extend the union in `src/types/lesson-blocks.ts`

Add a new interface (e.g. PollBlock), add it to the LessonBlock union, add a matching Zod schema to the discriminated-union list, add "poll" to BLOCK_TYPES, and register a BLOCK_LABELS entry.

Add a renderer in `src/components/lesson-blocks/renderers/`

Create BlockPoll.tsx, register it in LessonRenderer.tsx's dispatch switch. The exhaustive switch(block.type) will fail to compile until you do.

Add an editor in `src/components/lesson-blocks/editors/`

Create PollBlockEditor.tsx, register it in BlockEditor.tsx's dispatch switch, and add a case to emptyBlock() in src/types/lesson-blocks.ts so the "Add block" menu can insert a default instance.

Add an icon to `BLOCK_ICONS` in `BlockEditor.tsx`

Pick a lucide-react icon for the add-block menu. Keep it small (h-4 w-4).

Update the Flutter renderer (when mobile work resumes)

Mirror the web renderer in Dart. The pattern is identical: switch (block.type) maps to Widget builders.

Interactive blocks (Phase 8)

Phase 8 adds four interactive block types that record student answers and compute feedback server-side:

TypeWhat it doesCorrect answer concept
mcqMultiple choice, single correct optioncorrectOptionId must match one of the options
short_answerFree-text with server-side match (exact / contains / regex)expectedAnswer + match rule
reflectionOpen-ended response, no correctnessnull — just records the answer
pollAnonymous vote with aggregated resultsnull — records vote + shows group totals

Required id

Unlike static blocks, interactive blocks must have a stable id field (usually a UUID). The BlockInteraction row keys on (enrollmentId, blockId), so missing ids would silently collapse multiple blocks into one interaction. emptyBlock() generates a UUID automatically when inserting an interactive block through the editor.

Server-side validation

The entire correctness decision happens in src/lib/lesson-blocks/answer-validation.ts. Clients submit the raw answer (selected option id, typed text) and the server looks up the canonical block in Lesson.contentBlocks, runs the match, and returns { isCorrect, feedback }. Clients never send a correctness flag — so a student editing localStorage can't fake a correct answer.

Short-answer regex match type compiles expectedAnswer as a JavaScript RegExp. Invalid regex at author time degrades to "no match" rather than crashing the request.

BlockInteraction model

model BlockInteraction {
  id              String   @id @default(cuid())
  institutionId   String
  enrollmentId    String
  lessonId        String
  blockId         String
  studentId       String
  answer          Json     // shape varies by block type
  isCorrect       Boolean? // null for reflection + poll
  feedbackShown   String?  // snapshot of block.explanation at answer time
  attempts        Int      @default(1)
  firstAnsweredAt DateTime @default(now())
  lastAnsweredAt  DateTime @default(now())

  @@unique([enrollmentId, blockId])
  @@index([institutionId, lessonId, blockId])
  @@index([institutionId, studentId, lessonId])
}

feedbackShown snapshots the instructor's explanation at answer time — so if the block is edited later, the student still sees the feedback they originally got on resume.

API endpoints

  • POST /api/lessons/[id]/blocks/[blockId]/answer — submits a student's answer. Validates, upserts the BlockInteraction row, bumps the attempts counter, returns the verdict.
  • GET /api/lessons/[id]/interactions — returns the caller's interactions for a lesson plus aggregated poll results for every poll block in the lesson. Used by the lesson player to hydrate interactive blocks on mount.

Both are enrollment-gated. Students need an ACTIVE enrollment in the course to submit answers (or the lesson has to be marked as free preview — in which case the server auto-creates a preview enrollment).

Max attempts

Every interactive block (except reflection) can define maxAttempts. Leaving it unset means unlimited. The server checks attempts >= maxAttempts before validating, so a student can't skip the limit by submitting junk — attempts count on every submit regardless of correctness.

Shuffle (MCQ only)

shuffleOptions: true randomizes option display order on every render. The underlying option id stays stable, so the server's validation isn't affected — the client just shuffles what the student sees.

Rendering on the student side

The lesson player page fetches /api/lessons/[id]/interactions alongside the lesson itself and passes the resulting interactions map + pollAggregates map into <LessonRenderer>. Each interactive block looks itself up by block.id and restores state (selected option, previous answer, correctness indicator, feedback).

Passing readOnly={true} to LessonRenderer disables submission — used in the authoring preview so the instructor can see how a block will look without creating spurious interactions.

Adding a new interactive block type

Same steps as static blocks (see "Adding a new block type" above), plus:

  1. Add a case to validateAnswer() in src/lib/lesson-blocks/answer-validation.ts
  2. Decide whether the type has a correct-answer concept (returns boolean in isCorrect) or not (returns null)
  3. The server's POST .../answer endpoint handles the new type automatically via the dispatcher in validateAnswer — no route changes needed
  4. The client renderer reads from existing (BlockInteractionState) to restore state on mount and calls submitBlockAnswer(lessonId, blockId, answer) on submit

Validation

Every write to Lesson.contentBlocks goes through lessonContentBlocksSchema.safeParse() in src/app/api/lessons/[id]/route.ts before hitting Prisma. Malformed client payloads return a 400 with the Zod flatten() details. The Prisma JSON column is accepted as an opaque object by the ORM — validation is the client's contract.

Row cap

LessonContentBlocks.blocks is capped at 500 blocks in the schema. That's generous for any realistic lesson (~100k characters of content when averaged across block sizes) while bounding pathological inputs from a runaway editor or scripted client.

The v1 paragraph editor toolbar does not include a Link button because @tiptap/starter-kit doesn't ship the Link extension. The block schema supports links in spans (InlineSpan.link), so renderers render them correctly — users just can't currently type them through the TipTap UI. Adding the @tiptap/extension-link package will land in a follow-up when it becomes a real pain point.