Files
personal-website/src/lib/language-learner/text.ts
Stan Runge 438623d857
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
add language learner project
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>
2026-09-24 17:34:56 +00:00

68 lines
2.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** 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, ' ');