Graduately/content/schema.ts
FBukovina f7470d333c Add the Next.js web app and shared lesson content.
Move lessons out of the C sources into JSON so the web client can use the same material as the desktop app, and point release builds at desktop/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 16:06:15 +02:00

337 lines
8.9 KiB
TypeScript

/**
* Shared lesson content schema.
*
* This is the single description of everything under `content/`. Both the
* extractor in `tools/` and the Next.js app in `web/` type against it, so a
* shape change here surfaces as a type error on both sides.
*/
export type Lang = 'cs' | 'en';
/** A short piece of text that may differ per UI language. */
export interface Localized {
cs: string;
en?: string;
}
/* ---------------------------------------------------------------- themes --- */
export interface ThemePalette {
crust: string;
base: string;
mantle: string;
surface0: string;
surface1: string;
surface2: string;
overlay: string;
text: string;
subtext: string;
accent: string;
accent2: string;
accent3: string;
success: string;
success2: string;
warning: string;
error: string;
onAccent: string;
node: string;
lockedBg: string;
lockedBorder: string;
rail: string;
finishDark: string;
finishLight: string;
finishStroke: string;
}
export interface Theme {
id: string;
name: string;
dark: ThemePalette;
light: ThemePalette;
}
/* -------------------------------------------------------------- subjects --- */
export interface Subject {
/** Stable slug used in URLs, e.g. `deutsch`, `networks`. */
id: string;
/** i18n key holding the display name. */
key: string;
icon: 'flag-de' | 'wifi' | 'chip' | 'book' | null;
unlocked: boolean;
/** Route the subject node opens, or null when it is a locked placeholder. */
route: string | null;
}
/* ----------------------------------------------------------------- units --- */
/**
* One node on a serpentine learning path.
*
* A node carries either a literal `title` (German unit names are the same in
* both languages) or a `titleKey` to look up in the translation tables.
*/
export interface UnitRef {
/** 1-based position on the path. */
number: number;
/** Slug of the lesson file under `content/lessons/<subject>/`, if any. */
lesson: string | null;
title?: Localized;
titleKey?: string;
/** Destination for nodes that open another path rather than a lesson. */
route?: string | null;
unlocked: boolean;
}
export interface Path {
subject: string;
/** Optional school-year grouping, as the networks subject uses. */
year?: number;
/** Set on secondary paths within a subject, e.g. the Czech reading list. */
branch?: string;
units: UnitRef[];
}
/* ----------------------------------------------------------- theory slides - */
export interface Slide {
/** Small label above the title, e.g. `1 / 4 • VLSM`. */
kicker: string;
title: string;
/** Highlighted aside shown under the title. */
tip: string | null;
lines: string[];
}
/* -------------------------------------------------------------- exercises -- */
/** Multiple choice. `correct` indexes into `options`. */
export interface ChoiceExercise {
kind: 'choice';
prompt: string;
options: string[];
correct: number;
hint?: string;
/** Czech gloss of the prompt, shown as a translation aid. */
meaning?: string;
}
/**
* A sentence split into literal segments with gaps between them.
*
* `segments.length === answers.length + 1`. Every gap variant in the desktop
* app (verb insertion, number words, sequences, possessives, letters) collapses
* into this one shape; `input` decides whether the gap is a dropdown fed from
* `pool` or a free text field.
*/
export interface FillExercise {
kind: 'fill';
segments: string[];
answers: string[];
input: 'select' | 'text';
/** Row label such as `1.` or `a)`. */
label?: string;
/** Group heading rendered above this row, e.g. a speaker's name. */
heading?: string;
/** Emoji or picture clue shown before the sentence. */
emoji?: string;
/** Extra clue such as the digits to spell out. */
clue?: string;
/** Czech translation revealed as a hint. */
meaning?: string;
/** Per-gap glosses, aligned with `answers`. */
glosses?: (string | null)[];
/**
* Which of the task's word lists feeds each gap, aligned with `answers`.
* Defaults to the task `pool`; `alt` selects `altPool`.
*/
gapPool?: ('main' | 'alt')[];
/** Gaps hold single letters rather than whole words, so render them narrow. */
letters?: boolean;
}
/** Free-form answer checked against a model answer rather than exactly. */
export interface FreeExercise {
kind: 'free';
question: string;
questionCs?: string;
sample: string;
sampleCs?: string;
}
/** Type the answer; `answers` lists all accepted spellings. */
export interface TypedExercise {
kind: 'typed';
prompt: string;
answers: string[];
meaning?: string;
/** Full model sentence revealed after checking, where one exists. */
model?: string;
/** Accepted answers are substrings of a longer sentence, not the whole input. */
substring?: boolean;
}
/**
* A prompt answered through several labelled fields, as the Czech grammar
* exercises use ("determine gender, number and case").
*/
export interface FieldsExercise {
kind: 'fields';
prompt: string;
fields: { label: string; answers: string[] }[];
/** The full correct answer, revealed after checking. */
solution?: string;
}
/**
* Self-assessed exercise: think of the answer, then reveal the model solution.
* Used where no automatic check is meaningful (morpheme analysis, punctuation,
* rewriting direct speech).
*/
export interface RevealExercise {
kind: 'reveal';
prompt: string;
solution: string;
}
/** Drag or click words into order to build a sentence. */
export interface AssemblyExercise {
kind: 'assembly';
prompt: string;
words: string[];
meaning?: string;
}
/** Sort labelled items into named groups. */
export interface AssignExercise {
kind: 'assign';
groups: string[];
items: { emoji: string | null; label: string; group: number; meaning?: string }[];
}
/**
* A dialogue where some replies are missing.
*
* `answer` may list several accepted words separated by `|`, matching the
* desktop app's answer comparison.
*/
export interface DialogueExercise {
kind: 'dialogue';
title: string;
rows: {
speaker: string;
before: string;
answer: string;
after: string;
meaning?: string;
}[];
}
/** Guess the word letter by letter. */
export interface HangmanExercise {
kind: 'hangman';
word: string;
hint: string;
}
/** Open profile question with a model answer ("Steckbrief"). */
export interface ProfileExercise {
kind: 'profile';
stem: string;
sample: string;
}
/** Editable grid, e.g. the possessive-pronoun table. */
export interface TableExercise {
kind: 'table';
columns: string[];
rows: string[];
/** `answers[row][col]`; a prefilled cell has `given: true`. */
answers: { value: string; given?: boolean }[][];
}
/** VLSM subnetting: split a network into four blocks. */
export interface SubnetExercise {
kind: 'subnet';
scenario: string;
solution: string;
blocks: {
prefix: number;
network: string;
broadcast: string;
firstHost: string;
lastHost: string;
}[] | null;
}
export type Exercise =
| ChoiceExercise
| FillExercise
| FreeExercise
| TypedExercise
| FieldsExercise
| RevealExercise
| AssemblyExercise
| AssignExercise
| DialogueExercise
| HangmanExercise
| ProfileExercise
| TableExercise
| SubnetExercise;
/* ---------------------------------------------------------------- lessons -- */
/** A group of exercises presented as one task on the unit map. */
export interface Task {
/** 1-based bubble number on the unit map. */
number: number;
/** Short label for the map bubble. */
title: string;
/** Longer heading for the exercise page, where it differs from `title`. */
heading?: string;
/** i18n key for the instruction line. */
subtitleKey: string | null;
/** Literal instruction, where the desktop app passes text instead of a key. */
subtitle?: string;
/** Worked example shown above the items. */
example?: string;
/** Extra note under the instruction. */
note?: string;
/** Words offered to every `select` gap in this task. */
pool?: string[];
/** Second word list, selected per gap via `FillExercise.gapPool`. */
altPool?: string[];
items: Exercise[];
}
export interface VocabSection {
header: string;
items: { prompt: string; answers: string[]; meaning?: string }[];
}
export interface Lesson {
/** File slug, unique within the subject. */
id: string;
subject: string;
/** Position on the subject path. */
number: number;
/** Literal title, or `titleKey` when the text comes from the i18n tables. */
title?: Localized;
titleKey?: string;
subtitle?: Localized;
subtitleKey?: string;
/** Theory shown before the exercises. */
slides: Slide[];
tasks: Task[];
/** Optional vocabulary trainer branch. */
vocab?: VocabSection[];
}
/* --------------------------------------------------------------- manifest -- */
export interface ContentManifest {
subjects: Subject[];
paths: Path[];
/** Lesson ids per subject, in path order. */
lessons: Record<string, string[]>;
}