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>
This commit is contained in:
FBukovina 2026-09-17 16:06:15 +02:00
commit f7470d333c
113 changed files with 25817 additions and 9 deletions

145
tools/extract/mluvnice.mjs Normal file
View file

@ -0,0 +1,145 @@
import { readFileSync } from 'node:fs';
import { parseArray, trimNulls } from '../lib/cparse.mjs';
/**
* Czech grammar ("Mluvnice"), 20 exercises in one lesson.
*
* Unlike the other subjects, mluvnice.c passes its instructions as literal
* Czech strings rather than i18n keys, so they are copied verbatim from the
* `build_mluvnice_exercise_page` switch below.
*/
/** One row per `case` in `build_mluvnice_exercise_page`, in the same order. */
const EXERCISES = [
['typed', 'Pravopis – i/y po obojetných souhláskách', 'Doplňte i/í nebo y/ý.', null],
['choice', 'Pravopis – velká/malá písmena', 'Vyberte správnou variantu.', null],
['typed', 'Slovní druhy', 'Určete slovní druh podtržených slov.',
'Do pole napište název slovního druhu.'],
['typed', 'Mluvnické kategorie – podstatná jména',
'Určete rod, číslo a pád podtržených slov.',
'Vyplňte tři pole: rod, číslo a pád.'],
['typed', 'Mluvnické kategorie – slovesa',
'Určete osobu, číslo, čas a způsob u těchto sloves.',
'U rozkazovacího a podmiňovacího způsobu napište do pole čas „neurčitý“.'],
['reveal', 'Slovotvorba', 'Rozeberte slova na morfémy.',
'Určete předponu, kořen, příponu a koncovku.'],
['typed', 'Větné členy', 'Určete větný člen podtržených výrazů.', null],
['typed', 'Druhy vedlejších vět', 'Určete druh vedlejší věty.', null],
['typed', 'Synonyma', 'Ke každému slovu napište synonymum.', null],
['typed', 'Antonyma', 'Napište opak.', null],
['typed', 'Pravopis – s/z na začátku slova', 'Vyberte správnou předponu s-/z-.', null],
['reveal', 'Skladba – najděte chybu',
'Každá věta obsahuje jednu chybu. Najděte ji a opravte.', null],
['typed', 'Tvarosloví – správný tvar', 'Dejte slovo do správného tvaru.', null],
['choice', 'Výběr ze čtyř možností', 'Vyberte správnou odpověď.', null],
['reveal', 'Přímá a nepřímá řeč', 'Přepište přímou řeč na nepřímou.', null],
['reveal', 'Slovní zásoba – rozdíly', 'Vysvětlete rozdíl mezi těmito dvojicemi slov.', null],
['reveal', 'Interpunkce', 'Doplňte čárky tam, kde patří.', null],
['typed', 'Obrazná pojmenování',
'Určete, o jaký druh obrazného pojmenování jde.', null],
['typed', 'Stylistika – slohové útvary',
'Přiřaďte ukázku ke správnému slohovému útvaru.', null],
['choice', 'Souhrnné opakování', 'Vyberte správnou odpověď.', null],
];
/** Only exercise 14 ships per-question explanations. */
const CHOICE_EXPLANATIONS = { 14: 'e14_expl' };
/** `MluvItem`: prompt, shown, nblank, hint[5], answers[5]. */
function fieldsItem(row, label) {
const [prompt, shown, nblank, hints, answers] = row;
if (typeof nblank !== 'number' || nblank < 1) {
throw new Error(`${label}: expected at least one blank, got ${nblank}`);
}
return {
kind: 'fields',
prompt: String(prompt),
fields: Array.from({ length: nblank }, (_, i) => ({
label: String(hints[i] ?? ''),
answers: String(answers[i] ?? '').split('|').map((a) => a.trim()).filter(Boolean),
})),
...(shown ? { solution: String(shown) } : {}),
};
}
function choiceItem(row, expl, label) {
const [prompt, options, nOptions, correct] = row;
const list = trimNulls(options ?? []).map(String);
const n = typeof nOptions === 'number' ? nOptions : list.length;
if (typeof correct !== 'number' || correct < 0 || correct >= n) {
throw new Error(`${label}: correct index ${correct} out of range for ${n} options`);
}
return {
kind: 'choice',
prompt: String(prompt),
options: list.slice(0, n),
correct,
...(expl ? { hint: String(expl) } : {}),
};
}
export function extractMluvnice(desktopDir) {
const src = readFileSync(`${desktopDir}/mluvnice.c`, 'utf8');
const names = parseArray(src, 'mluv_names').map(String);
if (names.length !== EXERCISES.length) {
throw new Error(`mluv_names has ${names.length} entries but the extractor knows ${EXERCISES.length}`);
}
const tasks = EXERCISES.map(([type, heading, subtitle, note], index) => {
const number = index + 1;
const label = `mluvnice ex${number}`;
const base = {
number,
// Short label for the map bubble; the page shows the full heading.
title: names[index],
heading,
subtitleKey: null,
subtitle,
...(note ? { note } : {}),
};
if (type === 'choice') {
const explName = CHOICE_EXPLANATIONS[number];
const expls = explName ? parseArray(src, explName).map(String) : [];
return {
...base,
items: parseArray(src, `e${number}_qs`)
.map((row, i) => choiceItem(row, expls[i], `${label} q${i + 1}`)),
};
}
if (type === 'reveal') {
return {
...base,
items: parseArray(src, `e${number}_items`).map(([prompt, solution]) => ({
kind: 'reveal',
prompt: String(prompt),
solution: String(solution),
})),
};
}
return {
...base,
items: parseArray(src, `e${number}_items`)
.map((row, i) => fieldsItem(row, `${label} item ${i + 1}`)),
};
});
return {
id: 'mluvnice',
subject: 'czech',
number: 1,
title: { cs: 'Mluvnice', en: 'Czech grammar' },
subtitle: {
cs: 'Vyberte cvičení a dokončete je.',
en: 'Pick an exercise and complete it.',
},
slides: [],
tasks,
};
}