@yext/phonenumber-util 0.3.1 → 0.4.1

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.
@@ -0,0 +1,37 @@
1
+ name: Publish to NPM
2
+
3
+ permissions:
4
+ contents: read
5
+ id-token: write
6
+
7
+ on:
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ publish:
12
+ environment: Release
13
+ name: Publish to NPM
14
+ runs-on: ubuntu-latest
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Setup Node.js
21
+ uses: actions/setup-node@v4
22
+ with:
23
+ node-version: '24'
24
+ cache: 'npm'
25
+ registry-url: 'https://registry.npmjs.org'
26
+
27
+ - name: Install dependencies
28
+ run: npm ci
29
+
30
+ - name: Run tests
31
+ run: npm test
32
+
33
+ - name: Run linting
34
+ run: npm run lint
35
+
36
+ - name: Publish to NPM
37
+ run: npm publish --provenance --access public
@@ -15,6 +15,9 @@ jobs:
15
15
  steps:
16
16
  - uses: actions/checkout@v4
17
17
  - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: '18'
20
+ cache: 'npm'
18
21
  - run: npm ci
19
22
  - run: npm run lint
20
23
  - run: npm test
package/.nvmrc CHANGED
@@ -1 +1 @@
1
- 22
1
+ 24
package/CODEOWNERS CHANGED
@@ -1,2 +1,2 @@
1
1
  # The following aliases have approval permissions in this repo:
2
- * @imbrianj @mi4l @DerekWW @jperezhearsay @Bearbar00008
2
+ * @imbrianj @mi4l @DerekWW @jperezhearsay @Bearbar00008 @jocruz-yext
package/README.md CHANGED
@@ -39,6 +39,85 @@ npm test
39
39
  npx vitest run --coverage
40
40
  ```
41
41
 
42
+ #### Release Process
43
+
44
+ This package is automatically published to NPM via GitHub Actions when a new version tag is pushed.
45
+
46
+ To publish a new version:
47
+
48
+ 1. **Bump the version** using one of these commands:
49
+
50
+ ```bash
51
+ npm run version:patch # For bug fixes (0.4.0 → 0.4.1)
52
+ npm run version:minor # For new features (0.4.0 → 0.5.0)
53
+ npm run version:major # For breaking changes (0.4.0 → 1.0.0)
54
+ ```
55
+
56
+ 2. **Push the changes and tags**:
57
+
58
+ ```bash
59
+ npm run release
60
+ ```
61
+
62
+ This will run tests, linting, and push both commits and tags to GitHub.
63
+
64
+ 3. **GitHub Actions will automatically**:
65
+ - Run tests and linting
66
+ - Publish to NPM with provenance
67
+ - Use OIDC authentication for secure publishing
68
+
69
+ ### ⚠️ **SECURITY WARNING**
70
+
71
+ **The `rawNumber` field contains UNSANITIZED user input and poses XSS risks if displayed in web applications.**
72
+
73
+ #### 🚨 **NEVER do this:**
74
+
75
+ ```javascript
76
+ // DANGEROUS - Direct HTML insertion
77
+ document.getElementById('phone').innerHTML = result.rawNumber;
78
+
79
+ // DANGEROUS - Template literal insertion
80
+ element.innerHTML = `Call ${result.rawNumber}`;
81
+
82
+ // DANGEROUS - React without escaping
83
+ return <div>{result.rawNumber}</div>;
84
+ ```
85
+
86
+ #### ✅ **Safe approaches:**
87
+
88
+ ```javascript
89
+ // SAFE - Use formatted version for display
90
+ document.getElementById('phone').textContent = result.formattedNumber;
91
+
92
+ // SAFE - Use rawNumber for string replacement/processing
93
+ const updatedText = originalText.replace(
94
+ result.rawNumber,
95
+ result.formattedNumber,
96
+ );
97
+
98
+ // SAFE - Escape before HTML insertion
99
+ document.getElementById('phone').innerHTML = escapeHtml(result.rawNumber);
100
+
101
+ // SAFE - React with proper text content
102
+ return <div>{result.formattedNumber}</div>;
103
+ ```
104
+
105
+ #### **Why `rawNumber` exists:**
106
+
107
+ The `rawNumber` field enables powerful text replacement functionality:
108
+
109
+ ```javascript
110
+ const text = 'Call me at (310) 555-1234 anytime!';
111
+ const numbers = findNumbersInString(text);
112
+ let updatedText = text;
113
+
114
+ numbers.forEach((phone) => {
115
+ // Replace original with formatted version
116
+ updatedText = updatedText.replace(phone.rawNumber, phone.formattedNumber);
117
+ });
118
+ // Result: "Call me at (310) 555-1234 anytime!" → formatted consistently
119
+ ```
120
+
42
121
  ### Warning
43
122
 
44
123
  The returned object will include a `rawNumber` value. This value is the return of the exact value passed to the function. No sanitization occurs with this value. If you reference this number, ensure you sanitize it _BEFORE_ passing to this function.
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@yext/phonenumber-util",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "author": "bajohnson@hearsaycorp.com",
5
5
  "license": "BSD-3-Clause",
6
6
  "description": "Utility for extracting and validating phone numbers",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
7
10
  "type": "module",
8
11
  "main": "src",
9
12
  "exports": {
@@ -22,19 +25,23 @@
22
25
  "test": "TZ=America/Los_Angeles vitest",
23
26
  "build": "node -e \"console.log('No build script for vanilla JS')\"",
24
27
  "prepare": "husky",
25
- "make-badges": "istanbul-badges-readme"
28
+ "make-badges": "istanbul-badges-readme",
29
+ "version:patch": "npm version patch",
30
+ "version:minor": "npm version minor",
31
+ "version:major": "npm version major",
32
+ "release": "npm run test && npm run lint && git push && git push --tags"
26
33
  },
27
34
  "devDependencies": {
28
- "@eslint/js": "^9.31.0",
29
- "@vitest/coverage-v8": "^3.2.4",
30
- "eslint": "^9.31.0",
31
- "generate-license-file": "4.0.0",
32
- "globals": "^16.3.0",
35
+ "@eslint/js": "^9.38.0",
36
+ "@vitest/coverage-v8": "^4.0.2",
37
+ "eslint": "^9.38.0",
38
+ "generate-license-file": "^4.1.0",
39
+ "globals": "^16.4.0",
33
40
  "husky": "^9.1.7",
34
41
  "istanbul-badges-readme": "^1.9.0",
35
- "lint-staged": "^16.1.2",
42
+ "lint-staged": "^16.2.6",
36
43
  "prettier": "^3.6.2",
37
- "vitest": "^3.2.4"
44
+ "vitest": "^4.0.2"
38
45
  },
39
46
  "eslintConfig": {},
40
47
  "lint-staged": {
@@ -433,6 +433,11 @@ describe('Phone number pretty formatting', () => {
433
433
  regionCode: '47',
434
434
  format: findPhoneFormat({ regionCode: '47', e164: '+471740876543' }),
435
435
  },
436
+ egyptStringFormat: {
437
+ e164: '+201012345678',
438
+ regionCode: '20',
439
+ format: findPhoneFormat({ regionCode: '20', e164: '+201012345678' }),
440
+ },
436
441
  };
437
442
 
438
443
  expect(formatPhoneNumber(testNumbers.nullCase)).toBe(null);
@@ -443,5 +448,68 @@ describe('Phone number pretty formatting', () => {
443
448
  expect(formatPhoneNumber(testNumbers.norwayUnexpected)).toBe(
444
449
  '+47174087654',
445
450
  );
451
+ // Egypt has a string format (not array), this tests the else if (formatRaw)
452
+ expect(formatPhoneNumber(testNumbers.egyptStringFormat)).toBe(
453
+ '+20 101 234 5678',
454
+ );
455
+ });
456
+ });
457
+
458
+ describe('E.164 formatting edge cases', () => {
459
+ describe('formatPhoneNumberForE164 with missing required fields', () => {
460
+ it('should format US numbers with separate area code correctly', () => {
461
+ expect(
462
+ formatPhoneNumberForE164({
463
+ regionCode: '1',
464
+ areaCode: '310',
465
+ localNumber: '3496200',
466
+ }),
467
+ ).toBe('+13103496200');
468
+ });
469
+
470
+ it('should format international numbers without area code correctly', () => {
471
+ expect(
472
+ formatPhoneNumberForE164({
473
+ regionCode: '44',
474
+ areaCode: null,
475
+ localNumber: '2079460958',
476
+ }),
477
+ ).toBe('+442079460958');
478
+ });
479
+ });
480
+
481
+ describe('International number parsing with unknown region codes', () => {
482
+ it('should handle international numbers that do not match any known region code in the loop', () => {
483
+ // Test a number with + prefix and valid length but an unrecognized region code
484
+ // The parser should try all region code lengths (1, 2, 3 digits) but find no match in PHONE_FORMATS
485
+ const phoneParts = getPhoneParts('+00012345678');
486
+ // Should not have regionCode since +000, +00, or +0 are not valid region codes
487
+ expect(phoneParts.regionCode).toBeNull();
488
+ expect(phoneParts.localNumber).toBeNull();
489
+ });
490
+
491
+ it('should handle numbers that skip the international parsing else-if block entirely', () => {
492
+ // Test numbers with + prefix that exceed the maximum length for international number parsing
493
+ // The parser checks strippedPhoneNumber.length <= 14, so 16 chars (15 digits + plus sign) is too long
494
+ // This ensures the else-if branch is not entered, leaving regionCode and localNumber as null
495
+ const longNumber = getPhoneParts('+123456789012345'); // 15 digits, 16 chars total
496
+ expect(longNumber.regionCode).toBeNull();
497
+ expect(longNumber.localNumber).toBeNull();
498
+ });
499
+ });
500
+
501
+ describe('findNumbersInString with multiple phone numbers', () => {
502
+ it('should extract multiple valid phone numbers from a single string', () => {
503
+ const text = 'Call me at +1 (310) 349-6200 or +44 20 7946 0958.';
504
+ const results = findNumbersInString(text);
505
+ expect(results.length).toBe(2);
506
+ expect(results[0].e164).toBe('+13103496200');
507
+ expect(results[1].e164).toBe('+442079460958');
508
+ });
509
+
510
+ it('should return an empty array when no valid phone numbers are present', () => {
511
+ const text = 'No phone numbers here!';
512
+ expect(findNumbersInString(text)).toEqual([]);
513
+ });
446
514
  });
447
515
  });
@@ -5,6 +5,7 @@ import {
5
5
  findTimeDetails,
6
6
  findTimeFromAreaCode,
7
7
  findRegionFromRegionCode,
8
+ findAllNumbersInfoInString,
8
9
  } from '../geo.js';
9
10
  import { AREA_CODE_LIST } from '../areaCodeList.js';
10
11
  import { AREA_CODES, REGION_CODES } from '../phoneCodes.js';
@@ -188,6 +189,200 @@ const canadianPhone = {
188
189
  },
189
190
  };
190
191
 
192
+ const longStringWithNumbers = `
193
+ It was a cold, rainy evening when Sophie stumbled upon the weathered journal in the attic of her late grandfather's home. The journal's leather cover was cracked, its pages yellowed with time. Inside, however, was a web of mystery that would unravel Sophie's life over the coming weeks. The most intriguing part? Each entry ended with a phone number-a mix of US, Canadian, and international numbers-written in her grandfather's meticulous hand.
194
+ The first number, 617-555-0134, was scribbled below a cryptic entry about "the Boston job." Sophie's heart raced as she dialed the Massachusetts number. A gruff voice answered on the third ring. "This is Detective Harris. Who's calling?" Sophie's voice trembled as she explained the journal. Harris's tone softened. "If that journal belonged to Robert Fields, you need to be careful. He was involved in some deep, dangerous stuff."
195
+ Sophie wasn't deterred. Instead, her curiosity grew. The next number was Canadian: +1-416-555-2468, written beneath an entry about a maple leaf emblem. The line connected to a cafe in Toronto. A barista named Elena answered and, after a moment of hesitation, said, "I remember Mr. Fields. He came in every Tuesday for a year. Always ordered the same thing-a double espresso-and left me an envelope every time."
196
+ Sophie's pulse quickened as Elena offered to send a picture of one of the envelopes. Within an hour, Sophie received an email with an image showing a wax seal bearing an intricate insignia. Below it, another phone number was scrawled: +44 20 7946 0958, a UK number. Without hesitation, Sophie dialed.
197
+ "Briggs Antiquities, London," a posh voice answered. Sophie explained her discovery, and the receptionist transferred her to a man named Charles. "Ah, Robert Fields," Charles said wistfully. "He was a loyal patron, always seeking artifacts tied to the Knights Templar. If you have his journal, you might find yourself part of a larger puzzle."
198
+ The puzzle, it seemed, spanned the globe. Another entry mentioned "the desert winds" and was linked to a number in Dubai: +971 4 555 1234. The number connected her to a man named Tariq, who revealed that Robert had been searching for a rare relic-a golden compass said to point to treasure. Tariq's description matched the insignia Sophie had seen on the envelope.
199
+ "But why so many numbers?" Sophie muttered to herself. Each call led to another layer of intrigue. A South African number, +27 21 555 6789, took her to Cape Town, where a historian named Amara spoke of Robert's fascination with lost languages. "He believed there was a cipher hidden in ancient texts. If he left you his journal, you're meant to decode it."
200
+ Another number, +55-11-5555-1234 in Brazil, led her to a tech entrepreneur in Sao Paulo who had once helped Robert crack encrypted files. "I thought he was crazy," the entrepreneur said. "But now? Maybe he was onto something."
201
+ Sophie pieced together her grandfather's movements over decades. Each phone number represented a person who had played a role in his quest. An Australian number, +61 2 5551 2345, introduced her to a diver named Liam who had helped Robert retrieve artifacts from a shipwreck. "He was fearless," Liam recalled. "And determined."
202
+ Back in the US, a Seattle number, 206-555-7890, connected Sophie to a lawyer who held a key to Robert's safety deposit box. Inside was a single piece of parchment and yet another number: +49 30 555 4321, this time in Germany. The number belonged to a librarian in Berlin who recognized the parchment as part of a centuries-old map.
203
+ The map, Sophie realized, was the heart of the mystery. Each phone number had been a breadcrumb leading her closer to understanding her grandfather's obsession. The final number, scribbled in bold at the back of the journal, was unlike the others. It was an Argentinian number: +54 11 5555 6789. Sophie hesitated, her finger hovering over the dial button. She took a deep breath and called.
204
+ "Sophie Fields," a voice answered before she could speak. "We've been expecting you."
205
+ "Who is this?" she demanded, her voice trembling.
206
+ "A friend of your grandfather," the voice replied. "He knew you would finish what he started."
207
+ The call ended abruptly, leaving Sophie with more questions than answers. But as she stared at the journal, now marked with notes and connections, she felt a sense of purpose. Each phone number was a clue, each person a piece of a puzzle her grandfather had trusted her to solve.
208
+ And Sophie was determined to finish what Robert Fields had begun.`;
209
+ const longStringOutput = [
210
+ {
211
+ index: 464,
212
+ lastIndex: 476,
213
+ areaCode: '617',
214
+ e164: '+16175550134',
215
+ format: '(xxx) xxx-xxxx',
216
+ formattedNumber: '(617) 555-0134',
217
+ href: 'tel:+16175550134',
218
+ localNumber: '5550134',
219
+ rawNumber: '617-555-0134',
220
+ regionCode: '1',
221
+ timezoneOffset: '-04:00',
222
+ stateHasMultipleTimezones: false,
223
+ areaCodeHasMultipleTimezones: false,
224
+ daylightSavings: true,
225
+ estimatedTime: false,
226
+ state: { name: 'Massachusetts', code: 'MA' },
227
+ region: { name: 'United States', code: 'US', flag: '🇺🇸' },
228
+ localTimeReadable: '11:00:00 AM',
229
+ localTime24Hour: '11:00:00',
230
+ isTCPAQuietHours: false,
231
+ isQuietHours: false,
232
+ },
233
+ {
234
+ index: 961,
235
+ lastIndex: 976,
236
+ areaCode: '416',
237
+ e164: '+14165552468',
238
+ format: '(xxx) xxx-xxxx',
239
+ formattedNumber: '(416) 555-2468',
240
+ href: 'tel:+14165552468',
241
+ localNumber: '5552468',
242
+ rawNumber: '+1-416-555-2468',
243
+ regionCode: '1',
244
+ timezoneOffset: '-04:00',
245
+ stateHasMultipleTimezones: true,
246
+ areaCodeHasMultipleTimezones: false,
247
+ daylightSavings: true,
248
+ estimatedTime: false,
249
+ state: { name: 'Ontario', code: 'ON' },
250
+ region: { name: 'Canada', code: 'CA', flag: '🇨🇦' },
251
+ localTimeReadable: '11:00:00 AM',
252
+ localTime24Hour: '11:00:00',
253
+ isCRTCQuietHours: false,
254
+ isQuietHours: false,
255
+ },
256
+ {
257
+ index: 1524,
258
+ lastIndex: 1540,
259
+ areaCode: null,
260
+ e164: '+442079460958',
261
+ format: '+xx xxxx xxxxxx',
262
+ formattedNumber: '+44 2079 460958',
263
+ href: 'tel:+442079460958',
264
+ localNumber: '2079460958',
265
+ rawNumber: '+44 20 7946 0958',
266
+ regionCode: '44',
267
+ name: 'United Kingdom',
268
+ code: 'GB',
269
+ flag: '🇬🇧',
270
+ },
271
+ {
272
+ index: 2056,
273
+ lastIndex: 2071,
274
+ areaCode: null,
275
+ e164: '+97145551234',
276
+ format: '+xxx xx xxx xxxx',
277
+ formattedNumber: '+97145551234',
278
+ href: 'tel:+97145551234',
279
+ localNumber: '45551234',
280
+ rawNumber: '+971 4 555 1234',
281
+ regionCode: '971',
282
+ name: 'United Arab Emirates',
283
+ code: 'AE',
284
+ flag: '🇦🇪',
285
+ },
286
+ {
287
+ index: 2422,
288
+ lastIndex: 2437,
289
+ areaCode: null,
290
+ e164: '+27215556789',
291
+ format: '+xx xx xxx xxxx',
292
+ formattedNumber: '+27 21 555 6789',
293
+ href: 'tel:+27215556789',
294
+ localNumber: '215556789',
295
+ rawNumber: '+27 21 555 6789',
296
+ regionCode: '27',
297
+ name: 'South Africa',
298
+ code: 'ZA',
299
+ flag: '🇿🇦',
300
+ },
301
+ {
302
+ index: 2672,
303
+ lastIndex: 2688,
304
+ areaCode: null,
305
+ e164: '+551155551234',
306
+ format: '+xx xx xxxx-xxxx',
307
+ formattedNumber: '+55 11 5555-1234',
308
+ href: 'tel:+551155551234',
309
+ localNumber: '1155551234',
310
+ rawNumber: '+55-11-5555-1234',
311
+ regionCode: '55',
312
+ name: 'Brazil',
313
+ code: 'BR',
314
+ flag: '🇧🇷',
315
+ },
316
+ {
317
+ index: 3045,
318
+ lastIndex: 3060,
319
+ areaCode: null,
320
+ e164: '+61255512345',
321
+ format: '+xx xxx xxx xxx',
322
+ formattedNumber: '+61 255 512 345',
323
+ href: 'tel:+61255512345',
324
+ localNumber: '255512345',
325
+ rawNumber: '+61 2 5551 2345',
326
+ regionCode: '61',
327
+ name: 'Australia',
328
+ code: 'AU',
329
+ flag: '🇦🇺',
330
+ },
331
+ {
332
+ index: 3244,
333
+ lastIndex: 3256,
334
+ areaCode: '206',
335
+ e164: '+12065557890',
336
+ format: '(xxx) xxx-xxxx',
337
+ formattedNumber: '(206) 555-7890',
338
+ href: 'tel:+12065557890',
339
+ localNumber: '5557890',
340
+ rawNumber: '206-555-7890',
341
+ regionCode: '1',
342
+ timezoneOffset: '-07:00',
343
+ stateHasMultipleTimezones: false,
344
+ areaCodeHasMultipleTimezones: false,
345
+ daylightSavings: true,
346
+ estimatedTime: false,
347
+ state: { name: 'Washington', code: 'WA' },
348
+ region: { name: 'United States', code: 'US', flag: '🇺🇸' },
349
+ localTimeReadable: '8:00:00 AM',
350
+ localTime24Hour: '08:00:00',
351
+ isTCPAQuietHours: false,
352
+ isQuietHours: false,
353
+ },
354
+ {
355
+ index: 3397,
356
+ lastIndex: 3412,
357
+ areaCode: null,
358
+ e164: '+49305554321',
359
+ format: '+xx xx xxxxxxx',
360
+ formattedNumber: '+49 30 5554321',
361
+ href: 'tel:+49305554321',
362
+ localNumber: '305554321',
363
+ rawNumber: '+49 30 555 4321',
364
+ regionCode: '49',
365
+ name: 'Germany',
366
+ code: 'DE',
367
+ flag: '🇩🇪',
368
+ },
369
+ {
370
+ index: 3820,
371
+ lastIndex: 3836,
372
+ areaCode: null,
373
+ e164: '+541155556789',
374
+ format: '+xx xxx-xxx-xxxx',
375
+ formattedNumber: '+54 115-555-6789',
376
+ href: 'tel:+541155556789',
377
+ localNumber: '1155556789',
378
+ rawNumber: '+54 11 5555 6789',
379
+ regionCode: '54',
380
+ name: 'Argentina',
381
+ code: 'AR',
382
+ flag: '🇦🇷',
383
+ },
384
+ ];
385
+
191
386
  describe('Validate that every allow-list area code has matching geo and time info', () => {
192
387
  it('should ensure every area code in AREA_CODE_LIST has a matching region code in AREA_CODES', () => {
193
388
  AREA_CODE_LIST.forEach((areaCode) => {
@@ -204,7 +399,7 @@ describe('Validate that every allow-list area code has matching geo and time inf
204
399
  });
205
400
 
206
401
  Object.keys(AREA_CODES).forEach((areaCode) => {
207
- const exists = AREA_CODE_LIST.includes(areaCode);
402
+ const exists = AREA_CODE_LIST.has(areaCode);
208
403
 
209
404
  if (!exists) {
210
405
  console.warn(
@@ -380,3 +575,37 @@ describe('Provides region name for a given region code', () => {
380
575
  expect(findRegionFromRegionCode(33).name).toEqual('France');
381
576
  });
382
577
  });
578
+
579
+ describe('Extracts all useful phone info from a long string of text', () => {
580
+ it('Returns all phone numbers and geo data', () => {
581
+ const numbers = findAllNumbersInfoInString(
582
+ longStringWithNumbers,
583
+ new Date('2024-07-20T08:00:00'),
584
+ );
585
+
586
+ expect(numbers).toEqual(longStringOutput);
587
+ });
588
+ });
589
+
590
+ describe('Area code region information mapping', () => {
591
+ it('should include region details when area code has region information', () => {
592
+ const result = findTimeFromAreaCode('206', new Date('2024-07-15T08:00:00'));
593
+
594
+ expect(result.region).toBeDefined();
595
+ expect(result.region.name).toBe('United States');
596
+ expect(result.region.code).toBe('US');
597
+ expect(result.state).toBeDefined();
598
+ expect(result.state.name).toBe('Washington');
599
+ expect(result.state.code).toBe('WA');
600
+ });
601
+
602
+ it('should include region flag for area codes with region property', () => {
603
+ // Test with Puerto Rico which has a region property with flag
604
+ const result = findTimeFromAreaCode('787', new Date('2024-07-15T08:00:00'));
605
+
606
+ expect(result.region).toBeDefined();
607
+ expect(result.region.flag).toBeDefined();
608
+ expect(result.region.name).toBe('Puerto Rico');
609
+ expect(result.region.code).toBe('PR');
610
+ });
611
+ });
@@ -2,7 +2,7 @@
2
2
  // If a new area code is added to this array, it must also be added to the AREA_CODES object in phoneCodes.js.
3
3
  // If a new area code is added and covers a region that has multiple timezones, it will need to be added to the STATES_WITH_MULTIPLE_TIMEZONES object in timezones.js.
4
4
  // If a new area code is added and covers a region that has portions that do and portions that do not adhere to daylight savings time, it will need to be added to the AREA_CODES_WITH_MULTIPLE_DAYLIGHT_SAVINGS object in daylightSavings.js.
5
- export const AREA_CODE_LIST = [
5
+ export const AREA_CODE_LIST = new Set([
6
6
  '201',
7
7
  '202',
8
8
  '203',
@@ -504,4 +504,4 @@ export const AREA_CODE_LIST = [
504
504
  '985',
505
505
  '986',
506
506
  '989',
507
- ];
507
+ ]);
package/src/base.d.ts CHANGED
@@ -14,6 +14,11 @@ export interface PhoneValidationResult {
14
14
  isValid: boolean;
15
15
  }
16
16
 
17
+ export interface PhoneNumberInText extends PhoneParts {
18
+ index: number;
19
+ lastIndex: number;
20
+ }
21
+
17
22
  export function formatPhoneNumberForE164(
18
23
  phoneParts: Pick<PhoneParts, 'regionCode' | 'areaCode' | 'localNumber'>
19
24
  ): string | null;
@@ -22,26 +27,23 @@ export function formatPhoneNumberLink(
22
27
  phoneParts: Pick<PhoneParts, 'regionCode' | 'areaCode' | 'localNumber'>
23
28
  ): string | null;
24
29
 
25
- export function isValidPhoneNumber(phoneNumber: string): boolean;
30
+ export function isValidPhoneNumber(phoneNumber?: string | null | undefined): boolean;
26
31
 
27
- export function isValidPhoneNumberWithDescription(phoneNumber: string): PhoneValidationResult;
32
+ export function isValidPhoneNumberWithDescription(phoneNumber?: string | null | undefined): PhoneValidationResult;
28
33
 
29
- export function getPhoneParts(phoneNumber?: string | null): PhoneParts;
34
+ export function getPhoneParts(phoneNumber?: string | null | undefined): PhoneParts;
30
35
 
31
- export function sanitizeRawNumber(phoneNumber: string): string;
36
+ export function sanitizeRawNumber(phoneNumber?: string | null | undefined): string;
32
37
 
33
- export function findNumbersInString(text: string): Array<{
34
- index: number;
35
- lastIndex: number;
36
- } & PhoneParts>;
38
+ export function findNumbersInString(text?: string | null | undefined): PhoneNumberInText[];
37
39
 
38
40
  export function findPhoneFormat(params: {
39
- regionCode: string;
40
- e164: string;
41
+ regionCode?: string | null;
42
+ e164?: string | null;
41
43
  }): string;
42
44
 
43
45
  export function formatPhoneNumber(params: {
44
- format: string;
45
- e164: string;
46
- regionCode: string;
46
+ format?: string | null;
47
+ e164?: string | null;
48
+ regionCode?: string | null;
47
49
  }): string | null;
package/src/base.js CHANGED
@@ -225,7 +225,7 @@ export const getPhoneParts = (phoneNumber) => {
225
225
  // If no region code is provided, assume US with the format 3109309000 after being stripped of non-numeric values.
226
226
  // We'll try and derive the area code by looking it up against the known area codes.
227
227
  else if (strippedPhoneNumber.length === US_PHONE_LENGTH) {
228
- if (AREA_CODE_LIST.indexOf(strippedPhoneNumber.substring(0, 3)) !== -1) {
228
+ if (AREA_CODE_LIST.has(strippedPhoneNumber.substring(0, 3))) {
229
229
  phoneParts.regionCode = '1';
230
230
  phoneParts.areaCode = strippedPhoneNumber.substring(0, 3);
231
231
  phoneParts.localNumber = strippedPhoneNumber.substring(3);
@@ -264,7 +264,7 @@ export const getPhoneParts = (phoneNumber) => {
264
264
  if (
265
265
  phoneParts.regionCode === '1' &&
266
266
  phoneParts.areaCode &&
267
- AREA_CODE_LIST.indexOf(phoneParts.areaCode) === -1
267
+ !AREA_CODE_LIST.has(phoneParts.areaCode)
268
268
  ) {
269
269
  phoneParts.areaCode = null;
270
270
  }
@@ -362,15 +362,16 @@ export const findPhoneFormat = ({ regionCode, e164 }) => {
362
362
  if (formatRaw && numberLength) {
363
363
  // The PHONE_FORMATS will have arrays for regions with inconsistent number lengths / formats.
364
364
  if (Array.isArray(formatRaw)) {
365
- formatRaw.forEach((value) => {
365
+ for (const value of formatRaw) {
366
366
  const templateLength = value.split('x').length - 1;
367
367
  if (numberLength === templateLength) {
368
368
  format = value;
369
+ break;
369
370
  }
370
- });
371
+ }
371
372
  }
372
373
  // Some region (such as the US) will have a consistent format, so we expect a string.
373
- else if (formatRaw) {
374
+ else {
374
375
  format = formatRaw;
375
376
  }
376
377
  }
package/src/geo.d.ts CHANGED
@@ -1,40 +1,86 @@
1
- export function isDaylightSavingTime(date?: Date): boolean;
2
-
3
- export function formatTimeOffset(offset: string): string;
1
+ export interface RegionInfo {
2
+ name: string;
3
+ code: string;
4
+ flag: string;
5
+ }
4
6
 
5
- export function offsetTieBreaker(timezones: string[], date: Date): string;
7
+ export interface StateInfo {
8
+ name: string;
9
+ code: string;
10
+ }
6
11
 
7
- export function findTimeDetails(
8
- offset: string,
9
- date: Date,
10
- state: string
11
- ): {
12
+ export interface TimeDetails {
12
13
  localTimeReadable: string;
13
14
  localTime24Hour: string;
14
15
  isTCPAQuietHours?: boolean;
15
16
  isCRTCQuietHours?: boolean;
16
17
  isQuietHours: boolean;
17
- };
18
+ }
18
19
 
19
- export function findTimeFromAreaCode(
20
- areaCode: string,
21
- date?: Date
22
- ): {
20
+ export interface AreaCodeTimeInfo {
23
21
  timezoneOffset: string | null;
24
22
  daylightSavings: boolean | null;
25
23
  stateHasMultipleTimezones: boolean | null;
26
- state: { name: string; code: string } | null;
27
- region?: { name: string; code: string; flag: string };
28
24
  areaCodeHasMultipleTimezones: boolean | null;
29
25
  estimatedTime: boolean;
26
+ state?: StateInfo;
27
+ region?: RegionInfo;
28
+ localTimeReadable?: string;
30
29
  localTime24Hour?: string;
30
+ isTCPAQuietHours?: boolean;
31
+ isCRTCQuietHours?: boolean;
32
+ isQuietHours?: boolean;
33
+ }
34
+
35
+ export interface PhoneNumberWithGeoInfo {
36
+ index: number;
37
+ lastIndex: number;
38
+ areaCode: string | null;
39
+ e164: string | null;
40
+ format: string | null;
41
+ formattedNumber: string | null;
42
+ href: string | null;
43
+ localNumber: string | null;
44
+ rawNumber: string | undefined; // Can be undefined, matches base PhoneParts
45
+ regionCode: string | null;
46
+ // Geo-specific fields (all present in actual return)
47
+ timezoneOffset?: string | null;
48
+ daylightSavings?: boolean | null;
49
+ stateHasMultipleTimezones?: boolean | null;
50
+ areaCodeHasMultipleTimezones?: boolean | null;
51
+ estimatedTime?: boolean;
52
+ state?: StateInfo | null;
53
+ region?: RegionInfo | null;
31
54
  localTimeReadable?: string;
55
+ localTime24Hour?: string;
32
56
  isTCPAQuietHours?: boolean;
33
57
  isCRTCQuietHours?: boolean;
34
58
  isQuietHours?: boolean;
35
- };
59
+ }
60
+
61
+ export function isDaylightSavingTime(date?: Date): boolean;
62
+
63
+ export function formatTimeOffset(offset: string): string;
64
+
65
+ export function offsetTieBreaker(timezones: string[], date: Date): string;
66
+
67
+ export function findTimeDetails(
68
+ offset: string,
69
+ date: Date,
70
+ stateName: string
71
+ ): TimeDetails;
72
+
73
+ export function findTimeFromAreaCode(
74
+ areaCode: string,
75
+ date?: Date
76
+ ): AreaCodeTimeInfo;
36
77
 
37
78
  export function findRegionFromRegionCode(
38
79
  regionCode: string | number,
39
80
  areaCode?: string
40
- ): { name: string; code: string; flag: string } | undefined;
81
+ ): RegionInfo | null;
82
+
83
+ export function findAllNumbersInfoInString(
84
+ text: string,
85
+ date?: Date
86
+ ): PhoneNumberWithGeoInfo[];
package/src/geo.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  STATES_THAT_DONT_HAVE_DAYLIGHT_SAVINGS,
13
13
  AREA_CODES_WITH_MULTIPLE_DAYLIGHT_SAVINGS,
14
14
  } from './daylightSavings.js';
15
+ import { findNumbersInString } from './base.js';
15
16
 
16
17
  /**
17
18
  * Determines whether the given date is within daylight saving time for the local time zone.
@@ -154,13 +155,11 @@ export function findTimeFromAreaCode(areaCode, date = new Date()) {
154
155
  code: AREA_CODES[areaCode].code,
155
156
  };
156
157
 
157
- if (AREA_CODES[areaCode].region) {
158
- returnTime.region = {
159
- name: AREA_CODES[areaCode].region.name,
160
- code: AREA_CODES[areaCode].region.code,
161
- flag: AREA_CODES[areaCode].region.flag,
162
- };
163
- }
158
+ returnTime.region = {
159
+ name: AREA_CODES[areaCode].region.name,
160
+ code: AREA_CODES[areaCode].region.code,
161
+ flag: AREA_CODES[areaCode].region.flag,
162
+ };
164
163
  }
165
164
 
166
165
  if (!stateName || !STATE_TIMEZONES[stateName]) {
@@ -256,3 +255,22 @@ export function findRegionFromRegionCode(regionCode, areaCode) {
256
255
 
257
256
  return regionInfo;
258
257
  }
258
+
259
+ /**
260
+ * Finds all phone numbers in a string and adds in geographical and/or time zone information to that object.
261
+ *
262
+ * @param {string} text - The text to search for phone numbers.
263
+ * @param {Date} [date=new Date()] - The date to use for determining time zone information. Defaults to the current date.
264
+ * @returns {Array<object>} An array of objects, where each object represents a found phone number
265
+ * and includes details from `findNumbersInString` as well as geographical and/or time zone information.
266
+ */
267
+ export function findAllNumbersInfoInString(text, date = new Date()) {
268
+ const numbers = findNumbersInString(text);
269
+
270
+ return numbers.map((item) => {
271
+ const geo = item.areaCode
272
+ ? findTimeFromAreaCode(item.areaCode, date)
273
+ : findRegionFromRegionCode(item.regionCode);
274
+ return { ...item, ...geo };
275
+ });
276
+ }
@@ -15,34 +15,35 @@ export const PHONE_FORMATS = {
15
15
  40: '+xx xxx xxx xxx', // Romania
16
16
  41: '+xx xxx xxx xx xx', // Switzerland
17
17
  43: [
18
- '+xx xxxx xxx-xxx', // 10 digits (mobile)
19
- '+xx xxxx xxx-xxxx', // 11 digits
18
+ '+xx xxxx xxxx', // 10 digits (landline)
19
+ '+xx xxxx xxxx xx', // 12 digits (mobile)
20
20
  ], // Austria
21
21
  44: [
22
- '+xx xxxx xxxxxx', // 10 digits (landline)
23
- '+xx xxxxx xxxxxx', // 11 digits (mobile)
22
+ '+xx xxxx xxxxxx', // 12 digits (landline/mobile)
23
+ '+xx xxxxx xxxxxx', // 13 digits (some mobiles)
24
24
  ], // United Kingdom
25
25
  45: '+xx xx xx xx xx', // Denmark
26
26
  46: '+xx xx-xxx xx xx', // Sweden
27
27
  47: '+xx xxx xx xxx', // Norway
28
28
  48: '+xx xxx xxx xxx', // Poland
29
29
  49: [
30
- '+xx xxx xxxxxxx', // 10 digits (standard)
31
- '+xx xxxx xxxxxxxx', // 11 digits (mobile)
30
+ '+xx xx xxxxxxx', // 11 digits (landline: +49 + 2-digit area code + 7 digits)
31
+ '+xx xxx xxxxxxx', // 12 digits (landline: +49 + 3-digit area code + 7 digits)
32
+ '+xx xxx xxxxxxxx', // 12 digits (mobile: +49 + 3-digit prefix + 8 digits)
32
33
  ], // Germany
33
34
  51: '+xx xxx xxx xxx', // Peru
34
35
  52: [
35
- '+xx xxx xxx xxxx', // 10 digits (standard)
36
- '+xx xx xx xxxx xxxx', // 12 digits (with area code)
36
+ '+xx xxx xxx xxxx', // 12 digits (landline)
37
+ '+xx xxx xxx xxxx', // 13 digits (mobile with 1)
37
38
  ], // Mexico
38
39
  53: '+xx x xxx xxxx', // Cuba
39
40
  54: [
40
- '+xx xxx-xxx-xxxx', // 10 digits (mobile and Buenos Aires)
41
- '+xx xxxx-xx-xxxx', // 11 digits (mobile with 9 prefix)
41
+ '+xx xxx-xxx-xxxx', // 12 digits (landline)
42
+ '+xx xxxx-xxx-xxxx', // 13 digits (mobile with 9 prefix)
42
43
  ], // Argentina
43
44
  55: [
44
- '+xx xx xxxx-xxxx', // 10 digits (standard)
45
- '+xx xx xxxxx-xxxx', // 11 digits (mobile)
45
+ '+xx xx xxxx-xxxx', // 12 digits (landline)
46
+ '+xx xx xxxxx-xxxx', // 13 digits (mobile)
46
47
  ], // Brazil
47
48
  56: '+xx x xxxx xxxx', // Chile
48
49
  57: '+xx xxx xxx xxxx', // Colombia