mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-28 07:34:34 +00:00
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:
parent
4708c69d9c
commit
f7470d333c
113 changed files with 25817 additions and 9 deletions
239
tools/build-content.mjs
Normal file
239
tools/build-content.mjs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regenerate `content/` from the desktop C sources.
|
||||
*
|
||||
* The C app remains the authoring surface for now, so this is a one-way
|
||||
* export: run it after changing lesson data in `desktop/` to refresh the JSON
|
||||
* the web app reads. It is deliberately strict — a shape it does not recognise
|
||||
* throws rather than emitting a half-empty lesson.
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { parseArray } from './lib/cparse.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { extractThemes } from './extract/themes.mjs';
|
||||
import { extractI18n } from './extract/i18n.mjs';
|
||||
import { extractNetworks } from './extract/networks.mjs';
|
||||
import { extractDeutschUnit1 } from './extract/deutsch.mjs';
|
||||
import { extractDeutschUnit2 } from './extract/deutsch-u2.mjs';
|
||||
import { extractDeutschUnit3 } from './extract/deutsch-u3.mjs';
|
||||
import { extractHardware } from './extract/hardware.mjs';
|
||||
import { extractMluvnice } from './extract/mluvnice.mjs';
|
||||
import { extractCetba } from './extract/cetba.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DESKTOP = resolve(ROOT, 'desktop');
|
||||
const CONTENT = resolve(ROOT, 'content');
|
||||
|
||||
const write = (relPath, data) => {
|
||||
const file = resolve(CONTENT, relPath);
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
|
||||
return relPath;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------- subjects --- */
|
||||
|
||||
/** Mirrors `sub_keys` in subjects.c; only four subjects have content today. */
|
||||
const SUBJECTS = [
|
||||
{ id: 'deutsch', icon: 'flag-de', route: '/deutsch' },
|
||||
{ id: 'networks', icon: 'wifi', route: '/networks' },
|
||||
{ id: 'hardware', icon: 'chip', route: '/hardware' },
|
||||
{ id: 'czech', icon: 'book', route: '/czech' },
|
||||
{ id: 'civics', icon: null, route: null },
|
||||
{ id: 'english', icon: null, route: null },
|
||||
{ id: 'math', icon: null, route: null },
|
||||
{ id: 'physics', icon: null, route: null },
|
||||
{ id: 'science', icon: null, route: null },
|
||||
{ id: 'tech-graphics', icon: null, route: null },
|
||||
{ id: 'presentation', icon: null, route: null },
|
||||
{ id: 'programming', icon: null, route: null },
|
||||
{ id: 'software', icon: null, route: null },
|
||||
];
|
||||
|
||||
function extractSubjects() {
|
||||
const keys = parseArray(readFileSync(`${DESKTOP}/subjects.c`, 'utf8'), 'sub_keys').map(String);
|
||||
if (keys.length !== SUBJECTS.length) {
|
||||
throw new Error(`subjects.c has ${keys.length} keys but the extractor knows ${SUBJECTS.length}`);
|
||||
}
|
||||
return SUBJECTS.map((s, i) => ({ ...s, key: keys[i], unlocked: s.route !== null }));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- paths --- */
|
||||
|
||||
/** German unit titles from `unit_meta_init` in main.c. */
|
||||
const DEUTSCH_TITLES = [
|
||||
'Neue Freunde', 'Aus aller Welt', 'Bei uns zu Hause',
|
||||
'Schule und Freizeit', 'Guten Appetit!', 'Mein Tagesablauf',
|
||||
'Meine Freunde', 'Wir treffen uns in Salzburg',
|
||||
'Mein Haus ist meine Burg', 'Urlaub in Österreich',
|
||||
];
|
||||
|
||||
/**
|
||||
* `HW_UNITS` bubbles are drawn but only `HW_LESSONS` of them are playable, so
|
||||
* the trailing nodes stay locked placeholders as they do on the desktop map.
|
||||
*/
|
||||
const HW_UNITS = 10;
|
||||
|
||||
/** `cz_keys` in czech.c; "Literatura" has no content yet. */
|
||||
const CZECH_NODES = [
|
||||
{ number: 1, title: 'Literatura', route: null },
|
||||
{ number: 2, title: 'Mluvnice', route: '/czech/mluvnice' },
|
||||
{ number: 3, title: 'Maturitní četba', route: '/czech/reading' },
|
||||
];
|
||||
|
||||
function buildPaths({ networkLessons, deutschLessons, hardwareLessons, cetbaLessons }) {
|
||||
const done = new Set(deutschLessons.map((l) => l.number));
|
||||
|
||||
const deutsch = {
|
||||
subject: 'deutsch',
|
||||
units: DEUTSCH_TITLES.map((title, i) => ({
|
||||
number: i + 1,
|
||||
// Units 1-3 are playable in the desktop app; the rest are placeholders.
|
||||
lesson: done.has(i + 1) ? `unit-${String(i + 1).padStart(2, '0')}` : null,
|
||||
title: { cs: title, en: title },
|
||||
unlocked: i < 3 && done.has(i + 1),
|
||||
})),
|
||||
};
|
||||
|
||||
const networks = {
|
||||
subject: 'networks',
|
||||
year: 1,
|
||||
units: networkLessons.map((l) => ({
|
||||
number: l.number,
|
||||
lesson: l.id,
|
||||
titleKey: l.titleKey,
|
||||
unlocked: true,
|
||||
})),
|
||||
};
|
||||
|
||||
const byNumber = new Map(hardwareLessons.map((l) => [l.number, l]));
|
||||
const hardware = {
|
||||
subject: 'hardware',
|
||||
units: Array.from({ length: HW_UNITS }, (_, i) => {
|
||||
const lesson = byNumber.get(i + 1);
|
||||
return {
|
||||
number: i + 1,
|
||||
lesson: lesson?.id ?? null,
|
||||
titleKey: lesson?.titleKey ?? `hw_unit${i + 1}`,
|
||||
unlocked: Boolean(lesson),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const czech = {
|
||||
subject: 'czech',
|
||||
units: CZECH_NODES.map((node) => ({
|
||||
number: node.number,
|
||||
lesson: null,
|
||||
route: node.route,
|
||||
title: { cs: node.title, en: node.title },
|
||||
unlocked: node.route !== null,
|
||||
})),
|
||||
};
|
||||
|
||||
const reading = {
|
||||
subject: 'czech',
|
||||
branch: 'reading',
|
||||
units: cetbaLessons.map((l, i) => ({
|
||||
number: i + 1,
|
||||
lesson: l.id,
|
||||
title: l.title,
|
||||
unlocked: true,
|
||||
})),
|
||||
};
|
||||
|
||||
return [deutsch, networks, hardware, czech, reading];
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- main --- */
|
||||
|
||||
function main() {
|
||||
rmSync(resolve(CONTENT, 'lessons'), { recursive: true, force: true });
|
||||
|
||||
const written = [];
|
||||
|
||||
written.push(write('themes.json', extractThemes(DESKTOP)));
|
||||
|
||||
const i18n = extractI18n(DESKTOP);
|
||||
written.push(write('i18n/ui.json', i18n.ui));
|
||||
written.push(write('i18n/content.json', i18n.content));
|
||||
|
||||
const subjects = extractSubjects();
|
||||
written.push(write('subjects.json', subjects));
|
||||
|
||||
const networkLessons = extractNetworks(DESKTOP);
|
||||
for (const lesson of networkLessons) {
|
||||
written.push(write(`lessons/networks/${lesson.id}.json`, lesson));
|
||||
}
|
||||
|
||||
const deutschLessons = [
|
||||
extractDeutschUnit1(DESKTOP),
|
||||
extractDeutschUnit2(DESKTOP),
|
||||
extractDeutschUnit3(DESKTOP),
|
||||
];
|
||||
for (const lesson of deutschLessons) {
|
||||
written.push(write(`lessons/deutsch/${lesson.id}.json`, lesson));
|
||||
}
|
||||
|
||||
const hardwareLessons = extractHardware(DESKTOP);
|
||||
for (const lesson of hardwareLessons) {
|
||||
written.push(write(`lessons/hardware/${lesson.id}.json`, lesson));
|
||||
}
|
||||
|
||||
const czechLessons = [extractMluvnice(DESKTOP), ...extractCetba(DESKTOP)];
|
||||
for (const lesson of czechLessons) {
|
||||
written.push(write(`lessons/czech/${lesson.id}.json`, lesson));
|
||||
}
|
||||
|
||||
const cetbaLessons = czechLessons.filter((l) => l.id !== 'mluvnice');
|
||||
|
||||
const paths = buildPaths({
|
||||
networkLessons,
|
||||
deutschLessons,
|
||||
hardwareLessons,
|
||||
cetbaLessons,
|
||||
});
|
||||
written.push(write('paths.json', paths));
|
||||
|
||||
written.push(write('manifest.json', {
|
||||
subjects: subjects.map((s) => s.id),
|
||||
lessons: {
|
||||
deutsch: deutschLessons.map((l) => l.id),
|
||||
networks: networkLessons.map((l) => l.id),
|
||||
hardware: hardwareLessons.map((l) => l.id),
|
||||
czech: czechLessons.map((l) => l.id),
|
||||
},
|
||||
}));
|
||||
|
||||
const stats = {
|
||||
'network lessons': networkLessons.length,
|
||||
'network slides': networkLessons.reduce((a, l) => a + l.slides.length, 0),
|
||||
'network items': networkLessons.reduce(
|
||||
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
|
||||
'deutsch tasks': deutschLessons.reduce((a, l) => a + l.tasks.length, 0),
|
||||
'deutsch items': deutschLessons.reduce(
|
||||
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
|
||||
'vocab words': deutschLessons.reduce(
|
||||
(a, l) => a + (l.vocab ?? []).reduce((b, s) => b + s.items.length, 0), 0),
|
||||
'hardware lessons': hardwareLessons.length,
|
||||
'hardware slides': hardwareLessons.reduce((a, l) => a + l.slides.length, 0),
|
||||
'hardware items': hardwareLessons.reduce(
|
||||
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
|
||||
'czech lessons': czechLessons.length,
|
||||
'czech tasks': czechLessons.reduce((a, l) => a + l.tasks.length, 0),
|
||||
'czech items': czechLessons.reduce(
|
||||
(a, l) => a + l.tasks.reduce((b, t) => b + t.items.length, 0), 0),
|
||||
'ui strings': Object.keys(i18n.ui.cs).length,
|
||||
'content strings': Object.keys(i18n.content.cs).length,
|
||||
themes: 10,
|
||||
};
|
||||
|
||||
console.log(`wrote ${written.length} files to content/`);
|
||||
for (const [k, v] of Object.entries(stats)) console.log(` ${k}: ${v}`);
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Add table
Add a link
Reference in a new issue