@mboka-dev/core 0.0.0-stage → 0.0.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.
- package/README.md +15 -2
- package/dist/cjs/generated/data.d.ts +5 -0
- package/dist/cjs/generated/data.js +46 -0
- package/dist/cjs/geo.d.ts +22 -0
- package/dist/cjs/geo.js +55 -0
- package/dist/cjs/index.d.ts +5 -0
- package/dist/cjs/index.js +7 -0
- package/dist/cjs/money.d.ts +19 -0
- package/dist/cjs/money.js +28 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/phone.d.ts +21 -0
- package/dist/cjs/phone.js +84 -0
- package/dist/cjs/text.d.ts +7 -0
- package/dist/cjs/text.js +21 -0
- package/dist/cjs/types.d.ts +61 -0
- package/dist/cjs/types.js +3 -0
- package/dist/esm/generated/data.d.ts +5 -0
- package/dist/esm/generated/data.js +43 -0
- package/dist/esm/geo.d.ts +22 -0
- package/dist/esm/geo.js +47 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/money.d.ts +19 -0
- package/dist/esm/money.js +24 -0
- package/dist/esm/phone.d.ts +21 -0
- package/dist/esm/phone.js +79 -0
- package/dist/esm/text.d.ts +7 -0
- package/dist/esm/text.js +18 -0
- package/dist/esm/types.d.ts +61 -0
- package/dist/esm/types.js +2 -0
- package/package.json +59 -4
package/README.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mboka-dev/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Local data and helpers for Congo-Brazzaville: +242 phone numbers, administrative divisions, FCFA amounts. Zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @mboka-dev/core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { phone, money, geo, text } from '@mboka-dev/core';
|
|
11
|
+
|
|
12
|
+
phone.parse('06 123 45 67').e164; // '+242061234567'
|
|
13
|
+
money.format(125000); // '125 000 FCFA'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Documentation (in French): https://github.com/mboka-dev/core
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DepartementsFile, CommunesFile, ArrondissementsFile, NumerotationFile } from '../types.js';
|
|
2
|
+
export declare const arrondissements: ArrondissementsFile;
|
|
3
|
+
export declare const communes: CommunesFile;
|
|
4
|
+
export declare const departements: DepartementsFile;
|
|
5
|
+
export declare const numerotation: NumerotationFile;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.numerotation = exports.departements = exports.communes = exports.arrondissements = void 0;
|
|
4
|
+
exports.arrondissements = {
|
|
5
|
+
"items": []
|
|
6
|
+
};
|
|
7
|
+
exports.communes = {
|
|
8
|
+
"items": []
|
|
9
|
+
};
|
|
10
|
+
exports.departements = {
|
|
11
|
+
"items": []
|
|
12
|
+
};
|
|
13
|
+
exports.numerotation = {
|
|
14
|
+
"countryCode": "242",
|
|
15
|
+
"nationalNumberLength": 9,
|
|
16
|
+
"nationalGrouping": [
|
|
17
|
+
2,
|
|
18
|
+
3,
|
|
19
|
+
2,
|
|
20
|
+
2
|
|
21
|
+
],
|
|
22
|
+
"verification": {
|
|
23
|
+
"status": "a_verifier",
|
|
24
|
+
"note": "Bootstrap value. To be confirmed against the national numbering plan published by the regulator (issue #2)."
|
|
25
|
+
},
|
|
26
|
+
"prefixes": [
|
|
27
|
+
{
|
|
28
|
+
"prefix": "06",
|
|
29
|
+
"type": "mobile",
|
|
30
|
+
"operator": "MTN",
|
|
31
|
+
"verification": {
|
|
32
|
+
"status": "a_verifier",
|
|
33
|
+
"note": "Bootstrap value, see issue #2."
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"prefix": "05",
|
|
38
|
+
"type": "mobile",
|
|
39
|
+
"operator": "Airtel",
|
|
40
|
+
"verification": {
|
|
41
|
+
"status": "a_verifier",
|
|
42
|
+
"note": "Bootstrap value, see issue #2."
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Arrondissement, Commune, Departement } from './types.js';
|
|
2
|
+
export type Level = 'departement' | 'commune' | 'arrondissement';
|
|
3
|
+
export type GeoMatch = ({
|
|
4
|
+
level: 'departement';
|
|
5
|
+
} & Departement) | ({
|
|
6
|
+
level: 'commune';
|
|
7
|
+
} & Commune) | ({
|
|
8
|
+
level: 'arrondissement';
|
|
9
|
+
} & Arrondissement);
|
|
10
|
+
export declare function departements(): Departement[];
|
|
11
|
+
export declare function communes(departementId?: string): Commune[];
|
|
12
|
+
export declare function arrondissements(communeId?: string): Arrondissement[];
|
|
13
|
+
/** All entries whose official name or alias matches, in order: départements, communes, arrondissements. */
|
|
14
|
+
export declare function findAll(name: string, options?: {
|
|
15
|
+
level?: Level;
|
|
16
|
+
}): GeoMatch[];
|
|
17
|
+
/** First match (exact, after normalization), or null. Fuzzy matching is planned for v0.3. */
|
|
18
|
+
export declare function find(name: string, options?: {
|
|
19
|
+
level?: Level;
|
|
20
|
+
}): GeoMatch | null;
|
|
21
|
+
/** Official name for a free-text input, or null if unknown. Handy to clean a data column. */
|
|
22
|
+
export declare function normalizeName(name: string): string | null;
|
package/dist/cjs/geo.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.departements = departements;
|
|
4
|
+
exports.communes = communes;
|
|
5
|
+
exports.arrondissements = arrondissements;
|
|
6
|
+
exports.findAll = findAll;
|
|
7
|
+
exports.find = find;
|
|
8
|
+
exports.normalizeName = normalizeName;
|
|
9
|
+
const data_js_1 = require("./generated/data.js");
|
|
10
|
+
const text_js_1 = require("./text.js");
|
|
11
|
+
// Data is plain JSON, so a JSON round-trip is a safe deep copy in every runtime.
|
|
12
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
13
|
+
function departements() {
|
|
14
|
+
return clone(data_js_1.departements.items);
|
|
15
|
+
}
|
|
16
|
+
function communes(departementId) {
|
|
17
|
+
const items = departementId ? data_js_1.communes.items.filter((c) => c.departement === departementId) : data_js_1.communes.items;
|
|
18
|
+
return clone(items);
|
|
19
|
+
}
|
|
20
|
+
function arrondissements(communeId) {
|
|
21
|
+
const items = communeId ? data_js_1.arrondissements.items.filter((a) => a.commune === communeId) : data_js_1.arrondissements.items;
|
|
22
|
+
return clone(items);
|
|
23
|
+
}
|
|
24
|
+
function matches(entry, needle) {
|
|
25
|
+
return [entry.nom, ...entry.aliases].some((n) => (0, text_js_1.normalize)(n) === needle);
|
|
26
|
+
}
|
|
27
|
+
/** All entries whose official name or alias matches, in order: départements, communes, arrondissements. */
|
|
28
|
+
function findAll(name, options = {}) {
|
|
29
|
+
const needle = (0, text_js_1.normalize)(name);
|
|
30
|
+
if (!needle)
|
|
31
|
+
return [];
|
|
32
|
+
const results = [];
|
|
33
|
+
const want = (level) => !options.level || options.level === level;
|
|
34
|
+
if (want('departement'))
|
|
35
|
+
for (const d of data_js_1.departements.items)
|
|
36
|
+
if (matches(d, needle))
|
|
37
|
+
results.push({ level: 'departement', ...clone(d) });
|
|
38
|
+
if (want('commune'))
|
|
39
|
+
for (const c of data_js_1.communes.items)
|
|
40
|
+
if (matches(c, needle))
|
|
41
|
+
results.push({ level: 'commune', ...clone(c) });
|
|
42
|
+
if (want('arrondissement'))
|
|
43
|
+
for (const a of data_js_1.arrondissements.items)
|
|
44
|
+
if (matches(a, needle))
|
|
45
|
+
results.push({ level: 'arrondissement', ...clone(a) });
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
/** First match (exact, after normalization), or null. Fuzzy matching is planned for v0.3. */
|
|
49
|
+
function find(name, options = {}) {
|
|
50
|
+
return findAll(name, options)[0] ?? null;
|
|
51
|
+
}
|
|
52
|
+
/** Official name for a free-text input, or null if unknown. Handy to clean a data column. */
|
|
53
|
+
function normalizeName(name) {
|
|
54
|
+
return find(name)?.nom ?? null;
|
|
55
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.text = exports.geo = exports.money = exports.phone = void 0;
|
|
4
|
+
exports.phone = require("./phone.js");
|
|
5
|
+
exports.money = require("./money.js");
|
|
6
|
+
exports.geo = require("./geo.js");
|
|
7
|
+
exports.text = require("./text.js");
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface FormatOptions {
|
|
2
|
+
/** Text after the amount. Default 'FCFA'. Use 'XAF' for the ISO code, '' for none. */
|
|
3
|
+
label?: string;
|
|
4
|
+
/** Thousands separator. Default U+202F (narrow no-break space). */
|
|
5
|
+
separator?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ToWordsOptions {
|
|
8
|
+
/** Append "franc(s) CFA". Default true. */
|
|
9
|
+
currency?: boolean;
|
|
10
|
+
/** 'traditionnelle' (default) or '1990' (hyphens between all numerals). */
|
|
11
|
+
orthographe?: 'traditionnelle' | '1990';
|
|
12
|
+
}
|
|
13
|
+
/** money.format(125000) === '125 000 FCFA' (with non-breaking spaces) */
|
|
14
|
+
export declare function format(amount: number, options?: FormatOptions): string;
|
|
15
|
+
/**
|
|
16
|
+
* Amount in words: toWords(125000) === 'cent vingt-cinq mille francs CFA'.
|
|
17
|
+
* Specified by fixtures/money/to-words.json. Not implemented yet: see the open issue.
|
|
18
|
+
*/
|
|
19
|
+
export declare function toWords(amount: number, _options?: ToWordsOptions): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.format = format;
|
|
4
|
+
exports.toWords = toWords;
|
|
5
|
+
function assertAmount(amount, fn) {
|
|
6
|
+
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
|
|
7
|
+
throw new TypeError(`money.${fn} expects a finite number`);
|
|
8
|
+
}
|
|
9
|
+
if (!Number.isSafeInteger(amount)) {
|
|
10
|
+
throw new RangeError(`money.${fn} expects an integer amount: the CFA franc has no subunit`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** money.format(125000) === '125 000 FCFA' (with non-breaking spaces) */
|
|
14
|
+
function format(amount, options = {}) {
|
|
15
|
+
assertAmount(amount, 'format');
|
|
16
|
+
const { label = 'FCFA', separator = ' ' } = options;
|
|
17
|
+
const sign = amount < 0 ? '-' : '';
|
|
18
|
+
const grouped = Math.abs(amount).toString().replace(/\B(?=(\d{3})+(?!\d))/g, separator);
|
|
19
|
+
return label ? `${sign}${grouped} ${label}` : `${sign}${grouped}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Amount in words: toWords(125000) === 'cent vingt-cinq mille francs CFA'.
|
|
23
|
+
* Specified by fixtures/money/to-words.json. Not implemented yet: see the open issue.
|
|
24
|
+
*/
|
|
25
|
+
function toWords(amount, _options = {}) {
|
|
26
|
+
assertAmount(amount, 'toWords');
|
|
27
|
+
throw new Error('money.toWords is not implemented yet. Contributions welcome: see fixtures/money/to-words.json');
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PhonePrefix } from './types.js';
|
|
2
|
+
export type InvalidReason = 'empty' | 'invalid_characters' | 'foreign_number' | 'length' | 'unknown_prefix';
|
|
3
|
+
export type FormatStyle = 'national' | 'international' | 'e164';
|
|
4
|
+
export interface ParsedPhone {
|
|
5
|
+
input: string;
|
|
6
|
+
valid: boolean;
|
|
7
|
+
/** +242061234567 */
|
|
8
|
+
e164: string | null;
|
|
9
|
+
/** 06 123 45 67 */
|
|
10
|
+
national: string | null;
|
|
11
|
+
/** +242 06 123 45 67 */
|
|
12
|
+
international: string | null;
|
|
13
|
+
operator: string | null;
|
|
14
|
+
type: PhonePrefix['type'] | null;
|
|
15
|
+
reason: InvalidReason | null;
|
|
16
|
+
}
|
|
17
|
+
/** Parses any common way of writing a Congo-Brazzaville number. Never throws on bad numbers, only on non-strings. */
|
|
18
|
+
export declare function parse(input: string): ParsedPhone;
|
|
19
|
+
export declare function isValid(input: string): boolean;
|
|
20
|
+
/** Formats a number in the requested style. Throws if the number is invalid. */
|
|
21
|
+
export declare function format(input: string, style?: FormatStyle): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parse = parse;
|
|
4
|
+
exports.isValid = isValid;
|
|
5
|
+
exports.format = format;
|
|
6
|
+
const data_js_1 = require("./generated/data.js");
|
|
7
|
+
const SEPARATORS = /[\s().\-/]/g;
|
|
8
|
+
function group(digits) {
|
|
9
|
+
const sizes = data_js_1.numerotation.nationalGrouping ?? [digits.length];
|
|
10
|
+
const parts = [];
|
|
11
|
+
let i = 0;
|
|
12
|
+
for (const size of sizes) {
|
|
13
|
+
if (i >= digits.length)
|
|
14
|
+
break;
|
|
15
|
+
parts.push(digits.slice(i, i + size));
|
|
16
|
+
i += size;
|
|
17
|
+
}
|
|
18
|
+
if (i < digits.length)
|
|
19
|
+
parts.push(digits.slice(i));
|
|
20
|
+
return parts.join(' ');
|
|
21
|
+
}
|
|
22
|
+
function invalid(input, reason) {
|
|
23
|
+
return { input, valid: false, e164: null, national: null, international: null, operator: null, type: null, reason };
|
|
24
|
+
}
|
|
25
|
+
/** Parses any common way of writing a Congo-Brazzaville number. Never throws on bad numbers, only on non-strings. */
|
|
26
|
+
function parse(input) {
|
|
27
|
+
if (typeof input !== 'string')
|
|
28
|
+
throw new TypeError('phone.parse expects a string');
|
|
29
|
+
const compact = input.replace(SEPARATORS, '');
|
|
30
|
+
if (compact === '')
|
|
31
|
+
return invalid(input, 'empty');
|
|
32
|
+
if (!/^\+?[0-9]+$/.test(compact))
|
|
33
|
+
return invalid(input, 'invalid_characters');
|
|
34
|
+
const cc = data_js_1.numerotation.countryCode;
|
|
35
|
+
const length = data_js_1.numerotation.nationalNumberLength;
|
|
36
|
+
let national;
|
|
37
|
+
if (compact.startsWith('+') || compact.startsWith('00')) {
|
|
38
|
+
const rest = compact.startsWith('+') ? compact.slice(1) : compact.slice(2);
|
|
39
|
+
if (!rest.startsWith(cc))
|
|
40
|
+
return invalid(input, 'foreign_number');
|
|
41
|
+
national = rest.slice(cc.length);
|
|
42
|
+
}
|
|
43
|
+
else if (compact.startsWith(cc) && compact.length === cc.length + length) {
|
|
44
|
+
national = compact.slice(cc.length);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
national = compact;
|
|
48
|
+
}
|
|
49
|
+
if (national.length !== length)
|
|
50
|
+
return invalid(input, 'length');
|
|
51
|
+
const prefix = data_js_1.numerotation.prefixes.find((p) => national.startsWith(p.prefix));
|
|
52
|
+
if (!prefix)
|
|
53
|
+
return invalid(input, 'unknown_prefix');
|
|
54
|
+
const grouped = group(national);
|
|
55
|
+
return {
|
|
56
|
+
input,
|
|
57
|
+
valid: true,
|
|
58
|
+
e164: `+${cc}${national}`,
|
|
59
|
+
national: grouped,
|
|
60
|
+
international: `+${cc} ${grouped}`,
|
|
61
|
+
operator: prefix.operator ?? null,
|
|
62
|
+
type: prefix.type,
|
|
63
|
+
reason: null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function isValid(input) {
|
|
67
|
+
return typeof input === 'string' && parse(input).valid;
|
|
68
|
+
}
|
|
69
|
+
/** Formats a number in the requested style. Throws if the number is invalid. */
|
|
70
|
+
function format(input, style = 'national') {
|
|
71
|
+
const parsed = parse(input);
|
|
72
|
+
if (!parsed.valid)
|
|
73
|
+
throw new Error(`Invalid +242 number (${parsed.reason}): "${input}"`);
|
|
74
|
+
switch (style) {
|
|
75
|
+
case 'national':
|
|
76
|
+
return parsed.national;
|
|
77
|
+
case 'international':
|
|
78
|
+
return parsed.international;
|
|
79
|
+
case 'e164':
|
|
80
|
+
return parsed.e164;
|
|
81
|
+
default:
|
|
82
|
+
throw new Error(`Unknown format style "${String(style)}". Use national, international or e164.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/cjs/text.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalize = normalize;
|
|
4
|
+
const LIGATURES = { œ: 'oe', Œ: 'OE', æ: 'ae', Æ: 'AE' };
|
|
5
|
+
/**
|
|
6
|
+
* Normalizes free text for comparison: lowercase, no accents,
|
|
7
|
+
* any run of non-alphanumeric characters becomes a single space.
|
|
8
|
+
*
|
|
9
|
+
* normalize('Pointe-Noire') === 'pointe noire'
|
|
10
|
+
*/
|
|
11
|
+
function normalize(input) {
|
|
12
|
+
if (typeof input !== 'string')
|
|
13
|
+
throw new TypeError('text.normalize expects a string');
|
|
14
|
+
return input
|
|
15
|
+
.replace(/[œŒæÆ]/g, (c) => LIGATURES[c] ?? c)
|
|
16
|
+
.normalize('NFD')
|
|
17
|
+
.replace(/\p{M}/gu, '')
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
20
|
+
.trim();
|
|
21
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface Verification {
|
|
2
|
+
status: 'verifie' | 'a_verifier';
|
|
3
|
+
source?: string;
|
|
4
|
+
sourceUrl?: string;
|
|
5
|
+
verifiedAt?: string;
|
|
6
|
+
verifiedBy?: string;
|
|
7
|
+
note?: string;
|
|
8
|
+
}
|
|
9
|
+
interface Validity {
|
|
10
|
+
validFrom?: string;
|
|
11
|
+
validUntil?: string;
|
|
12
|
+
replacedBy?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface Departement extends Validity {
|
|
15
|
+
id: string;
|
|
16
|
+
nom: string;
|
|
17
|
+
iso3166_2?: string;
|
|
18
|
+
chefLieu?: string;
|
|
19
|
+
aliases: string[];
|
|
20
|
+
verification: Verification;
|
|
21
|
+
}
|
|
22
|
+
export interface Commune extends Validity {
|
|
23
|
+
id: string;
|
|
24
|
+
nom: string;
|
|
25
|
+
departement: string;
|
|
26
|
+
aliases: string[];
|
|
27
|
+
verification: Verification;
|
|
28
|
+
}
|
|
29
|
+
export interface Arrondissement extends Validity {
|
|
30
|
+
id: string;
|
|
31
|
+
nom: string;
|
|
32
|
+
numero?: number;
|
|
33
|
+
commune: string;
|
|
34
|
+
aliases: string[];
|
|
35
|
+
verification: Verification;
|
|
36
|
+
}
|
|
37
|
+
export interface PhonePrefix {
|
|
38
|
+
prefix: string;
|
|
39
|
+
type: 'mobile' | 'fixe' | 'special';
|
|
40
|
+
operator?: string;
|
|
41
|
+
mobileMoney?: string;
|
|
42
|
+
note?: string;
|
|
43
|
+
verification: Verification;
|
|
44
|
+
}
|
|
45
|
+
export interface DepartementsFile {
|
|
46
|
+
items: Departement[];
|
|
47
|
+
}
|
|
48
|
+
export interface CommunesFile {
|
|
49
|
+
items: Commune[];
|
|
50
|
+
}
|
|
51
|
+
export interface ArrondissementsFile {
|
|
52
|
+
items: Arrondissement[];
|
|
53
|
+
}
|
|
54
|
+
export interface NumerotationFile {
|
|
55
|
+
countryCode: '242';
|
|
56
|
+
nationalNumberLength: number;
|
|
57
|
+
nationalGrouping?: number[];
|
|
58
|
+
verification: Verification;
|
|
59
|
+
prefixes: PhonePrefix[];
|
|
60
|
+
}
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DepartementsFile, CommunesFile, ArrondissementsFile, NumerotationFile } from '../types.js';
|
|
2
|
+
export declare const arrondissements: ArrondissementsFile;
|
|
3
|
+
export declare const communes: CommunesFile;
|
|
4
|
+
export declare const departements: DepartementsFile;
|
|
5
|
+
export declare const numerotation: NumerotationFile;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const arrondissements = {
|
|
2
|
+
"items": []
|
|
3
|
+
};
|
|
4
|
+
export const communes = {
|
|
5
|
+
"items": []
|
|
6
|
+
};
|
|
7
|
+
export const departements = {
|
|
8
|
+
"items": []
|
|
9
|
+
};
|
|
10
|
+
export const numerotation = {
|
|
11
|
+
"countryCode": "242",
|
|
12
|
+
"nationalNumberLength": 9,
|
|
13
|
+
"nationalGrouping": [
|
|
14
|
+
2,
|
|
15
|
+
3,
|
|
16
|
+
2,
|
|
17
|
+
2
|
|
18
|
+
],
|
|
19
|
+
"verification": {
|
|
20
|
+
"status": "a_verifier",
|
|
21
|
+
"note": "Bootstrap value. To be confirmed against the national numbering plan published by the regulator (issue #2)."
|
|
22
|
+
},
|
|
23
|
+
"prefixes": [
|
|
24
|
+
{
|
|
25
|
+
"prefix": "06",
|
|
26
|
+
"type": "mobile",
|
|
27
|
+
"operator": "MTN",
|
|
28
|
+
"verification": {
|
|
29
|
+
"status": "a_verifier",
|
|
30
|
+
"note": "Bootstrap value, see issue #2."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"prefix": "05",
|
|
35
|
+
"type": "mobile",
|
|
36
|
+
"operator": "Airtel",
|
|
37
|
+
"verification": {
|
|
38
|
+
"status": "a_verifier",
|
|
39
|
+
"note": "Bootstrap value, see issue #2."
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Arrondissement, Commune, Departement } from './types.js';
|
|
2
|
+
export type Level = 'departement' | 'commune' | 'arrondissement';
|
|
3
|
+
export type GeoMatch = ({
|
|
4
|
+
level: 'departement';
|
|
5
|
+
} & Departement) | ({
|
|
6
|
+
level: 'commune';
|
|
7
|
+
} & Commune) | ({
|
|
8
|
+
level: 'arrondissement';
|
|
9
|
+
} & Arrondissement);
|
|
10
|
+
export declare function departements(): Departement[];
|
|
11
|
+
export declare function communes(departementId?: string): Commune[];
|
|
12
|
+
export declare function arrondissements(communeId?: string): Arrondissement[];
|
|
13
|
+
/** All entries whose official name or alias matches, in order: départements, communes, arrondissements. */
|
|
14
|
+
export declare function findAll(name: string, options?: {
|
|
15
|
+
level?: Level;
|
|
16
|
+
}): GeoMatch[];
|
|
17
|
+
/** First match (exact, after normalization), or null. Fuzzy matching is planned for v0.3. */
|
|
18
|
+
export declare function find(name: string, options?: {
|
|
19
|
+
level?: Level;
|
|
20
|
+
}): GeoMatch | null;
|
|
21
|
+
/** Official name for a free-text input, or null if unknown. Handy to clean a data column. */
|
|
22
|
+
export declare function normalizeName(name: string): string | null;
|
package/dist/esm/geo.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { departements as depFile, communes as comFile, arrondissements as arrFile } from './generated/data.js';
|
|
2
|
+
import { normalize } from './text.js';
|
|
3
|
+
// Data is plain JSON, so a JSON round-trip is a safe deep copy in every runtime.
|
|
4
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
5
|
+
export function departements() {
|
|
6
|
+
return clone(depFile.items);
|
|
7
|
+
}
|
|
8
|
+
export function communes(departementId) {
|
|
9
|
+
const items = departementId ? comFile.items.filter((c) => c.departement === departementId) : comFile.items;
|
|
10
|
+
return clone(items);
|
|
11
|
+
}
|
|
12
|
+
export function arrondissements(communeId) {
|
|
13
|
+
const items = communeId ? arrFile.items.filter((a) => a.commune === communeId) : arrFile.items;
|
|
14
|
+
return clone(items);
|
|
15
|
+
}
|
|
16
|
+
function matches(entry, needle) {
|
|
17
|
+
return [entry.nom, ...entry.aliases].some((n) => normalize(n) === needle);
|
|
18
|
+
}
|
|
19
|
+
/** All entries whose official name or alias matches, in order: départements, communes, arrondissements. */
|
|
20
|
+
export function findAll(name, options = {}) {
|
|
21
|
+
const needle = normalize(name);
|
|
22
|
+
if (!needle)
|
|
23
|
+
return [];
|
|
24
|
+
const results = [];
|
|
25
|
+
const want = (level) => !options.level || options.level === level;
|
|
26
|
+
if (want('departement'))
|
|
27
|
+
for (const d of depFile.items)
|
|
28
|
+
if (matches(d, needle))
|
|
29
|
+
results.push({ level: 'departement', ...clone(d) });
|
|
30
|
+
if (want('commune'))
|
|
31
|
+
for (const c of comFile.items)
|
|
32
|
+
if (matches(c, needle))
|
|
33
|
+
results.push({ level: 'commune', ...clone(c) });
|
|
34
|
+
if (want('arrondissement'))
|
|
35
|
+
for (const a of arrFile.items)
|
|
36
|
+
if (matches(a, needle))
|
|
37
|
+
results.push({ level: 'arrondissement', ...clone(a) });
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
/** First match (exact, after normalization), or null. Fuzzy matching is planned for v0.3. */
|
|
41
|
+
export function find(name, options = {}) {
|
|
42
|
+
return findAll(name, options)[0] ?? null;
|
|
43
|
+
}
|
|
44
|
+
/** Official name for a free-text input, or null if unknown. Handy to clean a data column. */
|
|
45
|
+
export function normalizeName(name) {
|
|
46
|
+
return find(name)?.nom ?? null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface FormatOptions {
|
|
2
|
+
/** Text after the amount. Default 'FCFA'. Use 'XAF' for the ISO code, '' for none. */
|
|
3
|
+
label?: string;
|
|
4
|
+
/** Thousands separator. Default U+202F (narrow no-break space). */
|
|
5
|
+
separator?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ToWordsOptions {
|
|
8
|
+
/** Append "franc(s) CFA". Default true. */
|
|
9
|
+
currency?: boolean;
|
|
10
|
+
/** 'traditionnelle' (default) or '1990' (hyphens between all numerals). */
|
|
11
|
+
orthographe?: 'traditionnelle' | '1990';
|
|
12
|
+
}
|
|
13
|
+
/** money.format(125000) === '125 000 FCFA' (with non-breaking spaces) */
|
|
14
|
+
export declare function format(amount: number, options?: FormatOptions): string;
|
|
15
|
+
/**
|
|
16
|
+
* Amount in words: toWords(125000) === 'cent vingt-cinq mille francs CFA'.
|
|
17
|
+
* Specified by fixtures/money/to-words.json. Not implemented yet: see the open issue.
|
|
18
|
+
*/
|
|
19
|
+
export declare function toWords(amount: number, _options?: ToWordsOptions): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
function assertAmount(amount, fn) {
|
|
2
|
+
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
|
|
3
|
+
throw new TypeError(`money.${fn} expects a finite number`);
|
|
4
|
+
}
|
|
5
|
+
if (!Number.isSafeInteger(amount)) {
|
|
6
|
+
throw new RangeError(`money.${fn} expects an integer amount: the CFA franc has no subunit`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** money.format(125000) === '125 000 FCFA' (with non-breaking spaces) */
|
|
10
|
+
export function format(amount, options = {}) {
|
|
11
|
+
assertAmount(amount, 'format');
|
|
12
|
+
const { label = 'FCFA', separator = ' ' } = options;
|
|
13
|
+
const sign = amount < 0 ? '-' : '';
|
|
14
|
+
const grouped = Math.abs(amount).toString().replace(/\B(?=(\d{3})+(?!\d))/g, separator);
|
|
15
|
+
return label ? `${sign}${grouped} ${label}` : `${sign}${grouped}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Amount in words: toWords(125000) === 'cent vingt-cinq mille francs CFA'.
|
|
19
|
+
* Specified by fixtures/money/to-words.json. Not implemented yet: see the open issue.
|
|
20
|
+
*/
|
|
21
|
+
export function toWords(amount, _options = {}) {
|
|
22
|
+
assertAmount(amount, 'toWords');
|
|
23
|
+
throw new Error('money.toWords is not implemented yet. Contributions welcome: see fixtures/money/to-words.json');
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PhonePrefix } from './types.js';
|
|
2
|
+
export type InvalidReason = 'empty' | 'invalid_characters' | 'foreign_number' | 'length' | 'unknown_prefix';
|
|
3
|
+
export type FormatStyle = 'national' | 'international' | 'e164';
|
|
4
|
+
export interface ParsedPhone {
|
|
5
|
+
input: string;
|
|
6
|
+
valid: boolean;
|
|
7
|
+
/** +242061234567 */
|
|
8
|
+
e164: string | null;
|
|
9
|
+
/** 06 123 45 67 */
|
|
10
|
+
national: string | null;
|
|
11
|
+
/** +242 06 123 45 67 */
|
|
12
|
+
international: string | null;
|
|
13
|
+
operator: string | null;
|
|
14
|
+
type: PhonePrefix['type'] | null;
|
|
15
|
+
reason: InvalidReason | null;
|
|
16
|
+
}
|
|
17
|
+
/** Parses any common way of writing a Congo-Brazzaville number. Never throws on bad numbers, only on non-strings. */
|
|
18
|
+
export declare function parse(input: string): ParsedPhone;
|
|
19
|
+
export declare function isValid(input: string): boolean;
|
|
20
|
+
/** Formats a number in the requested style. Throws if the number is invalid. */
|
|
21
|
+
export declare function format(input: string, style?: FormatStyle): string;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { numerotation as plan } from './generated/data.js';
|
|
2
|
+
const SEPARATORS = /[\s().\-/]/g;
|
|
3
|
+
function group(digits) {
|
|
4
|
+
const sizes = plan.nationalGrouping ?? [digits.length];
|
|
5
|
+
const parts = [];
|
|
6
|
+
let i = 0;
|
|
7
|
+
for (const size of sizes) {
|
|
8
|
+
if (i >= digits.length)
|
|
9
|
+
break;
|
|
10
|
+
parts.push(digits.slice(i, i + size));
|
|
11
|
+
i += size;
|
|
12
|
+
}
|
|
13
|
+
if (i < digits.length)
|
|
14
|
+
parts.push(digits.slice(i));
|
|
15
|
+
return parts.join(' ');
|
|
16
|
+
}
|
|
17
|
+
function invalid(input, reason) {
|
|
18
|
+
return { input, valid: false, e164: null, national: null, international: null, operator: null, type: null, reason };
|
|
19
|
+
}
|
|
20
|
+
/** Parses any common way of writing a Congo-Brazzaville number. Never throws on bad numbers, only on non-strings. */
|
|
21
|
+
export function parse(input) {
|
|
22
|
+
if (typeof input !== 'string')
|
|
23
|
+
throw new TypeError('phone.parse expects a string');
|
|
24
|
+
const compact = input.replace(SEPARATORS, '');
|
|
25
|
+
if (compact === '')
|
|
26
|
+
return invalid(input, 'empty');
|
|
27
|
+
if (!/^\+?[0-9]+$/.test(compact))
|
|
28
|
+
return invalid(input, 'invalid_characters');
|
|
29
|
+
const cc = plan.countryCode;
|
|
30
|
+
const length = plan.nationalNumberLength;
|
|
31
|
+
let national;
|
|
32
|
+
if (compact.startsWith('+') || compact.startsWith('00')) {
|
|
33
|
+
const rest = compact.startsWith('+') ? compact.slice(1) : compact.slice(2);
|
|
34
|
+
if (!rest.startsWith(cc))
|
|
35
|
+
return invalid(input, 'foreign_number');
|
|
36
|
+
national = rest.slice(cc.length);
|
|
37
|
+
}
|
|
38
|
+
else if (compact.startsWith(cc) && compact.length === cc.length + length) {
|
|
39
|
+
national = compact.slice(cc.length);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
national = compact;
|
|
43
|
+
}
|
|
44
|
+
if (national.length !== length)
|
|
45
|
+
return invalid(input, 'length');
|
|
46
|
+
const prefix = plan.prefixes.find((p) => national.startsWith(p.prefix));
|
|
47
|
+
if (!prefix)
|
|
48
|
+
return invalid(input, 'unknown_prefix');
|
|
49
|
+
const grouped = group(national);
|
|
50
|
+
return {
|
|
51
|
+
input,
|
|
52
|
+
valid: true,
|
|
53
|
+
e164: `+${cc}${national}`,
|
|
54
|
+
national: grouped,
|
|
55
|
+
international: `+${cc} ${grouped}`,
|
|
56
|
+
operator: prefix.operator ?? null,
|
|
57
|
+
type: prefix.type,
|
|
58
|
+
reason: null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function isValid(input) {
|
|
62
|
+
return typeof input === 'string' && parse(input).valid;
|
|
63
|
+
}
|
|
64
|
+
/** Formats a number in the requested style. Throws if the number is invalid. */
|
|
65
|
+
export function format(input, style = 'national') {
|
|
66
|
+
const parsed = parse(input);
|
|
67
|
+
if (!parsed.valid)
|
|
68
|
+
throw new Error(`Invalid +242 number (${parsed.reason}): "${input}"`);
|
|
69
|
+
switch (style) {
|
|
70
|
+
case 'national':
|
|
71
|
+
return parsed.national;
|
|
72
|
+
case 'international':
|
|
73
|
+
return parsed.international;
|
|
74
|
+
case 'e164':
|
|
75
|
+
return parsed.e164;
|
|
76
|
+
default:
|
|
77
|
+
throw new Error(`Unknown format style "${String(style)}". Use national, international or e164.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
package/dist/esm/text.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const LIGATURES = { œ: 'oe', Œ: 'OE', æ: 'ae', Æ: 'AE' };
|
|
2
|
+
/**
|
|
3
|
+
* Normalizes free text for comparison: lowercase, no accents,
|
|
4
|
+
* any run of non-alphanumeric characters becomes a single space.
|
|
5
|
+
*
|
|
6
|
+
* normalize('Pointe-Noire') === 'pointe noire'
|
|
7
|
+
*/
|
|
8
|
+
export function normalize(input) {
|
|
9
|
+
if (typeof input !== 'string')
|
|
10
|
+
throw new TypeError('text.normalize expects a string');
|
|
11
|
+
return input
|
|
12
|
+
.replace(/[œŒæÆ]/g, (c) => LIGATURES[c] ?? c)
|
|
13
|
+
.normalize('NFD')
|
|
14
|
+
.replace(/\p{M}/gu, '')
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
17
|
+
.trim();
|
|
18
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface Verification {
|
|
2
|
+
status: 'verifie' | 'a_verifier';
|
|
3
|
+
source?: string;
|
|
4
|
+
sourceUrl?: string;
|
|
5
|
+
verifiedAt?: string;
|
|
6
|
+
verifiedBy?: string;
|
|
7
|
+
note?: string;
|
|
8
|
+
}
|
|
9
|
+
interface Validity {
|
|
10
|
+
validFrom?: string;
|
|
11
|
+
validUntil?: string;
|
|
12
|
+
replacedBy?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface Departement extends Validity {
|
|
15
|
+
id: string;
|
|
16
|
+
nom: string;
|
|
17
|
+
iso3166_2?: string;
|
|
18
|
+
chefLieu?: string;
|
|
19
|
+
aliases: string[];
|
|
20
|
+
verification: Verification;
|
|
21
|
+
}
|
|
22
|
+
export interface Commune extends Validity {
|
|
23
|
+
id: string;
|
|
24
|
+
nom: string;
|
|
25
|
+
departement: string;
|
|
26
|
+
aliases: string[];
|
|
27
|
+
verification: Verification;
|
|
28
|
+
}
|
|
29
|
+
export interface Arrondissement extends Validity {
|
|
30
|
+
id: string;
|
|
31
|
+
nom: string;
|
|
32
|
+
numero?: number;
|
|
33
|
+
commune: string;
|
|
34
|
+
aliases: string[];
|
|
35
|
+
verification: Verification;
|
|
36
|
+
}
|
|
37
|
+
export interface PhonePrefix {
|
|
38
|
+
prefix: string;
|
|
39
|
+
type: 'mobile' | 'fixe' | 'special';
|
|
40
|
+
operator?: string;
|
|
41
|
+
mobileMoney?: string;
|
|
42
|
+
note?: string;
|
|
43
|
+
verification: Verification;
|
|
44
|
+
}
|
|
45
|
+
export interface DepartementsFile {
|
|
46
|
+
items: Departement[];
|
|
47
|
+
}
|
|
48
|
+
export interface CommunesFile {
|
|
49
|
+
items: Commune[];
|
|
50
|
+
}
|
|
51
|
+
export interface ArrondissementsFile {
|
|
52
|
+
items: Arrondissement[];
|
|
53
|
+
}
|
|
54
|
+
export interface NumerotationFile {
|
|
55
|
+
countryCode: '242';
|
|
56
|
+
nationalNumberLength: number;
|
|
57
|
+
nationalGrouping?: number[];
|
|
58
|
+
verification: Verification;
|
|
59
|
+
prefixes: PhonePrefix[];
|
|
60
|
+
}
|
|
61
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mboka-dev/core",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Local data and helpers for Congo-Brazzaville: +242 phone numbers, administrative divisions, FCFA amounts.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"congo",
|
|
7
|
+
"brazzaville",
|
|
8
|
+
"cg",
|
|
9
|
+
"phone",
|
|
10
|
+
"fcfa",
|
|
11
|
+
"xaf",
|
|
12
|
+
"geo",
|
|
13
|
+
"africa"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Euloge and contributors",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/mboka-dev/core.git",
|
|
20
|
+
"directory": "packages/js"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/mboka-dev/core#readme",
|
|
23
|
+
"bugs": "https://github.com/mboka-dev/core/issues",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"main": "./dist/cjs/index.js",
|
|
30
|
+
"module": "./dist/esm/index.js",
|
|
31
|
+
"types": "./dist/esm/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"import": {
|
|
35
|
+
"types": "./dist/esm/index.d.ts",
|
|
36
|
+
"default": "./dist/esm/index.js"
|
|
37
|
+
},
|
|
38
|
+
"require": {
|
|
39
|
+
"types": "./dist/cjs/index.d.ts",
|
|
40
|
+
"default": "./dist/cjs/index.js"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"README.md"
|
|
47
|
+
],
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist/cjs/package.json',JSON.stringify({type:'commonjs'}))\"",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"test": "npm run build && node --test \"test/*.test.mjs\"",
|
|
55
|
+
"prepublishOnly": "npm test"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "^22.20.0",
|
|
59
|
+
"typescript": "^5.9.3"
|
|
60
|
+
}
|
|
61
|
+
}
|