Skip to content
← Public packages

@kentcdodds/exchange-threads

Open kody.exchange threads from Kody. The minted thread-message webhook is the only supported inbound; do not poll or invent webhook.site.

src/schema-input-errors.ts

87 lines · 3.1 KB · TypeScript
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<number>(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<string, unknown>)) {
			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}`,
	)
}