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