add language learner project
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
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:
67
src/lib/language-learner/text.ts
Normal file
67
src/lib/language-learner/text.ts
Normal 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, ' ');
|
||||
Reference in New Issue
Block a user