@gusnips/br 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Salomé
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # @gusnips/br
2
+
3
+ Checks and formats Brazilian documents and phone numbers: CPF, CNPJ, phones and CEP. The same
4
+ code runs on a server, in a browser and in React Native. No dependencies.
5
+
6
+ ```ts
7
+ import { isValidCnpj } from "@gusnips/br";
8
+
9
+ isValidCnpj("12.ABC.345/01DE-35"); // true
10
+ ```
11
+
12
+ ## CPF and CNPJ
13
+
14
+ ```ts
15
+ import { formatCnpj, isValidCpf, maskDocument } from "@gusnips/br";
16
+
17
+ isValidCpf("111.444.777-35"); // true
18
+ formatCnpj("12abc34501de35"); // "12.ABC.345/01DE-35"
19
+ maskDocument("1114447"); // "111.444.7", for a field as somebody types
20
+ ```
21
+
22
+ **Since July 2026 a new CNPJ can have letters.** The first twelve characters can be A-Z as well as
23
+ digits. The last two, the check digits, are still numbers. `isValidCnpj`, `formatCnpj` and
24
+ `maskCnpj` accept both kinds. A checker that strips letters before it checks turns these CNPJs down,
25
+ so a company registered this year can't sign up.
26
+
27
+ Three kinds of function, and they do different jobs:
28
+
29
+ - `formatCpf` and `formatCnpj` display a **complete** value. They add back the leading zeros a
30
+ number column drops (`1144477735` becomes `011.444.777-35`), and anything that isn't a document
31
+ comes back unchanged.
32
+ - `maskCpf`, `maskCnpj` and `maskDocument` format **partial** input as it is typed, and never pad.
33
+ `maskDocument` switches from CPF to CNPJ after 11 digits, or at the first letter.
34
+ - `isValidCpf` and `isValidCnpj` check the check digits. A value made of one repeated digit is always
35
+ refused, because the arithmetic alone would accept it.
36
+
37
+ `classifyDocument` answers "is this a CPF or a CNPJ?" by shape alone, without checking the digits.
38
+ Government datasets contain test CPFs whose check digits fail, and those are still people.
39
+
40
+ `cpfCheckDigits` and `cnpjCheckDigits` compute the last two digits from the rest. Use them to build a
41
+ valid document in a test, or to complete a head office's CNPJ from its 8-character root:
42
+ `root + "0001"` plus the two digits.
43
+
44
+ ## Phones
45
+
46
+ ```ts
47
+ import { brMobileVariants, formatBrPhone, toE164Br } from "@gusnips/br";
48
+
49
+ toE164Br("(11) 98765-4321"); // "+5511987654321"
50
+ formatBrPhone("5511987654321"); // "(11) 98765-4321"
51
+ brMobileVariants("5511987654321"); // ["5511987654321", "551187654321"]
52
+ ```
53
+
54
+ `parseBrPhone` reads a number however it was written: with or without `+55`, with punctuation, or
55
+ with the trunk `0` people dial between cities. It returns `null` for anything it can't read. It
56
+ checks the area code against the 67 in use, and it refuses a subscriber made of one repeated digit,
57
+ like `99999-9999`.
58
+
59
+ **Brazilian numbers only, on purpose.** A general phone library that reads bare digits guesses the
60
+ country from the first few, and for a Brazilian mobile typed without `+55` it often guesses wrong:
61
+ area code 31 reads as the Netherlands and 81 as Japan. If you also take foreign numbers, send the
62
+ ones that start with `+` and aren't `+55` to an international library, and send the rest here.
63
+
64
+ `55` is both the country code and an area code (Rio Grande do Sul), so length decides first. Eleven
65
+ digits starting with 55 are a mobile in area 55. Only a 12- or 13-digit number carries the country
66
+ code.
67
+
68
+ `brMobileVariants` gives both forms a mobile may be registered under on WhatsApp: with the ninth
69
+ digit and without it. An account created before the ninth digit existed can still use the short
70
+ form. Look up both. Pass it an international number, so run `toE164Br` on user input first. Guessing
71
+ that bare digits are Brazilian is how a foreign number gets matched to somebody else.
72
+
73
+ ## CEP
74
+
75
+ ```ts
76
+ import { formatCep, normalizeCep } from "@gusnips/br";
77
+
78
+ formatCep("01310100"); // "01310-100"
79
+ normalizeCep("01310-100"); // "01310100", or null when it isn't eight digits
80
+ ```
81
+
82
+ Looking a CEP up is a network call to a provider you choose, so it isn't here. A real CEP can also
83
+ cover a whole small town with no street name, so a lookup that finds no street doesn't mean the CEP
84
+ is wrong.
85
+
86
+ ## Not here
87
+
88
+ - **Error messages.** Every function returns a boolean, a string or `null`. The sentence the reader
89
+ sees belongs in your own translations:
90
+ `isValidCpf(value) ? undefined : t("errors.cpfInvalid")`.
91
+ - **zod.** Wrapping a check is one line, `z.string().refine(isValidCpf)`, and the message is yours.
package/dist/cep.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * CEP, the Brazilian postal code: eight digits, shown as `01310-100`.
3
+ *
4
+ * Only the shape lives here. Looking a CEP up is a network call to a provider you choose, and a
5
+ * CEP can be real and still have no street — a whole small town can share one — so "the lookup
6
+ * found no street" is not "this CEP is invalid".
7
+ */
8
+ /** The eight digits, or `null` when there are not eight. */
9
+ export declare function normalizeCep(value: string): string | null;
10
+ /** `01310-100`. Anything that is not eight digits comes back as it was. */
11
+ export declare function formatCep(value: string): string;
12
+ /** Formats a CEP as it is typed. Stops at eight digits. */
13
+ export declare function maskCep(partial: string): string;
14
+ //# sourceMappingURL=cep.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cep.d.ts","sourceRoot":"","sources":["../src/cep.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,4DAA4D;AAC5D,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGzD;AAED,2EAA2E;AAC3E,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAG/C;AAED,2DAA2D;AAC3D,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAG/C"}
package/dist/cep.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * CEP, the Brazilian postal code: eight digits, shown as `01310-100`.
3
+ *
4
+ * Only the shape lives here. Looking a CEP up is a network call to a provider you choose, and a
5
+ * CEP can be real and still have no street — a whole small town can share one — so "the lookup
6
+ * found no street" is not "this CEP is invalid".
7
+ */
8
+ /** The eight digits, or `null` when there are not eight. */
9
+ export function normalizeCep(value) {
10
+ const digits = value.replace(/\D/g, "");
11
+ return digits.length === 8 ? digits : null;
12
+ }
13
+ /** `01310-100`. Anything that is not eight digits comes back as it was. */
14
+ export function formatCep(value) {
15
+ const digits = normalizeCep(value);
16
+ return digits === null ? value : `${digits.slice(0, 5)}-${digits.slice(5)}`;
17
+ }
18
+ /** Formats a CEP as it is typed. Stops at eight digits. */
19
+ export function maskCep(partial) {
20
+ const d = partial.replace(/\D/g, "").slice(0, 8);
21
+ return d.length <= 5 ? d : `${d.slice(0, 5)}-${d.slice(5)}`;
22
+ }
23
+ //# sourceMappingURL=cep.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cep.js","sourceRoot":"","sources":["../src/cep.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,4DAA4D;AAC5D,MAAM,UAAU,YAAY,CAAC,KAAa;IACxC,MAAM,MAAM,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACxC,OAAO,MAAM,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7C,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,SAAS,CAAC,KAAa;IACrC,MAAM,MAAM,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;IACnC,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;AAC9E,CAAC;AAED,2DAA2D;AAC3D,MAAM,UAAU,OAAO,CAAC,OAAe;IACrC,MAAM,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACjD,OAAO,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;AAC9D,CAAC"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * CPF and CNPJ: check digits, display, input masks.
3
+ *
4
+ * Three concerns sit side by side here, and the adopters that merged them kept them apart for a
5
+ * reason: a DISPLAY formatter works on a complete value and zero-pads it, a MASK formats partial
6
+ * input as somebody types and never pads, and a VALIDATOR says whether the check digits hold.
7
+ *
8
+ * The CNPJ is alphanumeric from July 2026 (Receita Federal, IN RFB 2.229/2024): the first twelve
9
+ * characters may be A-Z as well as digits, and the two check digits stay numeric. Every copy this
10
+ * package replaced stripped letters before it checked, so a company registered under the new
11
+ * format was refused everywhere — and one display formatter turned `12.ABC.345/01DE-35` into a
12
+ * different, numeric CNPJ. The check digits are the same mod-11 over the same weights, with each
13
+ * character counting as its ASCII code minus 48, so a digit counts as itself and `A` as 17.
14
+ */
15
+ export type DocumentKind = "cpf" | "cnpj";
16
+ /**
17
+ * Which document this is, by SHAPE only: 11 digits is a CPF, 14 CNPJ characters is a CNPJ.
18
+ *
19
+ * Deliberately not a validity check. Government datasets carry test CPFs that fail the check digit,
20
+ * and an adopter reading those has to know "this is a person" without calling the record invalid —
21
+ * keep "what is it" and "does it check" as two questions.
22
+ */
23
+ export declare function classifyDocument(value: string): DocumentKind | null;
24
+ /** The two check digits for the first nine digits of a CPF. */
25
+ export declare function cpfCheckDigits(base9: string): string;
26
+ /** The two check digits for the first twelve characters of a CNPJ, numeric or alphanumeric. */
27
+ export declare function cnpjCheckDigits(base12: string): string;
28
+ /** Whether a CPF's check digits hold. Formatted or bare. Eleven repeated digits never pass. */
29
+ export declare function isValidCpf(value: string): boolean;
30
+ /** Whether a CNPJ's check digits hold — numeric or alphanumeric, formatted or bare. */
31
+ export declare function isValidCnpj(value: string): boolean;
32
+ /**
33
+ * A complete CPF for display: `123.456.789-09`.
34
+ *
35
+ * Zero-pads, because government datasets store it as a number and drop the leading zeros. Anything
36
+ * that is not a CPF comes back as it was, so a bad row shows as itself rather than as "".
37
+ */
38
+ export declare function formatCpf(value: string): string;
39
+ /**
40
+ * A complete CNPJ for display: `12.345.678/0001-95` or `12.ABC.345/01DE-35`.
41
+ *
42
+ * Zero-pads a numeric value for the same reason {@link formatCpf} does. An alphanumeric one cannot
43
+ * have lost a zero to a number column, so it is never padded. Anything else comes back as it was.
44
+ */
45
+ export declare function formatCnpj(value: string): string;
46
+ /** A CPF for display with its middle hidden: `123.***.***-09`. Anything else comes back as it was. */
47
+ export declare function redactCpf(value: string): string;
48
+ /** Formats a CPF as it is typed. Never pads; stops at 11 digits. */
49
+ export declare function maskCpf(partial: string): string;
50
+ /**
51
+ * Formats a CNPJ as it is typed, letters included. Never pads; stops at 14 characters, and the last
52
+ * two only take digits, because the check digits stay numeric.
53
+ */
54
+ export declare function maskCnpj(partial: string): string;
55
+ /**
56
+ * One field for either document: a CPF mask up to 11 digits, a CNPJ mask past that — or as soon as
57
+ * a letter is typed, since only a CNPJ can carry one.
58
+ */
59
+ export declare function maskDocument(partial: string): string;
60
+ //# sourceMappingURL=document.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"document.d.ts","sourceRoot":"","sources":["../src/document.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,MAAM,CAAC;AAY1C;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAGnE;AAED,+DAA+D;AAC/D,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAWpD;AAED,+FAA+F;AAC/F,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAetD;AAED,+FAA+F;AAC/F,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAKjD;AAED,uFAAuF;AACvF,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAKlD;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKhD;AAED,sGAAsG;AACtG,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED,oEAAoE;AACpE,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAM/C;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAGpD"}
@@ -0,0 +1,155 @@
1
+ /**
2
+ * CPF and CNPJ: check digits, display, input masks.
3
+ *
4
+ * Three concerns sit side by side here, and the adopters that merged them kept them apart for a
5
+ * reason: a DISPLAY formatter works on a complete value and zero-pads it, a MASK formats partial
6
+ * input as somebody types and never pads, and a VALIDATOR says whether the check digits hold.
7
+ *
8
+ * The CNPJ is alphanumeric from July 2026 (Receita Federal, IN RFB 2.229/2024): the first twelve
9
+ * characters may be A-Z as well as digits, and the two check digits stay numeric. Every copy this
10
+ * package replaced stripped letters before it checked, so a company registered under the new
11
+ * format was refused everywhere — and one display formatter turned `12.ABC.345/01DE-35` into a
12
+ * different, numeric CNPJ. The check digits are the same mod-11 over the same weights, with each
13
+ * character counting as its ASCII code minus 48, so a digit counts as itself and `A` as 17.
14
+ */
15
+ /** Digits only. A CPF is numeric and stays numeric, so a pasted label or letter is noise. */
16
+ function cpfCharacters(value) {
17
+ return value.replace(/\D/g, "");
18
+ }
19
+ /** Digits and A-Z, uppercased — the characters a CNPJ can hold. */
20
+ function cnpjCharacters(value) {
21
+ return value.toUpperCase().replace(/[^0-9A-Z]/g, "");
22
+ }
23
+ /**
24
+ * Which document this is, by SHAPE only: 11 digits is a CPF, 14 CNPJ characters is a CNPJ.
25
+ *
26
+ * Deliberately not a validity check. Government datasets carry test CPFs that fail the check digit,
27
+ * and an adopter reading those has to know "this is a person" without calling the record invalid —
28
+ * keep "what is it" and "does it check" as two questions.
29
+ */
30
+ export function classifyDocument(value) {
31
+ if (cpfCharacters(value).length === 11 && !/[A-Za-z]/.test(value))
32
+ return "cpf";
33
+ return /^[0-9A-Z]{12}\d{2}$/.test(cnpjCharacters(value)) ? "cnpj" : null;
34
+ }
35
+ /** The two check digits for the first nine digits of a CPF. */
36
+ export function cpfCheckDigits(base9) {
37
+ const digits = cpfCharacters(base9);
38
+ if (digits.length !== 9)
39
+ throw new RangeError("A CPF base is 9 digits.");
40
+ const next = (source) => {
41
+ let sum = 0;
42
+ for (let i = 0; i < source.length; i++)
43
+ sum += Number(source[i]) * (source.length + 1 - i);
44
+ const rest = (sum * 10) % 11;
45
+ return rest === 10 ? 0 : rest;
46
+ };
47
+ const first = next(digits);
48
+ return `${first}${next(`${digits}${first}`)}`;
49
+ }
50
+ /** The two check digits for the first twelve characters of a CNPJ, numeric or alphanumeric. */
51
+ export function cnpjCheckDigits(base12) {
52
+ const chars = cnpjCharacters(base12);
53
+ if (!/^[0-9A-Z]{12}$/.test(chars))
54
+ throw new RangeError("A CNPJ base is 12 characters.");
55
+ const next = (source) => {
56
+ let sum = 0;
57
+ // The weights run 2, 3 … 9 from the RIGHT and wrap, which is the 5→2, 9→2 walk read backwards.
58
+ for (let i = 0; i < source.length; i++) {
59
+ const weight = ((source.length - 1 - i) % 8) + 2;
60
+ sum += (source.charCodeAt(i) - 48) * weight;
61
+ }
62
+ const rest = sum % 11;
63
+ return rest < 2 ? 0 : 11 - rest;
64
+ };
65
+ const first = next(chars);
66
+ return `${first}${next(`${chars}${first}`)}`;
67
+ }
68
+ /** Whether a CPF's check digits hold. Formatted or bare. Eleven repeated digits never pass. */
69
+ export function isValidCpf(value) {
70
+ const digits = cpfCharacters(value);
71
+ if (digits.length !== 11 || /[A-Za-z]/.test(value))
72
+ return false;
73
+ if (/^(\d)\1{10}$/.test(digits))
74
+ return false;
75
+ return cpfCheckDigits(digits.slice(0, 9)) === digits.slice(9);
76
+ }
77
+ /** Whether a CNPJ's check digits hold — numeric or alphanumeric, formatted or bare. */
78
+ export function isValidCnpj(value) {
79
+ const chars = cnpjCharacters(value);
80
+ if (!/^[0-9A-Z]{12}\d{2}$/.test(chars))
81
+ return false;
82
+ if (/^(.)\1{13}$/.test(chars))
83
+ return false;
84
+ return cnpjCheckDigits(chars.slice(0, 12)) === chars.slice(12);
85
+ }
86
+ /**
87
+ * A complete CPF for display: `123.456.789-09`.
88
+ *
89
+ * Zero-pads, because government datasets store it as a number and drop the leading zeros. Anything
90
+ * that is not a CPF comes back as it was, so a bad row shows as itself rather than as "".
91
+ */
92
+ export function formatCpf(value) {
93
+ const digits = cpfCharacters(value);
94
+ if (digits.length === 0 || digits.length > 11 || /[A-Za-z]/.test(value))
95
+ return value;
96
+ return digits.padStart(11, "0").replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4");
97
+ }
98
+ /**
99
+ * A complete CNPJ for display: `12.345.678/0001-95` or `12.ABC.345/01DE-35`.
100
+ *
101
+ * Zero-pads a numeric value for the same reason {@link formatCpf} does. An alphanumeric one cannot
102
+ * have lost a zero to a number column, so it is never padded. Anything else comes back as it was.
103
+ */
104
+ export function formatCnpj(value) {
105
+ let chars = cnpjCharacters(value);
106
+ if (/^\d{1,13}$/.test(chars))
107
+ chars = chars.padStart(14, "0");
108
+ if (!/^[0-9A-Z]{12}\d{2}$/.test(chars))
109
+ return value;
110
+ return `${chars.slice(0, 2)}.${chars.slice(2, 5)}.${chars.slice(5, 8)}/${chars.slice(8, 12)}-${chars.slice(12)}`;
111
+ }
112
+ /** A CPF for display with its middle hidden: `123.***.***-09`. Anything else comes back as it was. */
113
+ export function redactCpf(value) {
114
+ const digits = cpfCharacters(value);
115
+ if (digits.length !== 11 || /[A-Za-z]/.test(value))
116
+ return value;
117
+ return `${digits.slice(0, 3)}.***.***-${digits.slice(9)}`;
118
+ }
119
+ /** Formats a CPF as it is typed. Never pads; stops at 11 digits. */
120
+ export function maskCpf(partial) {
121
+ const d = cpfCharacters(partial).slice(0, 11);
122
+ if (d.length <= 3)
123
+ return d;
124
+ if (d.length <= 6)
125
+ return `${d.slice(0, 3)}.${d.slice(3)}`;
126
+ if (d.length <= 9)
127
+ return `${d.slice(0, 3)}.${d.slice(3, 6)}.${d.slice(6)}`;
128
+ return `${d.slice(0, 3)}.${d.slice(3, 6)}.${d.slice(6, 9)}-${d.slice(9)}`;
129
+ }
130
+ /**
131
+ * Formats a CNPJ as it is typed, letters included. Never pads; stops at 14 characters, and the last
132
+ * two only take digits, because the check digits stay numeric.
133
+ */
134
+ export function maskCnpj(partial) {
135
+ const all = cnpjCharacters(partial);
136
+ const c = all.slice(0, 12) + all.slice(12).replace(/\D/g, "").slice(0, 2);
137
+ if (c.length <= 2)
138
+ return c;
139
+ if (c.length <= 5)
140
+ return `${c.slice(0, 2)}.${c.slice(2)}`;
141
+ if (c.length <= 8)
142
+ return `${c.slice(0, 2)}.${c.slice(2, 5)}.${c.slice(5)}`;
143
+ if (c.length <= 12)
144
+ return `${c.slice(0, 2)}.${c.slice(2, 5)}.${c.slice(5, 8)}/${c.slice(8)}`;
145
+ return `${c.slice(0, 2)}.${c.slice(2, 5)}.${c.slice(5, 8)}/${c.slice(8, 12)}-${c.slice(12)}`;
146
+ }
147
+ /**
148
+ * One field for either document: a CPF mask up to 11 digits, a CNPJ mask past that — or as soon as
149
+ * a letter is typed, since only a CNPJ can carry one.
150
+ */
151
+ export function maskDocument(partial) {
152
+ const chars = cnpjCharacters(partial);
153
+ return chars.length <= 11 && /^\d*$/.test(chars) ? maskCpf(chars) : maskCnpj(chars);
154
+ }
155
+ //# sourceMappingURL=document.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"document.js","sourceRoot":"","sources":["../src/document.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ export { classifyDocument, cnpjCheckDigits, cpfCheckDigits, formatCnpj, formatCpf, isValidCnpj, isValidCpf, maskCnpj, maskCpf, maskDocument, redactCpf, } from "./document.ts";
2
+ export type { DocumentKind } from "./document.ts";
3
+ export { brMobileVariants, formatBrPhone, isValidBrPhone, maskBrPhone, parseBrPhone, toE164Br, } from "./phone.ts";
4
+ export type { BrPhone } from "./phone.ts";
5
+ export { formatCep, maskCep, normalizeCep } from "./cep.ts";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,cAAc,EACd,UAAU,EACV,SAAS,EACT,WAAW,EACX,UAAU,EACV,QAAQ,EACR,OAAO,EACP,YAAY,EACZ,SAAS,GACV,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EACL,gBAAgB,EAChB,aAAa,EACb,cAAc,EACd,WAAW,EACX,YAAY,EACZ,QAAQ,GACT,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { classifyDocument, cnpjCheckDigits, cpfCheckDigits, formatCnpj, formatCpf, isValidCnpj, isValidCpf, maskCnpj, maskCpf, maskDocument, redactCpf, } from "./document.js";
2
+ export { brMobileVariants, formatBrPhone, isValidBrPhone, maskBrPhone, parseBrPhone, toE164Br, } from "./phone.js";
3
+ export { formatCep, maskCep, normalizeCep } from "./cep.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,cAAc,EACd,UAAU,EACV,SAAS,EACT,WAAW,EACX,UAAU,EACV,QAAQ,EACR,OAAO,EACP,YAAY,EACZ,SAAS,GACV,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,gBAAgB,EAChB,aAAa,EACb,cAAc,EACd,WAAW,EACX,YAAY,EACZ,QAAQ,GACT,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Brazilian phone numbers: read, check, format, and the two forms of an older mobile.
3
+ *
4
+ * Brazilian only, on purpose. A number that is not Brazilian comes back as `null`, never as a
5
+ * guess: a general parser reading bare digits guesses a country from the first ones, and for a
6
+ * Brazilian mobile typed without `+55` that guess is wrong often — area code 31 reads as the
7
+ * Netherlands and 81 as Japan. If you take international numbers too, send the ones that start
8
+ * with `+` and are not `+55` to a real international library, and keep this for the rest.
9
+ */
10
+ export interface BrPhone {
11
+ /** The two-digit area code. */
12
+ ddd: string;
13
+ /** Everything after it: nine digits for a mobile, eight for a landline. */
14
+ subscriber: string;
15
+ kind: "mobile" | "landline";
16
+ }
17
+ /**
18
+ * Reads a Brazilian number in whatever shape a person or a system wrote it — `(11) 98765-4321`,
19
+ * `+55 11 98765-4321`, `5511987654321`, `011 3456-7890` — or returns `null`.
20
+ *
21
+ * **Length decides before the prefix does**, because `55` is ambiguous: it is the country code and
22
+ * also a DDD (Rio Grande do Sul). Eleven digits starting with 55 are a gaúcho mobile, not a country
23
+ * code in front of nine digits; only 12 or 13 digits carry the country. The copy that tested the
24
+ * prefix first also left every São Paulo-state mobile (DDDs 11-19) without its `55`, because it
25
+ * read an 11-digit number starting with 1 as North American.
26
+ *
27
+ * Strict about what it accepts: a real DDD; a mobile is nine digits starting with 9 (every mobile
28
+ * has had the ninth digit since 2016); a landline is eight starting with 2-5; and a subscriber of
29
+ * one repeated digit is a placeholder somebody typed to get past a form.
30
+ */
31
+ export declare function parseBrPhone(input: string): BrPhone | null;
32
+ /** Whether {@link parseBrPhone} reads it. */
33
+ export declare function isValidBrPhone(input: string): boolean;
34
+ /** `+5511987654321`, or `null` when it is not a Brazilian number. Drop the `+` for WhatsApp. */
35
+ export declare function toE164Br(input: string): string | null;
36
+ /** `(11) 98765-4321` or `(11) 3456-7890`. Anything it cannot read comes back as it was. */
37
+ export declare function formatBrPhone(input: string): string;
38
+ /**
39
+ * Formats a number as it is typed: `(41) 98822-9199`. Stops at 11 digits, the longest DDD plus
40
+ * mobile, because a longer string would render as a broken Brazilian number rather than a
41
+ * readable foreign one.
42
+ */
43
+ export declare function maskBrPhone(partial: string): string;
44
+ /**
45
+ * Both forms a Brazilian mobile may be registered under, the given one first: with the ninth
46
+ * digit and without it. Anything else comes back alone.
47
+ *
48
+ * An account created before the ninth digit can still be registered without it, and WhatsApp no
49
+ * longer reliably bridges the two, so a lookup that tries one form misses the person — or opens a
50
+ * second conversation with somebody already in one. Which form an account uses cannot be known
51
+ * from the number, so this gives candidates to look up, never a rewrite.
52
+ *
53
+ * Takes an INTERNATIONAL number (`5511987654321`, `+55 11 98765-4321`), not a bare national one:
54
+ * guessing that ten or eleven bare digits are Brazilian is exactly how a foreign number gets a
55
+ * "twin" that belongs to somebody else. Use {@link toE164Br} first for user input.
56
+ */
57
+ export declare function brMobileVariants(international: string): string[];
58
+ //# sourceMappingURL=phone.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"phone.d.ts","sourceRoot":"","sources":["../src/phone.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAYH,MAAM,WAAW,OAAO;IACtB,+BAA+B;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,2EAA2E;IAC3E,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,QAAQ,GAAG,UAAU,CAAC;CAC7B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CAuB1D;AAED,6CAA6C;AAC7C,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAErD;AAED,gGAAgG;AAChG,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGrD;AAED,2FAA2F;AAC3F,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKnD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAOnD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,EAAE,CAchE"}
package/dist/phone.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Brazilian phone numbers: read, check, format, and the two forms of an older mobile.
3
+ *
4
+ * Brazilian only, on purpose. A number that is not Brazilian comes back as `null`, never as a
5
+ * guess: a general parser reading bare digits guesses a country from the first ones, and for a
6
+ * Brazilian mobile typed without `+55` that guess is wrong often — area code 31 reads as the
7
+ * Netherlands and 81 as Japan. If you take international numbers too, send the ones that start
8
+ * with `+` and are not `+55` to a real international library, and keep this for the rest.
9
+ */
10
+ /**
11
+ * The 67 area codes (DDD) in use. Anything else — 00, 20, 23, 30, 90 — is a typo or a placeholder,
12
+ * never a number somebody can answer.
13
+ */
14
+ const AREA_CODES = new Set("11 12 13 14 15 16 17 18 19 21 22 24 27 28 31 32 33 34 35 37 38 41 42 43 44 45 46 47 48 49 51 53 54 55 61 62 63 64 65 66 67 68 69 71 73 74 75 77 79 81 82 83 84 85 86 87 88 89 91 92 93 94 95 96 97 98 99".split(" "));
15
+ /**
16
+ * Reads a Brazilian number in whatever shape a person or a system wrote it — `(11) 98765-4321`,
17
+ * `+55 11 98765-4321`, `5511987654321`, `011 3456-7890` — or returns `null`.
18
+ *
19
+ * **Length decides before the prefix does**, because `55` is ambiguous: it is the country code and
20
+ * also a DDD (Rio Grande do Sul). Eleven digits starting with 55 are a gaúcho mobile, not a country
21
+ * code in front of nine digits; only 12 or 13 digits carry the country. The copy that tested the
22
+ * prefix first also left every São Paulo-state mobile (DDDs 11-19) without its `55`, because it
23
+ * read an 11-digit number starting with 1 as North American.
24
+ *
25
+ * Strict about what it accepts: a real DDD; a mobile is nine digits starting with 9 (every mobile
26
+ * has had the ninth digit since 2016); a landline is eight starting with 2-5; and a subscriber of
27
+ * one repeated digit is a placeholder somebody typed to get past a form.
28
+ */
29
+ export function parseBrPhone(input) {
30
+ const trimmed = input.trim();
31
+ let digits = trimmed.replace(/\D/g, "");
32
+ if (trimmed.startsWith("+")) {
33
+ // A number written with + says its country, and only +55 is ours.
34
+ if (!digits.startsWith("55"))
35
+ return null;
36
+ digits = digits.slice(2);
37
+ }
38
+ else if (digits.startsWith("0") && (digits.length === 11 || digits.length === 12)) {
39
+ // The trunk 0 people dial between cities. No DDD starts with 0, so this cannot be anything else.
40
+ // ponytail: the carrier code form (0 + 2-digit carrier + DDD) is not read; it comes back null.
41
+ digits = digits.slice(1);
42
+ }
43
+ else if (digits.length === 12 || digits.length === 13) {
44
+ if (!digits.startsWith("55"))
45
+ return null;
46
+ digits = digits.slice(2);
47
+ }
48
+ if (digits.length !== 10 && digits.length !== 11)
49
+ return null;
50
+ const ddd = digits.slice(0, 2);
51
+ const subscriber = digits.slice(2);
52
+ if (!AREA_CODES.has(ddd) || /^(\d)\1+$/.test(subscriber))
53
+ return null;
54
+ if (subscriber.length === 9)
55
+ return subscriber.startsWith("9") ? { ddd, subscriber, kind: "mobile" } : null;
56
+ return /^[2-5]/.test(subscriber) ? { ddd, subscriber, kind: "landline" } : null;
57
+ }
58
+ /** Whether {@link parseBrPhone} reads it. */
59
+ export function isValidBrPhone(input) {
60
+ return parseBrPhone(input) !== null;
61
+ }
62
+ /** `+5511987654321`, or `null` when it is not a Brazilian number. Drop the `+` for WhatsApp. */
63
+ export function toE164Br(input) {
64
+ const phone = parseBrPhone(input);
65
+ return phone === null ? null : `+55${phone.ddd}${phone.subscriber}`;
66
+ }
67
+ /** `(11) 98765-4321` or `(11) 3456-7890`. Anything it cannot read comes back as it was. */
68
+ export function formatBrPhone(input) {
69
+ const phone = parseBrPhone(input);
70
+ if (phone === null)
71
+ return input;
72
+ const { ddd, subscriber } = phone;
73
+ return `(${ddd}) ${subscriber.slice(0, -4)}-${subscriber.slice(-4)}`;
74
+ }
75
+ /**
76
+ * Formats a number as it is typed: `(41) 98822-9199`. Stops at 11 digits, the longest DDD plus
77
+ * mobile, because a longer string would render as a broken Brazilian number rather than a
78
+ * readable foreign one.
79
+ */
80
+ export function maskBrPhone(partial) {
81
+ const d = partial.replace(/\D/g, "").slice(0, 11);
82
+ if (d.length === 0)
83
+ return "";
84
+ if (d.length <= 2)
85
+ return `(${d}`;
86
+ if (d.length <= 6)
87
+ return `(${d.slice(0, 2)}) ${d.slice(2)}`;
88
+ if (d.length <= 10)
89
+ return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`;
90
+ return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;
91
+ }
92
+ /**
93
+ * Both forms a Brazilian mobile may be registered under, the given one first: with the ninth
94
+ * digit and without it. Anything else comes back alone.
95
+ *
96
+ * An account created before the ninth digit can still be registered without it, and WhatsApp no
97
+ * longer reliably bridges the two, so a lookup that tries one form misses the person — or opens a
98
+ * second conversation with somebody already in one. Which form an account uses cannot be known
99
+ * from the number, so this gives candidates to look up, never a rewrite.
100
+ *
101
+ * Takes an INTERNATIONAL number (`5511987654321`, `+55 11 98765-4321`), not a bare national one:
102
+ * guessing that ten or eleven bare digits are Brazilian is exactly how a foreign number gets a
103
+ * "twin" that belongs to somebody else. Use {@link toE164Br} first for user input.
104
+ */
105
+ export function brMobileVariants(international) {
106
+ const digits = international.replace(/\D/g, "");
107
+ const match = /^55(\d{2})(\d{8,9})$/.exec(digits);
108
+ if (match === null)
109
+ return [digits];
110
+ const [, ddd = "", subscriber = ""] = match;
111
+ if (!AREA_CODES.has(ddd))
112
+ return [digits];
113
+ if (subscriber.length === 9 && subscriber.startsWith("9")) {
114
+ return [digits, `55${ddd}${subscriber.slice(1)}`];
115
+ }
116
+ // Eight digits starting 6-9 is a mobile from before the ninth digit; 2-5 is a landline, which
117
+ // never had one.
118
+ if (subscriber.length === 8 && /^[6-9]/.test(subscriber))
119
+ return [digits, `55${ddd}9${subscriber}`];
120
+ return [digits];
121
+ }
122
+ //# sourceMappingURL=phone.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"phone.js","sourceRoot":"","sources":["../src/phone.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;GAGG;AACH,MAAM,UAAU,GAAG,IAAI,GAAG,CACxB,0MAA0M,CAAC,KAAK,CAC9M,GAAG,CACJ,CACF,CAAC;AAUF;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,YAAY,CAAC,KAAa;IACxC,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;IAC7B,IAAI,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACxC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QAC5B,kEAAkE;QAClE,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QAC1C,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC3B,CAAC;SAAM,IAAI,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,EAAE,IAAI,MAAM,CAAC,MAAM,KAAK,EAAE,CAAC,EAAE,CAAC;QACpF,iGAAiG;QACjG,+FAA+F;QAC/F,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC3B,CAAC;SAAM,IAAI,MAAM,CAAC,MAAM,KAAK,EAAE,IAAI,MAAM,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QACxD,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QAC1C,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC3B,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,KAAK,EAAE,IAAI,MAAM,CAAC,MAAM,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC;IAE9D,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC/B,MAAM,UAAU,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACnC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,WAAW,CAAC,IAAI,CAAC,UAAU,CAAC;QAAE,OAAO,IAAI,CAAC;IACtE,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;QACzB,OAAO,UAAU,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;IACjF,OAAO,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AAClF,CAAC;AAED,6CAA6C;AAC7C,MAAM,UAAU,cAAc,CAAC,KAAa;IAC1C,OAAO,YAAY,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC;AACtC,CAAC;AAED,gGAAgG;AAChG,MAAM,UAAU,QAAQ,CAAC,KAAa;IACpC,MAAM,KAAK,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;IAClC,OAAO,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,GAAG,GAAG,KAAK,CAAC,UAAU,EAAE,CAAC;AACtE,CAAC;AAED,2FAA2F;AAC3F,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,MAAM,KAAK,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;IAClC,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IACjC,MAAM,EAAE,GAAG,EAAE,UAAU,EAAE,GAAG,KAAK,CAAC;IAClC,OAAO,IAAI,GAAG,KAAK,UAAU,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AACvE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,OAAe;IACzC,MAAM,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAClD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAC9B,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC,EAAE,CAAC;IAClC,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;IAC7D,IAAI,CAAC,CAAC,MAAM,IAAI,EAAE;QAAE,OAAO,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;IAC/E,OAAO,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;AAC7D,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,gBAAgB,CAAC,aAAqB;IACpD,MAAM,MAAM,GAAG,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAChD,MAAM,KAAK,GAAG,sBAAsB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAClD,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IACpC,MAAM,CAAC,EAAE,GAAG,GAAG,EAAE,EAAE,UAAU,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC;IAC5C,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IAC1C,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,IAAI,UAAU,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QAC1D,OAAO,CAAC,MAAM,EAAE,KAAK,GAAG,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IACpD,CAAC;IACD,8FAA8F;IAC9F,iBAAiB;IACjB,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC;QACtD,OAAO,CAAC,MAAM,EAAE,KAAK,GAAG,IAAI,UAAU,EAAE,CAAC,CAAC;IAC5C,OAAO,CAAC,MAAM,CAAC,CAAC;AAClB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@gusnips/br",
3
+ "version": "0.1.0",
4
+ "description": "Brazilian documents and phone numbers: CPF and CNPJ (the alphanumeric CNPJ too), phones, CEP. Check digits, display, input masks. Zero dependencies, no framework.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Gustavo Salomé",
8
+ "homepage": "https://github.com/gusnips/frontkit/tree/main/br#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/gusnips/frontkit/issues"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "sideEffects": false,
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "keywords": [
33
+ "brazil",
34
+ "cpf",
35
+ "cnpj",
36
+ "cep",
37
+ "phone",
38
+ "validation",
39
+ "typescript"
40
+ ],
41
+ "scripts": {
42
+ "build": "rm -rf dist && tsc",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "vitest run --passWithNoTests",
45
+ "test:watch": "vitest",
46
+ "lint": "eslint src",
47
+ "sync:docs": "cp ../LICENSE .",
48
+ "prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build && bun run sync:docs",
49
+ "release:patch": "bun pm version patch && bun publish --access public",
50
+ "release:minor": "bun pm version minor && bun publish --access public",
51
+ "release:major": "bun pm version major && bun publish --access public"
52
+ },
53
+ "devDependencies": {
54
+ "typescript": "^5.9.3",
55
+ "vitest": "^4.1.2"
56
+ },
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/gusnips/frontkit.git",
60
+ "directory": "br"
61
+ }
62
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatCep, maskCep, normalizeCep } from "./cep.ts";
3
+
4
+ describe("CEP", () => {
5
+ it("reads eight digits in any punctuation", () => {
6
+ expect(normalizeCep("01310-100")).toBe("01310100");
7
+ expect(normalizeCep("01.310-100")).toBe("01310100");
8
+ expect(normalizeCep("1310100")).toBe(null);
9
+ });
10
+
11
+ it("formats a complete one and leaves the rest alone", () => {
12
+ expect(formatCep("01310100")).toBe("01310-100");
13
+ expect(formatCep("1310100")).toBe("1310100");
14
+ });
15
+
16
+ it("masks as it is typed", () => {
17
+ expect(["013", "01310", "013101", "0131010099"].map(maskCep)).toEqual([
18
+ "013",
19
+ "01310",
20
+ "01310-1",
21
+ "01310-100",
22
+ ]);
23
+ });
24
+ });
package/src/cep.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * CEP, the Brazilian postal code: eight digits, shown as `01310-100`.
3
+ *
4
+ * Only the shape lives here. Looking a CEP up is a network call to a provider you choose, and a
5
+ * CEP can be real and still have no street — a whole small town can share one — so "the lookup
6
+ * found no street" is not "this CEP is invalid".
7
+ */
8
+
9
+ /** The eight digits, or `null` when there are not eight. */
10
+ export function normalizeCep(value: string): string | null {
11
+ const digits = value.replace(/\D/g, "");
12
+ return digits.length === 8 ? digits : null;
13
+ }
14
+
15
+ /** `01310-100`. Anything that is not eight digits comes back as it was. */
16
+ export function formatCep(value: string): string {
17
+ const digits = normalizeCep(value);
18
+ return digits === null ? value : `${digits.slice(0, 5)}-${digits.slice(5)}`;
19
+ }
20
+
21
+ /** Formats a CEP as it is typed. Stops at eight digits. */
22
+ export function maskCep(partial: string): string {
23
+ const d = partial.replace(/\D/g, "").slice(0, 8);
24
+ return d.length <= 5 ? d : `${d.slice(0, 5)}-${d.slice(5)}`;
25
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ classifyDocument,
4
+ cnpjCheckDigits,
5
+ cpfCheckDigits,
6
+ formatCnpj,
7
+ formatCpf,
8
+ isValidCnpj,
9
+ isValidCpf,
10
+ maskCnpj,
11
+ maskCpf,
12
+ maskDocument,
13
+ redactCpf,
14
+ } from "./document.ts";
15
+
16
+ // The Receita Federal's own example of the alphanumeric format.
17
+ const ALPHANUMERIC = "12.ABC.345/01DE-35";
18
+
19
+ describe("CPF", () => {
20
+ it("accepts real check digits, formatted or bare", () => {
21
+ expect(isValidCpf("111.444.777-35")).toBe(true);
22
+ expect(isValidCpf("52998224725")).toBe(true);
23
+ expect(cpfCheckDigits("111444777")).toBe("35");
24
+ });
25
+
26
+ it("refuses a wrong digit, a wrong length, a letter, and one repeated digit", () => {
27
+ expect(isValidCpf("111.444.777-36")).toBe(false);
28
+ expect(isValidCpf("1114447773")).toBe(false);
29
+ expect(isValidCpf("111.444.777-3A5")).toBe(false);
30
+ // Every repeated digit passes the arithmetic, which is why it is refused on its own.
31
+ for (let d = 0; d <= 9; d++) expect(isValidCpf(String(d).repeat(11))).toBe(false);
32
+ });
33
+
34
+ it("zero-pads for display, because datasets store it as a number", () => {
35
+ expect(formatCpf("1144477735")).toBe("011.444.777-35");
36
+ expect(formatCpf("not a cpf")).toBe("not a cpf");
37
+ expect(redactCpf("111.444.777-35")).toBe("111.***.***-35");
38
+ });
39
+
40
+ it("masks as it is typed, without padding", () => {
41
+ expect(["1", "1114", "1114447", "1114447773", "111444777356"].map(maskCpf)).toEqual([
42
+ "1",
43
+ "111.4",
44
+ "111.444.7",
45
+ "111.444.777-3",
46
+ "111.444.777-35",
47
+ ]);
48
+ });
49
+ });
50
+
51
+ describe("CNPJ", () => {
52
+ it("checks the alphanumeric CNPJ that every copy it replaces refused", () => {
53
+ expect(isValidCnpj(ALPHANUMERIC)).toBe(true);
54
+ expect(isValidCnpj("12abc34501de35")).toBe(true);
55
+ expect(cnpjCheckDigits("12ABC34501DE")).toBe("35");
56
+ expect(isValidCnpj("12.ABC.345/01DE-36")).toBe(false);
57
+ // The check digits stay numeric.
58
+ expect(isValidCnpj("12.ABC.345/01DE-3A")).toBe(false);
59
+ });
60
+
61
+ it("checks a numeric CNPJ the way it always did", () => {
62
+ expect(isValidCnpj("11.222.333/0001-81")).toBe(true);
63
+ expect(isValidCnpj("11222333000182")).toBe(false);
64
+ for (let d = 0; d <= 9; d++) expect(isValidCnpj(String(d).repeat(14))).toBe(false);
65
+ });
66
+
67
+ // An independent derivation of the numeric weights — the left-to-right 5→2, 9→2 walk the old
68
+ // copies used — so a mistake in the right-to-left formula cannot agree with itself.
69
+ it("agrees with the classic numeric walk on 2,000 random bases", () => {
70
+ const classic = (base: string): string => {
71
+ const next = (s: string): number => {
72
+ let sum = 0;
73
+ let weight = s.length === 12 ? 5 : 6;
74
+ for (const c of s) {
75
+ sum += Number(c) * weight;
76
+ weight = weight === 2 ? 9 : weight - 1;
77
+ }
78
+ return sum % 11 < 2 ? 0 : 11 - (sum % 11);
79
+ };
80
+ const first = next(base);
81
+ return `${first}${next(base + first)}`;
82
+ };
83
+ for (let i = 0; i < 2000; i++) {
84
+ const base = Array.from({ length: 12 }, () => Math.floor(Math.random() * 10)).join("");
85
+ expect(cnpjCheckDigits(base)).toBe(classic(base));
86
+ }
87
+ });
88
+
89
+ it("keeps the letters when it formats, and pads only a numeric value", () => {
90
+ // One copy stripped the letters here and displayed a different, numeric CNPJ.
91
+ expect(formatCnpj("12abc34501de35")).toBe(ALPHANUMERIC);
92
+ expect(formatCnpj("1222333000181")).toBe("01.222.333/0001-81");
93
+ expect(formatCnpj("nope")).toBe("nope");
94
+ });
95
+
96
+ it("masks letters as they are typed and keeps the last two numeric", () => {
97
+ expect(maskCnpj("12abc")).toBe("12.ABC");
98
+ expect(maskCnpj("12ABC34501DE")).toBe("12.ABC.345/01DE");
99
+ expect(maskCnpj("12ABC34501DEX3")).toBe("12.ABC.345/01DE-3");
100
+ expect(maskCnpj("12ABC34501DE35999")).toBe(ALPHANUMERIC);
101
+ });
102
+ });
103
+
104
+ describe("either document", () => {
105
+ it("classifies by shape, not by check digits", () => {
106
+ // A dataset's test CPF fails the check digit and is still a person.
107
+ expect(classifyDocument("111.444.777-00")).toBe("cpf");
108
+ expect(classifyDocument(ALPHANUMERIC)).toBe("cnpj");
109
+ expect(classifyDocument("11.222.333/0001-81")).toBe("cnpj");
110
+ expect(classifyDocument("123")).toBe(null);
111
+ });
112
+
113
+ it("switches from the CPF mask to the CNPJ mask past 11 digits, or at the first letter", () => {
114
+ expect(maskDocument("11144477735")).toBe("111.444.777-35");
115
+ expect(maskDocument("112223330001")).toBe("11.222.333/0001");
116
+ expect(maskDocument("12A")).toBe("12.A");
117
+ });
118
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * CPF and CNPJ: check digits, display, input masks.
3
+ *
4
+ * Three concerns sit side by side here, and the adopters that merged them kept them apart for a
5
+ * reason: a DISPLAY formatter works on a complete value and zero-pads it, a MASK formats partial
6
+ * input as somebody types and never pads, and a VALIDATOR says whether the check digits hold.
7
+ *
8
+ * The CNPJ is alphanumeric from July 2026 (Receita Federal, IN RFB 2.229/2024): the first twelve
9
+ * characters may be A-Z as well as digits, and the two check digits stay numeric. Every copy this
10
+ * package replaced stripped letters before it checked, so a company registered under the new
11
+ * format was refused everywhere — and one display formatter turned `12.ABC.345/01DE-35` into a
12
+ * different, numeric CNPJ. The check digits are the same mod-11 over the same weights, with each
13
+ * character counting as its ASCII code minus 48, so a digit counts as itself and `A` as 17.
14
+ */
15
+
16
+ export type DocumentKind = "cpf" | "cnpj";
17
+
18
+ /** Digits only. A CPF is numeric and stays numeric, so a pasted label or letter is noise. */
19
+ function cpfCharacters(value: string): string {
20
+ return value.replace(/\D/g, "");
21
+ }
22
+
23
+ /** Digits and A-Z, uppercased — the characters a CNPJ can hold. */
24
+ function cnpjCharacters(value: string): string {
25
+ return value.toUpperCase().replace(/[^0-9A-Z]/g, "");
26
+ }
27
+
28
+ /**
29
+ * Which document this is, by SHAPE only: 11 digits is a CPF, 14 CNPJ characters is a CNPJ.
30
+ *
31
+ * Deliberately not a validity check. Government datasets carry test CPFs that fail the check digit,
32
+ * and an adopter reading those has to know "this is a person" without calling the record invalid —
33
+ * keep "what is it" and "does it check" as two questions.
34
+ */
35
+ export function classifyDocument(value: string): DocumentKind | null {
36
+ if (cpfCharacters(value).length === 11 && !/[A-Za-z]/.test(value)) return "cpf";
37
+ return /^[0-9A-Z]{12}\d{2}$/.test(cnpjCharacters(value)) ? "cnpj" : null;
38
+ }
39
+
40
+ /** The two check digits for the first nine digits of a CPF. */
41
+ export function cpfCheckDigits(base9: string): string {
42
+ const digits = cpfCharacters(base9);
43
+ if (digits.length !== 9) throw new RangeError("A CPF base is 9 digits.");
44
+ const next = (source: string): number => {
45
+ let sum = 0;
46
+ for (let i = 0; i < source.length; i++) sum += Number(source[i]) * (source.length + 1 - i);
47
+ const rest = (sum * 10) % 11;
48
+ return rest === 10 ? 0 : rest;
49
+ };
50
+ const first = next(digits);
51
+ return `${first}${next(`${digits}${first}`)}`;
52
+ }
53
+
54
+ /** The two check digits for the first twelve characters of a CNPJ, numeric or alphanumeric. */
55
+ export function cnpjCheckDigits(base12: string): string {
56
+ const chars = cnpjCharacters(base12);
57
+ if (!/^[0-9A-Z]{12}$/.test(chars)) throw new RangeError("A CNPJ base is 12 characters.");
58
+ const next = (source: string): number => {
59
+ let sum = 0;
60
+ // The weights run 2, 3 … 9 from the RIGHT and wrap, which is the 5→2, 9→2 walk read backwards.
61
+ for (let i = 0; i < source.length; i++) {
62
+ const weight = ((source.length - 1 - i) % 8) + 2;
63
+ sum += (source.charCodeAt(i) - 48) * weight;
64
+ }
65
+ const rest = sum % 11;
66
+ return rest < 2 ? 0 : 11 - rest;
67
+ };
68
+ const first = next(chars);
69
+ return `${first}${next(`${chars}${first}`)}`;
70
+ }
71
+
72
+ /** Whether a CPF's check digits hold. Formatted or bare. Eleven repeated digits never pass. */
73
+ export function isValidCpf(value: string): boolean {
74
+ const digits = cpfCharacters(value);
75
+ if (digits.length !== 11 || /[A-Za-z]/.test(value)) return false;
76
+ if (/^(\d)\1{10}$/.test(digits)) return false;
77
+ return cpfCheckDigits(digits.slice(0, 9)) === digits.slice(9);
78
+ }
79
+
80
+ /** Whether a CNPJ's check digits hold — numeric or alphanumeric, formatted or bare. */
81
+ export function isValidCnpj(value: string): boolean {
82
+ const chars = cnpjCharacters(value);
83
+ if (!/^[0-9A-Z]{12}\d{2}$/.test(chars)) return false;
84
+ if (/^(.)\1{13}$/.test(chars)) return false;
85
+ return cnpjCheckDigits(chars.slice(0, 12)) === chars.slice(12);
86
+ }
87
+
88
+ /**
89
+ * A complete CPF for display: `123.456.789-09`.
90
+ *
91
+ * Zero-pads, because government datasets store it as a number and drop the leading zeros. Anything
92
+ * that is not a CPF comes back as it was, so a bad row shows as itself rather than as "".
93
+ */
94
+ export function formatCpf(value: string): string {
95
+ const digits = cpfCharacters(value);
96
+ if (digits.length === 0 || digits.length > 11 || /[A-Za-z]/.test(value)) return value;
97
+ return digits.padStart(11, "0").replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4");
98
+ }
99
+
100
+ /**
101
+ * A complete CNPJ for display: `12.345.678/0001-95` or `12.ABC.345/01DE-35`.
102
+ *
103
+ * Zero-pads a numeric value for the same reason {@link formatCpf} does. An alphanumeric one cannot
104
+ * have lost a zero to a number column, so it is never padded. Anything else comes back as it was.
105
+ */
106
+ export function formatCnpj(value: string): string {
107
+ let chars = cnpjCharacters(value);
108
+ if (/^\d{1,13}$/.test(chars)) chars = chars.padStart(14, "0");
109
+ if (!/^[0-9A-Z]{12}\d{2}$/.test(chars)) return value;
110
+ return `${chars.slice(0, 2)}.${chars.slice(2, 5)}.${chars.slice(5, 8)}/${chars.slice(8, 12)}-${chars.slice(12)}`;
111
+ }
112
+
113
+ /** A CPF for display with its middle hidden: `123.***.***-09`. Anything else comes back as it was. */
114
+ export function redactCpf(value: string): string {
115
+ const digits = cpfCharacters(value);
116
+ if (digits.length !== 11 || /[A-Za-z]/.test(value)) return value;
117
+ return `${digits.slice(0, 3)}.***.***-${digits.slice(9)}`;
118
+ }
119
+
120
+ /** Formats a CPF as it is typed. Never pads; stops at 11 digits. */
121
+ export function maskCpf(partial: string): string {
122
+ const d = cpfCharacters(partial).slice(0, 11);
123
+ if (d.length <= 3) return d;
124
+ if (d.length <= 6) return `${d.slice(0, 3)}.${d.slice(3)}`;
125
+ if (d.length <= 9) return `${d.slice(0, 3)}.${d.slice(3, 6)}.${d.slice(6)}`;
126
+ return `${d.slice(0, 3)}.${d.slice(3, 6)}.${d.slice(6, 9)}-${d.slice(9)}`;
127
+ }
128
+
129
+ /**
130
+ * Formats a CNPJ as it is typed, letters included. Never pads; stops at 14 characters, and the last
131
+ * two only take digits, because the check digits stay numeric.
132
+ */
133
+ export function maskCnpj(partial: string): string {
134
+ const all = cnpjCharacters(partial);
135
+ const c = all.slice(0, 12) + all.slice(12).replace(/\D/g, "").slice(0, 2);
136
+ if (c.length <= 2) return c;
137
+ if (c.length <= 5) return `${c.slice(0, 2)}.${c.slice(2)}`;
138
+ if (c.length <= 8) return `${c.slice(0, 2)}.${c.slice(2, 5)}.${c.slice(5)}`;
139
+ if (c.length <= 12) return `${c.slice(0, 2)}.${c.slice(2, 5)}.${c.slice(5, 8)}/${c.slice(8)}`;
140
+ return `${c.slice(0, 2)}.${c.slice(2, 5)}.${c.slice(5, 8)}/${c.slice(8, 12)}-${c.slice(12)}`;
141
+ }
142
+
143
+ /**
144
+ * One field for either document: a CPF mask up to 11 digits, a CNPJ mask past that — or as soon as
145
+ * a letter is typed, since only a CNPJ can carry one.
146
+ */
147
+ export function maskDocument(partial: string): string {
148
+ const chars = cnpjCharacters(partial);
149
+ return chars.length <= 11 && /^\d*$/.test(chars) ? maskCpf(chars) : maskCnpj(chars);
150
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ export {
2
+ classifyDocument,
3
+ cnpjCheckDigits,
4
+ cpfCheckDigits,
5
+ formatCnpj,
6
+ formatCpf,
7
+ isValidCnpj,
8
+ isValidCpf,
9
+ maskCnpj,
10
+ maskCpf,
11
+ maskDocument,
12
+ redactCpf,
13
+ } from "./document.ts";
14
+ export type { DocumentKind } from "./document.ts";
15
+ export {
16
+ brMobileVariants,
17
+ formatBrPhone,
18
+ isValidBrPhone,
19
+ maskBrPhone,
20
+ parseBrPhone,
21
+ toE164Br,
22
+ } from "./phone.ts";
23
+ export type { BrPhone } from "./phone.ts";
24
+ export { formatCep, maskCep, normalizeCep } from "./cep.ts";
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ brMobileVariants,
4
+ formatBrPhone,
5
+ isValidBrPhone,
6
+ maskBrPhone,
7
+ parseBrPhone,
8
+ toE164Br,
9
+ } from "./phone.ts";
10
+
11
+ describe("parseBrPhone", () => {
12
+ it("reads a number in every shape it gets written in", () => {
13
+ for (const input of [
14
+ "(11) 98765-4321",
15
+ "11987654321",
16
+ "5511987654321",
17
+ "+55 11 98765-4321",
18
+ "011 98765-4321",
19
+ ]) {
20
+ expect(toE164Br(input)).toBe("+5511987654321");
21
+ }
22
+ expect(parseBrPhone("(11) 3456-7890")).toEqual({
23
+ ddd: "11",
24
+ subscriber: "34567890",
25
+ kind: "landline",
26
+ });
27
+ });
28
+
29
+ // One copy tested an 11-digit number starting with 1 as North American before it tested for
30
+ // Brazil, so every mobile in DDDs 11-19 was stored without its 55 and inbound lookups missed.
31
+ it("keeps São Paulo-state mobiles Brazilian", () => {
32
+ for (let ddd = 11; ddd <= 19; ddd++) {
33
+ expect(toE164Br(`${ddd}987654321`)).toBe(`+55${ddd}987654321`);
34
+ }
35
+ });
36
+
37
+ // 55 is the country code AND a DDD. Length decides first.
38
+ it("reads 11 digits starting with 55 as DDD 55, and 13 as the country code", () => {
39
+ expect(toE164Br("55987654321")).toBe("+5555987654321");
40
+ expect(toE164Br("5555987654321")).toBe("+5555987654321");
41
+ expect(toE164Br("5534567890")).toBe("+555534567890");
42
+ });
43
+
44
+ // A general parser guessed a country from bare digits: 31 became the Netherlands, 81 Japan.
45
+ it("never guesses a foreign country", () => {
46
+ expect(toE164Br("31987654321")).toBe("+5531987654321");
47
+ expect(toE164Br("81987654321")).toBe("+5581987654321");
48
+ expect(parseBrPhone("+1 415 555 0100")).toBe(null);
49
+ expect(parseBrPhone("+351 912 345 678")).toBe(null);
50
+ expect(parseBrPhone("14155550100")).toBe(null);
51
+ });
52
+
53
+ it("refuses what nobody can answer", () => {
54
+ expect(isValidBrPhone("(20) 98765-4321")).toBe(false); // no such DDD
55
+ expect(isValidBrPhone("(11) 99999-9999")).toBe(false); // a placeholder
56
+ expect(isValidBrPhone("(31) 61234-5678")).toBe(false); // nine digits, not a mobile
57
+ expect(isValidBrPhone("(11) 8765-4321")).toBe(false); // a mobile from before the ninth digit
58
+ expect(isValidBrPhone("12345")).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe("display", () => {
63
+ it("formats what it can read and leaves the rest alone", () => {
64
+ expect(formatBrPhone("5511987654321")).toBe("(11) 98765-4321");
65
+ expect(formatBrPhone("1134567890")).toBe("(11) 3456-7890");
66
+ expect(formatBrPhone("+1 415 555 0100")).toBe("+1 415 555 0100");
67
+ });
68
+
69
+ it("masks as it is typed", () => {
70
+ expect(
71
+ ["4", "41", "41988", "4198822919", "41988229199", "419882291990"].map(maskBrPhone),
72
+ ).toEqual(["(4", "(41", "(41) 988", "(41) 9882-2919", "(41) 98822-9199", "(41) 98822-9199"]);
73
+ });
74
+ });
75
+
76
+ describe("brMobileVariants", () => {
77
+ it("gives both forms of a mobile, the given one first", () => {
78
+ expect(brMobileVariants("5511987654321")).toEqual(["5511987654321", "551187654321"]);
79
+ expect(brMobileVariants("+55 11 8765-4321")).toEqual(["551187654321", "5511987654321"]);
80
+ });
81
+
82
+ it("gives a landline, a foreign number and an unknown DDD alone", () => {
83
+ expect(brMobileVariants("551134567890")).toEqual(["551134567890"]);
84
+ expect(brMobileVariants("14155550100")).toEqual(["14155550100"]);
85
+ expect(brMobileVariants("5520987654321")).toEqual(["5520987654321"]);
86
+ });
87
+
88
+ // Guessing that bare digits are Brazilian is how a foreign number got a twin that belongs to
89
+ // someone else.
90
+ it("does not treat a bare national number as international", () => {
91
+ expect(brMobileVariants("11987654321")).toEqual(["11987654321"]);
92
+ });
93
+ });
package/src/phone.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Brazilian phone numbers: read, check, format, and the two forms of an older mobile.
3
+ *
4
+ * Brazilian only, on purpose. A number that is not Brazilian comes back as `null`, never as a
5
+ * guess: a general parser reading bare digits guesses a country from the first ones, and for a
6
+ * Brazilian mobile typed without `+55` that guess is wrong often — area code 31 reads as the
7
+ * Netherlands and 81 as Japan. If you take international numbers too, send the ones that start
8
+ * with `+` and are not `+55` to a real international library, and keep this for the rest.
9
+ */
10
+
11
+ /**
12
+ * The 67 area codes (DDD) in use. Anything else — 00, 20, 23, 30, 90 — is a typo or a placeholder,
13
+ * never a number somebody can answer.
14
+ */
15
+ const AREA_CODES = new Set(
16
+ "11 12 13 14 15 16 17 18 19 21 22 24 27 28 31 32 33 34 35 37 38 41 42 43 44 45 46 47 48 49 51 53 54 55 61 62 63 64 65 66 67 68 69 71 73 74 75 77 79 81 82 83 84 85 86 87 88 89 91 92 93 94 95 96 97 98 99".split(
17
+ " ",
18
+ ),
19
+ );
20
+
21
+ export interface BrPhone {
22
+ /** The two-digit area code. */
23
+ ddd: string;
24
+ /** Everything after it: nine digits for a mobile, eight for a landline. */
25
+ subscriber: string;
26
+ kind: "mobile" | "landline";
27
+ }
28
+
29
+ /**
30
+ * Reads a Brazilian number in whatever shape a person or a system wrote it — `(11) 98765-4321`,
31
+ * `+55 11 98765-4321`, `5511987654321`, `011 3456-7890` — or returns `null`.
32
+ *
33
+ * **Length decides before the prefix does**, because `55` is ambiguous: it is the country code and
34
+ * also a DDD (Rio Grande do Sul). Eleven digits starting with 55 are a gaúcho mobile, not a country
35
+ * code in front of nine digits; only 12 or 13 digits carry the country. The copy that tested the
36
+ * prefix first also left every São Paulo-state mobile (DDDs 11-19) without its `55`, because it
37
+ * read an 11-digit number starting with 1 as North American.
38
+ *
39
+ * Strict about what it accepts: a real DDD; a mobile is nine digits starting with 9 (every mobile
40
+ * has had the ninth digit since 2016); a landline is eight starting with 2-5; and a subscriber of
41
+ * one repeated digit is a placeholder somebody typed to get past a form.
42
+ */
43
+ export function parseBrPhone(input: string): BrPhone | null {
44
+ const trimmed = input.trim();
45
+ let digits = trimmed.replace(/\D/g, "");
46
+ if (trimmed.startsWith("+")) {
47
+ // A number written with + says its country, and only +55 is ours.
48
+ if (!digits.startsWith("55")) return null;
49
+ digits = digits.slice(2);
50
+ } else if (digits.startsWith("0") && (digits.length === 11 || digits.length === 12)) {
51
+ // The trunk 0 people dial between cities. No DDD starts with 0, so this cannot be anything else.
52
+ // ponytail: the carrier code form (0 + 2-digit carrier + DDD) is not read; it comes back null.
53
+ digits = digits.slice(1);
54
+ } else if (digits.length === 12 || digits.length === 13) {
55
+ if (!digits.startsWith("55")) return null;
56
+ digits = digits.slice(2);
57
+ }
58
+ if (digits.length !== 10 && digits.length !== 11) return null;
59
+
60
+ const ddd = digits.slice(0, 2);
61
+ const subscriber = digits.slice(2);
62
+ if (!AREA_CODES.has(ddd) || /^(\d)\1+$/.test(subscriber)) return null;
63
+ if (subscriber.length === 9)
64
+ return subscriber.startsWith("9") ? { ddd, subscriber, kind: "mobile" } : null;
65
+ return /^[2-5]/.test(subscriber) ? { ddd, subscriber, kind: "landline" } : null;
66
+ }
67
+
68
+ /** Whether {@link parseBrPhone} reads it. */
69
+ export function isValidBrPhone(input: string): boolean {
70
+ return parseBrPhone(input) !== null;
71
+ }
72
+
73
+ /** `+5511987654321`, or `null` when it is not a Brazilian number. Drop the `+` for WhatsApp. */
74
+ export function toE164Br(input: string): string | null {
75
+ const phone = parseBrPhone(input);
76
+ return phone === null ? null : `+55${phone.ddd}${phone.subscriber}`;
77
+ }
78
+
79
+ /** `(11) 98765-4321` or `(11) 3456-7890`. Anything it cannot read comes back as it was. */
80
+ export function formatBrPhone(input: string): string {
81
+ const phone = parseBrPhone(input);
82
+ if (phone === null) return input;
83
+ const { ddd, subscriber } = phone;
84
+ return `(${ddd}) ${subscriber.slice(0, -4)}-${subscriber.slice(-4)}`;
85
+ }
86
+
87
+ /**
88
+ * Formats a number as it is typed: `(41) 98822-9199`. Stops at 11 digits, the longest DDD plus
89
+ * mobile, because a longer string would render as a broken Brazilian number rather than a
90
+ * readable foreign one.
91
+ */
92
+ export function maskBrPhone(partial: string): string {
93
+ const d = partial.replace(/\D/g, "").slice(0, 11);
94
+ if (d.length === 0) return "";
95
+ if (d.length <= 2) return `(${d}`;
96
+ if (d.length <= 6) return `(${d.slice(0, 2)}) ${d.slice(2)}`;
97
+ if (d.length <= 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`;
98
+ return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`;
99
+ }
100
+
101
+ /**
102
+ * Both forms a Brazilian mobile may be registered under, the given one first: with the ninth
103
+ * digit and without it. Anything else comes back alone.
104
+ *
105
+ * An account created before the ninth digit can still be registered without it, and WhatsApp no
106
+ * longer reliably bridges the two, so a lookup that tries one form misses the person — or opens a
107
+ * second conversation with somebody already in one. Which form an account uses cannot be known
108
+ * from the number, so this gives candidates to look up, never a rewrite.
109
+ *
110
+ * Takes an INTERNATIONAL number (`5511987654321`, `+55 11 98765-4321`), not a bare national one:
111
+ * guessing that ten or eleven bare digits are Brazilian is exactly how a foreign number gets a
112
+ * "twin" that belongs to somebody else. Use {@link toE164Br} first for user input.
113
+ */
114
+ export function brMobileVariants(international: string): string[] {
115
+ const digits = international.replace(/\D/g, "");
116
+ const match = /^55(\d{2})(\d{8,9})$/.exec(digits);
117
+ if (match === null) return [digits];
118
+ const [, ddd = "", subscriber = ""] = match;
119
+ if (!AREA_CODES.has(ddd)) return [digits];
120
+ if (subscriber.length === 9 && subscriber.startsWith("9")) {
121
+ return [digits, `55${ddd}${subscriber.slice(1)}`];
122
+ }
123
+ // Eight digits starting 6-9 is a mobile from before the ninth digit; 2-5 is a landline, which
124
+ // never had one.
125
+ if (subscriber.length === 8 && /^[6-9]/.test(subscriber))
126
+ return [digits, `55${ddd}9${subscriber}`];
127
+ return [digits];
128
+ }