@lincyaw/addon-classroom 0.0.0-stage → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +30 -2
  3. package/components/PmExercise.css +58 -0
  4. package/components/PmExercise.tsx +165 -0
  5. package/components/PmPredict.css +31 -0
  6. package/components/PmPredict.tsx +182 -0
  7. package/components/PmPulse.css +24 -0
  8. package/components/PmPulse.tsx +109 -0
  9. package/components/PmQuestionBox.css +16 -0
  10. package/components/PmQuestionBox.tsx +155 -0
  11. package/components/PmQuiz.css +43 -0
  12. package/components/PmQuiz.tsx +221 -0
  13. package/package.json +44 -3
  14. package/src/client/ClassroomOverlays.tsx +15 -0
  15. package/src/client/ClassroomPanel.css +32 -0
  16. package/src/client/ClassroomPanel.tsx +213 -0
  17. package/src/client/ClassroomToolbar.css +26 -0
  18. package/src/client/ClassroomToolbar.tsx +47 -0
  19. package/src/client/NoteSpot.css +50 -0
  20. package/src/client/NoteSpot.tsx +119 -0
  21. package/src/client/NotesLayer.css +9 -0
  22. package/src/client/NotesLayer.tsx +113 -0
  23. package/src/client/QuestionItem.css +17 -0
  24. package/src/client/QuestionItem.tsx +106 -0
  25. package/src/client/ai-answers.ts +29 -0
  26. package/src/client/browser-store.ts +86 -0
  27. package/src/client/composables.ts +43 -0
  28. package/src/client/env.d.ts +7 -0
  29. package/src/client/ids.ts +3 -0
  30. package/src/client/index.ts +4 -0
  31. package/src/client/plugin.ts +57 -0
  32. package/src/client/style.css +44 -0
  33. package/src/domain/config.ts +25 -0
  34. package/src/domain/exercise.ts +143 -0
  35. package/src/domain/index.ts +40 -0
  36. package/src/domain/messages.ts +298 -0
  37. package/src/domain/notes.ts +12 -0
  38. package/src/domain/place.ts +25 -0
  39. package/src/domain/predict.ts +34 -0
  40. package/src/domain/pulse.ts +19 -0
  41. package/src/domain/questions.ts +42 -0
  42. package/src/domain/quiz.ts +63 -0
  43. package/src/domain/records.ts +97 -0
  44. package/src/domain/source.ts +55 -0
  45. package/src/domain/summary.ts +96 -0
  46. package/src/domain/tally.ts +8 -0
  47. package/src/node/classroom-pass.ts +84 -0
  48. package/src/node/components.ts +10 -0
  49. package/src/node/directives.ts +77 -0
  50. package/src/node/fallbacks.ts +86 -0
  51. package/src/node/notebook.ts +32 -0
  52. package/src/node/presmium.ts +21 -0
  53. package/src/node/reveals.ts +29 -0
@@ -0,0 +1,96 @@
1
+ import type { Participant, SessionStatus } from '@lincyaw/kernel'
2
+ import type { SectionNumbers } from './config.ts'
3
+ import { exerciseTally } from './exercise.ts'
4
+ import type { ExerciseTally, Submission } from './exercise.ts'
5
+ import { placeIndex, placeKey } from './place.ts'
6
+ import type { Place } from './place.ts'
7
+ import { predictionTally } from './predict.ts'
8
+ import type { Prediction, PredictionTally } from './predict.ts'
9
+ import { pulseTally } from './pulse.ts'
10
+ import type { PulseVote } from './pulse.ts'
11
+ import type { Question } from './questions.ts'
12
+ import { countedAnswers, quizTally } from './quiz.ts'
13
+ import type { Answer, QuizState, QuizTally } from './quiz.ts'
14
+ import { latestPerParticipant } from './tally.ts'
15
+
16
+ /** A component's place, numbered in this window's version, and its id (the place's key). */
17
+ type Where = Place & { id: string }
18
+
19
+ export interface ClassroomRecords {
20
+ quizzes: (QuizState & { id: string })[]
21
+ answers: Answer[]
22
+ predictions: Prediction[]
23
+ votes: PulseVote[]
24
+ submissions: Submission[]
25
+ numbers?: SectionNumbers
26
+ }
27
+
28
+ /** The tallies of each component of the classroom, in the order of their places. */
29
+ export interface ClassroomRows {
30
+ quizzes: (Where & { open: boolean, options: string[], tally: QuizTally })[]
31
+ predictions: (Where & { question: string, choices?: string[], tally: PredictionTally })[]
32
+ pulse: (Where & { levels: string[], counts: number[] })[]
33
+ exercises: (Where & { tally: ExerciseTally })[]
34
+ }
35
+
36
+ export function classroomRows(records: ClassroomRecords): ClassroomRows {
37
+ const where = (place: Place): Where => ({ id: placeKey(place), section: place.section, block: place.block, title: place.title, index: placeIndex(place, records.numbers) })
38
+ const latestByPlace = <T extends Place>(items: T[], id: (item: T) => string): T[] =>
39
+ [...new Map(items.map(item => [id(item), item])).values()].sort((a, b) => placeIndex(a, records.numbers) - placeIndex(b, records.numbers))
40
+ return {
41
+ quizzes: latestByPlace(records.quizzes, q => q.id).map(quiz => ({
42
+ ...where(quiz),
43
+ open: quiz.open,
44
+ options: quiz.options,
45
+ tally: quizTally(quiz.options.length, quiz.correct, countedAnswers(quiz.id, quiz, records.answers)),
46
+ })),
47
+ predictions: latestByPlace(records.predictions, p => p.predictId).map(prediction => ({
48
+ ...where(prediction),
49
+ question: prediction.question,
50
+ ...prediction.choices ? { choices: prediction.choices } : {},
51
+ tally: predictionTally(prediction.predictId, prediction.choices?.length ?? 0, records.predictions),
52
+ })),
53
+ pulse: latestByPlace(records.votes, v => v.pulseId).map(vote => ({
54
+ ...where(vote),
55
+ levels: vote.levels,
56
+ counts: pulseTally(vote.pulseId, vote.levels.length, records.votes),
57
+ })),
58
+ exercises: latestByPlace(records.submissions, s => s.exerciseId).map(submission => ({
59
+ ...where(submission),
60
+ tally: exerciseTally(latestPerParticipant(records.submissions.filter(s => s.exerciseId === submission.exerciseId))),
61
+ })),
62
+ }
63
+ }
64
+
65
+ export function unansweredCount(questions: readonly Question[]): number {
66
+ return questions.filter(q => !q.answered).length
67
+ }
68
+
69
+ export interface ClassroomSummary {
70
+ status: SessionStatus
71
+ learners: number
72
+ questions: { asked: number, unanswered: number }
73
+ quizzes: (Where & { open: boolean, options: string[], counts: number[], respondents: number, correct: number })[]
74
+ predictions: (Where & { question: string, choices?: string[], counts?: number[], respondents: number })[]
75
+ pulse: (Where & { levels: string[], counts: number[] })[]
76
+ exercises: (Where & { submitted: number, passed: number })[]
77
+ }
78
+
79
+ export interface SummaryInput extends ClassroomRecords {
80
+ status: SessionStatus
81
+ participants: Participant[]
82
+ questions: Question[]
83
+ }
84
+
85
+ export function classroomSummary(input: SummaryInput): ClassroomSummary {
86
+ const rows = classroomRows(input)
87
+ return {
88
+ status: input.status,
89
+ learners: input.participants.filter(p => p.role === 'learner').length,
90
+ questions: { asked: input.questions.length, unanswered: unansweredCount(input.questions) },
91
+ quizzes: rows.quizzes.map(({ tally, ...quiz }) => ({ ...quiz, ...tally })),
92
+ predictions: rows.predictions.map(({ tally, choices, question, ...place }) => ({ ...place, question, ...choices ? { choices, counts: tally.counts } : {}, respondents: tally.respondents })),
93
+ pulse: rows.pulse,
94
+ exercises: rows.exercises.map(({ tally, ...place }) => ({ ...place, submitted: tally.submitted, passed: tally.passed })),
95
+ }
96
+ }
@@ -0,0 +1,8 @@
1
+ export function latestPerParticipant<T extends { participant: string, at: number }>(items: Iterable<T>): T[] {
2
+ const latest = new Map<string, T>()
3
+ for (const item of items) {
4
+ const before = latest.get(item.participant)
5
+ if (!before || item.at >= before.at) latest.set(item.participant, item)
6
+ }
7
+ return [...latest.values()]
8
+ }
@@ -0,0 +1,84 @@
1
+ import { deckLocale, isRecord } from '@lincyaw/kernel'
2
+ import type { ComponentNode, IRBlock, IRDocument, IRPass, IRSection, PassContext } from '@lincyaw/kernel'
3
+ import { CLASSROOM_ID, isAuthorLabel, isForStudents, MESSAGES } from '../domain/index.ts'
4
+ import type { ClassroomMessages, Place } from '../domain/index.ts'
5
+ import { isClassroomComponent, isPredict } from './components.ts'
6
+ import { fallbackOf } from './fallbacks.ts'
7
+
8
+ /** The blocks of a section, with what a {predict} reveals in front of it. */
9
+ function sectionBlocks(section: IRSection): IRBlock[] {
10
+ return section.blocks.flatMap((block) => {
11
+ const { node } = block
12
+ return isPredict(node) ? [...(node.children ?? []).filter(child => child.id !== 'prompt'), block] : [block]
13
+ })
14
+ }
15
+
16
+ /** Writes the number of every section students have into the plugin's configuration (`presmium.classroom.sections`). */
17
+ function writeSectionNumbers(doc: IRDocument): void {
18
+ const own = doc.config.presmium[CLASSROOM_ID]
19
+ doc.config.presmium[CLASSROOM_ID] = {
20
+ ...isRecord(own) ? own : {},
21
+ sections: Object.fromEntries(doc.sections.filter(isForStudents).map(section => [section.id, section.index])),
22
+ }
23
+ }
24
+
25
+ function isLabelled(lines: readonly string[], from: number, at: number, id: string): boolean {
26
+ let end = at
27
+ while (end < lines.length && lines[end]!.trim().startsWith(':')) end++
28
+ return lines.slice(from - 1, end).some(line => line.trim() === `(${id})=` || line.trim() === `:label: ${id}`)
29
+ }
30
+
31
+ /** Warns of a label the author wrote that looks like a generated block id. */
32
+ async function checkLabel(block: IRBlock, section: IRSection, linesOf: (file: string) => Promise<string[]>, ctx: PassContext): Promise<void> {
33
+ const { source } = block
34
+ if (isAuthorLabel(block.id)) return
35
+ const from = source.file === section.source.file ? section.source.line : 1
36
+ if (!isLabelled(await linesOf(source.file), from, source.line, block.id)) return
37
+ ctx.report({
38
+ checkId: CLASSROOM_ID,
39
+ severity: 'warning',
40
+ message: `label (${block.id})= ends in -<number> like a generated block id: it takes no notes and the presenter's lists do not name it; choose another label`,
41
+ source: { file: source.file, line: source.line },
42
+ })
43
+ }
44
+
45
+ /** Gives a classroom component its place, its default props and its fallback, and checks its target. */
46
+ function placeComponent(node: ComponentNode, block: IRBlock, section: IRSection, blocks: readonly IRBlock[], t: ClassroomMessages, ctx: PassContext): void {
47
+ node.props.place = { section: section.id, block: block.id, title: section.title, index: section.index } satisfies Place
48
+ if (node.name === 'PmPulse') {
49
+ node.props.question ??= t.pulse.question
50
+ node.props.levels ??= t.pulse.levels
51
+ }
52
+ node.fallback = fallbackOf(node, t)
53
+ const { target } = node.props
54
+ if (typeof target === 'string' && !blocks.some(b => b.id === target)) {
55
+ ctx.report({
56
+ checkId: CLASSROOM_ID,
57
+ severity: 'warning',
58
+ message: `{question-box}: no block (${target})= in section ${section.id}; questions are attached to the section`,
59
+ source: { file: block.source.file, line: block.source.line },
60
+ })
61
+ delete node.props.target
62
+ }
63
+ }
64
+
65
+ export const classroomPass: IRPass = {
66
+ name: 'presmium.classroom.places',
67
+ async run(doc, ctx) {
68
+ const t = MESSAGES[deckLocale(ctx.lang)]
69
+ writeSectionNumbers(doc)
70
+ const sources = new Map<string, Promise<string[]>>()
71
+ const linesOf = (file: string) => {
72
+ let lines = sources.get(file)
73
+ if (!lines) sources.set(file, lines = ctx.src.read(file).then(text => text?.split('\n') ?? []))
74
+ return lines
75
+ }
76
+ for (const section of doc.sections) {
77
+ const blocks = sectionBlocks(section)
78
+ for (const block of blocks) {
79
+ await checkLabel(block, section, linesOf, ctx)
80
+ if (isClassroomComponent(block.node)) placeComponent(block.node, block, section, blocks, t, ctx)
81
+ }
82
+ }
83
+ },
84
+ }
@@ -0,0 +1,10 @@
1
+ import type { ComponentNode, IRNode } from '@lincyaw/kernel'
2
+ import { CLASSROOM_ID } from '../domain/index.ts'
3
+
4
+ export function isClassroomComponent(node: IRNode): node is ComponentNode {
5
+ return node.type === 'component' && node.plugin === CLASSROOM_ID
6
+ }
7
+
8
+ export function isPredict(node: IRNode): node is ComponentNode {
9
+ return isClassroomComponent(node) && node.name === 'PmPredict'
10
+ }
@@ -0,0 +1,77 @@
1
+ import type { ComponentNode, DirectiveContext, DirectiveSpec, IRBlock, MdastNode } from '@lincyaw/kernel'
2
+ import { CLASSROOM_ID, exerciseSource, optionList, quizSource } from '../domain/index.ts'
3
+
4
+ function component(name: string, props: Record<string, unknown>, prompt: MdastNode[], ctx: DirectiveContext): ComponentNode {
5
+ const children: IRBlock[] = prompt.length ? [{ id: 'prompt', role: 'visual', at: 0, source: ctx.source, node: { type: 'markdown', mdast: { type: 'root', children: prompt } } }] : []
6
+ return { type: 'component', plugin: CLASSROOM_ID, name, props, ...children.length ? { children } : {} }
7
+ }
8
+
9
+ const body = (text: string, ctx: DirectiveContext): MdastNode => (text.trim() ? ctx.parse(text) : { type: 'root', children: [] })
10
+
11
+ export const quizDirective: DirectiveSpec = {
12
+ name: 'quiz',
13
+ body: true,
14
+ toIR(input, ctx) {
15
+ const { prompt, options, correct } = quizSource(body(input.body, ctx))
16
+ if (options.length < 2) ctx.report({ checkId: CLASSROOM_ID, severity: 'error', message: '{quiz} needs at least two options, written as a task list: `- [ ] wrong`, `- [x] right`' })
17
+ else if (correct.length === 0) ctx.report({ checkId: CLASSROOM_ID, severity: 'error', message: '{quiz}: mark the right options with `[x]`' })
18
+ return { node: component('PmQuiz', { options, correct }, prompt, ctx) }
19
+ },
20
+ }
21
+
22
+ export const exerciseDirective: DirectiveSpec = {
23
+ name: 'exercise',
24
+ arg: true,
25
+ body: true,
26
+ options: { timeout: 'number' },
27
+ toIR(input, ctx) {
28
+ const lang = input.arg?.trim() || 'python'
29
+ const { prompt, code } = exerciseSource(body(input.body, ctx))
30
+ const [starter, checks] = code
31
+ if (!checks) ctx.report({ checkId: CLASSROOM_ID, severity: 'error', message: '{exercise} needs two code blocks: the starter code, then the checks' })
32
+ if (code.length > 2) ctx.report({ checkId: CLASSROOM_ID, severity: 'warning', message: '{exercise}: code blocks after the second (the checks) are ignored' })
33
+ if (lang !== 'python') ctx.report({ checkId: CLASSROOM_ID, severity: 'warning', message: `{exercise}: checks run Python; ${lang} exercises cannot be checked` })
34
+ const props: Record<string, unknown> = { lang, starter: starter?.value ?? '', checks: checks?.value ?? '' }
35
+ const timeout = Number(input.options.timeout)
36
+ if (Number.isFinite(timeout) && timeout > 0) props.timeout = timeout
37
+ return { node: component('PmExercise', props, prompt, ctx) }
38
+ },
39
+ }
40
+
41
+ export const questionBoxDirective: DirectiveSpec = {
42
+ name: 'question-box',
43
+ body: true,
44
+ options: { target: 'string' },
45
+ toIR(input, ctx) {
46
+ const target = typeof input.options.target === 'string' ? input.options.target.trim() : ''
47
+ return { node: component('PmQuestionBox', target ? { target } : {}, body(input.body, ctx).children ?? [], ctx) }
48
+ },
49
+ }
50
+
51
+ export const pulseDirective: DirectiveSpec = {
52
+ name: 'pulse',
53
+ arg: true,
54
+ body: true,
55
+ options: { levels: 'string' },
56
+ toIR(input, ctx) {
57
+ const question = input.arg?.trim()
58
+ const levels = optionList(input.options.levels)
59
+ return { node: component('PmPulse', { ...question ? { question } : {}, ...levels ? { levels } : {} }, body(input.body, ctx).children ?? [], ctx) }
60
+ },
61
+ }
62
+
63
+ export const predictDirective: DirectiveSpec = {
64
+ name: 'predict',
65
+ arg: true,
66
+ body: true,
67
+ options: { choices: 'string' },
68
+ toIR(input, ctx) {
69
+ const question = input.arg?.trim() ?? ''
70
+ const choices = optionList(input.options.choices)
71
+ if (!question) ctx.report({ checkId: CLASSROOM_ID, severity: 'error', message: '{predict} needs the question as its argument: ```{predict} How many …?' })
72
+ if (input.options.choices !== undefined && !choices) ctx.report({ checkId: CLASSROOM_ID, severity: 'warning', message: '{predict}: :choices: needs at least two, separated by commas; readers write their prediction instead' })
73
+ const node = component('PmPredict', { question, ...choices ? { choices } : {} }, body(input.body, ctx).children ?? [], ctx)
74
+ node.ownsCode = false
75
+ return { node }
76
+ },
77
+ }
@@ -0,0 +1,86 @@
1
+ import { deckLocale, docBlocks } from '@lincyaw/kernel'
2
+ import type { ComponentNode, IRBlock, IRPass, MdastNode, RunResult } from '@lincyaw/kernel'
3
+ import { MESSAGES, optionLetter } from '../domain/index.ts'
4
+ import type { ClassroomMessages } from '../domain/index.ts'
5
+ import { isPredict } from './components.ts'
6
+
7
+ const text = (value: string): MdastNode => ({ type: 'text', value })
8
+ const paragraph = (...children: MdastNode[]): MdastNode => ({ type: 'paragraph', children })
9
+ const strong = (value: string): MdastNode => ({ type: 'strong', children: [text(value)] })
10
+ const strings = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : [])
11
+ const html = (value: string): MdastNode => ({ type: 'html', value })
12
+ const numbered = (items: string[]): MdastNode => ({ type: 'list', ordered: true, spread: false, children: items.map(item => ({ type: 'listItem', children: [paragraph(text(item))] })) })
13
+ const lettered = (items: string[]): MdastNode[] => items.map((item, i) => paragraph(text(`${optionLetter(i)}. ${item}`)))
14
+
15
+ const joined = (value: unknown): string => (Array.isArray(value) ? value.join('') : typeof value === 'string' ? value : '')
16
+
17
+ function outputText(run: RunResult): string {
18
+ if (!run.outputs) return [run.stdout, run.stderr].join('')
19
+ return run.outputs.map((output) => {
20
+ if (output.output_type === 'stream') return joined(output.text)
21
+ if (output.output_type === 'error') return `${output.ename}: ${output.evalue}\n`
22
+ const text = joined(output.data['text/plain'])
23
+ return text && !text.endsWith('\n') ? `${text}\n` : text
24
+ }).join('')
25
+ }
26
+
27
+ function blockContent({ node }: IRBlock): MdastNode[] {
28
+ if (node.type === 'markdown') return node.mdast.type === 'root' ? node.mdast.children ?? [] : [node.mdast]
29
+ if (node.type === 'code') {
30
+ const output = (node.outputs ?? []).map(outputText).join('').trimEnd()
31
+ return [
32
+ ...node.echo === false ? [] : [{ type: 'code', lang: node.lang, value: node.value }],
33
+ ...output ? [{ type: 'code', lang: 'text', value: output }] : [],
34
+ ]
35
+ }
36
+ return node.fallback?.children ?? []
37
+ }
38
+
39
+ function foldedReveal(node: ComponentNode, t: ClassroomMessages): MdastNode[] {
40
+ const ids = new Set([...strings(node.props.reveal), ...strings(node.props.notes)])
41
+ const content = (node.children ?? []).filter(block => ids.has(block.id)).flatMap(blockContent)
42
+ return content.length ? [html(`<details data-static="open"><summary>${t.fallback.reveal}</summary>`), ...content, html('</details>')] : []
43
+ }
44
+
45
+ function componentBody(node: ComponentNode, t: ClassroomMessages): MdastNode[] {
46
+ const { props } = node
47
+ switch (node.name) {
48
+ case 'PmQuiz':
49
+ return [
50
+ ...lettered(strings(props.options)),
51
+ html(`<details><summary>${t.fallback.answer}</summary>${(props.correct as number[] ?? []).map(optionLetter).join(t.quiz.letters)}</details>`),
52
+ ]
53
+ case 'PmExercise':
54
+ return [
55
+ { type: 'code', lang: String(props.lang), value: String(props.starter ?? '') },
56
+ paragraph(strong(t.fallback.checks)),
57
+ { type: 'code', lang: String(props.lang), value: String(props.checks ?? '') },
58
+ ]
59
+ case 'PmQuestionBox':
60
+ return [paragraph(strong(t.fallback.questions.label), text(t.fallback.questions.text))]
61
+ case 'PmPulse':
62
+ return [paragraph(strong(t.fallback.pulse.label), text(t.fallback.pulse.text(String(props.question), strings(props.levels))))]
63
+ case 'PmPredict':
64
+ return [props.choices ? numbered(strings(props.choices)) : paragraph(text(t.fallback.write)), ...foldedReveal(node, t)]
65
+ default:
66
+ return []
67
+ }
68
+ }
69
+
70
+ export function fallbackOf(node: ComponentNode, t: ClassroomMessages): MdastNode {
71
+ const prompt = node.children?.find(block => block.id === 'prompt')?.node
72
+ const question = node.name === 'PmPredict' ? [paragraph(strong(t.fallback.predict), text(`${t.colon}${String(node.props.question)}`))] : []
73
+ return { type: 'root', children: [...question, ...prompt?.type === 'markdown' ? prompt.mdast.children ?? [] : [], ...componentBody(node, t)] }
74
+ }
75
+
76
+ export const predictFallbacksPass: IRPass = {
77
+ name: 'presmium.classroom.predict-fallbacks',
78
+ after: 'execute',
79
+ order: 1,
80
+ run(doc, ctx) {
81
+ const t = MESSAGES[deckLocale(ctx.lang)]
82
+ for (const { block: { node } } of docBlocks(doc)) {
83
+ if (isPredict(node)) node.fallback = fallbackOf(node, t)
84
+ }
85
+ },
86
+ }
@@ -0,0 +1,32 @@
1
+ import { deckLocale } from '@lincyaw/kernel'
2
+ import type { ComponentNode, IRBlock, MdastNode, RenderContext, RendererContribution } from '@lincyaw/kernel'
3
+ import { MESSAGES } from '../domain/index.ts'
4
+
5
+ function code(source: string) {
6
+ return { cell_type: 'code' as const, source, outputs: [], execution_count: null }
7
+ }
8
+
9
+ function exerciseCells(node: ComponentNode, _block: IRBlock, ctx: RenderContext): unknown[] {
10
+ const t = MESSAGES[deckLocale(ctx.lang)]
11
+ const prompt = node.children?.find(block => block.node.type === 'markdown')?.node
12
+ const intro = prompt?.type === 'markdown' ? prompt.mdast.children ?? [] : []
13
+ const label: MdastNode = { type: 'paragraph', children: [{ type: 'strong', children: [{ type: 'text', value: t.fallback.exercise }] }] }
14
+ return [
15
+ ...intro.length ? [{ cell_type: 'markdown', mdast: { type: 'root', children: [label, ...intro] } }] : [],
16
+ code(String(node.props.starter ?? '')),
17
+ code(`${t.fallback.checkCell}\n${String(node.props.checks ?? '')}`),
18
+ ]
19
+ }
20
+
21
+ function predictCells(node: ComponentNode): unknown[] {
22
+ return node.fallback ? [{ cell_type: 'markdown', mdast: node.fallback }] : []
23
+ }
24
+
25
+ export const notebookContribution: RendererContribution = {
26
+ components: {
27
+ PmExercise: exerciseCells,
28
+ PmPredict: predictCells,
29
+ PmQuestionBox: () => [],
30
+ PmPulse: () => [],
31
+ },
32
+ }
@@ -0,0 +1,21 @@
1
+ import { createRequire } from 'node:module'
2
+ import type { PresmiumNodePlugin } from '@lincyaw/kernel'
3
+ import { CLASSROOM_ID } from '../domain/index.ts'
4
+ import { classroomPass } from './classroom-pass.ts'
5
+ import { exerciseDirective, predictDirective, pulseDirective, questionBoxDirective, quizDirective } from './directives.ts'
6
+ import { predictFallbacksPass } from './fallbacks.ts'
7
+ import { notebookContribution } from './notebook.ts'
8
+ import { revealsPass } from './reveals.ts'
9
+
10
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
11
+
12
+ const plugin: PresmiumNodePlugin = {
13
+ id: CLASSROOM_ID,
14
+ version,
15
+ kernel: '^1.0.0',
16
+ directives: [quizDirective, exerciseDirective, questionBoxDirective, pulseDirective, predictDirective],
17
+ passes: [revealsPass, classroomPass, predictFallbacksPass],
18
+ rendererExtensions: { notebook: notebookContribution },
19
+ }
20
+
21
+ export default plugin
@@ -0,0 +1,29 @@
1
+ import { deckLocale, docBlocks } from '@lincyaw/kernel'
2
+ import type { IRPass } from '@lincyaw/kernel'
3
+ import { CLASSROOM_ID, MESSAGES } from '../domain/index.ts'
4
+ import { isPredict } from './components.ts'
5
+
6
+ /** Predictions consume authored child identities and their explicit step visibility. */
7
+ export const revealsPass: IRPass = {
8
+ name: 'presmium.classroom.predict-reveals',
9
+ order: -1,
10
+ run(doc, ctx) {
11
+ const t = MESSAGES[deckLocale(ctx.lang)].predict
12
+ for (const { block } of docBlocks(doc)) {
13
+ const { node } = block
14
+ if (!isPredict(node)) continue
15
+ const children = new Set(node.children?.map(child => child.id) ?? [])
16
+ for (const property of ['reveal', 'notes'] as const) {
17
+ const targets = node.props[property]
18
+ if (targets === undefined) continue
19
+ if (!Array.isArray(targets) || targets.some(target => typeof target !== 'string') || new Set(targets).size !== targets.length) {
20
+ ctx.report({ checkId: CLASSROOM_ID, severity: 'error', message: t.invalidTargets(property), source: block.source })
21
+ continue
22
+ }
23
+ for (const target of targets) if (!children.has(target)) ctx.report({
24
+ checkId: CLASSROOM_ID, severity: 'error', message: t.missingTarget(property, target, block.id), source: block.source,
25
+ })
26
+ }
27
+ }
28
+ },
29
+ }