src / detectors.ts
export type SpanType = "CB" | "NIR" | "IBAN" | "TEL" | "EMAIL" | "NOM" | "CUSTOM";
export type Span = {
start: number;
end: number;
type: SpanType;
value: string;
};
export function luhn(digits: string): boolean {
if (digits.length < 13 || digits.length > 19) return false;
let sum = 0;
let alt = false;
for (let i = digits.length - 1; i >= 0; i--) {
const code = digits.charCodeAt(i) - 48;
if (code < 0 || code > 9) return false;
let n = code;
if (alt) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alt = !alt;
}
return sum % 10 === 0;
}
export function nirChecksum(thirteen: string, check: string): boolean {
if (thirteen.length !== 13 || check.length !== 2) return false;
if (!/^\d{13}$/.test(thirteen) || !/^\d{2}$/.test(check)) return false;
const num = parseInt(thirteen, 10);
const expected = 97 - (num % 97);
return expected === parseInt(check, 10);
}
export function ibanCheck(raw: string): boolean {
const iban = raw.replace(/\s+/g, "").toUpperCase();
if (iban.length < 15 || iban.length > 34) return false;
if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(iban)) return false;
const rearranged = iban.slice(4) + iban.slice(0, 4);
let numeric = "";
for (const ch of rearranged) {
const code = ch.charCodeAt(0);
if (code >= 65 && code <= 90) numeric += (code - 55).toString();
else if (code >= 48 && code <= 57) numeric += ch;
else return false;
}
let remainder = 0;
for (let i = 0; i < numeric.length; i += 7) {
const chunk = remainder.toString() + numeric.slice(i, i + 7);
remainder = parseInt(chunk, 10) % 97;
}
return remainder === 1;
}
const CC_RE = /(?:\d[ -]?){12,18}\d/g;
const NIR_RE = /\b([12][ ]?\d{2}[ ]?\d{2}[ ]?\d{2}[ ]?\d{3}[ ]?\d{3})[ ]?(\d{2})\b/g;
const IBAN_RE = /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{4}){2,7}(?:[ ]?[A-Z0-9]{1,4})?\b/g;
const TEL_RE = /(?:\+33|0033)[ .-]?[1-9](?:[ .-]?\d{2}){4}|0[1-9](?:[ .-]?\d{2}){4}/g;
const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
export function detectAll(text: string): Span[] {
const spans: Span[] = [];
for (const m of text.matchAll(EMAIL_RE)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "EMAIL", value: m[0] });
}
for (const m of text.matchAll(IBAN_RE)) {
if (ibanCheck(m[0])) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "IBAN", value: m[0] });
}
}
for (const m of text.matchAll(TEL_RE)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "TEL", value: m[0] });
}
for (const m of text.matchAll(NIR_RE)) {
const thirteen = m[1].replace(/\s/g, "");
const check = m[2];
if (nirChecksum(thirteen, check)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "NIR", value: m[0] });
}
}
for (const m of text.matchAll(CC_RE)) {
const digits = m[0].replace(/[ -]/g, "");
if (luhn(digits)) {
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "CB", value: m[0] });
}
}
return spans;
}
export function dedupeSpans(spans: Span[]): Span[] {
const sorted = [...spans].sort((a, b) => {
const lenDiff = (b.end - b.start) - (a.end - a.start);
if (lenDiff !== 0) return lenDiff;
return a.start - b.start;
});
const taken: Span[] = [];
for (const s of sorted) {
if (taken.some((t) => s.start < t.end && s.end > t.start)) continue;
taken.push(s);
}
taken.sort((a, b) => a.start - b.start);
return taken;
}