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>
68 lines
2.3 KiB
TypeScript
68 lines
2.3 KiB
TypeScript
/** 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, ' ');
|