diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..592e6c7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: ci + +on: + push: + branches: [main] + tags: + - '[0-9]+.[0-9]+.[0-9]+' + - '[0-9]+.[0-9]+.[0-9]+-*' + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref_type != 'tag' }} + +permissions: + contents: write + id-token: write + +jobs: + ci: + uses: coroboros/ci/.github/workflows/javascript-npm-packages.yml@v0 + secrets: + NPM_CONFIG_FILE: ${{ secrets.NPM_CONFIG_FILE }} + NPM_EXTRA_CONFIG: ${{ secrets.NPM_EXTRA_CONFIG }} + NPM_PACKAGE_REGISTRY: ${{ secrets.NPM_PACKAGE_REGISTRY }} + NPM_PACKAGE_PROXY_REGISTRY: ${{ secrets.NPM_PACKAGE_PROXY_REGISTRY }} + NPM_PACKAGE_REGISTRY_TOKEN: ${{ secrets.NPM_PACKAGE_REGISTRY_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index bd32bbc..761ec2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,49 +1,59 @@ # @coroboros/location-timezone -Lookup helpers for capitals, countries, cities, ANSI states (USA), and IANA timezones. Data is compressed JSON parsed once at module load. +Lookup helpers for capitals, countries, cities, ANSI states (USA), and IANA timezones. Data is compressed JSON parsed once at module load, then indexed via `Map`/`Set` for O(1) lookups. ## Canonical rules Follows the Coroboros engineering global rules. Repo-specific divergences are stated inline in `## Rules` below. -> **Public-repo hygiene:** this is shipped into a public community repo. Never reference private `~/.claude/rules/*` paths, local machine paths, or the migration recipe here — keep it generic. +> **Public-repo hygiene:** this is shipped into a public community repo. Never reference private rule paths, local machine paths, or the migration recipe here — keep it generic. ## Tech Stack -- TypeScript strict, ES modules + CJS dual build (tsdown) -- Vitest for tests, Biome for lint/format +- TypeScript strict, ES modules + CJS dual build (tsdown), multi-entry with subpath exports +- Vitest for tests, `fast-check` for property tests +- `mitata` for benchmarks (`pnpm bench`) +- `tsx` for `pnpm build:data` +- Biome for lint/format - Node.js 22 LTS - One runtime dependency: `zipson` (data decompression) ## Commands -- `pnpm build` — bundle ESM + CJS + types to `dist/` -- `pnpm test` — run the Vitest suite +- `pnpm build` — bundle ESM + CJS + types for the main entry and the four subpaths into `dist/` +- `pnpm build:data` — regenerate `src/data/*.json` from `scripts/data/{countries.csv,country-capitals.json,locations.json}` via `tsx scripts/clean-and-generate.ts` +- `pnpm test` — run the Vitest suite (109 tests, incl. property-based) +- `pnpm test:coverage` — same with `@vitest/coverage-v8` - `pnpm lint` / `pnpm lint:fix` — Biome check - `pnpm typecheck` — `tsc --noEmit` +- `pnpm bench` — build then run `bench/location-timezone.bench.mjs` - `pnpm dev` — `tsdown --watch` ## Important Files -- `src/index.ts` — public entry; named exports + default merged object -- `src/countries.ts`, `src/locations.ts`, `src/states-ansi.ts`, `src/timezones.ts` — per-domain query helpers -- `src/helpers.ts` — internal `exists`, `is`, `hasLen`, `match`, `isValidCountryIso` +- `src/index.ts` — main entry; named exports + default merged object +- `src/countries.ts`, `src/locations.ts`, `src/states-ansi.ts`, `src/timezones.ts` — per-domain query helpers; each is a public subpath entry (`@coroboros/location-timezone/`) +- `src/helpers.ts` — internal `exists`, `is`, `hasLen`, `match` (not exported publicly) - `src/interfaces.ts` — `Capital`, `Country`, `Location`, `StateAnsi` -- `src/data/index.ts` — loads and parses zipson-compressed JSON +- `src/data/{countries,locations,states-ansi,timezones}.ts` — per-domain data layer: parses compressed JSON, freezes the arrays, builds Map/Set indexes - `src/data/*.json` — compressed payloads (committed; not human-edited) -- `tests/` — Vitest suites, one spec per source module -- `scripts/` — data rebuild scripts (CommonJS); NOT shipped on npm (not in `files`). Excluded from `biome` and `tsc`. Run via `pnpm build:data` to regenerate `src/data/*.json` from `scripts/data/{countries.csv,country-capitals.json,locations.json}`. +- `tests/` — Vitest suites, one spec per source module plus `location-timezone.property.test.ts` for fast-check invariants +- `bench/{location-timezone.bench.mjs,baseline.md}` — mitata bench + 1.0.0 baseline with regression budget +- `scripts/` — data rebuild scripts (TypeScript via `tsx`); NOT shipped on npm (not in `files`). Excluded from `biome` and `tsc`. Run via `pnpm build:data`. ## Public API (1.0.0 contract) -- 29 functions across 4 domains (capitals/countries — 12, locations — 6, states ANSI — 5, timezones — 6). -- 4 interfaces: `Capital`, `Country`, `Location`, `StateAnsi`. -- Two export shapes: - - Named: `import { findCapitalOfCountryIso } from '@coroboros/location-timezone'` - - Default merged object: `import locationTimezone from '@coroboros/location-timezone'` then `.findX(...)` +- 4 domains (capitals/countries, locations, states ANSI, timezones) across 29 functions +- 4 interfaces: `Capital`, `Country`, `Location`, `StateAnsi` +- Three import shapes: + - Main entry named: `import { findCapitalOfCountryIso } from '@coroboros/location-timezone'` + - Main entry default merged object: `import locationTimezone from '@coroboros/location-timezone'` then `.findX(...)` + - Per-domain subpath: `import { findCountryByIso } from '@coroboros/location-timezone/countries'` ## Rules - **NEVER** break the public API above. Signatures, return shapes, and `undefined`/`[]` "not found" semantics are the 1.0.0 contract. - **NEVER** add a runtime dependency beyond `zipson` without user approval. - **NEVER** use `axios`, `request`, or `node-fetch` — use native `fetch` (Node 22+). -- **NEVER** mutate the arrays returned from `getCountries`, `getCapitals`, `getLocations`, `getStatesAnsi`, `getTimezones`, `getCountryIso2Codes`, `getCountryIso3Codes` — they are shared references to internal data. +- **NEVER** mutate the arrays returned from `getCountries`, `getCapitals`, `getLocations`, `getStatesAnsi`, `getTimezones`, `getCountryIso2Codes`, `getCountryIso3Codes` — they are frozen, shared references to internal data. Returned bucket arrays from `findLocationsByX` and `findTimezonesByX` are also frozen. - Empty string `''` is the standard "absent" marker on `Capital`/`Country`/`Location`/`StateAnsi` string fields. Never substitute `null` or `undefined`. -- Run `pnpm lint && pnpm typecheck && pnpm test` before every commit. +- Run `pnpm lint && pnpm typecheck && pnpm test` before every commit. Run `pnpm bench` against `bench/baseline.md` when touching `src/{countries,locations,states-ansi,timezones,helpers}.ts` or any `src/data/*.ts` — no regression > 5% on any bucket at fixed feature set. - Scoped package — `publishConfig.access = "public"` is mandatory, do not remove. - `scripts/` is the only place that uses `country-locale-map`; `tests/` is the only place that uses `joi`. Both stay in `devDependencies`. +- **Publish** — one-off npm token bootstrap for the first `1.0.0` publish (npm has no pre-publish Trusted Publisher form for not-yet-existing scoped packages), then OIDC Trusted Publisher + `npm provenance` from `1.0.1+`. Never re-add the token once OIDC is configured. `pnpm publish` from a developer machine is forbidden — publish is CI-owned. +- **Git** — `main`-only; branch → PR → squash-merge → tag the merge commit. The tag is the only manual step; release automation (version bump, `CHANGELOG.md`, npm publish, GitHub release) is owned by [`coroboros/ci`](https://github.com/coroboros/ci). Never hand-edit `package.json` version or `CHANGELOG.md`. Run `pnpm lint && pnpm typecheck && pnpm test && pnpm build` before tagging. diff --git a/README.md b/README.md index 5ea281a..f41a8e4 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ **Capital, country, city, ANSI state, and IANA timezone lookups for Node.js.** -Lookup helpers for capitals, countries, cities, ANSI states, and IANA timezones. Data: UN country list, CIA World Factbook, ISO 3166-1, ANSI state codes, and Node's `Intl.supportedValuesOf('timeZone')` — compressed via `zipson`. +Curated UN country names, CIA Factbook official forms, ISO 3166-1 codes, ANSI FIPS state codes, IANA timezones, and ~40,000 city coordinates. One zipson-compressed package. O(1) lookups. [![npm](https://img.shields.io/npm/v/@coroboros/location-timezone?style=flat-square&color=000000)](https://www.npmjs.com/package/@coroboros/location-timezone) -[![branch](https://img.shields.io/badge/branch-stable-000000?style=flat-square)](https://github.com/coroboros/location-timezone) +[![ci](https://img.shields.io/github/actions/workflow/status/coroboros/location-timezone/ci.yml?branch=main&style=flat-square&label=ci&color=000000)](https://github.com/coroboros/location-timezone/actions/workflows/ci.yml) [![license](https://img.shields.io/badge/license-MIT-000000?style=flat-square)](https://opensource.org/licenses/MIT) [![stars](https://img.shields.io/github/stars/coroboros/location-timezone?style=flat-square&label=stars&color=000000)](https://github.com/coroboros/location-timezone) [![coroboros.com](https://img.shields.io/badge/coroboros.com-000000?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTAiLz48cGF0aCBkPSJNMiAxMmgyME0xMiAyYTE1LjMgMTUuMyAwIDAgMSA0IDEwIDE1LjMgMTUuMyAwIDAgMS00IDEwIDE1LjMgMTUuMyAwIDAgMS00LTEwIDE1LjMgMTUuMyAwIDAgMSA0LTEweiIvPjwvc3ZnPg==)](https://coroboros.com) @@ -23,8 +23,10 @@ Lookup helpers for capitals, countries, cities, ANSI states, and IANA timezones. - [Requirements](#requirements) - [Install](#install) - [Usage](#usage) +- [Why this exists](#why-this-exists) - [Data](#data) - [API](#api) +- [Subpath exports](#subpath-exports) - [Limitations](#limitations) - [Contributing](#contributing) - [License](#license) @@ -81,6 +83,10 @@ const locationTimezone = require('@coroboros/location-timezone').default; locationTimezone.findStateAnsiByUspsCode('NY'); ``` +## Why this exists + +Country, capital, city, ANSI state, and timezone data normally ships in separate npm packages with mismatched cross-references. `@coroboros/location-timezone` consolidates them into one zipson-compressed payload: UN country names, CIA Factbook official forms, ISO 3166-1 codes, ANSI FIPS state codes, IANA timezones, and ~40,000 cities with coordinates. Lookups resolve in O(1) via `Map` and `Set` indexes built once at module load. See [`bench/baseline.md`](bench/baseline.md) for the head-to-head numbers vs the pre-optim linear-scan baseline. + ## Data - Country names and ISO 3166-1 alpha-2 / alpha-3 codes from [the UN country list](https://unterm.un.org/unterm2/en/country) and [the CIA World Factbook](https://www.cia.gov/the-world-factbook/). @@ -98,45 +104,73 @@ locationTimezone.findStateAnsiByUspsCode('NY');
Capital -| Property | Type | Notes | -|---|---|---| -| `name` | `string` | UTF-8 name. | +
+ +A country's capital city. + +| Property | Type | Description | +| --- | --- | --- | +| `name` | `string` | UTF-8 name. Empty string when the country has no capital. | | `nameAscii` | `string` | ASCII transliteration. | | `latitude` | `number` | Decimal degrees. | | `longitude` | `number` | Decimal degrees. | | `province` | `string` | | | `state` | `string` | USPS code (US only). Empty string otherwise. | | `timezone` | `string` | IANA timezone. | -| `country?` | `Country` | Optional back-reference. | +| `country?` | [`Country`](#types) | Back-reference. Always present at runtime on capitals returned by the API. | + +
+ +
+Coordinates + +
+ +Bounding-box bounds for [`findLocationsByCoordinates`](#locations). Pass at least one latitude bound and one longitude bound; missing bounds default to `±Infinity`. + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `latitudeFrom?` | `number` | `Number.NEGATIVE_INFINITY` | Southern latitude bound. | +| `latitudeTo?` | `number` | `Number.POSITIVE_INFINITY` | Northern latitude bound. | +| `longitudeFrom?` | `number` | `Number.NEGATIVE_INFINITY` | Western longitude bound. | +| `longitudeTo?` | `number` | `Number.POSITIVE_INFINITY` | Eastern longitude bound. |
Country -| Property | Type | Notes | -|---|---|---| +
+ +A country with ISO codes and IANA timezones. + +| Property | Type | Description | +| --- | --- | --- | | `name` | `string` | UN short form. | | `officialName` | `string` | UN long form. | | `iso2` | `string` | ISO 3166-1 alpha-2. | | `iso3` | `string` | ISO 3166-1 alpha-3. | -| `timezones` | `string[]` | IANA timezones. | -| `capital?` | `Capital` | Optional back-reference. | +| `timezones` | `ReadonlyArray` | IANA timezones, sorted ascending. | +| `capital?` | [`Capital`](#types) | Back-reference. Always present at runtime on countries returned by the API. |
Location -| Property | Type | Notes | -|---|---|---| +
+ +A city with coordinates and its country. + +| Property | Type | Description | +| --- | --- | --- | | `city` | `string` | UTF-8 name. | | `cityAscii` | `string` | ASCII transliteration. | -| `country` | `Country` | | +| `country` | [`Country`](#types) | The country it belongs to. | | `latitude` | `number` | Decimal degrees. | | `longitude` | `number` | Decimal degrees. | | `province` | `string` | | -| `state` | `string` | USPS code (US only). | +| `state` | `string` | USPS code (US only). Empty string otherwise. | | `timezone` | `string` | IANA timezone. |
@@ -144,11 +178,15 @@ locationTimezone.findStateAnsiByUspsCode('NY');
StateAnsi -| Property | Type | Notes | -|---|---|---| -| `fipsCode` | `string` | FIPS state code. | -| `gnisid` | `string` | Geographic Names Information System Identifier. | -| `name` | `string` | | +
+ +A US state with ANSI identifiers. + +| Property | Type | Description | +| --- | --- | --- | +| `fipsCode` | `string` | FIPS state code (2 digits). | +| `gnisid` | `string` | Geographic Names Information System Identifier (8 digits). | +| `name` | `string` | State name. | | `uspsCode` | `string` | USPS two-letter code. |
@@ -156,308 +194,720 @@ locationTimezone.findStateAnsiByUspsCode('NY'); ### Capitals and countries
-findCapitalOfCountryIso(code: string): Capital | undefined +findCapitalOfCountryIso(code) + +
+ +Find the capital of a country by its ISO 3166-1 alpha-2 or alpha-3 code. Case-insensitive. + +**Parameters** -Find the capital of a country by ISO 3166-1 alpha-2 or alpha-3 code. +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `code` | `string` | *(required)* | Country ISO code (alpha-2 or alpha-3). | -| Parameter | Type | Notes | -|---|---|---| -| `code` | `string` | Country ISO code, case-insensitive. | +**Returns** — [`Capital`](#types) `| undefined`. The matching capital, or `undefined` when `code` does not resolve. + +**Examples** + +```ts +findCapitalOfCountryIso('JP'); // → { name: 'Tokyo', ... } +findCapitalOfCountryIso('jpn'); // → { name: 'Tokyo', ... } +findCapitalOfCountryIso('XX'); // → undefined +```
-findCapitalOfCountryName(name: string): Capital | undefined +findCapitalOfCountryName(name) + +
+ +Find the capital of a country by short or official name. Case-insensitive. + +**Parameters** -Find the capital of a country by short or official name. +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *(required)* | Country name (UN short form or official form). | -| Parameter | Type | Notes | -|---|---|---| -| `name` | `string` | Country name, case-insensitive. | +**Returns** — [`Capital`](#types) `| undefined`. The matching capital, or `undefined` when `name` does not resolve. + +**Examples** + +```ts +findCapitalOfCountryName('Japan'); // → { name: 'Tokyo', ... } +findCapitalOfCountryName('British Indian Ocean Territory'); // → { name: 'Diego Garcia', ... } +findCapitalOfCountryName('The British Indian Ocean Territory'); // same — official form +```
-findCountryByCapitalName(name: string): Country | undefined +findCountryByCapitalName(name) + +
+ +Find a country by its capital name (UTF-8 or ASCII). Case-insensitive. + +**Parameters** -Find a country by its capital name. +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *(required)* | Capital name (`Capital.name` or `Capital.nameAscii`). | -| Parameter | Type | Notes | -|---|---|---| -| `name` | `string` | Capital name, UTF-8 or ASCII, case-insensitive. | +**Returns** — [`Country`](#types) `| undefined`. The matching country, or `undefined` when `name` does not resolve. + +**Examples** + +```ts +findCountryByCapitalName('Tokyo'); // → { name: 'Japan', ... } +findCountryByCapitalName('Diego Garcia'); // → { name: 'British Indian Ocean Territory', ... } +findCountryByCapitalName(''); // → undefined +```
-findCountryByIso(code: string): Country | undefined +findCountryByIso(code) + +
+ +Find a country by ISO 3166-1 alpha-2 or alpha-3 code. Case-insensitive. + +**Parameters** -Find a country by ISO 3166-1 alpha-2 or alpha-3 code. +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `code` | `string` | *(required)* | Country ISO code (alpha-2 or alpha-3). | -| Parameter | Type | Notes | -|---|---|---| -| `code` | `string` | Country ISO code, case-insensitive. | +**Returns** — [`Country`](#types) `| undefined`. The matching country, or `undefined` when `code` does not resolve. + +**Examples** + +```ts +findCountryByIso('BF'); // → { name: 'Burkina Faso', iso2: 'BF', iso3: 'BFA', ... } +findCountryByIso('BFA'); // → { name: 'Burkina Faso', ... } +findCountryByIso('cL'); // → { name: 'Chile', ... } — case-insensitive +```
-findCountryByName(name: string): Country | undefined +findCountryByName(name) + +
+ +Find a country by short or official name. Case-insensitive. + +**Parameters** -Find a country by short or official name. +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *(required)* | Country name (`Country.name` or `Country.officialName`). | -| Parameter | Type | Notes | -|---|---|---| -| `name` | `string` | Country name, case-insensitive. | +**Returns** — [`Country`](#types) `| undefined`. The matching country, or `undefined` when `name` does not resolve. + +**Examples** + +```ts +findCountryByName('Costa Rica'); // → { name: 'Costa Rica', ... } +findCountryByName('The Republic of Costa Rica'); // same — official form +findCountryByName('The Territory of Cocos (Keeling) Islands'); // → official form match +```
-getCapitals(): Capital[] +getCapitals() + +
All country capitals, sorted by country name ascending. +**Returns** — `ReadonlyArray<`[`Capital`](#types)`>`. Frozen — do not mutate. + +**Examples** + +```ts +const capitals = getCapitals(); +capitals.length; // → number of countries +capitals.find(c => c.name === 'Tokyo')?.country?.iso2; // → 'JP' +``` +
-getCountries(): Country[] +getCountries() + +
All countries, sorted by country name ascending. +**Returns** — `ReadonlyArray<`[`Country`](#types)`>`. Frozen — do not mutate. + +**Examples** + +```ts +const countries = getCountries(); +countries.length; // → ~250 +countries.filter(c => c.timezones.length > 1).length; // multi-timezone countries +countries.find(c => c.iso2 === 'US')?.officialName; // → 'The United States of America' +``` +
-getCountryIso2CodeByIso3(iso3: string): string | undefined +getCountryIso2CodeByIso3(iso3) + +
+ +Get the ISO 3166-1 alpha-2 code paired with an alpha-3 code. Case-insensitive. + +**Parameters** + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `iso3` | `string` | *(required)* | Alpha-3 code. | + +**Returns** — `string | undefined`. The paired alpha-2 code, or `undefined` when `iso3` is unknown. -Map ISO 3166-1 alpha-3 to alpha-2. +**Examples** -| Parameter | Type | Notes | -|---|---|---| -| `iso3` | `string` | Alpha-3 code, case-insensitive. | +```ts +getCountryIso2CodeByIso3('THA'); // → 'TH' +getCountryIso2CodeByIso3('USA'); // → 'US' +getCountryIso2CodeByIso3('XXX'); // → undefined +```
-getCountryIso2Codes(): string[] +getCountryIso2Codes() + +
All ISO 3166-1 alpha-2 codes, sorted ascending. +**Returns** — `ReadonlyArray`. Frozen — do not mutate. + +**Examples** + +```ts +const codes = getCountryIso2Codes(); +codes.length; // → ~250 +codes.includes('JP'); // → true +codes[0]; // → 'AD' +``` +
-getCountryIso3CodeByIso2(iso2: string): string | undefined +getCountryIso3CodeByIso2(iso2) + +
+ +Get the ISO 3166-1 alpha-3 code paired with an alpha-2 code. Case-insensitive. + +**Parameters** -Map ISO 3166-1 alpha-2 to alpha-3. +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `iso2` | `string` | *(required)* | Alpha-2 code. | -| Parameter | Type | Notes | -|---|---|---| -| `iso2` | `string` | Alpha-2 code, case-insensitive. | +**Returns** — `string | undefined`. The paired alpha-3 code, or `undefined` when `iso2` is unknown. + +**Examples** + +```ts +getCountryIso3CodeByIso2('TH'); // → 'THA' +getCountryIso3CodeByIso2('US'); // → 'USA' +getCountryIso3CodeByIso2('XX'); // → undefined +```
-getCountryIso3Codes(): string[] +getCountryIso3Codes() + +
All ISO 3166-1 alpha-3 codes, sorted ascending. +**Returns** — `ReadonlyArray`. Frozen — do not mutate. + +**Examples** + +```ts +const codes = getCountryIso3Codes(); +codes.length; // → ~250 +codes.includes('JPN'); // → true +codes[0]; // → 'AFG' +``` +
-isValidCountryIso(code: string): { valid: boolean; iso2: boolean; iso3: boolean } +isValidCountryIso(code) -Validate an ISO 3166-1 alpha-2 or alpha-3 code. **Case-sensitive** — codes must be uppercase. +
-| Parameter | Type | Notes | -|---|---|---| -| `code` | `string` | ISO code, case-sensitive. | +Validate an ISO 3166-1 alpha-2 or alpha-3 code. **Case-sensitive** — codes must be uppercase. The `find*` helpers on this page accept any case. + +**Parameters** + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `code` | `string` | *(required)* | ISO code, uppercase. | + +**Returns** — `{ valid: boolean; iso2: boolean; iso3: boolean }`. `valid` is `true` when `code` resolves; `iso2` / `iso3` discriminates the form. + +**Examples** + +```ts +isValidCountryIso('JP'); // → { valid: true, iso2: true, iso3: false } +isValidCountryIso('JPN'); // → { valid: true, iso2: false, iso3: true } +isValidCountryIso('jp'); // → { valid: false, iso2: false, iso3: false } — lowercase rejected +isValidCountryIso('XX'); // → { valid: false, iso2: false, iso3: false } +```
### Locations
-findLocationsByCoordinates(coordinates: { latitudeFrom?, latitudeTo?, longitudeFrom?, longitudeTo? }): Location[] +findLocationsByCoordinates(coordinates) + +
Find locations within a bounding box. At least one latitude bound and one longitude bound must be set; missing bounds default to `±Infinity`. -| Parameter | Type | Default | -|---|---|---| -| `latitudeFrom?` | `number` | `Number.NEGATIVE_INFINITY` | -| `latitudeTo?` | `number` | `Number.POSITIVE_INFINITY` | -| `longitudeFrom?` | `number` | `Number.NEGATIVE_INFINITY` | -| `longitudeTo?` | `number` | `Number.POSITIVE_INFINITY` | +**Parameters** + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `coordinates` | [`Coordinates`](#types) | *(required)* | Bounding-box bounds. See the type for each field. | + +**Returns** — `ReadonlyArray<`[`Location`](#types)`>`. Locations within the box, or `[]` when neither latitude bound or neither longitude bound is set. + +**Examples** + +```ts +findLocationsByCoordinates({ latitudeFrom: 0, longitudeFrom: 0 }); +// → Location[] — north-east of the equator and the prime meridian + +findLocationsByCoordinates({ latitudeFrom: -55, latitudeTo: 1, longitudeFrom: -8, longitudeTo: 5 }); +// → Location[] inside the box + +findLocationsByCoordinates({}); // → [] +```
-findLocationsByCountryIso(code: string): Location[] +findLocationsByCountryIso(code) + +
+ +Find locations by country ISO 3166-1 alpha-2 or alpha-3 code. Case-insensitive. + +**Parameters** + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `code` | `string` | *(required)* | Country ISO code (alpha-2 or alpha-3). | -Find locations by ISO 3166-1 alpha-2 or alpha-3 code. +**Returns** — `ReadonlyArray<`[`Location`](#types)`>`. Locations in the country, or `[]` when `code` does not resolve. -| Parameter | Type | Notes | -|---|---|---| -| `code` | `string` | Country ISO code, case-insensitive. | +**Examples** + +```ts +findLocationsByCountryIso('JP'); // → Location[] — Japanese cities +findLocationsByCountryIso('JPN'); // same — alpha-3 works +findLocationsByCountryIso('XX'); // → [] +```
-findLocationsByCountryName(name: string, partialMatch?: boolean): Location[] +findLocationsByCountryName(name, partialMatch?) + +
+ +Find locations by country short or official name. Case-insensitive. + +**Parameters** + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *(required)* | Country name (`Country.name` or `Country.officialName`). | +| `partialMatch?` | `boolean` | `false` | When `true`, matches names that contain `name` as a substring. | -Find locations by country name. +**Returns** — `ReadonlyArray<`[`Location`](#types)`>`. Matching locations, or `[]` when nothing matches. -| Parameter | Type | Default | -|---|---|---| -| `name` | `string` | — | -| `partialMatch?` | `boolean` | `false` | +**Examples** + +```ts +findLocationsByCountryName('Timor-Leste'); // → Location[] +findLocationsByCountryName('The Democratic Republic of Timor-Leste'); // same — official form +findLocationsByCountryName('timor', true); // → Location[] — partial match +findLocationsByCountryName('timor', false); // → [] +```
-findLocationsByProvince(name: string, partialMatch?: boolean): Location[] +findLocationsByProvince(name, partialMatch?) + +
+ +Find locations by province. Case-insensitive. Province data is not exhaustively verified — see [Limitations](#limitations). + +**Parameters** + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *(required)* | Province name. Pass `''` to find locations with no province. | +| `partialMatch?` | `boolean` | `false` | When `true`, matches provinces that contain `name` as a substring. | -Find locations by province. Province data is not exhaustively verified — see [Limitations](#limitations). +**Returns** — `ReadonlyArray<`[`Location`](#types)`>`. Matching locations, or `[]` when nothing matches. -| Parameter | Type | Default | -|---|---|---| -| `name` | `string` | — | -| `partialMatch?` | `boolean` | `false` | +**Examples** + +```ts +findLocationsByProvince('Tristan da Cunha'); // → Location[] +findLocationsByProvince('Damascus'); // → Location[] +findLocationsByProvince('tokel', true); // → Location[] — partial: 'Tokelau' +```
-findLocationsByState(name: string, partialMatch?: boolean): Location[] +findLocationsByState(name, partialMatch?) + +
+ +Find locations by USPS state name or code (US only). Case-insensitive. + +**Parameters** + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *(required)* | USPS code or state field. Pass `''` to find non-US locations. | +| `partialMatch?` | `boolean` | `false` | When `true`, matches state fields that contain `name` as a substring. | -Find locations by USPS state name or code (US only). +**Returns** — `ReadonlyArray<`[`Location`](#types)`>`. Matching locations, or `[]` when nothing matches. -| Parameter | Type | Default | -|---|---|---| -| `name` | `string` | — | -| `partialMatch?` | `boolean` | `false` | +**Examples** + +```ts +findLocationsByState('NY'); // → Location[] — New York +findLocationsByState('ny'); // same — case-insensitive +findLocationsByState('x', true); // → Location[] — partial: 'TX' +```
-getLocations(): Location[] +getLocations() + +
All locations, sorted by city name ascending. +**Returns** — `ReadonlyArray<`[`Location`](#types)`>`. Frozen — do not mutate. + +**Examples** + +```ts +const locations = getLocations(); +locations.length; // → ~40,000 +locations.filter(l => l.country.iso2 === 'JP').length; // Japanese cities +``` +
### States ANSI
-findStateAnsiByFipsCode(code: string): StateAnsi | undefined +findStateAnsiByFipsCode(code) + +
+ +Find a US state by its FIPS State Code ANSI. + +**Parameters** + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `code` | `string` | *(required)* | FIPS code (length 2). | + +**Returns** — [`StateAnsi`](#types) `| undefined`. The matching state, or `undefined` when `code` is not a known FIPS code. -Find a US state by FIPS code (2 characters). +**Examples** -| Parameter | Type | Notes | -|---|---|---| -| `code` | `string` | FIPS code, case-insensitive, length 2. | +```ts +findStateAnsiByFipsCode('05'); // → { name: 'Arkansas', uspsCode: 'AR', gnisid: '00068085', ... } +findStateAnsiByFipsCode('99'); // → undefined +findStateAnsiByFipsCode(''); // → undefined — length 2 required +```
-findStateAnsiByGnisid(id: string): StateAnsi | undefined +findStateAnsiByGnisid(id) + +
+ +Find a US state by its GNISID (Geographic Names Information System Identifier). + +**Parameters** + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `id` | `string` | *(required)* | GNISID (8 digits). | + +**Returns** — [`StateAnsi`](#types) `| undefined`. The matching state, or `undefined` when `id` is unknown. -Find a US state by GNISID. +**Examples** -| Parameter | Type | Notes | -|---|---|---| -| `id` | `string` | GNISID, case-insensitive. | +```ts +findStateAnsiByGnisid('00068085'); // → { name: 'Arkansas', ... } +findStateAnsiByGnisid('99999999'); // → undefined +```
-findStateAnsiByName(name: string): StateAnsi | undefined +findStateAnsiByName(name) + +
+ +Find a US state by name. Case-insensitive. + +**Parameters** + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *(required)* | State name. | + +**Returns** — [`StateAnsi`](#types) `| undefined`. The matching state, or `undefined` when `name` does not resolve. -Find a US state by name. +**Examples** -| Parameter | Type | Notes | -|---|---|---| -| `name` | `string` | State name, case-insensitive. | +```ts +findStateAnsiByName('Arkansas'); // → { uspsCode: 'AR', fipsCode: '05', ... } +findStateAnsiByName('arkansas'); // same — case-insensitive +findStateAnsiByName('Atlantis'); // → undefined +```
-findStateAnsiByUspsCode(code: string): StateAnsi | undefined +findStateAnsiByUspsCode(code) + +
+ +Find a US state by USPS code. Case-insensitive. -Find a US state by USPS code (2 characters). +**Parameters** -| Parameter | Type | Notes | -|---|---|---| -| `code` | `string` | USPS code, case-insensitive, length 2. | +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `code` | `string` | *(required)* | USPS two-letter code. | + +**Returns** — [`StateAnsi`](#types) `| undefined`. The matching state, or `undefined` when `code` is not a known USPS code. + +**Examples** + +```ts +findStateAnsiByUspsCode('AR'); // → { name: 'Arkansas', fipsCode: '05', ... } +findStateAnsiByUspsCode('ar'); // same — case-insensitive +findStateAnsiByUspsCode('ZZ'); // → undefined +```
-getStatesAnsi(): StateAnsi[] +getStatesAnsi() + +
All US states (ANSI), sorted by name ascending. +**Returns** — `ReadonlyArray<`[`StateAnsi`](#types)`>`. Frozen — do not mutate. + +**Examples** + +```ts +const states = getStatesAnsi(); +states.length; // → 56 (50 states + DC + territories) +states.find(s => s.uspsCode === 'CA')?.name; // → 'California' +``` +
### Timezones
-findTimezoneByCapitalOfCountryIso(code: string): string | undefined +findTimezoneByCapitalOfCountryIso(code) + +
+ +IANA timezone for a country's capital, by ISO 3166-1 alpha-2 or alpha-3 code. Case-insensitive. + +**Parameters** -IANA timezone for a country's capital, by ISO 3166-1 alpha-2 or alpha-3 code. +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `code` | `string` | *(required)* | Country ISO code (alpha-2 or alpha-3). | -| Parameter | Type | Notes | -|---|---|---| -| `code` | `string` | Country ISO code, case-insensitive. | +**Returns** — `string | undefined`. The IANA timezone of the country's capital, or `undefined` when `code` does not resolve. + +**Examples** + +```ts +findTimezoneByCapitalOfCountryIso('JP'); // → 'Asia/Tokyo' +findTimezoneByCapitalOfCountryIso('FRA'); // → 'Europe/Paris' +findTimezoneByCapitalOfCountryIso('XX'); // → undefined +```
-findTimezoneByCapitalOfCountryName(name: string): string | undefined +findTimezoneByCapitalOfCountryName(name) + +
+ +IANA timezone for a country's capital, by country short or official name. Case-insensitive. + +**Parameters** -IANA timezone for a country's capital, by country name. +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *(required)* | Country name (`Country.name` or `Country.officialName`). | -| Parameter | Type | Notes | -|---|---|---| -| `name` | `string` | Country name, case-insensitive. | +**Returns** — `string | undefined`. The IANA timezone of the country's capital, or `undefined` when `name` does not resolve. + +**Examples** + +```ts +findTimezoneByCapitalOfCountryName('Japan'); // → 'Asia/Tokyo' +findTimezoneByCapitalOfCountryName('The French Republic'); // → 'Europe/Paris' +findTimezoneByCapitalOfCountryName('Atlantis'); // → undefined +```
-findTimezoneByCityName(name: string): string | undefined +findTimezoneByCityName(name) + +
+ +IANA timezone for a city, by name (UTF-8 or ASCII). Case-insensitive. + +**Parameters** -IANA timezone for a city, by name (UTF-8 or ASCII). +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *(required)* | City name. | -| Parameter | Type | Notes | -|---|---|---| -| `name` | `string` | City name, case-insensitive. | +**Returns** — `string | undefined`. The IANA timezone of the matching city, or `undefined` when `name` is empty or does not resolve. + +**Examples** + +```ts +findTimezoneByCityName('Tokyo'); // → 'Asia/Tokyo' +findTimezoneByCityName('Göteborg'); // → 'Europe/Stockholm' +findTimezoneByCityName('Goteborg'); // same — ASCII transliteration works too +findTimezoneByCityName(''); // → undefined +```
-findTimezonesByCountryIso(code: string): string[] +findTimezonesByCountryIso(code) + +
+ +All IANA timezones for a country, by ISO 3166-1 alpha-2 or alpha-3 code. Case-insensitive. + +**Parameters** -All IANA timezones for a country, by ISO 3166-1 alpha-2 or alpha-3 code. +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `code` | `string` | *(required)* | Country ISO code (alpha-2 or alpha-3). | -| Parameter | Type | Notes | -|---|---|---| -| `code` | `string` | Country ISO code, case-insensitive. | +**Returns** — `ReadonlyArray`. The country's timezones, or `[]` when `code` does not resolve. Frozen — do not mutate. + +**Examples** + +```ts +findTimezonesByCountryIso('US'); // → ['America/Adak', 'America/Anchorage', ...] +findTimezonesByCountryIso('JPN'); // → ['Asia/Tokyo'] +findTimezonesByCountryIso('XX'); // → [] +```
-findTimezonesByCountryName(name: string): string[] +findTimezonesByCountryName(name) + +
+ +All IANA timezones for a country, by short or official name. Case-insensitive. + +**Parameters** -All IANA timezones for a country, by name. +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `name` | `string` | *(required)* | Country name (`Country.name` or `Country.officialName`). | -| Parameter | Type | Notes | -|---|---|---| -| `name` | `string` | Country name, case-insensitive. | +**Returns** — `ReadonlyArray`. The country's timezones, or `[]` when `name` does not resolve. Frozen — do not mutate. + +**Examples** + +```ts +findTimezonesByCountryName('United States of America'); // → ['America/Adak', ...] +findTimezonesByCountryName('The United States of America'); // same — official form +findTimezonesByCountryName('Atlantis'); // → [] +```
-getTimezones(): string[] +getTimezones() + +
All IANA timezones (the subset returned by `Intl.supportedValuesOf('timeZone')` at data-build time), sorted ascending. +**Returns** — `ReadonlyArray`. Frozen — do not mutate. + +**Examples** + +```ts +const timezones = getTimezones(); +timezones.length; // → ~430 +timezones.includes('Asia/Tokyo'); // → true +timezones[0]; // → 'Africa/Abidjan' +``` +
+## Subpath exports + +The main entry `@coroboros/location-timezone` bundles every domain. For finer-grained tree-shaking, import only the domain you need: + +```ts +import { findStateAnsiByUspsCode } from '@coroboros/location-timezone/states-ansi'; // ~5 kB +import { findCountryByIso } from '@coroboros/location-timezone/countries'; // ~85 kB +import { findTimezoneByCityName } from '@coroboros/location-timezone/timezones'; // ~870 kB +import { findLocationsByCountryIso } from '@coroboros/location-timezone/locations'; // ~860 kB +``` + +Sizes include the chunked dependencies each subpath transitively pulls. The merged default object is only exposed on the main entry; subpaths expose named exports only. + ## Limitations - **Province data is unreliable** — `findLocationsByProvince` works on the field as-is but coverage and naming vary by country. Treat as best-effort. diff --git a/bench/baseline.md b/bench/baseline.md new file mode 100644 index 0000000..ccbaa18 --- /dev/null +++ b/bench/baseline.md @@ -0,0 +1,71 @@ +# Benchmark baseline + +Apple M1, Node 22.22.2 (arm64-darwin). Run `pnpm bench` to reproduce. + +Pre-optim numbers come from the migrated state before any indexing — +`Array.find` / `Array.filter` / `Array.includes` over the parsed arrays. +Post-optim numbers come from `1.0.0` with `Map` / `Set` indexes built +once at module load. + +## Post-optim (1.0.0) + +### Lookup latency + +| Bench | Pre-optim | Post-optim | Speedup | +| ---------------------------------------- | ---------: | ----------: | --------: | +| `findCountryByIso` (iso2, late hit) | 4.19 µs | 10.33 ns | 405× | +| `findCountryByIso` (iso3, late hit) | 5.87 µs | 14.91 ns | 394× | +| `findCountryByIso` (miss) | 876.63 ns | 14.06 ns | 62× | +| `findCountryByName` (exact, late hit) | 29.59 µs | 12.06 ns | 2,454× | +| `findCapitalOfCountryIso` (iso2, late) | 4.12 µs | 10.25 ns | 402× | +| `findTimezoneByCityName` (exact, late) | 292.59 µs | 27.03 ns | 10,825× | +| `findLocationsByCountryIso` (US) | 70.59 µs | 20.36 ns | 3,468× | +| `isValidCountryIso` (iso2 hit) | 599.21 ns | 5.36 ns | 112× | +| `isValidCountryIso` (iso3 hit) | 303.21 ns | 4.77 ns | 64× | +| `isValidCountryIso` (miss) | 653.29 ns | 6.04 ns | 108× | + +`findTimezoneByCityName` is the largest gain — it used to scan the full +40,000-entry locations array; the lowercase-city Map resolves it in a +single hash lookup. `findLocationsByCountryIso` returns the pre-grouped +bucket array directly, skipping the per-location filter. + +### Bundle size + +Pre-optim shipped a single entry. The optim pass split the data layer +into per-domain modules and added subpath exports +(`@coroboros/location-timezone/{countries,locations,states-ansi,timezones}`); +tsdown auto-emits shared chunks. Consumers importing only one domain pull +only the chunks they need. + +| Entry | Pre-optim | Post-optim (loaded) | Note | +| -------------- | ---------: | -------------------: | ----------------------------------------------: | +| `.` (full) | 876.88 kB | ~876 kB | merged surface, all chunks loaded | +| `/countries` | n/a | ~85 kB | 10× smaller | +| `/states-ansi` | n/a | ~5 kB | 73× smaller | +| `/timezones` | n/a | ~870 kB | requires locations + countries for city lookups | +| `/locations` | n/a | ~860 kB | requires countries for ISO validation helpers | + +Gzip on the ESM main entry: pre-optim 202.57 kB, post-optim ~203 kB (the +sum of the chunks the main entry pulls). + +## Why partial-match paths stay linear + +`findLocationsByCountryName(_, partialMatch=true)`, +`findLocationsByProvince(_, partialMatch=true)`, and +`findLocationsByState(_, partialMatch=true)` use case-insensitive +substring matching. No `Map` / `Set` / `Trie` index helps that without +significant code or memory cost. The exact-match path of each function +uses the corresponding index; the partial path scans linearly. + +`findLocationsByCoordinates` scans the locations array for a +bounding-box test — no good 2D index without an rtree or kd-tree +dependency, which would conflict with the zero-additional-runtime-dep +rule. + +## Going-forward target + +**No regression > 5% on any bucket at fixed feature set.** Hash-table +hits are nearly cache-line bound; variance is low enough to hold the bar +tight. Feature additions that legitimately cost time (an extra +indirection, a new validation step) reset the bar for the buckets they +affect — document the new floor here when that happens. diff --git a/bench/location-timezone.bench.mjs b/bench/location-timezone.bench.mjs new file mode 100644 index 0000000..b7524ef --- /dev/null +++ b/bench/location-timezone.bench.mjs @@ -0,0 +1,76 @@ +/** + * Micro-benchmark for the lookup hot paths. + * + * Usage (from the package root): + * pnpm bench + * + * Inputs are deliberately late-alphabet to expose worst-case linear-scan cost + * on the pre-optim baseline; post-optim Map/Set lookups are O(1) regardless + * of input position. + */ +import { bench, group, run } from 'mitata'; +import { + findCapitalOfCountryIso, + findCountryByIso, + findCountryByName, + findLocationsByCountryIso, + findTimezoneByCityName, + isValidCountryIso, +} from '../dist/index.mjs'; + +const LATE_ISO2 = 'ZW'; +const LATE_ISO3 = 'ZWE'; +const LATE_NAME = 'Zimbabwe'; +const POPULOUS_ISO2 = 'US'; +const LATE_CITY = 'Zurich'; +const MISS_ISO = 'XX'; + +group('findCountryByIso', () => { + bench('iso2 (hit, late)', () => { + findCountryByIso(LATE_ISO2); + }); + bench('iso3 (hit, late)', () => { + findCountryByIso(LATE_ISO3); + }); + bench('iso (miss)', () => { + findCountryByIso(MISS_ISO); + }); +}); + +group('findCountryByName', () => { + bench('exact (hit, late)', () => { + findCountryByName(LATE_NAME); + }); +}); + +group('findCapitalOfCountryIso', () => { + bench('iso2 (hit, late)', () => { + findCapitalOfCountryIso(LATE_ISO2); + }); +}); + +group('findTimezoneByCityName', () => { + bench('exact (hit, late)', () => { + findTimezoneByCityName(LATE_CITY); + }); +}); + +group('findLocationsByCountryIso', () => { + bench('populous (US)', () => { + findLocationsByCountryIso(POPULOUS_ISO2); + }); +}); + +group('isValidCountryIso', () => { + bench('iso2 (hit, late)', () => { + isValidCountryIso(LATE_ISO2); + }); + bench('iso3 (hit, late)', () => { + isValidCountryIso(LATE_ISO3); + }); + bench('miss', () => { + isValidCountryIso(MISS_ISO); + }); +}); + +await run({ colors: true }); diff --git a/package.json b/package.json index 57cafc2..53dfd9f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,46 @@ "default": "./dist/index.cjs" } }, + "./countries": { + "import": { + "types": "./dist/countries.d.mts", + "default": "./dist/countries.mjs" + }, + "require": { + "types": "./dist/countries.d.cts", + "default": "./dist/countries.cjs" + } + }, + "./locations": { + "import": { + "types": "./dist/locations.d.mts", + "default": "./dist/locations.mjs" + }, + "require": { + "types": "./dist/locations.d.cts", + "default": "./dist/locations.cjs" + } + }, + "./states-ansi": { + "import": { + "types": "./dist/states-ansi.d.mts", + "default": "./dist/states-ansi.mjs" + }, + "require": { + "types": "./dist/states-ansi.d.cts", + "default": "./dist/states-ansi.cjs" + } + }, + "./timezones": { + "import": { + "types": "./dist/timezones.d.mts", + "default": "./dist/timezones.mjs" + }, + "require": { + "types": "./dist/timezones.d.cts", + "default": "./dist/timezones.cjs" + } + }, "./package.json": "./package.json" }, "files": [ @@ -28,7 +68,8 @@ ], "scripts": { "build": "tsdown", - "build:data": "node scripts/clean-and-generate.js", + "build:data": "tsx scripts/clean-and-generate.ts", + "bench": "pnpm build && node bench/location-timezone.bench.mjs", "dev": "tsdown --watch", "lint": "biome check .", "lint:fix": "biome check --write .", @@ -80,8 +121,11 @@ "@types/node": "^22.0.0", "@vitest/coverage-v8": "^4.1.7", "country-locale-map": "^1.9.12", + "fast-check": "^4.8.0", "joi": "^18.2.1", + "mitata": "^1.0.34", "tsdown": "^0.22.0", + "tsx": "^4.22.3", "typescript": "^6.0.3", "vitest": "^4.1.7" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ddbb93..dc9c8c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,18 +24,27 @@ importers: country-locale-map: specifier: ^1.9.12 version: 1.9.12 + fast-check: + specifier: ^4.8.0 + version: 4.8.0 joi: specifier: ^18.2.1 version: 18.2.1 + mitata: + specifier: ^1.0.34 + version: 1.0.34 tsdown: specifier: ^0.22.0 - version: 0.22.0(typescript@6.0.3) + version: 0.22.0(tsx@4.22.3)(typescript@6.0.3) + tsx: + specifier: ^4.22.3 + version: 4.22.3 typescript: specifier: ^6.0.3 version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@22.19.19)(@vitest/coverage-v8@4.1.7)(vite@8.0.13(@types/node@22.19.19)) + version: 4.1.7(@types/node@22.19.19)(@vitest/coverage-v8@4.1.7)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(tsx@4.22.3)) packages: @@ -152,6 +161,162 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hapi/address@5.1.1': resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} engines: {node: '>=14.0.0'} @@ -409,6 +574,11 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -416,6 +586,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-check@4.8.0: + resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} + engines: {node: '>=12.17.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -565,6 +739,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mitata@1.0.34: + resolution: {integrity: sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -587,6 +764,9 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -698,6 +878,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -895,6 +1080,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + '@hapi/address@5.1.1': dependencies: '@hapi/hoek': 11.0.7 @@ -1023,7 +1286,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.7(@types/node@22.19.19)(@vitest/coverage-v8@4.1.7)(vite@8.0.13(@types/node@22.19.19)) + vitest: 4.1.7(@types/node@22.19.19)(@vitest/coverage-v8@4.1.7)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(tsx@4.22.3)) '@vitest/expect@4.1.7': dependencies: @@ -1034,13 +1297,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@8.0.13(@types/node@22.19.19))': + '@vitest/mocker@4.1.7(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(tsx@4.22.3))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.13(@types/node@22.19.19) + vite: 8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(tsx@4.22.3) '@vitest/pretty-format@4.1.7': dependencies: @@ -1104,12 +1367,45 @@ snapshots: es-module-lexer@2.1.0: {} + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 expect-type@1.3.0: {} + fast-check@4.8.0: + dependencies: + pure-rand: 8.4.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1229,6 +1525,8 @@ snapshots: dependencies: semver: 7.8.0 + mitata@1.0.34: {} + nanoid@3.3.12: {} obug@2.1.1: {} @@ -1245,6 +1543,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pure-rand@8.4.0: {} + quansync@1.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -1315,7 +1615,7 @@ snapshots: tree-kill@1.2.2: {} - tsdown@0.22.0(typescript@6.0.3): + tsdown@0.22.0(tsx@4.22.3)(typescript@6.0.3): dependencies: ansis: 4.3.0 cac: 7.0.0 @@ -1333,6 +1633,7 @@ snapshots: tree-kill: 1.2.2 unconfig-core: 7.5.0 optionalDependencies: + tsx: 4.22.3 typescript: 6.0.3 transitivePeerDependencies: - '@ts-macro/tsc' @@ -1343,6 +1644,12 @@ snapshots: tslib@2.8.1: optional: true + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + typescript@6.0.3: {} unconfig-core@7.5.0: @@ -1352,7 +1659,7 @@ snapshots: undici-types@6.21.0: {} - vite@8.0.13(@types/node@22.19.19): + vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(tsx@4.22.3): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -1361,12 +1668,14 @@ snapshots: tinyglobby: 0.2.16 optionalDependencies: '@types/node': 22.19.19 + esbuild: 0.28.0 fsevents: 2.3.3 + tsx: 4.22.3 - vitest@4.1.7(@types/node@22.19.19)(@vitest/coverage-v8@4.1.7)(vite@8.0.13(@types/node@22.19.19)): + vitest@4.1.7(@types/node@22.19.19)(@vitest/coverage-v8@4.1.7)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(tsx@4.22.3)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@22.19.19)) + '@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(tsx@4.22.3)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -1383,7 +1692,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.13(@types/node@22.19.19) + vite: 8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(tsx@4.22.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e84744a..c5d37b1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +allowBuilds: + esbuild: true minimumReleaseAgeExclude: - '@vitest/coverage-v8@4.1.7' - '@vitest/expect@4.1.7' diff --git a/scripts/clean-and-generate.js b/scripts/clean-and-generate.js deleted file mode 100644 index e22522b..0000000 --- a/scripts/clean-and-generate.js +++ /dev/null @@ -1,352 +0,0 @@ -/** - * Data Cleaner and File Generator - */ - -/* eslint-disable no-console */ - -const { writeFileSync } = require('fs'); -const clm = require('country-locale-map'); -const { stringify } = require('zipson'); -const countryIso2Codes = require('./country-iso2-codes'); -const countryIso3Codes = require('./country-iso3-codes'); -const countryCapitalsJson = require('./data/country-capitals.json'); -const generateCountries = require('./generate-countries'); -const locationsJson = require('./data/locations.json'); -const statesAnsi = require('./states-ansi'); - -// utils -const exists = (thing) => thing !== undefined && thing !== null && !Number.isNaN(thing); -const isString = (thing) => exists(thing) && thing.constructor === String; -const isNonEmptyString = (thing) => isString(thing) && thing.trim() !== ''; -const isNumber = (thing) => exists(thing) && thing.constructor === Number; -const isAscii = (str) => isNonEmptyString(str) - && ![...str].some((char) => char.charCodeAt(0) > 127); - -const timezones = Intl.supportedValuesOf('timeZone'); -const countries = {}; -const countryCapitals = {}; -const countryIso2ByIso3Codes = {}; -const countryIso3ByIso2Codes = {}; -const locations = {}; - -console.log('⏳ Cleaning data and building files...'); - -// NOTE: generate countries from the UN file -const countryFormalNamesByCountryName = generateCountries(); -const countryNames = Object.keys(countryFormalNamesByCountryName); - -// NOTE: write states ansi -writeFileSync(`${__dirname}/../src/data/states-ansi.json`, JSON.stringify({ data: stringify(statesAnsi) })); - -// NOTE: write ISO 3611-1 alpha-2 and alpha-3 codes files -writeFileSync(`${__dirname}/../src/data/country-iso2-codes.json`, JSON.stringify({ data: stringify(countryIso2Codes) })); -writeFileSync(`${__dirname}/../src/data/country-iso3-codes.json`, JSON.stringify({ data: stringify(countryIso3Codes) })); - -// NOTE: write timezones file -writeFileSync(`${__dirname}/../src/data/timezones.json`, JSON.stringify({ data: stringify(timezones) })); - -// NOTE: check every countries iso2 are listed in files -const countryIso2CodesLocations = [...new Set(locationsJson.map((location) => location.countryIso2))]; - -countryIso2Codes.forEach((countryIso2) => { - if (!countryIso2CodesLocations.includes(countryIso2)) { - return console.error(`🚨 Missing country iso2 code ${countryIso2} in locations file`); - } - - return true; -}); - -// check every countries iso3 are listed in files -const countryIso3CodesLocations = [...new Set(locationsJson.map((location) => location.countryIso3))]; - -countryIso3Codes.forEach((countryIso3) => { - if (!countryIso3CodesLocations.includes(countryIso3)) { - return console.error(`🚨 Missing country iso3 code ${countryIso3} in locations file`); - } - - return true; -}); - -// check every states ansi are listed in the locations file -const states = [...new Set(locationsJson.map((location) => location.stateAnsi))]; - -statesAnsi.forEach((state) => { - if (!states.includes(state.uspsCode)) { - return console.error(`🚨 Missing state ansi ${state.uspsCode} in locations file`); - } - - return true; -}); - -// get a list of all countries referenced in the countryCapitalsJson file -const countriesListIncountryCapitalsJson = [ - ...new Set(countryCapitalsJson.map((country) => country.country)), -]; - -// check every country is referenced in the UN list -countriesListIncountryCapitalsJson.forEach((countryName) => { - if (!countryNames.includes(countryName)) { - console.error(`🚨 Country with short name ${countryName} is in the countryCapitalsJson file but not in the official list`); - } -}); - -countryNames.forEach((countryName, index) => { - if (!countriesListIncountryCapitalsJson.includes(countryName)) { - console.error(`🚨 Country with short name ${countryName} is referenced in the official list but not in the countryCapitalsJson file`); - } -}); - -// NOTE: rebuild by sorting by country and check data -locationsJson.forEach((location) => { - // check city - if (!isString(location.city)) { - return console.error(`🚨 Location error for country ${location.country}: location.city must be a string, got ${location.city}`); - } - - // check cityAscii - if (!isString(location.cityAscii)) { - return console.error(`🚨 Location error for country ${location.country}: location.cityAscii must be a string, got ${location.cityAscii}`); - } - - if (location.cityAscii && !isAscii(location.cityAscii)) { - [...location.cityAscii].forEach((char) => console.log(char, char.charCodeAt(0))); - return console.error(`🚨 Location error for country ${location.country}: location.cityAscii must only have ascii codes, got ${location.cityAscii}`); - } - - // check latitude - if (!isNumber(location.latitude)) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.latitude must be a number, got ${location.latitude}`); - } - - // check longitude - if (!isNumber(location.longitude)) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.longitude must be a number, got ${location.longitude}`); - } - - // check country - if (!isNonEmptyString(location.country)) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.country must be a non empty string, got ${location.country}`); - } - - if (!isAscii(location.country)) { - console.warn(`⚠️ Location warning for country ${location.country}: location.country has non Ascii characters, got ${location.country}`); - } - - // check every countries are present in the official list - if (!countryNames.includes(location.country)) { - return console.error(`🚨 Location error for country ${location.country}: location.country is not referenced in the official list`); - } - - // check every countries are listed in countryCapitalsJson so we have all the capitals - if (!countriesListIncountryCapitalsJson.includes(location.country)) { - return console.error(`🚨 Location error for country ${location.country}: location.country does not have a capital in countryCapitalsJson file`); - } - - // check countryIso2 - if (!isNonEmptyString(location.countryIso2)) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso2 must be a non empty string, got ${location.countryIso2}`); - } - - if (location.countryIso2.length !== 2 - && location.countryIso2.toUpperCase() !== location.countryIso2) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso2 must only be two characters in uppercase, got ${location.countryIso2}`); - } - - if (!countryIso2Codes.includes(location.countryIso2)) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso2 must be a valid ISO 3166 Alpha2 code, got ${location.countryIso2}`); - } - - // check countryIso3 - if (!isNonEmptyString(location.countryIso3)) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso3 must be a non empty string, got ${location.countryIso3}`); - } - - if (location.countryIso3.length !== 2 - && location.countryIso3.toUpperCase() !== location.countryIso3) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso3 must only be three characters in uppercase, got ${location.countryIso3}`); - } - - if (!countryIso3Codes.includes(location.countryIso3)) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso3 must be a valid ISO 3166 Alpha3 code, got ${location.countryIso3}`); - } - - // check every country iso2 and iso3 is a valid pair - const iso2 = clm.getAlpha2ByAlpha3(location.countryIso3); - const iso3 = clm.getAlpha3ByAlpha2(location.countryIso2); - - if (iso2 !== location.countryIso2 || iso3 !== location.countryIso3) { - if (!['XC/CYN', 'XS/SOL', 'XK/KOS'].includes(`${location.countryIso2}/${location.countryIso3}`)) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: countryIso2/countryIso3 pair is not valid, got ${location.countryIso2}/${location.countryIso3}`); - } - } - - // check province - if (!isString(location.province)) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.province must be a string, got ${location.province}`); - } - - // check stateAnsi - if (location.countryIso3 === 'USA') { - const found = statesAnsi.find((state) => state.uspsCode === location.stateAnsi); - - if (!found) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: invalid state ansi code, got ${location.state}`); - } - - if (found.name !== location.province) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: invalid state province name for this ansi code, got ${location.province}`); - } - } - - // check timezone - if (!isNonEmptyString(location.timezone)) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.timezone must be a non empty string, got ${location.timezone}`); - } - - if (!timezones.includes(location.timezone)) { - return console.error(`🚨 Location error for country ${location.country} and city ${location.city}: location.timezone is not a supported timezone, got ${location.timezone}`); - } - - // NOTE: build countries file - if (!countries[location.country]) { - countries[location.country] = { - name: location.country, - officialName: countryFormalNamesByCountryName[location.country], - iso2: location.countryIso2, - iso3: location.countryIso3, - timezones: new Set(), - }; - } - - countries[location.country].timezones.add(location.timezone); - - // build locations file data - if (!locations[location.country]) { - locations[location.country] = {}; - } - - locations[location.country][location.city] = { - city: location.city, - cityAscii: location.cityAscii, - country: {}, - latitude: location.latitude, - longitude: location.longitude, - province: location.province, - state: location.stateAnsi || location.state || '', - timezone: location.timezone, - }; - - return true; -}); - -// sorting function -const sortByLocalNameVariant = (a, b) => a.localeCompare(b, { sensitivity: 'variant' }); - -// convert timezones to array and sort countries timezones -Object.keys(countries).forEach((country) => { - countries[country].timezones = [...countries[country].timezones.values()].sort(sortByLocalNameVariant); -}); - -// NOTE: add country information attached to each location -// (after to get all the timezones for a specific country) -Object.keys(locations).forEach((country) => { - Object.keys(locations[country]).forEach((location) => { - locations[country][location].country = { ...countries[country] }; - locations[country][location].country.timezones = [...countries[country].timezones]; - delete locations[country][location].country.capital; - }); -}); - -// NOTE: generate a clean version of countries capital based on the locations file -countryCapitalsJson.forEach((country) => { - const found = locationsJson.filter( - (location) => location.country?.toLowerCase() === country.country?.toLowerCase() - && (location.city?.toLowerCase() === country.capital?.toLowerCase() - || location.cityAscii?.toLowerCase() === country.capital?.toLowerCase()), - ); - - if (found?.length !== 1) { - console.error(`🚨 Country ${country.country} and its capital ${country.capital} not found in locationsJson file`); - } - - if (!countryCapitals[found[0].country]) { - countryCapitals[found[0].country] = {}; - } - - countryCapitals[found[0].country][found[0].city] = { - name: found[0].city, - nameAscii: found[0].cityAscii, - country: { - name: found[0].country, - officialName: countryFormalNamesByCountryName[found[0].country], - iso2: found[0].countryIso2, - iso3: found[0].countryIso3, - timezones: [...countries[found[0].country].timezones], - }, - latitude: found[0].latitude, - longitude: found[0].longitude, - province: found[0].province, - state: found[0].stateAnsi || found[0].state || '', - timezone: found[0].timezone, - }; - - // countries only - if (countries[found[0].country]) { - countries[found[0].country].capital = { - name: found[0].city, - nameAscii: found[0].cityAscii, - latitude: found[0].latitude, - longitude: found[0].longitude, - province: found[0].province, - state: found[0].stateAnsi || found[0].state || '', - timezone: found[0].timezone, - }; - - countries[found[0].country].timezones = [...countries[found[0].country].timezones]; - } - - // build country iso codes - countryIso2ByIso3Codes[found[0].countryIso3] = found[0].countryIso2; - countryIso3ByIso2Codes[found[0].countryIso2] = found[0].countryIso3; -}); - -// check we have the same amount of countries than countries capital -if (Object.keys(countries).length !== Object.keys(countryCapitals).length) { - console.error(`🚨 Found ${Object.keys(countries).length} countries but ${Object.keys(countryCapitals).length} countries are referenced with their capital`); -} - -// sort arrays by country name and then by city name -const countriesSorted = Object.keys(locations).sort(sortByLocalNameVariant); - -const locationsSortedByCountryAndCity = []; -const countryCapitalsSortedByCountryAndCity = []; -const countriesSortedByName = []; - -countriesSorted.forEach((country) => { - const citiesSorted = Object.keys(locations[country]).sort(sortByLocalNameVariant); - - citiesSorted.forEach((city) => { - locationsSortedByCountryAndCity.push(locations[country][city]); - }); - - const capitalsSorted = Object.keys(countryCapitals[country]).sort(sortByLocalNameVariant); - - capitalsSorted.forEach((capital) => { - countryCapitalsSortedByCountryAndCity.push(countryCapitals[country][capital]); - }); - - countriesSortedByName.push(countries[country]); -}); - -// NOTE: write locations file -writeFileSync(`${__dirname}/../src/data/locations.json`, JSON.stringify({ data: stringify(locationsSortedByCountryAndCity) })); - -// NOTE: write countries capital file -writeFileSync(`${__dirname}/../src/data/country-capitals.json`, JSON.stringify({ data: stringify(countryCapitalsSortedByCountryAndCity) })); - -// NOTE: write countries file -writeFileSync(`${__dirname}/../src/data/countries.json`, JSON.stringify({ data: stringify(countriesSortedByName) })); - -// NOTE: write country codes files -writeFileSync(`${__dirname}/../src/data/country-iso2-by-iso3-codes.json`, JSON.stringify({ data: stringify(countryIso2ByIso3Codes) })); -writeFileSync(`${__dirname}/../src/data/country-iso3-by-iso2-codes.json`, JSON.stringify({ data: stringify(countryIso3ByIso2Codes) })); diff --git a/scripts/clean-and-generate.ts b/scripts/clean-and-generate.ts new file mode 100644 index 0000000..2998fd3 --- /dev/null +++ b/scripts/clean-and-generate.ts @@ -0,0 +1,465 @@ +/** + * Data cleaner and file generator. + * + * Reads the raw inputs in `scripts/data/` and writes the zipson-compressed + * payloads to `src/data/`. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import clm from 'country-locale-map'; +import { stringify } from 'zipson'; +import countryIso2Codes from './country-iso2-codes.ts'; +import countryIso3Codes from './country-iso3-codes.ts'; +import generateCountries from './generate-countries.ts'; +import statesAnsi from './states-ansi.ts'; + +const __dirname = import.meta.dirname; + +interface RawLocation { + city: string; + cityAscii: string; + country: string; + countryIso2: string; + countryIso3: string; + latitude: number; + longitude: number; + province: string; + state?: string; + stateAnsi?: string; + timezone: string; +} + +interface RawCountryCapital { + country: string; + capital: string; +} + +interface CountryAccum { + name: string; + officialName: string; + iso2: string; + iso3: string; + timezones: Set | string[]; + capital?: { + name: string; + nameAscii: string; + latitude: number; + longitude: number; + province: string; + state: string; + timezone: string; + }; +} + +const isString = (thing: unknown): thing is string => typeof thing === 'string'; +const isNonEmptyString = (thing: unknown): thing is string => isString(thing) && thing.trim() !== ''; +const isNumber = (thing: unknown): thing is number => + typeof thing === 'number' && !Number.isNaN(thing); +const isAscii = (str: string): boolean => + isNonEmptyString(str) && ![...str].some((char) => char.charCodeAt(0) > 127); + +const dataDir = path.join(__dirname, 'data'); +const outDir = path.join(__dirname, '..', 'src', 'data'); + +const timezones = Intl.supportedValuesOf('timeZone'); +const countryCapitalsJson: RawCountryCapital[] = JSON.parse( + readFileSync(path.join(dataDir, 'country-capitals.json'), 'utf8'), +); +const locationsJson: RawLocation[] = JSON.parse( + readFileSync(path.join(dataDir, 'locations.json'), 'utf8'), +); + +const countries: Record = {}; +const countryCapitals: Record>> = {}; +const countryIso2ByIso3Codes: Record = {}; +const countryIso3ByIso2Codes: Record = {}; +const locations: Record>> = {}; + +console.log('⏳ Cleaning data and building files...'); + +// NOTE: generate countries from the UN file +const countryFormalNamesByCountryName = generateCountries(); +const countryNames = Object.keys(countryFormalNamesByCountryName); + +// NOTE: write states ansi +writeFileSync( + path.join(outDir, 'states-ansi.json'), + JSON.stringify({ data: stringify(statesAnsi) }), +); + +// NOTE: write ISO 3166-1 alpha-2 and alpha-3 codes files +writeFileSync( + path.join(outDir, 'country-iso2-codes.json'), + JSON.stringify({ data: stringify(countryIso2Codes) }), +); +writeFileSync( + path.join(outDir, 'country-iso3-codes.json'), + JSON.stringify({ data: stringify(countryIso3Codes) }), +); + +// NOTE: write timezones file +writeFileSync(path.join(outDir, 'timezones.json'), JSON.stringify({ data: stringify(timezones) })); + +// NOTE: check every countries iso2 are listed in files +const countryIso2CodesLocations = [...new Set(locationsJson.map((location) => location.countryIso2))]; +for (const countryIso2 of countryIso2Codes) { + if (!countryIso2CodesLocations.includes(countryIso2)) { + console.error(`🚨 Missing country iso2 code ${countryIso2} in locations file`); + } +} + +// check every countries iso3 are listed in files +const countryIso3CodesLocations = [...new Set(locationsJson.map((location) => location.countryIso3))]; +for (const countryIso3 of countryIso3Codes) { + if (!countryIso3CodesLocations.includes(countryIso3)) { + console.error(`🚨 Missing country iso3 code ${countryIso3} in locations file`); + } +} + +// check every states ansi are listed in the locations file +const states = [...new Set(locationsJson.map((location) => location.stateAnsi))]; +for (const state of statesAnsi) { + if (!states.includes(state.uspsCode)) { + console.error(`🚨 Missing state ansi ${state.uspsCode} in locations file`); + } +} + +// get a list of all countries referenced in the countryCapitalsJson file +const countriesListIncountryCapitalsJson = [ + ...new Set(countryCapitalsJson.map((country) => country.country)), +]; + +// check every country is referenced in the UN list +for (const countryName of countriesListIncountryCapitalsJson) { + if (!countryNames.includes(countryName)) { + console.error( + `🚨 Country with short name ${countryName} is in the countryCapitalsJson file but not in the official list`, + ); + } +} + +for (const countryName of countryNames) { + if (!countriesListIncountryCapitalsJson.includes(countryName)) { + console.error( + `🚨 Country with short name ${countryName} is referenced in the official list but not in the countryCapitalsJson file`, + ); + } +} + +// NOTE: rebuild by sorting by country and check data +for (const location of locationsJson) { + if (!isString(location.city)) { + console.error( + `🚨 Location error for country ${location.country}: location.city must be a string, got ${location.city}`, + ); + continue; + } + + if (!isString(location.cityAscii)) { + console.error( + `🚨 Location error for country ${location.country}: location.cityAscii must be a string, got ${location.cityAscii}`, + ); + continue; + } + + if (location.cityAscii && !isAscii(location.cityAscii)) { + for (const char of location.cityAscii) { + console.log(char, char.charCodeAt(0)); + } + console.error( + `🚨 Location error for country ${location.country}: location.cityAscii must only have ascii codes, got ${location.cityAscii}`, + ); + continue; + } + + if (!isNumber(location.latitude)) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.latitude must be a number, got ${location.latitude}`, + ); + continue; + } + + if (!isNumber(location.longitude)) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.longitude must be a number, got ${location.longitude}`, + ); + continue; + } + + if (!isNonEmptyString(location.country)) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.country must be a non empty string, got ${location.country}`, + ); + continue; + } + + if (!isAscii(location.country)) { + console.warn( + `⚠️ Location warning for country ${location.country}: location.country has non Ascii characters, got ${location.country}`, + ); + } + + if (!countryNames.includes(location.country)) { + console.error( + `🚨 Location error for country ${location.country}: location.country is not referenced in the official list`, + ); + continue; + } + + if (!countriesListIncountryCapitalsJson.includes(location.country)) { + console.error( + `🚨 Location error for country ${location.country}: location.country does not have a capital in countryCapitalsJson file`, + ); + continue; + } + + if (!isNonEmptyString(location.countryIso2)) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso2 must be a non empty string, got ${location.countryIso2}`, + ); + continue; + } + + if ( + location.countryIso2.length !== 2 && + location.countryIso2.toUpperCase() !== location.countryIso2 + ) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso2 must only be two characters in uppercase, got ${location.countryIso2}`, + ); + continue; + } + + if (!countryIso2Codes.includes(location.countryIso2)) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso2 must be a valid ISO 3166 Alpha2 code, got ${location.countryIso2}`, + ); + continue; + } + + if (!isNonEmptyString(location.countryIso3)) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso3 must be a non empty string, got ${location.countryIso3}`, + ); + continue; + } + + if ( + location.countryIso3.length !== 2 && + location.countryIso3.toUpperCase() !== location.countryIso3 + ) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso3 must only be three characters in uppercase, got ${location.countryIso3}`, + ); + continue; + } + + if (!countryIso3Codes.includes(location.countryIso3)) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.countryIso3 must be a valid ISO 3166 Alpha3 code, got ${location.countryIso3}`, + ); + continue; + } + + const iso2 = clm.getAlpha2ByAlpha3(location.countryIso3); + const iso3 = clm.getAlpha3ByAlpha2(location.countryIso2); + + if (iso2 !== location.countryIso2 || iso3 !== location.countryIso3) { + if ( + !['XC/CYN', 'XS/SOL', 'XK/KOS'].includes(`${location.countryIso2}/${location.countryIso3}`) + ) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: countryIso2/countryIso3 pair is not valid, got ${location.countryIso2}/${location.countryIso3}`, + ); + continue; + } + } + + if (!isString(location.province)) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.province must be a string, got ${location.province}`, + ); + continue; + } + + if (location.countryIso3 === 'USA') { + const found = statesAnsi.find((state) => state.uspsCode === location.stateAnsi); + if (!found) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: invalid state ansi code, got ${location.state}`, + ); + continue; + } + if (found.name !== location.province) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: invalid state province name for this ansi code, got ${location.province}`, + ); + continue; + } + } + + if (!isNonEmptyString(location.timezone)) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.timezone must be a non empty string, got ${location.timezone}`, + ); + continue; + } + + if (!timezones.includes(location.timezone)) { + console.error( + `🚨 Location error for country ${location.country} and city ${location.city}: location.timezone is not a supported timezone, got ${location.timezone}`, + ); + continue; + } + + if (!countries[location.country]) { + countries[location.country] = { + name: location.country, + officialName: countryFormalNamesByCountryName[location.country], + iso2: location.countryIso2, + iso3: location.countryIso3, + timezones: new Set(), + }; + } + + (countries[location.country].timezones as Set).add(location.timezone); + + if (!locations[location.country]) { + locations[location.country] = {}; + } + + locations[location.country][location.city] = { + city: location.city, + cityAscii: location.cityAscii, + country: {}, + latitude: location.latitude, + longitude: location.longitude, + province: location.province, + state: location.stateAnsi || location.state || '', + timezone: location.timezone, + }; +} + +const sortByLocalNameVariant = (a: string, b: string): number => + a.localeCompare(b, undefined, { sensitivity: 'variant' }); + +// convert timezones to array and sort countries timezones +for (const country of Object.keys(countries)) { + countries[country].timezones = [...(countries[country].timezones as Set).values()].sort( + sortByLocalNameVariant, + ); +} + +// NOTE: add country information attached to each location +// (after getting all the timezones for a specific country) +for (const country of Object.keys(locations)) { + for (const location of Object.keys(locations[country])) { + const c = { ...countries[country] }; + c.timezones = [...(countries[country].timezones as string[])]; + delete c.capital; + locations[country][location].country = c; + } +} + +// NOTE: generate a clean version of countries capital based on the locations file +for (const country of countryCapitalsJson) { + const found = locationsJson.filter( + (location) => + location.country?.toLowerCase() === country.country?.toLowerCase() && + (location.city?.toLowerCase() === country.capital?.toLowerCase() || + location.cityAscii?.toLowerCase() === country.capital?.toLowerCase()), + ); + + if (found?.length !== 1) { + console.error( + `🚨 Country ${country.country} and its capital ${country.capital} not found in locationsJson file`, + ); + } + + if (!countryCapitals[found[0].country]) { + countryCapitals[found[0].country] = {}; + } + + countryCapitals[found[0].country][found[0].city] = { + name: found[0].city, + nameAscii: found[0].cityAscii, + country: { + name: found[0].country, + officialName: countryFormalNamesByCountryName[found[0].country], + iso2: found[0].countryIso2, + iso3: found[0].countryIso3, + timezones: [...(countries[found[0].country].timezones as string[])], + }, + latitude: found[0].latitude, + longitude: found[0].longitude, + province: found[0].province, + state: found[0].stateAnsi || found[0].state || '', + timezone: found[0].timezone, + }; + + if (countries[found[0].country]) { + countries[found[0].country].capital = { + name: found[0].city, + nameAscii: found[0].cityAscii, + latitude: found[0].latitude, + longitude: found[0].longitude, + province: found[0].province, + state: found[0].stateAnsi || found[0].state || '', + timezone: found[0].timezone, + }; + countries[found[0].country].timezones = [...(countries[found[0].country].timezones as string[])]; + } + + countryIso2ByIso3Codes[found[0].countryIso3] = found[0].countryIso2; + countryIso3ByIso2Codes[found[0].countryIso2] = found[0].countryIso3; +} + +if (Object.keys(countries).length !== Object.keys(countryCapitals).length) { + console.error( + `🚨 Found ${Object.keys(countries).length} countries but ${Object.keys(countryCapitals).length} countries are referenced with their capital`, + ); +} + +// sort arrays by country name and then by city name +const countriesSorted = Object.keys(locations).sort(sortByLocalNameVariant); + +const locationsSortedByCountryAndCity: Array> = []; +const countryCapitalsSortedByCountryAndCity: Array> = []; +const countriesSortedByName: CountryAccum[] = []; + +for (const country of countriesSorted) { + const citiesSorted = Object.keys(locations[country]).sort(sortByLocalNameVariant); + for (const city of citiesSorted) { + locationsSortedByCountryAndCity.push(locations[country][city]); + } + const capitalsSorted = Object.keys(countryCapitals[country]).sort(sortByLocalNameVariant); + for (const capital of capitalsSorted) { + countryCapitalsSortedByCountryAndCity.push(countryCapitals[country][capital]); + } + countriesSortedByName.push(countries[country]); +} + +writeFileSync( + path.join(outDir, 'locations.json'), + JSON.stringify({ data: stringify(locationsSortedByCountryAndCity) }), +); + +writeFileSync( + path.join(outDir, 'country-capitals.json'), + JSON.stringify({ data: stringify(countryCapitalsSortedByCountryAndCity) }), +); + +writeFileSync( + path.join(outDir, 'countries.json'), + JSON.stringify({ data: stringify(countriesSortedByName) }), +); + +writeFileSync( + path.join(outDir, 'country-iso2-by-iso3-codes.json'), + JSON.stringify({ data: stringify(countryIso2ByIso3Codes) }), +); + +writeFileSync( + path.join(outDir, 'country-iso3-by-iso2-codes.json'), + JSON.stringify({ data: stringify(countryIso3ByIso2Codes) }), +); diff --git a/scripts/country-iso2-codes.js b/scripts/country-iso2-codes.ts similarity index 95% rename from scripts/country-iso2-codes.js rename to scripts/country-iso2-codes.ts index e52bd6f..b206c84 100644 --- a/scripts/country-iso2-codes.js +++ b/scripts/country-iso2-codes.ts @@ -1,5 +1,5 @@ // from: https://github.com/validatorjs/validator.js/blob/master/src/lib/isISO31661Alpha2.js -module.exports = [ +const countryIso2Codes: string[] = [ 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', @@ -27,3 +27,5 @@ module.exports = [ 'ZA', 'ZM', 'ZW', 'XC', 'XK', 'XS', // non-official ]; + +export default countryIso2Codes; diff --git a/scripts/country-iso3-codes.js b/scripts/country-iso3-codes.ts similarity index 94% rename from scripts/country-iso3-codes.js rename to scripts/country-iso3-codes.ts index 37662bb..e2effea 100644 --- a/scripts/country-iso3-codes.js +++ b/scripts/country-iso3-codes.ts @@ -1,5 +1,5 @@ // from: https://github.com/validatorjs/validator.js/blob/master/src/lib/isISO31661Alpha3.js -module.exports = [ +const countryIso3Codes: string[] = [ 'AFG', 'ALA', 'ALB', 'DZA', 'ASM', 'AND', 'AGO', 'AIA', 'ATA', 'ATG', 'ARG', 'ARM', 'ABW', 'AUS', 'AUT', 'AZE', 'BHS', 'BHR', 'BGD', 'BRB', 'BLR', 'BEL', 'BLZ', 'BEN', 'BMU', 'BTN', 'BOL', 'BES', 'BIH', 'BWA', 'BVT', 'BRA', 'IOT', 'BRN', 'BGR', 'BFA', 'BDI', 'KHM', 'CMR', 'CAN', 'CPV', 'CYM', 'CAF', 'TCD', 'CHL', 'CHN', 'CXR', 'CCK', @@ -16,5 +16,7 @@ module.exports = [ 'ESP', 'LKA', 'SDN', 'SUR', 'SJM', 'SWZ', 'SWE', 'CHE', 'SYR', 'TWN', 'TJK', 'TZA', 'THA', 'TLS', 'TGO', 'TKL', 'TON', 'TTO', 'TUN', 'TUR', 'TKM', 'TCA', 'TUV', 'UGA', 'UKR', 'ARE', 'GBR', 'USA', 'UMI', 'URY', 'UZB', 'VUT', 'VEN', 'VNM', 'VGB', 'VIR', 'WLF', 'ESH', 'YEM', 'ZMB', 'ZWE', - 'CYN', 'KOS', 'SOL', //non-official + 'CYN', 'KOS', 'SOL', // non-official ]; + +export default countryIso3Codes; diff --git a/scripts/generate-countries-md.ts b/scripts/generate-countries-md.ts index c3150e3..1cd4100 100644 --- a/scripts/generate-countries-md.ts +++ b/scripts/generate-countries-md.ts @@ -1,12 +1,12 @@ // Generate countries in a md file from generated data -import { writeFileSync } from 'fs'; -import locationTimezone from '../src'; +import { writeFileSync } from 'node:fs'; +import locationTimezone from '../src/index.ts'; const countries = locationTimezone.getCountries(); let md = '| Name | Official Name | ISO2 | ISO3 | Capital |\n| --- | --- | --- | --- | --- |\n'; -countries.forEach((country) => { +for (const country of countries) { md += `| ${country.name} | ${country.officialName} | ${country.iso2} | ${country.iso3} | ${country.capital?.name || ''} |\n`; -}); +} writeFileSync('./countries.md', md); diff --git a/scripts/generate-countries.js b/scripts/generate-countries.ts similarity index 78% rename from scripts/generate-countries.js rename to scripts/generate-countries.ts index 4edeb2f..ee40bfe 100644 --- a/scripts/generate-countries.js +++ b/scripts/generate-countries.ts @@ -1,10 +1,11 @@ // From the United Nations: https://unterm.un.org/unterm2/en/country // and the World Factbook: https://www.cia.gov/the-world-factbook/ -const { readFileSync, writeFileSync } = require('fs'); +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; -const exists = (thing) => !(thing === undefined || thing === null || Number.isNaN(thing)); +const __dirname = import.meta.dirname; -const missingCountries = { +const missingCountries: Record = { 'Åland Islands': 'Åland', 'American Samoa': 'The Territory of American Samoa', Anguilla: 'Anguilla', @@ -33,7 +34,7 @@ const missingCountries = { Greenland: 'Kalaallit Nunaat', Guadeloupe: 'Guadeloupe', Guam: 'The Territory of Guam', - 'Guernsey': 'The Bailiwick of Guernsey', + Guernsey: 'The Bailiwick of Guernsey', 'Heard Island and McDonald Islands': 'The Territory of Heard Island and McDonald Islands', 'Hong Kong': 'The Hong Kong Special Administrative Region of China', 'Isle of Man': 'The Isle of Man', @@ -64,35 +65,38 @@ const missingCountries = { Tokelau: 'Tokelau', 'Tristan da Cunha': 'Saint Helena, Ascension and Tristan da Cunha', 'Turks and Caicos Islands': 'The Turks and Caicos Islands', - 'U.S. Minor Outlying Islands': 'Baker Island, Howland Island, Jarvis Island, Johnston Atoll, Kingman Reef, Midway Atoll, Navassa Island, Palmyra Atoll, and Wake Island', + 'U.S. Minor Outlying Islands': + 'Baker Island, Howland Island, Jarvis Island, Johnston Atoll, Kingman Reef, Midway Atoll, Navassa Island, Palmyra Atoll, and Wake Island', 'U.S. Virgin Islands': 'The Virgin Islands of the United States', 'Wallis and Futuna': 'The Territory of the Wallis and Futuna Islands', 'Western Sahara': 'The Sahrawi Arab Democratic Republic', }; -module.exports = () => { - const countriesCsv = readFileSync(`${__dirname}/data/countries.csv`, 'utf8'); - const countryFormalNamesByCountryName = {}; +const exists = (thing: T): boolean => + !(thing === undefined || thing === null || (typeof thing === 'number' && Number.isNaN(thing))); + +const generateCountries = (): Record => { + const countriesCsv = readFileSync(path.join(__dirname, 'data', 'countries.csv'), 'utf8'); + const countryFormalNamesByCountryName: Record = {}; let nbCountries = 0; - countriesCsv.split('\n').splice(1).forEach((line) => { + for (const line of countriesCsv.split('\n').slice(1)) { const [countryShort, countryFormal] = line.replace(/\r/g, '').split(';'); - if (exists(countryShort) && exists(countryFormal)) { countryFormalNamesByCountryName[countryShort] = countryFormal; nbCountries += 1; } - }); + } nbCountries += Object.keys(missingCountries).length; const updated = { ...missingCountries, - ...countryFormalNamesByCountryName + ...countryFormalNamesByCountryName, }; writeFileSync( - `${__dirname}/data/country-formal-names-by-country-name.json`, + path.join(__dirname, 'data', 'country-formal-names-by-country-name.json'), JSON.stringify(updated), ); @@ -100,3 +104,5 @@ module.exports = () => { return updated; }; + +export default generateCountries; diff --git a/scripts/states-ansi.js b/scripts/states-ansi.js deleted file mode 100644 index ac11498..0000000 --- a/scripts/states-ansi.js +++ /dev/null @@ -1,352 +0,0 @@ -/** from https://www2.census.gov/geo/docs/reference/state.txt - * fipsCode: (Federal Information Processing Standard) FIPS State Code - * uspsCode: Official United States Postal Service (USPS) Code - * name: State name - * gnisid: Geographic Names Information System Identifier (GNISID) - */ -const statesAnsi = [ - { - fipsCode: '01', - uspsCode: 'AL', - name: 'Alabama', - gnisid: '01779775' - }, - { - fipsCode: '02', - uspsCode: 'AK', - name: 'Alaska', - gnisid: '01785533' - }, - { - fipsCode: '04', - uspsCode: 'AZ', - name: 'Arizona', - gnisid: '01779777' - }, - { - fipsCode: '05', - uspsCode: 'AR', - name: 'Arkansas', - gnisid: '00068085' - }, - { - fipsCode: '06', - uspsCode: 'CA', - name: 'California', - gnisid: '01779778' - }, - { - fipsCode: '08', - uspsCode: 'CO', - name: 'Colorado', - gnisid: '01779779' - }, - { - fipsCode: '09', - uspsCode: 'CT', - name: 'Connecticut', - gnisid: '01779780' - }, - { - fipsCode: '10', - uspsCode: 'DE', - name: 'Delaware', - gnisid: '01779781' - }, - { - fipsCode: '11', - uspsCode: 'DC', - name: 'District of Columbia', - gnisid: '01702382' - }, - { - fipsCode: '12', - uspsCode: 'FL', - name: 'Florida', - gnisid: '00294478' - }, - { - fipsCode: '13', - uspsCode: 'GA', - name: 'Georgia', - gnisid: '01705317' - }, - { - fipsCode: '15', - uspsCode: 'HI', - name: 'Hawaii', - gnisid: '01779782' - }, - { - fipsCode: '16', - uspsCode: 'ID', - name: 'Idaho', - gnisid: '01779783' - }, - { - fipsCode: '17', - uspsCode: 'IL', - name: 'Illinois', - gnisid: '01779784' - }, - { - fipsCode: '18', - uspsCode: 'IN', - name: 'Indiana', - gnisid: '00448508' - }, - { - fipsCode: '19', - uspsCode: 'IA', - name: 'Iowa', - gnisid: '01779785' - }, - { - fipsCode: '20', - uspsCode: 'KS', - name: 'Kansas', - gnisid: '00481813' - }, - { - fipsCode: '21', - uspsCode: 'KY', - name: 'Kentucky', - gnisid: '01779786' - }, - { - fipsCode: '22', - uspsCode: 'LA', - name: 'Louisiana', - gnisid: '01629543' - }, - { - fipsCode: '23', - uspsCode: 'ME', - name: 'Maine', - gnisid: '01779787' - }, - { - fipsCode: '24', - uspsCode: 'MD', - name: 'Maryland', - gnisid: '01714934' - }, - { - fipsCode: '25', - uspsCode: 'MA', - name: 'Massachusetts', - gnisid: '00606926' - }, - { - fipsCode: '26', - uspsCode: 'MI', - name: 'Michigan', - gnisid: '01779789' - }, - { - fipsCode: '27', - uspsCode: 'MN', - name: 'Minnesota', - gnisid: '00662849' - }, - { - fipsCode: '28', - uspsCode: 'MS', - name: 'Mississippi', - gnisid: '01779790' - }, - { - fipsCode: '29', - uspsCode: 'MO', - name: 'Missouri', - gnisid: '01779791' - }, - { - fipsCode: '30', - uspsCode: 'MT', - name: 'Montana', - gnisid: '00767982' - }, - { - fipsCode: '31', - uspsCode: 'NE', - name: 'Nebraska', - gnisid: '01779792' - }, - { - fipsCode: '32', - uspsCode: 'NV', - name: 'Nevada', - gnisid: '01779793' - }, - { - fipsCode: '33', - uspsCode: 'NH', - name: 'New Hampshire', - gnisid: '01779794' - }, - { - fipsCode: '34', - uspsCode: 'NJ', - name: 'New Jersey', - gnisid: '01779795' - }, - { - fipsCode: '35', - uspsCode: 'NM', - name: 'New Mexico', - gnisid: '00897535' - }, - { - fipsCode: '36', - uspsCode: 'NY', - name: 'New York', - gnisid: '01779796' - }, - { - fipsCode: '37', - uspsCode: 'NC', - name: 'North Carolina', - gnisid: '01027616' - }, - { - fipsCode: '38', - uspsCode: 'ND', - name: 'North Dakota', - gnisid: '01779797' - }, - { - fipsCode: '39', - uspsCode: 'OH', - name: 'Ohio', - gnisid: '01085497' - }, - { - fipsCode: '40', - uspsCode: 'OK', - name: 'Oklahoma', - gnisid: '01102857' - }, - { - fipsCode: '41', - uspsCode: 'OR', - name: 'Oregon', - gnisid: '01155107' - }, - { - fipsCode: '42', - uspsCode: 'PA', - name: 'Pennsylvania', - gnisid: '01779798' - }, - { - fipsCode: '44', - uspsCode: 'RI', - name: 'Rhode Island', - gnisid: '01219835' - }, - { - fipsCode: '45', - uspsCode: 'SC', - name: 'South Carolina', - gnisid: '01779799' - }, - { - fipsCode: '46', - uspsCode: 'SD', - name: 'South Dakota', - gnisid: '01785534' - }, - { - fipsCode: '47', - uspsCode: 'TN', - name: 'Tennessee', - gnisid: '01325873' - }, - { - fipsCode: '48', - uspsCode: 'TX', - name: 'Texas', - gnisid: '01779801' - }, - { - fipsCode: '49', - uspsCode: 'UT', - name: 'Utah', - gnisid: '01455989' - }, - { - fipsCode: '50', - uspsCode: 'VT', - name: 'Vermont', - gnisid: '01779802' - }, - { - fipsCode: '51', - uspsCode: 'VA', - name: 'Virginia', - gnisid: '01779803' - }, - { - fipsCode: '53', - uspsCode: 'WA', - name: 'Washington', - gnisid: '01779804' - }, - { - fipsCode: '54', - uspsCode: 'WV', - name: 'West Virginia', - gnisid: '01779805' - }, - { - fipsCode: '55', - uspsCode: 'WI', - name: 'Wisconsin', - gnisid: '01779806' - }, - { - fipsCode: '56', - uspsCode: 'WY', - name: 'Wyoming', - gnisid: '01779807' - }, - { - fipsCode: '60', - uspsCode: 'AS', - name: 'American Samoa', - gnisid: '01802701' - }, - { - fipsCode: '66', - uspsCode: 'GU', - name: 'Guam', - gnisid: '01802705' - }, - { - fipsCode: '69', - uspsCode: 'MP', - name: 'Northern Mariana Islands', - gnisid: '01779809' - }, - { - fipsCode: '72', - uspsCode: 'PR', - name: 'Puerto Rico', - gnisid: '01779808' - }, - { - fipsCode: '74', - uspsCode: 'UM', - name: 'U.S. Minor Outlying Islands', - gnisid: '01878752' - }, - { - fipsCode: '78', - uspsCode: 'VI', - name: 'U.S. Virgin Islands', - gnisid: '01802710' - } -]; - -module.exports = statesAnsi; diff --git a/scripts/states-ansi.ts b/scripts/states-ansi.ts new file mode 100644 index 0000000..bd0d174 --- /dev/null +++ b/scripts/states-ansi.ts @@ -0,0 +1,74 @@ +/** from https://www2.census.gov/geo/docs/reference/state.txt + * fipsCode: (Federal Information Processing Standard) FIPS State Code + * uspsCode: Official United States Postal Service (USPS) Code + * name: State name + * gnisid: Geographic Names Information System Identifier (GNISID) + */ +interface StateAnsi { + fipsCode: string; + uspsCode: string; + name: string; + gnisid: string; +} + +const statesAnsi: StateAnsi[] = [ + { fipsCode: '01', uspsCode: 'AL', name: 'Alabama', gnisid: '01779775' }, + { fipsCode: '02', uspsCode: 'AK', name: 'Alaska', gnisid: '01785533' }, + { fipsCode: '04', uspsCode: 'AZ', name: 'Arizona', gnisid: '01779777' }, + { fipsCode: '05', uspsCode: 'AR', name: 'Arkansas', gnisid: '00068085' }, + { fipsCode: '06', uspsCode: 'CA', name: 'California', gnisid: '01779778' }, + { fipsCode: '08', uspsCode: 'CO', name: 'Colorado', gnisid: '01779779' }, + { fipsCode: '09', uspsCode: 'CT', name: 'Connecticut', gnisid: '01779780' }, + { fipsCode: '10', uspsCode: 'DE', name: 'Delaware', gnisid: '01779781' }, + { fipsCode: '11', uspsCode: 'DC', name: 'District of Columbia', gnisid: '01702382' }, + { fipsCode: '12', uspsCode: 'FL', name: 'Florida', gnisid: '00294478' }, + { fipsCode: '13', uspsCode: 'GA', name: 'Georgia', gnisid: '01705317' }, + { fipsCode: '15', uspsCode: 'HI', name: 'Hawaii', gnisid: '01779782' }, + { fipsCode: '16', uspsCode: 'ID', name: 'Idaho', gnisid: '01779783' }, + { fipsCode: '17', uspsCode: 'IL', name: 'Illinois', gnisid: '01779784' }, + { fipsCode: '18', uspsCode: 'IN', name: 'Indiana', gnisid: '00448508' }, + { fipsCode: '19', uspsCode: 'IA', name: 'Iowa', gnisid: '01779785' }, + { fipsCode: '20', uspsCode: 'KS', name: 'Kansas', gnisid: '00481813' }, + { fipsCode: '21', uspsCode: 'KY', name: 'Kentucky', gnisid: '01779786' }, + { fipsCode: '22', uspsCode: 'LA', name: 'Louisiana', gnisid: '01629543' }, + { fipsCode: '23', uspsCode: 'ME', name: 'Maine', gnisid: '01779787' }, + { fipsCode: '24', uspsCode: 'MD', name: 'Maryland', gnisid: '01714934' }, + { fipsCode: '25', uspsCode: 'MA', name: 'Massachusetts', gnisid: '00606926' }, + { fipsCode: '26', uspsCode: 'MI', name: 'Michigan', gnisid: '01779789' }, + { fipsCode: '27', uspsCode: 'MN', name: 'Minnesota', gnisid: '00662849' }, + { fipsCode: '28', uspsCode: 'MS', name: 'Mississippi', gnisid: '01779790' }, + { fipsCode: '29', uspsCode: 'MO', name: 'Missouri', gnisid: '01779791' }, + { fipsCode: '30', uspsCode: 'MT', name: 'Montana', gnisid: '00767982' }, + { fipsCode: '31', uspsCode: 'NE', name: 'Nebraska', gnisid: '01779792' }, + { fipsCode: '32', uspsCode: 'NV', name: 'Nevada', gnisid: '01779793' }, + { fipsCode: '33', uspsCode: 'NH', name: 'New Hampshire', gnisid: '01779794' }, + { fipsCode: '34', uspsCode: 'NJ', name: 'New Jersey', gnisid: '01779795' }, + { fipsCode: '35', uspsCode: 'NM', name: 'New Mexico', gnisid: '00897535' }, + { fipsCode: '36', uspsCode: 'NY', name: 'New York', gnisid: '01779796' }, + { fipsCode: '37', uspsCode: 'NC', name: 'North Carolina', gnisid: '01027616' }, + { fipsCode: '38', uspsCode: 'ND', name: 'North Dakota', gnisid: '01779797' }, + { fipsCode: '39', uspsCode: 'OH', name: 'Ohio', gnisid: '01085497' }, + { fipsCode: '40', uspsCode: 'OK', name: 'Oklahoma', gnisid: '01102857' }, + { fipsCode: '41', uspsCode: 'OR', name: 'Oregon', gnisid: '01155107' }, + { fipsCode: '42', uspsCode: 'PA', name: 'Pennsylvania', gnisid: '01779798' }, + { fipsCode: '44', uspsCode: 'RI', name: 'Rhode Island', gnisid: '01219835' }, + { fipsCode: '45', uspsCode: 'SC', name: 'South Carolina', gnisid: '01779799' }, + { fipsCode: '46', uspsCode: 'SD', name: 'South Dakota', gnisid: '01785534' }, + { fipsCode: '47', uspsCode: 'TN', name: 'Tennessee', gnisid: '01325873' }, + { fipsCode: '48', uspsCode: 'TX', name: 'Texas', gnisid: '01779801' }, + { fipsCode: '49', uspsCode: 'UT', name: 'Utah', gnisid: '01455989' }, + { fipsCode: '50', uspsCode: 'VT', name: 'Vermont', gnisid: '01779802' }, + { fipsCode: '51', uspsCode: 'VA', name: 'Virginia', gnisid: '01779803' }, + { fipsCode: '53', uspsCode: 'WA', name: 'Washington', gnisid: '01779804' }, + { fipsCode: '54', uspsCode: 'WV', name: 'West Virginia', gnisid: '01779805' }, + { fipsCode: '55', uspsCode: 'WI', name: 'Wisconsin', gnisid: '01779806' }, + { fipsCode: '56', uspsCode: 'WY', name: 'Wyoming', gnisid: '01779807' }, + { fipsCode: '60', uspsCode: 'AS', name: 'American Samoa', gnisid: '01802701' }, + { fipsCode: '66', uspsCode: 'GU', name: 'Guam', gnisid: '01802705' }, + { fipsCode: '69', uspsCode: 'MP', name: 'Northern Mariana Islands', gnisid: '01779809' }, + { fipsCode: '72', uspsCode: 'PR', name: 'Puerto Rico', gnisid: '01779808' }, + { fipsCode: '74', uspsCode: 'UM', name: 'U.S. Minor Outlying Islands', gnisid: '01878752' }, + { fipsCode: '78', uspsCode: 'VI', name: 'U.S. Virgin Islands', gnisid: '01802710' }, +]; + +export default statesAnsi; diff --git a/src/countries.ts b/src/countries.ts index 89a13ac..df4e751 100644 --- a/src/countries.ts +++ b/src/countries.ts @@ -1,234 +1,150 @@ import { + capitalByCountryLowerName, + capitalByCountryLowerOfficialName, + capitalByIso2, + capitalByIso3, countries, + countryByCapitalLowerName, + countryByIso2, + countryByIso3, + countryByLowerName, + countryByLowerOfficialName, countryCapitals, countryIso2ByIso3Codes, countryIso2Codes, + countryIso2Set, countryIso3ByIso2Codes, countryIso3Codes, -} from './data/index.js'; -import { is, isValidCountryIso, match } from './helpers.js'; + countryIso3Set, +} from './data/countries.js'; import type { Capital, Country } from './interfaces.js'; /** - * @func findCapitalOfCountryIso Find the capital of a country by its - * country ISO 3166-1 alpha-2 or alpha-3 code. - * - * @param {string} code Country ISO code (case insensitive) - * @return {Capital|undefined} + * Find the capital of a country by its ISO 3166-1 alpha-2 or alpha-3 code. + * Case-insensitive. */ -export const findCapitalOfCountryIso = function findCapitalOfCountryIso( - code: string, -): Capital | undefined { - if (!is(String, code)) { +export const findCapitalOfCountryIso = (code: string): Capital | undefined => { + if (typeof code !== 'string') { return undefined; } - - const countryCode = code.toUpperCase(); - const { valid, iso2 } = isValidCountryIso(countryCode); - + const upper = code.toUpperCase(); + const { valid, iso2 } = isValidCountryIso(upper); if (!valid) { return undefined; } - - const alphaType = iso2 ? 'iso2' : 'iso3'; - - return countryCapitals.find((capital) => - match({ - source: capital.country![alphaType], - compare: code, - partial: false, - strict: false, - }), - ); + return iso2 ? capitalByIso2.get(upper) : capitalByIso3.get(upper); }; /** - * @func findCapitalOfCountryName Find the capital of a country by its name. - * - * @param {string} name Country name (case insensitive) - * @return {Capital|undefined} + * Find the capital of a country by its short or official name. Case-insensitive. */ -export const findCapitalOfCountryName = function findCapitalOfCountryName( - name: string, -): Capital | undefined { - if (!is(String, name)) { +export const findCapitalOfCountryName = (name: string): Capital | undefined => { + if (typeof name !== 'string') { return undefined; } - - return countryCapitals.find( - (capital) => - match({ - source: capital.country!.name, - compare: name, - partial: false, - strict: false, - }) || - match({ - source: capital.country!.officialName, - compare: name, - partial: false, - strict: false, - }), - ); + const lower = name.toLowerCase(); + return capitalByCountryLowerName.get(lower) ?? capitalByCountryLowerOfficialName.get(lower); }; /** - * @func findCountryByCapitalName Find a country by its capital name. - * - * @param {string} name Capital name (case insensitive, utf-8 or ascii) - * @return {Country|undefined} + * Find a country by its capital name (UTF-8 or ASCII). Case-insensitive. */ -export const findCountryByCapitalName = function findCountryByCapitalName( - name: string, -): Country | undefined { - if (!is(String, name) || name.trim() === '') { +export const findCountryByCapitalName = (name: string): Country | undefined => { + if (typeof name !== 'string' || name.trim() === '') { return undefined; } - - return countries.find( - (country) => - match({ - source: country.capital!.name, - compare: name, - partial: false, - strict: false, - }) || - match({ - source: country.capital!.nameAscii, - compare: name, - partial: false, - strict: false, - }), - ); + return countryByCapitalLowerName.get(name.toLowerCase()); }; /** - * @func findCountryByIso Find a country by its country ISO 3166-1 alpha-2 or alpha-3 code. - * - * @param {string} code Country ISO code (case insensitive) - * @return {Country|undefined} + * Find a country by its ISO 3166-1 alpha-2 or alpha-3 code. Case-insensitive. */ -export const findCountryByIso = function findCountryByIso(code: string): Country | undefined { - if (!is(String, code)) { +export const findCountryByIso = (code: string): Country | undefined => { + if (typeof code !== 'string') { return undefined; } - - const countryCode = code.toUpperCase(); - const { valid, iso2 } = isValidCountryIso(countryCode); - + const upper = code.toUpperCase(); + const { valid, iso2 } = isValidCountryIso(upper); if (!valid) { return undefined; } - - const alphaType = iso2 ? 'iso2' : 'iso3'; - - return countries.find((country) => - match({ - source: country[alphaType], - compare: code, - partial: false, - strict: false, - }), - ); + return iso2 ? countryByIso2.get(upper) : countryByIso3.get(upper); }; /** - * @func findCountryByName Find a country by its name. - * - * @param {string} name Country name (case insensitive) - * @return {Country|undefined} + * Find a country by its short or official name. Case-insensitive. */ -export const findCountryByName = function findCountryByName(name: string): Country | undefined { - if (!is(String, name)) { +export const findCountryByName = (name: string): Country | undefined => { + if (typeof name !== 'string') { return undefined; } - - return countries.find( - (country) => - match({ - source: country.name, - compare: name, - partial: false, - strict: false, - }) || - match({ - source: country.officialName, - compare: name, - partial: false, - strict: false, - }), - ); + const lower = name.toLowerCase(); + return countryByLowerName.get(lower) ?? countryByLowerOfficialName.get(lower); }; /** - * @func getCapitals Get all country capitals. - * - * @return {Capital[]} + * All country capitals, sorted by country name ascending. */ -export const getCapitals = function getCapitals(): Capital[] { - return countryCapitals; -}; +export const getCapitals = (): ReadonlyArray => countryCapitals; /** - * @func getCountries Get all countries. - * - * @return {Country[]} + * All countries, sorted by country name ascending. */ -export const getCountries = function getCountries(): Country[] { - return countries; -}; +export const getCountries = (): ReadonlyArray => countries; /** - * @func getCountryIso2CodeByIso3 Get the country ISO 3166-1 alpha-2 code - * related to an alpha-3 code. - * - * @param {string} iso3 Country ISO 3166-1 alpha-3 code (case insensitive) - * @return {string|undefined} + * Get the ISO 3166-1 alpha-2 code paired with an alpha-3 code. Case-insensitive. */ -export const getCountryIso2CodeByIso3 = function getCountryIso2CodeByIso3( - iso3: string, -): string | undefined { - if (!is(String, iso3)) { +export const getCountryIso2CodeByIso3 = (iso3: string): string | undefined => { + if (typeof iso3 !== 'string') { return undefined; } - return countryIso2ByIso3Codes[iso3.toUpperCase()]; }; /** - * @func getCountryIso2Codes Get all country ISO 3166-1 alpha-2 codes. - * - * @return {string[]} + * All ISO 3166-1 alpha-2 codes, sorted ascending. */ -export const getCountryIso2Codes = function getCountryIso2Codes(): string[] { - return countryIso2Codes; -}; +export const getCountryIso2Codes = (): ReadonlyArray => countryIso2Codes; /** - * @func getCountryIso3CodeByIso2 Get the country ISO 3166-1 alpha-2 code - * related to an alpha-3 code. - * - * @param {string} iso2 Country ISO 3166-1 alpha-2 code (case insensitive) - * @return {string|undefined} + * Get the ISO 3166-1 alpha-3 code paired with an alpha-2 code. Case-insensitive. */ -export const getCountryIso3CodeByIso2 = function getCountryIso3CodeByIso2( - iso2: string, -): string | undefined { - if (!is(String, iso2)) { +export const getCountryIso3CodeByIso2 = (iso2: string): string | undefined => { + if (typeof iso2 !== 'string') { return undefined; } - return countryIso3ByIso2Codes[iso2.toUpperCase()]; }; /** - * @func getCountryIso3Codes Get all country ISO 3166-1 alpha-3 codes. - * - * @return {string[]} + * All ISO 3166-1 alpha-3 codes, sorted ascending. */ -export const getCountryIso3Codes = function getCountryIso3Codes(): string[] { - return countryIso3Codes; -}; +export const getCountryIso3Codes = (): ReadonlyArray => countryIso3Codes; -// see helpers -export { isValidCountryIso }; +/** + * Validate an ISO 3166-1 alpha-2 or alpha-3 code. **Case-sensitive** — codes + * must be uppercase. + */ +export const isValidCountryIso = ( + code: string, +): { valid: boolean; iso2: boolean; iso3: boolean } => { + const res = { valid: false, iso2: false, iso3: false }; + if (typeof code !== 'string') { + return res; + } + if (code.length < 2 || code.length > 3) { + return res; + } + if (countryIso2Set.has(code)) { + res.valid = true; + res.iso2 = true; + return res; + } + if (countryIso3Set.has(code)) { + res.valid = true; + res.iso3 = true; + return res; + } + return res; +}; diff --git a/src/data/countries.ts b/src/data/countries.ts new file mode 100644 index 0000000..fb7bfb5 --- /dev/null +++ b/src/data/countries.ts @@ -0,0 +1,83 @@ +import { parse } from 'zipson'; +import type { Capital, Country } from '../interfaces.js'; +import countriesJson from './countries.json' with { type: 'json' }; +import countryCapitalsJson from './country-capitals.json' with { type: 'json' }; +import countryIso2ByIso3CodesJson from './country-iso2-by-iso3-codes.json' with { type: 'json' }; +import countryIso2CodesJson from './country-iso2-codes.json' with { type: 'json' }; +import countryIso3ByIso2CodesJson from './country-iso3-by-iso2-codes.json' with { type: 'json' }; +import countryIso3CodesJson from './country-iso3-codes.json' with { type: 'json' }; + +interface CapitalWithCountry extends Capital { + country: Country; +} + +interface CountryWithCapital extends Country { + capital: Capital; +} + +export const countries = parse(countriesJson.data) as CountryWithCapital[]; +export const countryCapitals = parse(countryCapitalsJson.data) as CapitalWithCountry[]; +export const countryIso2ByIso3Codes = parse(countryIso2ByIso3CodesJson.data) as Record< + string, + string +>; +export const countryIso2Codes = parse(countryIso2CodesJson.data) as string[]; +export const countryIso3ByIso2Codes = parse(countryIso3ByIso2CodesJson.data) as Record< + string, + string +>; +export const countryIso3Codes = parse(countryIso3CodesJson.data) as string[]; + +Object.freeze(countries); +Object.freeze(countryCapitals); +Object.freeze(countryIso2Codes); +Object.freeze(countryIso3Codes); +Object.freeze(countryIso2ByIso3Codes); +Object.freeze(countryIso3ByIso2Codes); + +export const countryByIso2 = new Map(); +export const countryByIso3 = new Map(); +export const countryByLowerName = new Map(); +export const countryByLowerOfficialName = new Map(); + +for (const country of countries) { + Object.freeze(country.timezones); + countryByIso2.set(country.iso2, country); + countryByIso3.set(country.iso3, country); + countryByLowerName.set(country.name.toLowerCase(), country); + countryByLowerOfficialName.set(country.officialName.toLowerCase(), country); +} + +export const capitalByIso2 = new Map(); +export const capitalByIso3 = new Map(); +export const capitalByCountryLowerName = new Map(); +export const capitalByCountryLowerOfficialName = new Map(); +export const countryByCapitalLowerName = new Map(); + +for (const capital of countryCapitals) { + Object.freeze(capital.country.timezones); + capitalByIso2.set(capital.country.iso2, capital); + capitalByIso3.set(capital.country.iso3, capital); + capitalByCountryLowerName.set(capital.country.name.toLowerCase(), capital); + capitalByCountryLowerOfficialName.set(capital.country.officialName.toLowerCase(), capital); + if (!capital.name) { + continue; + } + const country = countryByIso2.get(capital.country.iso2); + if (!country) { + continue; + } + const lowerName = capital.name.toLowerCase(); + if (!countryByCapitalLowerName.has(lowerName)) { + countryByCapitalLowerName.set(lowerName, country); + } + if (capital.nameAscii) { + const lowerAscii = capital.nameAscii.toLowerCase(); + if (lowerAscii !== lowerName && !countryByCapitalLowerName.has(lowerAscii)) { + countryByCapitalLowerName.set(lowerAscii, country); + } + } +} + +export const countryIso2Set = new Set(countryIso2Codes); +export const countryIso3Set = new Set(countryIso3Codes); diff --git a/src/data/index.ts b/src/data/index.ts deleted file mode 100644 index 01aa8fc..0000000 --- a/src/data/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { parse } from 'zipson'; -import type { Capital, Country, Location, StateAnsi } from '../interfaces.js'; -import countriesJson from './countries.json' with { type: 'json' }; -import countryCapitalsJson from './country-capitals.json' with { type: 'json' }; -import countryIso2ByIso3CodesJson from './country-iso2-by-iso3-codes.json' with { type: 'json' }; -import countryIso2CodesJson from './country-iso2-codes.json' with { type: 'json' }; -import countryIso3ByIso2CodesJson from './country-iso3-by-iso2-codes.json' with { type: 'json' }; -import countryIso3CodesJson from './country-iso3-codes.json' with { type: 'json' }; -import locationsJson from './locations.json' with { type: 'json' }; -import statesAnsiJson from './states-ansi.json' with { type: 'json' }; -import timezonesJson from './timezones.json' with { type: 'json' }; - -export const countries = parse(countriesJson.data) as Country[]; -export const countryCapitals = parse(countryCapitalsJson.data) as Capital[]; -export const countryIso2ByIso3Codes = parse(countryIso2ByIso3CodesJson.data) as Record< - string, - string ->; -export const countryIso2Codes = parse(countryIso2CodesJson.data) as string[]; -export const countryIso3ByIso2Codes = parse(countryIso3ByIso2CodesJson.data) as Record< - string, - string ->; -export const countryIso3Codes = parse(countryIso3CodesJson.data) as string[]; -export const locations = parse(locationsJson.data) as Location[]; -export const statesAnsi = parse(statesAnsiJson.data) as StateAnsi[]; -export const timezones = parse(timezonesJson.data) as string[]; diff --git a/src/data/locations.ts b/src/data/locations.ts new file mode 100644 index 0000000..7d7bb26 --- /dev/null +++ b/src/data/locations.ts @@ -0,0 +1,67 @@ +import { parse } from 'zipson'; +import type { Location } from '../interfaces.js'; +import locationsJson from './locations.json' with { type: 'json' }; + +export const locations = parse(locationsJson.data) as Location[]; +Object.freeze(locations); + +export const locationByCityLowerName = new Map(); +export const locationsByCountryLowerName = new Map(); +export const locationsByCountryLowerOfficialName = new Map(); +export const locationsByIso2 = new Map(); +export const locationsByIso3 = new Map(); +export const locationsByLowerProvince = new Map(); +export const locationsByLowerState = new Map(); + +const pushBucket = (map: Map, key: string, location: Location): void => { + const bucket = map.get(key); + if (bucket) { + bucket.push(location); + return; + } + map.set(key, [location]); +}; + +for (const location of locations) { + if (location.city) { + const lowerCity = location.city.toLowerCase(); + if (!locationByCityLowerName.has(lowerCity)) { + locationByCityLowerName.set(lowerCity, location); + } + } + if (location.cityAscii) { + const lowerCityAscii = location.cityAscii.toLowerCase(); + if (!locationByCityLowerName.has(lowerCityAscii)) { + locationByCityLowerName.set(lowerCityAscii, location); + } + } + pushBucket(locationsByCountryLowerName, location.country.name.toLowerCase(), location); + pushBucket( + locationsByCountryLowerOfficialName, + location.country.officialName.toLowerCase(), + location, + ); + pushBucket(locationsByIso2, location.country.iso2, location); + pushBucket(locationsByIso3, location.country.iso3, location); + // Province and state can be empty strings — index them as the '' bucket so + // `findLocationsByProvince('')` / `findLocationsByState('')` returns the + // matching subset (preserves pre-optim behavior). + pushBucket(locationsByLowerProvince, location.province.toLowerCase(), location); + pushBucket(locationsByLowerState, location.state.toLowerCase(), location); +} + +const freezeBuckets = (map: Map): void => { + for (const bucket of map.values()) { + Object.freeze(bucket); + } +}; + +freezeBuckets(locationsByCountryLowerName); +freezeBuckets(locationsByCountryLowerOfficialName); +freezeBuckets(locationsByIso2); +freezeBuckets(locationsByIso3); +freezeBuckets(locationsByLowerProvince); +freezeBuckets(locationsByLowerState); + +export const EMPTY_LOCATIONS: Location[] = []; +Object.freeze(EMPTY_LOCATIONS); diff --git a/src/data/states-ansi.ts b/src/data/states-ansi.ts new file mode 100644 index 0000000..1206544 --- /dev/null +++ b/src/data/states-ansi.ts @@ -0,0 +1,20 @@ +import { parse } from 'zipson'; +import type { StateAnsi } from '../interfaces.js'; +import statesAnsiJson from './states-ansi.json' with { type: 'json' }; + +export const statesAnsi = parse(statesAnsiJson.data) as StateAnsi[]; +Object.freeze(statesAnsi); + +export const stateAnsiByFipsCode = new Map(); +export const stateAnsiByGnisid = new Map(); +export const stateAnsiByLowerName = new Map(); +export const stateAnsiByUspsCode = new Map(); + +for (const state of statesAnsi) { + stateAnsiByFipsCode.set(state.fipsCode, state); + stateAnsiByGnisid.set(state.gnisid, state); + if (state.name) { + stateAnsiByLowerName.set(state.name.toLowerCase(), state); + } + stateAnsiByUspsCode.set(state.uspsCode, state); +} diff --git a/src/data/timezones.ts b/src/data/timezones.ts new file mode 100644 index 0000000..5324132 --- /dev/null +++ b/src/data/timezones.ts @@ -0,0 +1,5 @@ +import { parse } from 'zipson'; +import timezonesJson from './timezones.json' with { type: 'json' }; + +export const timezones = parse(timezonesJson.data) as string[]; +Object.freeze(timezones); diff --git a/src/helpers.ts b/src/helpers.ts index 5ab2411..e389826 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,5 +1,3 @@ -import { countryIso2Codes, countryIso3Codes } from './data/index.js'; - export function exists(thing: T): thing is NonNullable { return !( thing === undefined || @@ -13,19 +11,23 @@ export function is(Type: typeof Number, thing: unknown): thing is number; export function is(Type: typeof Boolean, thing: unknown): thing is boolean; export function is(Type: new (...args: never[]) => T, thing: unknown): thing is T; export function is(Type: unknown, thing: unknown): boolean { - if (!exists(Type) || !exists(thing)) { - return false; + if (Type === String) { + return typeof thing === 'string'; } - - if ((thing as { constructor?: unknown }).constructor === Type) { - return true; + if (Type === Number) { + return typeof thing === 'number' && !Number.isNaN(thing); } - - return typeof Type === 'function' && thing instanceof (Type as new (...args: never[]) => unknown); + if (Type === Boolean) { + return typeof thing === 'boolean'; + } + if (typeof Type === 'function' && exists(thing)) { + return thing instanceof (Type as new (...args: never[]) => unknown); + } + return false; } export function hasLen({ str, from, to }: { str: string; from: number; to: number }): boolean { - if (!is(String, str)) { + if (typeof str !== 'string') { return false; } @@ -50,7 +52,7 @@ export function match({ partial?: boolean; strict?: boolean; }): boolean { - if (!is(String, source) || !is(String, compare)) { + if (typeof source !== 'string' || typeof compare !== 'string') { return false; } @@ -64,25 +66,3 @@ export function match({ return source.toLowerCase() === compare.toLowerCase(); } - -export function isValidCountryIso(code: string): { valid: boolean; iso2: boolean; iso3: boolean } { - const res = { valid: false, iso2: false, iso3: false }; - - if (!hasLen({ str: code, from: 2, to: 3 })) { - return res; - } - - if (countryIso2Codes.includes(code)) { - res.valid = true; - res.iso2 = true; - return res; - } - - if (countryIso3Codes.includes(code)) { - res.valid = true; - res.iso3 = true; - return res; - } - - return res; -} diff --git a/src/index.ts b/src/index.ts index 32ce5d7..c204dfa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import * as statesAnsiNs from './states-ansi.js'; import * as timezonesNs from './timezones.js'; export * from './countries.js'; -export type { Capital, Country, Location, StateAnsi } from './interfaces.js'; +export type { Capital, Coordinates, Country, Location, StateAnsi } from './interfaces.js'; export * from './locations.js'; export * from './states-ansi.js'; export * from './timezones.js'; diff --git a/src/interfaces.ts b/src/interfaces.ts index 74b0547..c5d053b 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -35,3 +35,10 @@ export interface StateAnsi { name: string; uspsCode: string; } + +export interface Coordinates { + latitudeFrom?: number; + latitudeTo?: number; + longitudeFrom?: number; + longitudeTo?: number; +} diff --git a/src/locations.ts b/src/locations.ts index a134d1c..09c0688 100644 --- a/src/locations.ts +++ b/src/locations.ts @@ -1,177 +1,161 @@ -import { locations } from './data/index.js'; -import { exists, is, isValidCountryIso, match } from './helpers.js'; -import type { Location } from './interfaces.js'; +import { isValidCountryIso } from './countries.js'; +import { + EMPTY_LOCATIONS, + locations, + locationsByCountryLowerName, + locationsByCountryLowerOfficialName, + locationsByIso2, + locationsByIso3, + locationsByLowerProvince, + locationsByLowerState, +} from './data/locations.js'; +import { match } from './helpers.js'; +import type { Coordinates, Location } from './interfaces.js'; + +const freezeArray = (arr: Location[]): ReadonlyArray => { + Object.freeze(arr); + return arr; +}; /** - * Find locations within a bounding box. At least one latitude and one longitude bound - * must be set; missing bounds default to `±Infinity`. + * Find locations within a bounding box. At least one latitude bound and one + * longitude bound must be set; missing bounds default to `±Infinity`. */ -export const findLocationsByCoordinates = function findLocationsByCoordinates({ +export const findLocationsByCoordinates = ({ latitudeFrom, latitudeTo, longitudeFrom, longitudeTo, -}: { - latitudeFrom?: number; - latitudeTo?: number; - longitudeFrom?: number; - longitudeTo?: number; -}): Location[] { - const latFrom = is(Number, latitudeFrom) ? latitudeFrom : Number.NEGATIVE_INFINITY; - const latTo = is(Number, latitudeTo) ? latitudeTo : Number.POSITIVE_INFINITY; - const longFrom = is(Number, longitudeFrom) ? longitudeFrom : Number.NEGATIVE_INFINITY; - const longTo = is(Number, longitudeTo) ? longitudeTo : Number.POSITIVE_INFINITY; - - // at least one of each value is necessary - if ( - (!exists(latitudeFrom) && !exists(latitudeTo)) || - (!exists(longitudeFrom) && !exists(longitudeTo)) - ) { - return []; +}: Coordinates): ReadonlyArray => { + const latFromDefined = typeof latitudeFrom === 'number'; + const latToDefined = typeof latitudeTo === 'number'; + const longFromDefined = typeof longitudeFrom === 'number'; + const longToDefined = typeof longitudeTo === 'number'; + + if ((!latFromDefined && !latToDefined) || (!longFromDefined && !longToDefined)) { + return EMPTY_LOCATIONS; } - const res = locations.filter( - (location) => - location.latitude >= latFrom && - location.latitude <= latTo && - location.longitude >= longFrom && - location.longitude <= longTo, + const latFrom = latFromDefined ? latitudeFrom : Number.NEGATIVE_INFINITY; + const latTo = latToDefined ? latitudeTo : Number.POSITIVE_INFINITY; + const longFrom = longFromDefined ? longitudeFrom : Number.NEGATIVE_INFINITY; + const longTo = longToDefined ? longitudeTo : Number.POSITIVE_INFINITY; + + return freezeArray( + locations.filter( + (location) => + location.latitude >= latFrom && + location.latitude <= latTo && + location.longitude >= longFrom && + location.longitude <= longTo, + ), ); - - return res; }; /** - * @func findLocationsByCountryIso Find locations based on a country ISO 3166-1 alpha-2 - * or alpha-3 code. - * - * @param {string} code Country ISO code (case insensitive) - * @return {Location[]} + * Find locations by ISO 3166-1 alpha-2 or alpha-3 code. Case-insensitive. */ -export const findLocationsByCountryIso = function findLocationsByCountryIso( - code: string, -): Location[] { - if (!is(String, code)) { - return []; +export const findLocationsByCountryIso = (code: string): ReadonlyArray => { + if (typeof code !== 'string') { + return EMPTY_LOCATIONS; } - - const countryCode = code.toUpperCase(); - const { valid, iso2 } = isValidCountryIso(countryCode); - + const upper = code.toUpperCase(); + const { valid, iso2 } = isValidCountryIso(upper); if (!valid) { - return []; + return EMPTY_LOCATIONS; } - - const alphaType = iso2 ? 'iso2' : 'iso3'; - const res = locations.filter((location) => - match({ - source: location.country[alphaType], - compare: countryCode, - partial: false, - strict: true, - }), - ); - - return res; + return (iso2 ? locationsByIso2.get(upper) : locationsByIso3.get(upper)) ?? EMPTY_LOCATIONS; }; /** - * @func findLocationsByCountryName Find locations based on a country name. - * - * @param {string} name Country name (case insensitive) - * @param {boolean} partialMatch Whether to include partial matches - * @return {Location[]} + * Find locations by country short or official name. Case-insensitive. */ -export const findLocationsByCountryName = function findLocationsByCountryName( +export const findLocationsByCountryName = ( name: string, partialMatch: boolean = false, -): Location[] { - if (!is(String, name)) { - return []; +): ReadonlyArray => { + if (typeof name !== 'string') { + return EMPTY_LOCATIONS; } - - const partial = partialMatch === true; - const res = locations.filter( - (location) => - match({ - partial, - source: location.country.name, - compare: name, - strict: false, - }) || - match({ - partial, - source: location.country.officialName, - compare: name, - strict: false, - }), + if (partialMatch === true) { + return freezeArray( + locations.filter( + (location) => + match({ + partial: true, + source: location.country.name, + compare: name, + strict: false, + }) || + match({ + partial: true, + source: location.country.officialName, + compare: name, + strict: false, + }), + ), + ); + } + const lower = name.toLowerCase(); + return ( + locationsByCountryLowerName.get(lower) ?? + locationsByCountryLowerOfficialName.get(lower) ?? + EMPTY_LOCATIONS ); - - return res; }; /** - * @func findLocationsByProvince Find locations based on a province - * (not recommended, unreliable data). - * - * @param {string} name Province name (case insensitive) - * @param {boolean} partialMatch Whether to include partial matches - * @return {Location[]} + * Find locations by province. Case-insensitive. Province data is not + * exhaustively verified — treat partial matches as best-effort. */ -export const findLocationsByProvince = function findLocationsByProvince( +export const findLocationsByProvince = ( name: string, partialMatch: boolean = false, -): Location[] { - if (!is(String, name)) { - return []; +): ReadonlyArray => { + if (typeof name !== 'string') { + return EMPTY_LOCATIONS; } - - const partial = partialMatch === true; - const res = locations.filter((location) => - match({ - partial, - source: location.province, - compare: name, - strict: false, - }), - ); - - return res; + if (partialMatch === true) { + return freezeArray( + locations.filter((location) => + match({ + partial: true, + source: location.province, + compare: name, + strict: false, + }), + ), + ); + } + return locationsByLowerProvince.get(name.toLowerCase()) ?? EMPTY_LOCATIONS; }; /** - * @func findLocationsByState Find locations based on the state name. - * - * @param {string} name State name (case insensitive, 2 chars) - * @param {boolean} partialMatch Whether to include partial matches - * @return {Location[]} + * Find locations by USPS state name or code (US only). Case-insensitive. */ -export const findLocationsByState = function findLocationsByState( +export const findLocationsByState = ( name: string, partialMatch: boolean = false, -): Location[] { - if (!is(String, name)) { - return []; +): ReadonlyArray => { + if (typeof name !== 'string') { + return EMPTY_LOCATIONS; } - - const partial = partialMatch === true; - const res = locations.filter((location) => - match({ - partial, - source: location.state, - compare: name, - strict: false, - }), - ); - - return res; + if (partialMatch === true) { + return freezeArray( + locations.filter((location) => + match({ + partial: true, + source: location.state, + compare: name, + strict: false, + }), + ), + ); + } + return locationsByLowerState.get(name.toLowerCase()) ?? EMPTY_LOCATIONS; }; /** - * @func getLocations Get all locations. - * - * @return {Location[]} + * All locations, sorted by city name ascending. */ -export const getLocations = function getLocations(): Location[] { - return locations; -}; +export const getLocations = (): ReadonlyArray => locations; diff --git a/src/states-ansi.ts b/src/states-ansi.ts index 11789d7..e339dd8 100644 --- a/src/states-ansi.ts +++ b/src/states-ansi.ts @@ -1,111 +1,54 @@ -import { statesAnsi } from './data/index.js'; -import { hasLen, is, match } from './helpers.js'; +import { + stateAnsiByFipsCode, + stateAnsiByGnisid, + stateAnsiByLowerName, + stateAnsiByUspsCode, + statesAnsi, +} from './data/states-ansi.js'; +import { hasLen } from './helpers.js'; import type { StateAnsi } from './interfaces.js'; /** - * @func findStateAnsiByFipsCode Find the state's information based on the - * Federal Information Processing Standard (FIPS) State Code ANSI - * (American National Standards Institute, USA states only). - * - * @param {string} code FIPS ANSI code (case insensitive, 2 chars) - * @return {StateAnsi|undefined} + * Find a US state by FIPS State Code ANSI. Length-2 strings only. */ -export const findStateAnsiByFipsCode = function findStateAnsiByFipsCode( - code: string, -): StateAnsi | undefined { +export const findStateAnsiByFipsCode = (code: string): StateAnsi | undefined => { if (!hasLen({ str: code, from: 2, to: 2 })) { return undefined; } - - return statesAnsi.find((state) => - match({ - source: state.fipsCode, - compare: code, - partial: false, - strict: false, - }), - ); + return stateAnsiByFipsCode.get(code); }; /** - * @func findStateAnsiByGnisid Find the state's information based on the - * Geographic Names Information System Identifier (GNISID) ANSI - * (American National Standards Institute, USA states only). - * - * @param {string} id GNISID ANSI (case insensitive) - * @return {StateAnsi|undefined} + * Find a US state by GNISID (Geographic Names Information System Identifier). */ -export const findStateAnsiByGnisid = function findStateAnsiByGnisid( - id: string, -): StateAnsi | undefined { - if (!is(String, id)) { +export const findStateAnsiByGnisid = (id: string): StateAnsi | undefined => { + if (typeof id !== 'string') { return undefined; } - - return statesAnsi.find((state) => - match({ - source: state.gnisid, - compare: id, - partial: false, - strict: false, - }), - ); + return stateAnsiByGnisid.get(id); }; /** - * @func findStateAnsiByName Find the state's information by its name ANSI - * (American National Standards Institute, USA states only). - * - * @param {string} name Name ANSI (case insensitive) - * @return {StateAnsi|undefined} + * Find a US state by name. Case-insensitive. */ -export const findStateAnsiByName = function findStateAnsiByName( - name: string, -): StateAnsi | undefined { - if (!is(String, name)) { +export const findStateAnsiByName = (name: string): StateAnsi | undefined => { + if (typeof name !== 'string') { return undefined; } - - return statesAnsi.find((state) => - match({ - source: state.name, - compare: name, - partial: false, - strict: false, - }), - ); + return stateAnsiByLowerName.get(name.toLowerCase()); }; /** - * @func findStateAnsiByUspsCode Find the state's information based on the - * Official United States Postal Service (USPS) Code ANSI - * (American National Standards Institute, USA states only). - * - * @param {string} code USPS ANSI code (case insensitive, 2 chars) - * @return {StateAnsi|undefined} + * Find a US state by USPS code. Length-2 strings only. Case-insensitive. */ -export const findStateAnsiByUspsCode = function findStateAnsiByUspsCode( - code: string, -): StateAnsi | undefined { +export const findStateAnsiByUspsCode = (code: string): StateAnsi | undefined => { if (!hasLen({ str: code, from: 2, to: 2 })) { return undefined; } - - return statesAnsi.find((state) => - match({ - source: state.uspsCode, - compare: code, - partial: false, - strict: false, - }), - ); + return stateAnsiByUspsCode.get(code.toUpperCase()); }; /** - * @func getStatesAnsi Get all states ANSI (American National Standards Institute, USA states only). - * - * @return {StateAnsi[]} + * All US states (ANSI), sorted by name ascending. */ -export const getStatesAnsi = function getStatesAnsi(): StateAnsi[] { - return statesAnsi; -}; +export const getStatesAnsi = (): ReadonlyArray => statesAnsi; diff --git a/src/timezones.ts b/src/timezones.ts index c4c5986..fff52b6 100644 --- a/src/timezones.ts +++ b/src/timezones.ts @@ -1,179 +1,90 @@ -import { countries, countryCapitals, locations, timezones } from './data/index.js'; -import { exists, is, isValidCountryIso, match } from './helpers.js'; +import { isValidCountryIso } from './countries.js'; +import { + capitalByCountryLowerName, + capitalByCountryLowerOfficialName, + capitalByIso2, + capitalByIso3, + countryByIso2, + countryByIso3, + countryByLowerName, + countryByLowerOfficialName, +} from './data/countries.js'; +import { locationByCityLowerName } from './data/locations.js'; +import { timezones } from './data/timezones.js'; /** - * @func findTimezoneByCapitalOfCountryIso Find a timezone based on the capital - * of a country ISO 3166-1 alpha-2 or alpha-3 code. - * - * @param {string} code Country ISO code (case insensitive) - * @return {string|undefined} + * IANA timezone for a country's capital, by ISO 3166-1 alpha-2 or alpha-3 code. + * Case-insensitive. */ -export const findTimezoneByCapitalOfCountryIso = function findTimezoneByCapitalOfCountryIso( - code: string, -): string | undefined { - if (!is(String, code)) { +export const findTimezoneByCapitalOfCountryIso = (code: string): string | undefined => { + if (typeof code !== 'string') { return undefined; } - - const countryCode = code.toUpperCase(); - const { valid, iso2 } = isValidCountryIso(countryCode); - + const upper = code.toUpperCase(); + const { valid, iso2 } = isValidCountryIso(upper); if (!valid) { return undefined; } - - const alphaType = iso2 ? 'iso2' : 'iso3'; - return countryCapitals.find((capital) => - match({ - source: capital.country![alphaType], - compare: countryCode, - partial: false, - strict: true, - }), - )?.timezone; + return (iso2 ? capitalByIso2.get(upper) : capitalByIso3.get(upper))?.timezone; }; /** - * @func findTimezoneByCapitalOfCountryName Find a timezone based on the capital - * of a country (name). - * - * @param {string} name Country name (case insensitive) - * @return {string|undefined} + * IANA timezone for a country's capital, by country short or official name. + * Case-insensitive. */ -export const findTimezoneByCapitalOfCountryName = function findTimezoneByCapitalOfCountryName( - name: string, -): string | undefined { - if (!is(String, name)) { +export const findTimezoneByCapitalOfCountryName = (name: string): string | undefined => { + if (typeof name !== 'string') { return undefined; } - - return countryCapitals.find( - (capital) => - match({ - source: capital.country!.name, - compare: name, - partial: false, - strict: false, - }) || - match({ - source: capital.country!.officialName, - compare: name, - partial: false, - strict: false, - }), - )?.timezone; + const lower = name.toLowerCase(); + return (capitalByCountryLowerName.get(lower) ?? capitalByCountryLowerOfficialName.get(lower)) + ?.timezone; }; /** - * @func findTimezoneByCityName Find a timezone based on a city name. - * - * @param {string} name City name (case insensitive, utf-8 or ascii) - * @return {string|undefined} + * IANA timezone for a city, by name (UTF-8 or ASCII). Case-insensitive. */ -export const findTimezoneByCityName = function findTimezoneByCityName( - name: string, -): string | undefined { - if (!is(String, name) || name.trim() === '') { +export const findTimezoneByCityName = (name: string): string | undefined => { + if (typeof name !== 'string' || name.trim() === '') { return undefined; } - - return locations.find( - (location) => - match({ - source: location.city, - compare: name, - partial: false, - strict: false, - }) || - match({ - source: location.cityAscii, - compare: name, - partial: false, - strict: false, - }), - )?.timezone; + return locationByCityLowerName.get(name.toLowerCase())?.timezone; }; +const EMPTY_TIMEZONES: ReadonlyArray = Object.freeze([]); + /** - * @func findTimezonesByCountryIso Find timezones based on a country - * ISO 3166-1 alpha-2 or alpha-3 code. - * - * @param {string} code Country ISO code (case insensitive) - * @return {string[]} + * All IANA timezones for a country, by ISO 3166-1 alpha-2 or alpha-3 code. + * Case-insensitive. */ -export const findTimezonesByCountryIso = function findTimezonesByCountryIso( - code: string, -): string[] { - if (!is(String, code)) { - return []; +export const findTimezonesByCountryIso = (code: string): ReadonlyArray => { + if (typeof code !== 'string') { + return EMPTY_TIMEZONES; } - - const countryCode = code.toUpperCase(); - const { valid, iso2 } = isValidCountryIso(countryCode); - + const upper = code.toUpperCase(); + const { valid, iso2 } = isValidCountryIso(upper); if (!valid) { - return []; - } - - const alphaType = iso2 ? 'iso2' : 'iso3'; - - const country = countries.find((c) => - match({ - source: c[alphaType], - compare: code, - partial: false, - strict: false, - }), - ); - - if (!exists(country)) { - return []; + return EMPTY_TIMEZONES; } - - return country.timezones; + const country = iso2 ? countryByIso2.get(upper) : countryByIso3.get(upper); + return country?.timezones ?? EMPTY_TIMEZONES; }; /** - * @func findTimezonesByCountryName Find timezones based on a country name. - * - * @param {string} name Country name (case insensitive) - * @return {string[]} + * All IANA timezones for a country, by country short or official name. + * Case-insensitive. */ -export const findTimezonesByCountryName = function findTimezonesByCountryName( - name: string, -): string[] { - if (!is(String, name)) { - return []; +export const findTimezonesByCountryName = (name: string): ReadonlyArray => { + if (typeof name !== 'string') { + return EMPTY_TIMEZONES; } - - const country = countries.find( - (c) => - match({ - source: c.name, - compare: name, - partial: false, - strict: false, - }) || - match({ - source: c.officialName, - compare: name, - partial: false, - strict: false, - }), - ); - - if (!exists(country)) { - return []; - } - - return country.timezones; + const lower = name.toLowerCase(); + const country = countryByLowerName.get(lower) ?? countryByLowerOfficialName.get(lower); + return country?.timezones ?? EMPTY_TIMEZONES; }; /** - * @func getTimezones Get all timezones. - * - * @return {string[]} + * All IANA timezones (the subset returned by `Intl.supportedValuesOf('timeZone')` + * at data-build time), sorted ascending. */ -export const getTimezones = function getTimezones(): string[] { - return timezones; -}; +export const getTimezones = (): ReadonlyArray => timezones; diff --git a/tests/location-timezone.property.test.ts b/tests/location-timezone.property.test.ts new file mode 100644 index 0000000..dd61a73 --- /dev/null +++ b/tests/location-timezone.property.test.ts @@ -0,0 +1,167 @@ +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { + findCapitalOfCountryIso, + findCountryByIso, + findCountryByName, + getCapitals, + getCountries, + getCountryIso2CodeByIso3, + getCountryIso2Codes, + getCountryIso3CodeByIso2, + getCountryIso3Codes, + getLocations, + getTimezones, + isValidCountryIso, +} from '../src/index.js'; + +const ISO2_CODES = getCountryIso2Codes(); +const ISO3_CODES = getCountryIso3Codes(); + +describe('property: ISO code round-trips', () => { + it('iso2 → iso3 → iso2 is identity for every alpha-2 code', () => { + fc.assert( + fc.property(fc.constantFrom(...ISO2_CODES), (iso2) => { + const iso3 = getCountryIso3CodeByIso2(iso2); + expect(iso3).toBeDefined(); + if (iso3 === undefined) { + return; + } + expect(getCountryIso2CodeByIso3(iso3)).toBe(iso2); + }), + { numRuns: ISO2_CODES.length }, + ); + }); + + it('iso3 → iso2 → iso3 is identity for every alpha-3 code', () => { + fc.assert( + fc.property(fc.constantFrom(...ISO3_CODES), (iso3) => { + const iso2 = getCountryIso2CodeByIso3(iso3); + expect(iso2).toBeDefined(); + if (iso2 === undefined) { + return; + } + expect(getCountryIso3CodeByIso2(iso2)).toBe(iso3); + }), + { numRuns: ISO3_CODES.length }, + ); + }); +}); + +describe('property: closure', () => { + it('every Country.iso2 in getCountries() is in getCountryIso2Codes()', () => { + const iso2Set = new Set(ISO2_CODES); + for (const country of getCountries()) { + expect(iso2Set.has(country.iso2)).toBe(true); + } + }); + + it('every Country.iso3 in getCountries() is in getCountryIso3Codes()', () => { + const iso3Set = new Set(ISO3_CODES); + for (const country of getCountries()) { + expect(iso3Set.has(country.iso3)).toBe(true); + } + }); + + it('every Country.timezones entry is in getTimezones()', () => { + const tzSet = new Set(getTimezones()); + for (const country of getCountries()) { + for (const tz of country.timezones) { + expect(tzSet.has(tz)).toBe(true); + } + } + }); +}); + +describe('property: back-reference consistency', () => { + it('every Capital.country.iso2 resolves via findCountryByIso', () => { + for (const capital of getCapitals()) { + expect(capital.country).toBeDefined(); + if (capital.country === undefined) { + continue; + } + const country = findCountryByIso(capital.country.iso2); + expect(country).toBeDefined(); + expect(country?.iso2).toBe(capital.country.iso2); + } + }); + + it('every Location.country.iso2 resolves via findCountryByIso', () => { + for (const location of getLocations()) { + const country = findCountryByIso(location.country.iso2); + expect(country).toBeDefined(); + expect(country?.iso2).toBe(location.country.iso2); + } + }); +}); + +describe('property: case-insensitive find helpers', () => { + it('findCountryByIso accepts any case', () => { + fc.assert( + fc.property(fc.constantFrom(...ISO2_CODES), (iso2) => { + const upper = findCountryByIso(iso2); + const lower = findCountryByIso(iso2.toLowerCase()); + const mixed = findCountryByIso(`${iso2[0]}${iso2.slice(1).toLowerCase()}`); + expect(upper).toBeDefined(); + expect(lower).toBe(upper); + expect(mixed).toBe(upper); + }), + ); + }); + + it('findCapitalOfCountryIso accepts any case', () => { + fc.assert( + fc.property(fc.constantFrom(...ISO2_CODES), (iso2) => { + const upper = findCapitalOfCountryIso(iso2); + const lower = findCapitalOfCountryIso(iso2.toLowerCase()); + expect(lower).toBe(upper); + }), + ); + }); +}); + +describe('property: isValidCountryIso reflexivity', () => { + it('every alpha-2 code validates as iso2', () => { + for (const code of ISO2_CODES) { + const res = isValidCountryIso(code); + expect(res.valid).toBe(true); + expect(res.iso2).toBe(true); + expect(res.iso3).toBe(false); + } + }); + + it('every alpha-3 code validates as iso3', () => { + for (const code of ISO3_CODES) { + const res = isValidCountryIso(code); + expect(res.valid).toBe(true); + expect(res.iso2).toBe(false); + expect(res.iso3).toBe(true); + } + }); +}); + +describe('property: referential idempotency', () => { + it('findCountryByIso returns the same reference across calls', () => { + fc.assert( + fc.property(fc.constantFrom(...ISO2_CODES), (iso2) => { + const a = findCountryByIso(iso2); + const b = findCountryByIso(iso2); + expect(a).toBe(b); + }), + ); + }); +}); + +describe('property: find results live in get collections', () => { + it('findCountryByName result is in getCountries() when defined', () => { + fc.assert( + fc.property(fc.string(), (name) => { + const result = findCountryByName(name); + if (result === undefined) { + return; + } + expect(getCountries()).toContain(result); + }), + ); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 6a5999c..e5f0617 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,13 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['src/index.ts'], + entry: [ + 'src/index.ts', + 'src/countries.ts', + 'src/locations.ts', + 'src/states-ansi.ts', + 'src/timezones.ts', + ], format: ['esm', 'cjs'], dts: true, clean: true,