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 ZoddiscriminatedUnion - The renderer lives at
src/components/lesson-blocks/LessonRenderer.tsxand dispatches byblock.type - The authoring UI lives at
src/components/lesson-blocks/BlockEditor.tsxwith one editor per block type - The legacy
Lesson.contentHTML path is still wired in the student player — older lessons render viadangerouslySetInnerHTML, new lessons render viaLessonRenderer - 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.typeto a React component - Flutter renderer (when we get there) maps
block.typeto 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
contentHTML (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 tag | Block 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:
| Type | What it does | Correct answer concept |
|---|---|---|
mcq | Multiple choice, single correct option | correctOptionId must match one of the options |
short_answer | Free-text with server-side match (exact / contains / regex) | expectedAnswer + match rule |
reflection | Open-ended response, no correctness | null — just records the answer |
poll | Anonymous vote with aggregated results | null — 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:
- Add a case to
validateAnswer()insrc/lib/lesson-blocks/answer-validation.ts - Decide whether the type has a correct-answer concept (returns boolean in
isCorrect) or not (returns null) - The server's POST
.../answerendpoint handles the new type automatically via the dispatcher invalidateAnswer— no route changes needed - The client renderer reads from
existing(BlockInteractionState) to restore state on mount and callssubmitBlockAnswer(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.
Links inside inline text
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.