add language learner project
Some checks failed
Test and deploy staging / staging (push) Has been cancelled

Dutch and Korean (explained in English) and English (explained in Dutch)
from A1 to C1, with a Hangul level for Korean. Lessons with notes, words
and sentences generate multiple-choice, typing, sentence building, cloze,
listening and matching exercises. Endless challenges: a seeded daily
challenge, sprint, survival, mixed practice, level tests, spaced-repetition
reviews and generated drills (numbers, clock times, de/het, plurals, Dutch
and Korean conjugation, particles, Hangul reading, irregular verbs).
Progress is stored in localStorage with export/import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 17:34:56 +00:00
parent afb1f191df
commit 438623d857
69 changed files with 12669 additions and 4 deletions

View File

@@ -0,0 +1,67 @@
/** Lowercase, strip punctuation and diacritics-insensitive comparison helpers for answers. */
const PUNCTUATION = /[.,!?¿¡;:"“”„‘’'`()[\]{}…~·-]/g;
export function normalize(value: string): string {
return value
.normalize('NFC')
.toLowerCase()
.replace(/[’‘`]/g, "'")
.replace(PUNCTUATION, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function stripDiacritics(value: string): string {
// Only Latin marks: decomposing Hangul would break Korean answers.
return value.replace(/[À-ɏ]/g, (c) => c.normalize('NFD').replace(/[̀-ͯ]/g, ''));
}
export function distance(a: string, b: string): number {
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
let previous = row[0];
row[0] = i;
for (let j = 1; j <= b.length; j++) {
const current = row[j];
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
previous = current;
}
}
return row[b.length];
}
export type Verdict = 'correct' | 'accent' | 'typo' | 'wrong';
/** Compare a typed answer with every accepted answer, forgiving accents and one-letter typos. */
export function grade(input: string, accepted: string[]): { verdict: Verdict; match: string } {
const given = normalize(input);
let best: { verdict: Verdict; match: string } = { verdict: 'wrong', match: accepted[0] };
if (!given) return best;
for (const answer of accepted) {
const expected = normalize(answer);
if (given === expected) return { verdict: 'correct', match: answer };
if (stripDiacritics(given) === stripDiacritics(expected))
best = { verdict: 'accent', match: answer };
else if (
best.verdict === 'wrong' &&
[...expected].length >= 5 &&
distance(stripDiacritics(given), stripDiacritics(expected)) === 1
)
best = { verdict: 'typo', match: answer };
}
return best;
}
/** Split a sentence into word tiles, keeping punctuation attached to the word. */
export function tiles(sentence: string): string[] {
return sentence.split(/\s+/).filter(Boolean);
}
export function sameTiles(a: string[], b: string[]): boolean {
return normalize(a.join(' ')) === normalize(b.join(' '));
}
/** Key for telling multiple-choice options apart; unlike `normalize` it keeps apostrophes (its / it's). */
export const optionKey = (value: string) =>
value.normalize('NFC').trim().toLowerCase().replace(/\s+/g, ' ');