import type { ValidationError } from 'remix/data-schema' export function suggestClosestKey(unknownKey: string, knownKeys: readonly string[]): string | null { const norm = (s: string) => s.toLowerCase().replace(/[_-]/g, '') const target = norm(unknownKey) if (!target) return null let best: { key: string; score: number } | null = null for (const key of knownKeys) { const candidate = norm(key) if (!candidate) continue if (candidate === target) return key let score = 0 if (candidate.startsWith(target) || target.startsWith(candidate)) { score = Math.min(candidate.length, target.length) * 2 } const dist = levenshtein(candidate, target) score = Math.max(score, Math.max(candidate.length, target.length) - dist * 2) const maxLen = Math.max(candidate.length, target.length) if (dist <= 2 || dist / maxLen <= 0.34) { if (!best || score > best.score) best = { key, score } } } return best?.key ?? null } function levenshtein(a: string, b: string): number { const m = a.length const n = b.length const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)) for (let i = 0; i <= m; i++) dp[i]![0] = i for (let j = 0; j <= n; j++) dp[0]![j] = j for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1 dp[i]![j] = Math.min(dp[i - 1]![j]! + 1, dp[i]![j - 1]! + 1, dp[i - 1]![j - 1]! + cost) } } return dp[m]![n]! } function extractUnknownKey(issue: { path?: unknown; message?: string }): string | null { if (Array.isArray(issue.path) && issue.path.length === 1 && typeof issue.path[0] === 'string') { return issue.path[0] } const message = issue.message ?? '' for (const re of [ /unrecognized key[:\s]+['"]?([^'"]+)['"]?/i, /unknown key[:\s]+['"]?([^'"]+)['"]?/i, /unexpected key[:\s]+['"]?([^'"]+)['"]?/i, ]) { const match = message.match(re) if (match?.[1]) return match[1] } return null } export function formatSchemaValidationError( error: ValidationError, options: { surface: string; knownKeys: readonly string[]; hint: string; input?: unknown }, ): Error { const known = options.knownKeys const issueLines = (error.issues ?? []).map((issue) => { const path = Array.isArray(issue.path) && issue.path.length ? issue.path.join('.') : '(root)' const unknownKey = extractUnknownKey(issue) let line = `${path}: ${issue.message}` if (unknownKey) { const suggestion = suggestClosestKey(unknownKey, known) line += suggestion ? ` (did you mean ${suggestion}?)` : ` (unknown key ${JSON.stringify(unknownKey)})` } return line }) if ( options.input && typeof options.input === 'object' && !Array.isArray(options.input) ) { for (const key of Object.keys(options.input as Record)) { if (known.includes(key)) continue const suggestion = suggestClosestKey(key, known) const extra = suggestion ? `unknown key ${JSON.stringify(key)} (did you mean ${suggestion}?)` : `unknown key ${JSON.stringify(key)}` if (!issueLines.some((l) => l.includes(key))) issueLines.push(extra) } } return new Error( `${options.surface} input invalid: ${issueLines.join('; ') || error.message}. ${options.hint}`, ) }