geo-data-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1021 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Mohammed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# 🌍 geo-data
|
|
2
|
+
|
|
3
|
+
Copy only the countries you need. No bloat.
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Installing country data for your app?
|
|
9
|
+
npm install country-city-multilanguage # 6MB of world data 😱
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
You only need Saudi Arabia and UAE, but you're shipping data for 250 countries.
|
|
13
|
+
|
|
14
|
+
## The Solution
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx geo-data add sa ae # ~65KB instead of 6MB ✨
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 1. Initialize in your project
|
|
24
|
+
npx geo-data init
|
|
25
|
+
|
|
26
|
+
# 2. Add countries you need
|
|
27
|
+
npx geo-data add sa ae qa
|
|
28
|
+
|
|
29
|
+
# 3. Use in your code
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { getCities, getLocalizedName } from './src/data/geo';
|
|
34
|
+
|
|
35
|
+
const cities = getCities('SA');
|
|
36
|
+
// → [{ name: { en: "Riyadh", ar: "الرياض" }, ... }]
|
|
37
|
+
|
|
38
|
+
// With i18n
|
|
39
|
+
const cityName = getLocalizedName(city, 'ar'); // → "الرياض"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- 🎯 **Only what you need** — install 1 country, not 250
|
|
45
|
+
- 🌐 **Multilingual** — English, Arabic, French, Spanish, and 13 more languages
|
|
46
|
+
- 📦 **No runtime dependency** — data lives in your project
|
|
47
|
+
- 🔧 **Fully customizable** — it's just JSON, edit as needed
|
|
48
|
+
- ⚡ **TypeScript ready** — full type safety out of the box
|
|
49
|
+
- 🔄 **Offline support** — works offline after first download
|
|
50
|
+
- 🗑️ **Easy management** — add, update, or remove countries anytime
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Initialize configuration
|
|
56
|
+
npx geo-data init
|
|
57
|
+
|
|
58
|
+
# Add countries
|
|
59
|
+
npx geo-data add sa qa ae
|
|
60
|
+
npx geo-data add sa --force # Overwrite existing
|
|
61
|
+
npx geo-data add sa --dry-run # Preview changes
|
|
62
|
+
|
|
63
|
+
# List countries
|
|
64
|
+
npx geo-data list # All available (218 countries)
|
|
65
|
+
npx geo-data list --installed # Only installed
|
|
66
|
+
|
|
67
|
+
# Update installed countries
|
|
68
|
+
npx geo-data update # Re-download with current config
|
|
69
|
+
npx geo-data update --dry-run # Preview what would update
|
|
70
|
+
|
|
71
|
+
# Remove countries
|
|
72
|
+
npx geo-data remove sa # Remove with confirmation
|
|
73
|
+
npx geo-data rm sa --force # Remove without confirmation
|
|
74
|
+
|
|
75
|
+
# Manage cache
|
|
76
|
+
npx geo-data cache # Show cache info
|
|
77
|
+
npx geo-data cache clear # Clear offline cache
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
After `init`, you'll have a `geo-data.json`:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"$schema": "https://geo-data.dev/schema.json",
|
|
87
|
+
"outputDir": "./src/data/geo",
|
|
88
|
+
"languages": ["en", "ar"],
|
|
89
|
+
"includeCoordinates": true,
|
|
90
|
+
"typescript": true
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Option | Description | Default |
|
|
95
|
+
|--------|-------------|---------|
|
|
96
|
+
| `outputDir` | Where to put the data files | `./src/data/geo` |
|
|
97
|
+
| `languages` | Languages to include | `["en"]` |
|
|
98
|
+
| `includeCoordinates` | Include lat/lng for cities | `true` |
|
|
99
|
+
| `typescript` | Generate TypeScript types | `true` |
|
|
100
|
+
|
|
101
|
+
### Available Languages
|
|
102
|
+
|
|
103
|
+
`en`, `ar`, `de`, `es`, `fr`, `hi`, `it`, `ja`, `ko`, `nl`, `pl`, `pt`, `pt-BR`, `ru`, `tr`, `uk`, `zh`
|
|
104
|
+
|
|
105
|
+
## Data Schema
|
|
106
|
+
|
|
107
|
+
Each country file contains:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"code": "SA",
|
|
112
|
+
"name": { "en": "Saudi Arabia", "ar": "المملكة العربية السعودية" },
|
|
113
|
+
"phone": "+966",
|
|
114
|
+
"currency": "SAR",
|
|
115
|
+
"timezone": "Asia/Riyadh",
|
|
116
|
+
"flag": "🇸🇦",
|
|
117
|
+
"regions": [
|
|
118
|
+
{
|
|
119
|
+
"code": "SA-01",
|
|
120
|
+
"name": { "en": "Riyadh Region", "ar": "منطقة الرياض" },
|
|
121
|
+
"cities": [
|
|
122
|
+
{
|
|
123
|
+
"name": { "en": "Riyadh", "ar": "الرياض" },
|
|
124
|
+
"latitude": 24.7136,
|
|
125
|
+
"longitude": 46.6753
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Generated Helpers
|
|
134
|
+
|
|
135
|
+
The CLI generates an `index.ts` with helpful functions:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
import {
|
|
139
|
+
countries,
|
|
140
|
+
getCountry,
|
|
141
|
+
getRegions,
|
|
142
|
+
getCities,
|
|
143
|
+
getAllCities,
|
|
144
|
+
getLocalizedName,
|
|
145
|
+
getCountryCodes,
|
|
146
|
+
isValidCountryCode
|
|
147
|
+
} from './src/data/geo';
|
|
148
|
+
|
|
149
|
+
// Get all cities in a country
|
|
150
|
+
const cities = getCities('SA');
|
|
151
|
+
|
|
152
|
+
// Get cities in a specific region
|
|
153
|
+
const riyadhCities = getCities('SA', 'SA-01');
|
|
154
|
+
|
|
155
|
+
// Get localized name
|
|
156
|
+
const name = getLocalizedName(city, 'ar'); // "الرياض"
|
|
157
|
+
|
|
158
|
+
// Type-safe country codes
|
|
159
|
+
type CountryCode = 'SA' | 'AE' | 'QA'; // Based on what you installed
|
|
160
|
+
|
|
161
|
+
// Validation
|
|
162
|
+
if (isValidCountryCode(userInput)) {
|
|
163
|
+
const country = getCountry(userInput);
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Framework Examples
|
|
168
|
+
|
|
169
|
+
### React
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
import { getCities, getLocalizedName } from '@/data/geo';
|
|
173
|
+
import { useTranslation } from 'react-i18next';
|
|
174
|
+
|
|
175
|
+
function CitySelect({ country }: { country: 'SA' | 'AE' }) {
|
|
176
|
+
const { i18n } = useTranslation();
|
|
177
|
+
const cities = getCities(country);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<select>
|
|
181
|
+
{cities.map(city => (
|
|
182
|
+
<option key={city.name.en} value={city.name.en}>
|
|
183
|
+
{getLocalizedName(city, i18n.language)}
|
|
184
|
+
</option>
|
|
185
|
+
))}
|
|
186
|
+
</select>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Vue
|
|
192
|
+
|
|
193
|
+
```vue
|
|
194
|
+
<script setup lang="ts">
|
|
195
|
+
import { computed } from 'vue';
|
|
196
|
+
import { getCities, getLocalizedName } from '@/data/geo';
|
|
197
|
+
import { useI18n } from 'vue-i18n';
|
|
198
|
+
|
|
199
|
+
const props = defineProps<{ country: 'SA' | 'AE' }>();
|
|
200
|
+
const { locale } = useI18n();
|
|
201
|
+
const cities = computed(() => getCities(props.country));
|
|
202
|
+
</script>
|
|
203
|
+
|
|
204
|
+
<template>
|
|
205
|
+
<select>
|
|
206
|
+
<option v-for="city in cities" :key="city.name.en" :value="city.name.en">
|
|
207
|
+
{{ getLocalizedName(city, locale) }}
|
|
208
|
+
</option>
|
|
209
|
+
</select>
|
|
210
|
+
</template>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Data Sources
|
|
214
|
+
|
|
215
|
+
This package combines data from:
|
|
216
|
+
- [dr5hn/countries-states-cities-database](https://github.com/dr5hn/countries-states-cities-database) — Base country/city data
|
|
217
|
+
- [GeoNames](https://www.geonames.org/) — Arabic translations for cities worldwide
|
|
218
|
+
|
|
219
|
+
## Contributing
|
|
220
|
+
|
|
221
|
+
Found incorrect data? PRs welcome! Each country is a separate JSON file in `registry/countries/`.
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import chalk3 from "chalk";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
|
|
8
|
+
// src/commands/add.ts
|
|
9
|
+
import fs5 from "fs-extra";
|
|
10
|
+
import path4 from "path";
|
|
11
|
+
import { findBestMatch } from "string-similarity";
|
|
12
|
+
|
|
13
|
+
// src/utils/codegen.ts
|
|
14
|
+
import fs3 from "fs-extra";
|
|
15
|
+
import path2 from "path";
|
|
16
|
+
|
|
17
|
+
// src/utils/helpers.ts
|
|
18
|
+
import fs2 from "fs-extra";
|
|
19
|
+
|
|
20
|
+
// src/utils/ui.ts
|
|
21
|
+
import * as p from "@clack/prompts";
|
|
22
|
+
import chalk from "chalk";
|
|
23
|
+
import figlet from "figlet";
|
|
24
|
+
import gradient from "gradient-string";
|
|
25
|
+
var brandGradient = gradient(["#a855f7", "#6366f1", "#06b6d4"]);
|
|
26
|
+
function banner() {
|
|
27
|
+
console.log();
|
|
28
|
+
console.log(brandGradient(figlet.textSync("geodata", { font: "Big" })));
|
|
29
|
+
console.log();
|
|
30
|
+
}
|
|
31
|
+
function intro2(message) {
|
|
32
|
+
console.log();
|
|
33
|
+
p.intro(chalk.bgHex("#6366f1").white(` ${message || "geo-data"} `));
|
|
34
|
+
}
|
|
35
|
+
function outro2(message) {
|
|
36
|
+
p.outro(chalk.hex("#a855f7")(message));
|
|
37
|
+
}
|
|
38
|
+
function cancel2(message = "Operation cancelled.") {
|
|
39
|
+
p.cancel(message);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/utils/config.ts
|
|
43
|
+
import * as p2 from "@clack/prompts";
|
|
44
|
+
import fs from "fs-extra";
|
|
45
|
+
import path from "path";
|
|
46
|
+
|
|
47
|
+
// src/utils/schemas.ts
|
|
48
|
+
import { z } from "zod";
|
|
49
|
+
var CitySchema = z.object({
|
|
50
|
+
name: z.record(z.string(), z.string()),
|
|
51
|
+
latitude: z.number().optional(),
|
|
52
|
+
longitude: z.number().optional()
|
|
53
|
+
});
|
|
54
|
+
var RegionSchema = z.object({
|
|
55
|
+
code: z.string(),
|
|
56
|
+
name: z.record(z.string(), z.string()),
|
|
57
|
+
cities: z.array(CitySchema)
|
|
58
|
+
});
|
|
59
|
+
var CountryDataSchema = z.object({
|
|
60
|
+
code: z.string(),
|
|
61
|
+
iso3: z.string().optional(),
|
|
62
|
+
name: z.record(z.string(), z.string()),
|
|
63
|
+
phone: z.string(),
|
|
64
|
+
currency: z.string(),
|
|
65
|
+
timezone: z.string(),
|
|
66
|
+
flag: z.string(),
|
|
67
|
+
regions: z.array(RegionSchema)
|
|
68
|
+
});
|
|
69
|
+
var RegistryIndexSchema = z.object({
|
|
70
|
+
version: z.string(),
|
|
71
|
+
countries: z.record(
|
|
72
|
+
z.string(),
|
|
73
|
+
z.object({
|
|
74
|
+
name: z.object({ en: z.string(), ar: z.string().optional() }),
|
|
75
|
+
flag: z.string(),
|
|
76
|
+
languages: z.array(z.string())
|
|
77
|
+
})
|
|
78
|
+
)
|
|
79
|
+
});
|
|
80
|
+
var VALID_LANGUAGES = [
|
|
81
|
+
"en",
|
|
82
|
+
"ar",
|
|
83
|
+
"de",
|
|
84
|
+
"es",
|
|
85
|
+
"fr",
|
|
86
|
+
"hi",
|
|
87
|
+
"it",
|
|
88
|
+
"ja",
|
|
89
|
+
"ko",
|
|
90
|
+
"nl",
|
|
91
|
+
"pl",
|
|
92
|
+
"pt",
|
|
93
|
+
"pt-BR",
|
|
94
|
+
"ru",
|
|
95
|
+
"tr",
|
|
96
|
+
"uk",
|
|
97
|
+
"zh"
|
|
98
|
+
];
|
|
99
|
+
var ConfigSchema = z.object({
|
|
100
|
+
$schema: z.string().optional(),
|
|
101
|
+
outputDir: z.string().min(1, "outputDir must be a non-empty string"),
|
|
102
|
+
languages: z.array(z.string()).min(1, "languages must be a non-empty array"),
|
|
103
|
+
includeCoordinates: z.boolean(),
|
|
104
|
+
typescript: z.boolean()
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// src/utils/config.ts
|
|
108
|
+
var CONFIG_FILE = "geo-data.json";
|
|
109
|
+
function validateConfig(config) {
|
|
110
|
+
const result = ConfigSchema.safeParse(config);
|
|
111
|
+
if (!result.success) {
|
|
112
|
+
throw new Error(result.error.issues[0]?.message ?? "Invalid config");
|
|
113
|
+
}
|
|
114
|
+
for (const lang of result.data.languages) {
|
|
115
|
+
if (!VALID_LANGUAGES.includes(lang)) {
|
|
116
|
+
p2.log.warn(`Unknown language code "${lang}"`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return result.data;
|
|
120
|
+
}
|
|
121
|
+
async function getConfigResult() {
|
|
122
|
+
const configPath = path.resolve(process.cwd(), CONFIG_FILE);
|
|
123
|
+
if (!await fs.pathExists(configPath)) {
|
|
124
|
+
return { status: "not_found" };
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const raw = await fs.readJson(configPath);
|
|
128
|
+
const config = validateConfig(raw);
|
|
129
|
+
return { status: "ok", config };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { status: "invalid", error: errorMessage(error) };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function getConfig() {
|
|
135
|
+
const result = await getConfigResult();
|
|
136
|
+
if (result.status === "ok") return result.config;
|
|
137
|
+
if (result.status === "invalid") {
|
|
138
|
+
p2.log.error(`Error reading ${CONFIG_FILE}: ${result.error}`);
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/utils/helpers.ts
|
|
144
|
+
function errorMessage(error) {
|
|
145
|
+
return error instanceof Error ? error.message : String(error);
|
|
146
|
+
}
|
|
147
|
+
async function requireConfig(commandName) {
|
|
148
|
+
const result = await getConfigResult();
|
|
149
|
+
if (result.status === "ok") return result.config;
|
|
150
|
+
intro2(commandName);
|
|
151
|
+
if (result.status === "not_found") {
|
|
152
|
+
p.log.error("No geo-data.json found. Run 'npx geo-data init' first.");
|
|
153
|
+
} else {
|
|
154
|
+
p.log.error(`Invalid geo-data.json: ${result.error}`);
|
|
155
|
+
}
|
|
156
|
+
console.log();
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
async function getInstalledCountries(outputDir) {
|
|
160
|
+
const files = await fs2.readdir(outputDir).catch(() => []);
|
|
161
|
+
return files.filter((f) => f.endsWith(".json") && f !== "index.json").map((f) => f.replace(".json", ""));
|
|
162
|
+
}
|
|
163
|
+
function formatCountryDisplay(code, info) {
|
|
164
|
+
if (info) {
|
|
165
|
+
return `${info.flag} ${code.toUpperCase()} - ${info.name.en}`;
|
|
166
|
+
}
|
|
167
|
+
return code.toUpperCase();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/utils/codegen.ts
|
|
171
|
+
async function generateIndex(config) {
|
|
172
|
+
const codes = await getInstalledCountries(config.outputDir);
|
|
173
|
+
const validCodePattern = /^[a-z]{2}$/;
|
|
174
|
+
const safeCodes = codes.filter((code) => {
|
|
175
|
+
if (!validCodePattern.test(code)) {
|
|
176
|
+
console.warn(`Skipping invalid country code: ${code}`);
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
return true;
|
|
180
|
+
});
|
|
181
|
+
const ext = config.typescript ? "ts" : "js";
|
|
182
|
+
const indexPath = path2.join(config.outputDir, `index.${ext}`);
|
|
183
|
+
if (safeCodes.length === 0) {
|
|
184
|
+
const emptyContent = config.typescript ? `// Auto-generated by geo-data - no countries installed yet
|
|
185
|
+
// Run: npx geo-data add <country>
|
|
186
|
+
|
|
187
|
+
export const countries = {} as const;
|
|
188
|
+
export type CountryCode = never;
|
|
189
|
+
` : `// Auto-generated by geo-data - no countries installed yet
|
|
190
|
+
// Run: npx geo-data add <country>
|
|
191
|
+
|
|
192
|
+
export const countries = {};
|
|
193
|
+
`;
|
|
194
|
+
await fs3.writeFile(indexPath, emptyContent);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
safeCodes.sort();
|
|
198
|
+
const imports = safeCodes.map((code) => `import ${code}Data from './${code}.json' with { type: "json" };`).join("\n");
|
|
199
|
+
const countriesObj = safeCodes.map((code) => ` ${code.toUpperCase()}: ${code}Data,`).join("\n");
|
|
200
|
+
const types = config.typescript ? `
|
|
201
|
+
export type CountryCode = keyof typeof countries;
|
|
202
|
+
export type Country = (typeof countries)[CountryCode];
|
|
203
|
+
export type Region = Country['regions'][number];
|
|
204
|
+
export type City = Region['cities'][number];
|
|
205
|
+
export type LocalizedName = { name: { [key: string]: string | undefined; en: string } };
|
|
206
|
+
` : "";
|
|
207
|
+
const content = `// Auto-generated by geo-data - do not edit manually
|
|
208
|
+
// Regenerate with: npx geo-data update
|
|
209
|
+
${imports}
|
|
210
|
+
|
|
211
|
+
export const countries = {
|
|
212
|
+
${countriesObj}
|
|
213
|
+
} as const;
|
|
214
|
+
${types}
|
|
215
|
+
export function getCountry${config.typescript ? "(code: CountryCode)" : "(code)"} {
|
|
216
|
+
return countries[code];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function getRegions${config.typescript ? "(code: CountryCode)" : "(code)"} {
|
|
220
|
+
return countries[code].regions;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function getCities${config.typescript ? "(code: CountryCode, regionCode?: string)" : "(code, regionCode)"} {
|
|
224
|
+
const regions = getRegions(code);
|
|
225
|
+
if (regionCode) {
|
|
226
|
+
const region = regions.find(r => r.code === regionCode);
|
|
227
|
+
return region?.cities ?? [];
|
|
228
|
+
}
|
|
229
|
+
return regions.flatMap(r => r.cities);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function getAllCities${config.typescript ? "(code: CountryCode)" : "(code)"} {
|
|
233
|
+
return getRegions(code).flatMap(r => r.cities);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function getLocalizedName${config.typescript ? '(item: { name: Record<string, string | undefined> & { en: string } }, lang: string = "en"): string' : '(item, lang = "en")'} {
|
|
237
|
+
return item.name[lang] ?? item.name.en;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function getCountryCodes()${config.typescript ? ": CountryCode[]" : ""} {
|
|
241
|
+
return Object.keys(countries)${config.typescript ? " as CountryCode[]" : ""};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function isValidCountryCode${config.typescript ? "(code: string): code is CountryCode" : "(code)"} {
|
|
245
|
+
return code in countries;
|
|
246
|
+
}
|
|
247
|
+
`;
|
|
248
|
+
await fs3.writeFile(indexPath, content.trim() + "\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// src/utils/registry.ts
|
|
252
|
+
import * as p3 from "@clack/prompts";
|
|
253
|
+
import fs4 from "fs-extra";
|
|
254
|
+
import os from "os";
|
|
255
|
+
import path3 from "path";
|
|
256
|
+
var REGISTRY_BASE = process.env.GEO_DATA_REGISTRY || "https://cdn.jsdelivr.net/gh/H4ck3r-x0/geo-data@main/registry";
|
|
257
|
+
var CACHE_DIR = path3.join(os.homedir(), ".cache", "geo-data");
|
|
258
|
+
function isRemoteRegistry(url) {
|
|
259
|
+
return url.startsWith("http://") || url.startsWith("https://");
|
|
260
|
+
}
|
|
261
|
+
async function readCache(cacheKey, maxAgeMs) {
|
|
262
|
+
const cachePath = path3.join(CACHE_DIR, `${cacheKey}.json`);
|
|
263
|
+
if (await fs4.pathExists(cachePath)) {
|
|
264
|
+
const stat = await fs4.stat(cachePath);
|
|
265
|
+
if (Date.now() - stat.mtimeMs < maxAgeMs) {
|
|
266
|
+
return fs4.readJson(cachePath);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
async function writeCache(cacheKey, data) {
|
|
272
|
+
await fs4.ensureDir(CACHE_DIR);
|
|
273
|
+
await fs4.writeJson(path3.join(CACHE_DIR, `${cacheKey}.json`), data);
|
|
274
|
+
}
|
|
275
|
+
async function readStaleCache(cacheKey) {
|
|
276
|
+
const cachePath = path3.join(CACHE_DIR, `${cacheKey}.json`);
|
|
277
|
+
if (await fs4.pathExists(cachePath)) {
|
|
278
|
+
return fs4.readJson(cachePath);
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
async function fetchJson(urlOrPath) {
|
|
283
|
+
if (path3.isAbsolute(urlOrPath) || urlOrPath.startsWith(".")) {
|
|
284
|
+
return fs4.readJson(urlOrPath);
|
|
285
|
+
}
|
|
286
|
+
const controller = new AbortController();
|
|
287
|
+
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
288
|
+
try {
|
|
289
|
+
const response = await fetch(urlOrPath, { signal: controller.signal });
|
|
290
|
+
if (!response.ok) {
|
|
291
|
+
throw new Error(`Failed to fetch ${urlOrPath}: ${response.status}`);
|
|
292
|
+
}
|
|
293
|
+
return response.json();
|
|
294
|
+
} finally {
|
|
295
|
+
clearTimeout(timeout);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function fetchRegistry() {
|
|
299
|
+
const url = isRemoteRegistry(REGISTRY_BASE) ? `${REGISTRY_BASE}/index.json` : path3.join(REGISTRY_BASE, "index.json");
|
|
300
|
+
let data;
|
|
301
|
+
if (isRemoteRegistry(REGISTRY_BASE)) {
|
|
302
|
+
const cached = await readCache("registry-index", 60 * 60 * 1e3);
|
|
303
|
+
if (cached) {
|
|
304
|
+
data = cached;
|
|
305
|
+
} else {
|
|
306
|
+
try {
|
|
307
|
+
data = await fetchJson(url);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
const stale = await readStaleCache("registry-index");
|
|
310
|
+
if (stale) {
|
|
311
|
+
p3.log.warn("Network unavailable, using cached data");
|
|
312
|
+
data = stale;
|
|
313
|
+
} else {
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
data = await fetchJson(url);
|
|
320
|
+
}
|
|
321
|
+
const result = RegistryIndexSchema.safeParse(data);
|
|
322
|
+
if (!result.success) {
|
|
323
|
+
p3.log.warn(`Registry index failed validation: ${result.error.issues[0]?.message}`);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
if (isRemoteRegistry(REGISTRY_BASE)) {
|
|
327
|
+
await writeCache("registry-index", result.data);
|
|
328
|
+
}
|
|
329
|
+
return result.data;
|
|
330
|
+
}
|
|
331
|
+
async function fetchCountry(code, config) {
|
|
332
|
+
const url = isRemoteRegistry(REGISTRY_BASE) ? `${REGISTRY_BASE}/countries/${code}.json` : path3.join(REGISTRY_BASE, "countries", `${code}.json`);
|
|
333
|
+
let raw;
|
|
334
|
+
if (isRemoteRegistry(REGISTRY_BASE)) {
|
|
335
|
+
const cached = await readCache(`country-${code}`, 24 * 60 * 60 * 1e3);
|
|
336
|
+
if (cached) {
|
|
337
|
+
raw = cached;
|
|
338
|
+
} else {
|
|
339
|
+
try {
|
|
340
|
+
raw = await fetchJson(url);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
const stale = await readStaleCache(`country-${code}`);
|
|
343
|
+
if (stale) {
|
|
344
|
+
p3.log.warn("Network unavailable, using cached data");
|
|
345
|
+
raw = stale;
|
|
346
|
+
} else {
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
raw = await fetchJson(url);
|
|
353
|
+
}
|
|
354
|
+
const result = CountryDataSchema.safeParse(raw);
|
|
355
|
+
if (!result.success) {
|
|
356
|
+
p3.log.warn(`Country data for "${code}" failed validation: ${result.error.issues[0]?.message}`);
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
if (isRemoteRegistry(REGISTRY_BASE)) {
|
|
360
|
+
await writeCache(`country-${code}`, result.data);
|
|
361
|
+
}
|
|
362
|
+
return filterCountryData(result.data, config);
|
|
363
|
+
}
|
|
364
|
+
function filterCountryData(data, config) {
|
|
365
|
+
const filterName = (name) => {
|
|
366
|
+
const filtered = {};
|
|
367
|
+
for (const lang of config.languages) {
|
|
368
|
+
if (name[lang]) {
|
|
369
|
+
filtered[lang] = name[lang];
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (!filtered.en && name.en) {
|
|
373
|
+
filtered.en = name.en;
|
|
374
|
+
}
|
|
375
|
+
return filtered;
|
|
376
|
+
};
|
|
377
|
+
return {
|
|
378
|
+
...data,
|
|
379
|
+
name: filterName(data.name),
|
|
380
|
+
regions: data.regions.map((region) => ({
|
|
381
|
+
...region,
|
|
382
|
+
name: filterName(region.name),
|
|
383
|
+
cities: region.cities.map((city) => {
|
|
384
|
+
const filteredCity = {
|
|
385
|
+
name: filterName(city.name)
|
|
386
|
+
};
|
|
387
|
+
if (config.includeCoordinates && city.latitude !== void 0 && city.longitude !== void 0) {
|
|
388
|
+
filteredCity.latitude = city.latitude;
|
|
389
|
+
filteredCity.longitude = city.longitude;
|
|
390
|
+
}
|
|
391
|
+
return filteredCity;
|
|
392
|
+
})
|
|
393
|
+
}))
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
async function clearCache() {
|
|
397
|
+
await fs4.remove(CACHE_DIR);
|
|
398
|
+
}
|
|
399
|
+
async function getCacheInfo() {
|
|
400
|
+
if (!await fs4.pathExists(CACHE_DIR)) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
const files = await fs4.readdir(CACHE_DIR);
|
|
404
|
+
let size = 0;
|
|
405
|
+
for (const file of files) {
|
|
406
|
+
const stat = await fs4.stat(path3.join(CACHE_DIR, file));
|
|
407
|
+
size += stat.size;
|
|
408
|
+
}
|
|
409
|
+
return { size, files: files.length };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/commands/add.ts
|
|
413
|
+
async function add(countryCodes, options = {}) {
|
|
414
|
+
const config = await requireConfig("Add Countries");
|
|
415
|
+
if (!config) {
|
|
416
|
+
process.exitCode = 1;
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
intro2("Add Countries");
|
|
420
|
+
const codes = countryCodes.map((c) => c.toLowerCase());
|
|
421
|
+
const s = p.spinner();
|
|
422
|
+
s.start("Fetching registry");
|
|
423
|
+
try {
|
|
424
|
+
const registry = await fetchRegistry();
|
|
425
|
+
if (!registry) {
|
|
426
|
+
s.stop("Failed");
|
|
427
|
+
p.log.error("Could not load registry \u2014 invalid data from remote");
|
|
428
|
+
console.log();
|
|
429
|
+
process.exitCode = 1;
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const invalid = codes.filter((c) => !registry.countries[c]);
|
|
433
|
+
if (invalid.length > 0) {
|
|
434
|
+
s.stop("Registry loaded");
|
|
435
|
+
const allCodes = Object.keys(registry.countries);
|
|
436
|
+
for (const code of invalid) {
|
|
437
|
+
p.log.error(`Unknown country code: ${code.toUpperCase()}`);
|
|
438
|
+
const match = findBestMatch(code.toLowerCase(), allCodes);
|
|
439
|
+
if (match.bestMatch.rating > 0.3) {
|
|
440
|
+
const suggested = match.bestMatch.target;
|
|
441
|
+
const info = registry.countries[suggested];
|
|
442
|
+
p.log.info(`Did you mean ${info.flag} ${suggested.toUpperCase()} (${info.name.en})?`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
p.log.message("Run: npx geo-data list");
|
|
446
|
+
console.log();
|
|
447
|
+
process.exitCode = 1;
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const existing = [];
|
|
451
|
+
for (const code of codes) {
|
|
452
|
+
const outputPath = path4.join(config.outputDir, `${code}.json`);
|
|
453
|
+
if (await fs5.pathExists(outputPath)) {
|
|
454
|
+
existing.push(code);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
s.stop("Registry loaded");
|
|
458
|
+
if (existing.length > 0 && !options.force && !options.dryRun) {
|
|
459
|
+
const existingList = existing.map((code) => formatCountryDisplay(code, registry.countries[code])).join("\n");
|
|
460
|
+
p.note(existingList, "Already installed");
|
|
461
|
+
const overwrite = await p.confirm({
|
|
462
|
+
message: "Overwrite existing countries?",
|
|
463
|
+
initialValue: false
|
|
464
|
+
});
|
|
465
|
+
if (p.isCancel(overwrite) || !overwrite) {
|
|
466
|
+
const newCodes = codes.filter((c) => !existing.includes(c));
|
|
467
|
+
if (newCodes.length === 0) {
|
|
468
|
+
cancel2("Nothing to add");
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
codes.length = 0;
|
|
472
|
+
codes.push(...newCodes);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (options.dryRun) {
|
|
476
|
+
const list2 = codes.map((code) => formatCountryDisplay(code, registry.countries[code])).join("\n");
|
|
477
|
+
p.note(list2, "Would add (dry run)");
|
|
478
|
+
p.log.info(`Target: ${config.outputDir}/`);
|
|
479
|
+
console.log();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const added = [];
|
|
483
|
+
const failed = [];
|
|
484
|
+
for (const code of codes) {
|
|
485
|
+
const info = registry.countries[code];
|
|
486
|
+
s.start(`${info.flag} ${info.name.en}`);
|
|
487
|
+
try {
|
|
488
|
+
const countryData = await fetchCountry(code, config);
|
|
489
|
+
if (!countryData) {
|
|
490
|
+
s.stop(`${info.flag} ${info.name.en} - failed: invalid data`);
|
|
491
|
+
failed.push(code);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const outputPath = path4.join(config.outputDir, `${code}.json`);
|
|
495
|
+
await fs5.ensureDir(config.outputDir);
|
|
496
|
+
await fs5.writeJson(outputPath, countryData, { spaces: 2 });
|
|
497
|
+
const size = (JSON.stringify(countryData).length / 1024).toFixed(1);
|
|
498
|
+
s.stop(`${info.flag} ${info.name.en} (${size} KB)`);
|
|
499
|
+
added.push(code);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
s.stop(`${info.flag} ${info.name.en} - failed: ${errorMessage(error)}`);
|
|
502
|
+
failed.push(code);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (added.length > 0) {
|
|
506
|
+
const ext = config.typescript ? "ts" : "js";
|
|
507
|
+
s.start(`Generating index.${ext}`);
|
|
508
|
+
await generateIndex(config);
|
|
509
|
+
s.stop(`Generated index.${ext}`);
|
|
510
|
+
}
|
|
511
|
+
if (failed.length > 0) {
|
|
512
|
+
process.exitCode = 1;
|
|
513
|
+
p.log.warn(`Failed: ${failed.map((c) => c.toUpperCase()).join(", ")}`);
|
|
514
|
+
}
|
|
515
|
+
if (added.length > 0) {
|
|
516
|
+
p.note(`import { getCities } from '${config.outputDir}';`, "Usage");
|
|
517
|
+
outro2(`Added ${added.length} ${added.length === 1 ? "country" : "countries"}`);
|
|
518
|
+
}
|
|
519
|
+
} catch (error) {
|
|
520
|
+
s.stop("Failed");
|
|
521
|
+
process.exitCode = 1;
|
|
522
|
+
p.log.error(errorMessage(error));
|
|
523
|
+
console.log();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/commands/cache.ts
|
|
528
|
+
async function cache(action = "info") {
|
|
529
|
+
if (action !== "info" && action !== "clear") {
|
|
530
|
+
intro2("Cache");
|
|
531
|
+
p.log.error(`Unknown action: "${action}". Use "info" or "clear".`);
|
|
532
|
+
console.log();
|
|
533
|
+
process.exitCode = 1;
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
intro2("Cache");
|
|
537
|
+
if (action === "clear") {
|
|
538
|
+
const s = p.spinner();
|
|
539
|
+
s.start("Clearing cache");
|
|
540
|
+
try {
|
|
541
|
+
await clearCache();
|
|
542
|
+
s.stop("Cache cleared");
|
|
543
|
+
outro2("Done");
|
|
544
|
+
} catch (error) {
|
|
545
|
+
s.stop("Failed to clear cache");
|
|
546
|
+
process.exitCode = 1;
|
|
547
|
+
p.log.error(errorMessage(error));
|
|
548
|
+
console.log();
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const info = await getCacheInfo();
|
|
553
|
+
if (!info) {
|
|
554
|
+
p.log.info("No cached data");
|
|
555
|
+
console.log();
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const sizeKB = (info.size / 1024).toFixed(1);
|
|
559
|
+
const sizeMB = (info.size / 1024 / 1024).toFixed(2);
|
|
560
|
+
const sizeDisplay = info.size > 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`;
|
|
561
|
+
p.note(`Files: ${info.files}
|
|
562
|
+
Size: ${sizeDisplay}
|
|
563
|
+
Location: ~/.cache/geo-data/`, "Cache info");
|
|
564
|
+
p.log.message("Run: npx geo-data cache clear");
|
|
565
|
+
console.log();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/commands/init.ts
|
|
569
|
+
import fs6 from "fs-extra";
|
|
570
|
+
import path5 from "path";
|
|
571
|
+
async function detectDefaults() {
|
|
572
|
+
const cwd = process.cwd();
|
|
573
|
+
const hasTsconfig = await fs6.pathExists(path5.resolve(cwd, "tsconfig.json"));
|
|
574
|
+
const hasSrc = await fs6.pathExists(path5.resolve(cwd, "src"));
|
|
575
|
+
return {
|
|
576
|
+
outputDir: hasSrc ? "./src/data/geo" : "./data/geo",
|
|
577
|
+
typescript: hasTsconfig,
|
|
578
|
+
languages: ["en"],
|
|
579
|
+
includeCoordinates: false,
|
|
580
|
+
hints: {
|
|
581
|
+
outputDir: hasSrc ? "(src/ detected)" : "",
|
|
582
|
+
typescript: hasTsconfig ? "(tsconfig.json found)" : ""
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
async function init() {
|
|
587
|
+
banner();
|
|
588
|
+
intro2("Initialize");
|
|
589
|
+
const existingConfig = await getConfig();
|
|
590
|
+
if (existingConfig) {
|
|
591
|
+
const overwrite = await p.confirm({
|
|
592
|
+
message: `${CONFIG_FILE} already exists. Overwrite?`,
|
|
593
|
+
initialValue: false
|
|
594
|
+
});
|
|
595
|
+
if (p.isCancel(overwrite) || !overwrite) {
|
|
596
|
+
cancel2();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const defaults = await detectDefaults();
|
|
601
|
+
p.note(
|
|
602
|
+
[
|
|
603
|
+
`Output: ${defaults.outputDir} ${defaults.hints.outputDir}`,
|
|
604
|
+
`TypeScript: ${defaults.typescript ? "yes" : "no"} ${defaults.hints.typescript}`,
|
|
605
|
+
`Languages: ${defaults.languages.join(", ")}`,
|
|
606
|
+
`Coordinates: no`
|
|
607
|
+
].join("\n"),
|
|
608
|
+
"Detected settings"
|
|
609
|
+
);
|
|
610
|
+
const confirm = await p.confirm({
|
|
611
|
+
message: "Look good?",
|
|
612
|
+
initialValue: true
|
|
613
|
+
});
|
|
614
|
+
if (p.isCancel(confirm)) {
|
|
615
|
+
cancel2();
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
let { outputDir, languages, includeCoordinates, typescript } = defaults;
|
|
619
|
+
if (!confirm) {
|
|
620
|
+
const customOutputDir = await p.text({
|
|
621
|
+
message: "Where should geo data be stored?",
|
|
622
|
+
placeholder: defaults.outputDir,
|
|
623
|
+
defaultValue: defaults.outputDir
|
|
624
|
+
});
|
|
625
|
+
if (p.isCancel(customOutputDir)) {
|
|
626
|
+
cancel2();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
outputDir = customOutputDir;
|
|
630
|
+
const customLanguages = await p.multiselect({
|
|
631
|
+
message: "Which languages do you need?",
|
|
632
|
+
options: [
|
|
633
|
+
{ value: "en", label: "English", hint: "recommended" },
|
|
634
|
+
{ value: "ar", label: "Arabic (\u0627\u0644\u0639\u0631\u0628\u064A\u0629)" },
|
|
635
|
+
{ value: "fr", label: "French (Fran\xE7ais)" },
|
|
636
|
+
{ value: "es", label: "Spanish (Espa\xF1ol)" },
|
|
637
|
+
{ value: "de", label: "German (Deutsch)" },
|
|
638
|
+
{ value: "zh", label: "Chinese (\u4E2D\u6587)" },
|
|
639
|
+
{ value: "ja", label: "Japanese (\u65E5\u672C\u8A9E)" },
|
|
640
|
+
{ value: "pt", label: "Portuguese (Portugu\xEAs)" },
|
|
641
|
+
{ value: "ru", label: "Russian (\u0420\u0443\u0441\u0441\u043A\u0438\u0439)" },
|
|
642
|
+
{ value: "hi", label: "Hindi (\u0939\u093F\u0928\u094D\u0926\u0940)" },
|
|
643
|
+
{ value: "it", label: "Italian (Italiano)" },
|
|
644
|
+
{ value: "ko", label: "Korean (\uD55C\uAD6D\uC5B4)" },
|
|
645
|
+
{ value: "nl", label: "Dutch (Nederlands)" },
|
|
646
|
+
{ value: "pl", label: "Polish (Polski)" },
|
|
647
|
+
{ value: "pt-BR", label: "Brazilian Portuguese (Portugu\xEAs do Brasil)" },
|
|
648
|
+
{ value: "tr", label: "Turkish (T\xFCrk\xE7e)" },
|
|
649
|
+
{ value: "uk", label: "Ukrainian (\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430)" }
|
|
650
|
+
],
|
|
651
|
+
initialValues: ["en"],
|
|
652
|
+
required: true
|
|
653
|
+
});
|
|
654
|
+
if (p.isCancel(customLanguages)) {
|
|
655
|
+
cancel2();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
languages = customLanguages;
|
|
659
|
+
const customCoordinates = await p.confirm({
|
|
660
|
+
message: "Include coordinates (latitude/longitude)?",
|
|
661
|
+
initialValue: false
|
|
662
|
+
});
|
|
663
|
+
if (p.isCancel(customCoordinates)) {
|
|
664
|
+
cancel2();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
includeCoordinates = customCoordinates;
|
|
668
|
+
const customTypescript = await p.confirm({
|
|
669
|
+
message: "Generate TypeScript types?",
|
|
670
|
+
initialValue: defaults.typescript
|
|
671
|
+
});
|
|
672
|
+
if (p.isCancel(customTypescript)) {
|
|
673
|
+
cancel2();
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
typescript = customTypescript;
|
|
677
|
+
}
|
|
678
|
+
const config = {
|
|
679
|
+
$schema: "https://raw.githubusercontent.com/H4ck3r-x0/geo-data/main/schema.json",
|
|
680
|
+
outputDir,
|
|
681
|
+
languages,
|
|
682
|
+
includeCoordinates,
|
|
683
|
+
typescript
|
|
684
|
+
};
|
|
685
|
+
const s = p.spinner();
|
|
686
|
+
s.start("Creating configuration");
|
|
687
|
+
try {
|
|
688
|
+
await fs6.writeJson(CONFIG_FILE, config, { spaces: 2 });
|
|
689
|
+
await fs6.ensureDir(outputDir);
|
|
690
|
+
} catch (error) {
|
|
691
|
+
s.stop("Failed");
|
|
692
|
+
p.log.error(errorMessage(error));
|
|
693
|
+
console.log();
|
|
694
|
+
process.exitCode = 1;
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
s.stop("Configuration created");
|
|
698
|
+
const firstCountry = await p.text({
|
|
699
|
+
message: "Add your first country? (code or name, Enter to skip)",
|
|
700
|
+
placeholder: "e.g. sa, us, fr",
|
|
701
|
+
defaultValue: ""
|
|
702
|
+
});
|
|
703
|
+
if (!p.isCancel(firstCountry) && firstCountry.trim()) {
|
|
704
|
+
console.log();
|
|
705
|
+
await add([firstCountry.trim()], {});
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
p.note("npx geo-data add sa ae Add countries\nnpx geo-data pick Interactive picker", "Next steps");
|
|
709
|
+
outro2("Ready to go!");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// src/commands/list.ts
|
|
713
|
+
import chalk2 from "chalk";
|
|
714
|
+
async function list(options = {}) {
|
|
715
|
+
const config = await getConfig();
|
|
716
|
+
intro2(options.installed ? "Installed Countries" : "Available Countries");
|
|
717
|
+
const s = p.spinner();
|
|
718
|
+
s.start("Fetching countries");
|
|
719
|
+
try {
|
|
720
|
+
const registry = await fetchRegistry();
|
|
721
|
+
if (!registry) {
|
|
722
|
+
s.stop("Failed");
|
|
723
|
+
p.log.error("Could not load registry \u2014 invalid data from remote");
|
|
724
|
+
console.log();
|
|
725
|
+
process.exitCode = 1;
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
let installedSet = /* @__PURE__ */ new Set();
|
|
729
|
+
if (config) {
|
|
730
|
+
const installed = await getInstalledCountries(config.outputDir);
|
|
731
|
+
installedSet = new Set(installed);
|
|
732
|
+
}
|
|
733
|
+
s.stop("Countries loaded");
|
|
734
|
+
let countries = Object.entries(registry.countries).sort((a, b) => a[1].name.en.localeCompare(b[1].name.en));
|
|
735
|
+
if (options.installed) {
|
|
736
|
+
if (installedSet.size === 0) {
|
|
737
|
+
p.log.warn("No countries installed yet");
|
|
738
|
+
p.log.message("Run: npx geo-data add sa ae qa");
|
|
739
|
+
console.log();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
countries = countries.filter(([code]) => installedSet.has(code));
|
|
743
|
+
}
|
|
744
|
+
console.log();
|
|
745
|
+
let currentLetter = "";
|
|
746
|
+
for (const [code, info] of countries) {
|
|
747
|
+
const firstLetter = info.name.en[0].toUpperCase();
|
|
748
|
+
if (firstLetter !== currentLetter && !options.installed) {
|
|
749
|
+
if (currentLetter !== "") console.log();
|
|
750
|
+
currentLetter = firstLetter;
|
|
751
|
+
}
|
|
752
|
+
const installed = installedSet.has(code);
|
|
753
|
+
const marker = installed ? chalk2.green("\u25CF") : chalk2.dim("\u25CB");
|
|
754
|
+
const codeStyled = installed ? chalk2.green.bold(code.toUpperCase()) : chalk2.white(code.toUpperCase());
|
|
755
|
+
console.log(` ${marker} ${info.flag} ${codeStyled.padEnd(installed ? 14 : 6)} ${chalk2.dim(info.name.en)}`);
|
|
756
|
+
}
|
|
757
|
+
console.log();
|
|
758
|
+
if (options.installed) {
|
|
759
|
+
outro2(`${installedSet.size} countries installed`);
|
|
760
|
+
} else {
|
|
761
|
+
if (installedSet.size > 0) {
|
|
762
|
+
p.log.info(`${installedSet.size} installed ${chalk2.green("\u25CF")}`);
|
|
763
|
+
}
|
|
764
|
+
outro2(`${countries.length} countries available`);
|
|
765
|
+
}
|
|
766
|
+
} catch (error) {
|
|
767
|
+
s.stop("Failed to fetch countries");
|
|
768
|
+
process.exitCode = 1;
|
|
769
|
+
p.log.error(errorMessage(error));
|
|
770
|
+
console.log();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/commands/pick.ts
|
|
775
|
+
import prompts from "prompts";
|
|
776
|
+
async function pick() {
|
|
777
|
+
banner();
|
|
778
|
+
const config = await requireConfig("Pick Countries");
|
|
779
|
+
if (!config) {
|
|
780
|
+
process.exitCode = 1;
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
intro2("Pick Countries");
|
|
784
|
+
const s = p.spinner();
|
|
785
|
+
s.start("Loading countries");
|
|
786
|
+
try {
|
|
787
|
+
const registry = await fetchRegistry();
|
|
788
|
+
if (!registry) {
|
|
789
|
+
s.stop("Failed");
|
|
790
|
+
p.log.error("Could not load registry \u2014 invalid data from remote");
|
|
791
|
+
console.log();
|
|
792
|
+
process.exitCode = 1;
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const installed = await getInstalledCountries(config.outputDir);
|
|
796
|
+
const installedSet = new Set(installed);
|
|
797
|
+
s.stop("Countries loaded");
|
|
798
|
+
const countries = Object.entries(registry.countries).map(([code, info]) => ({
|
|
799
|
+
code,
|
|
800
|
+
...info,
|
|
801
|
+
installed: installedSet.has(code)
|
|
802
|
+
})).sort((a, b) => a.name.en.localeCompare(b.name.en));
|
|
803
|
+
p.log.message("Type to search, Space to select, Enter to confirm");
|
|
804
|
+
console.log();
|
|
805
|
+
const { selected } = await prompts({
|
|
806
|
+
type: "autocompleteMultiselect",
|
|
807
|
+
name: "selected",
|
|
808
|
+
message: "Select countries",
|
|
809
|
+
choices: countries.map((c) => ({
|
|
810
|
+
title: `${c.flag} ${c.code.toUpperCase()} - ${c.name.en}`,
|
|
811
|
+
value: c.code,
|
|
812
|
+
selected: c.installed,
|
|
813
|
+
disabled: c.installed ? "(installed)" : false
|
|
814
|
+
})),
|
|
815
|
+
instructions: false
|
|
816
|
+
});
|
|
817
|
+
if (!selected || selected.length === 0) {
|
|
818
|
+
console.log();
|
|
819
|
+
cancel2("No countries selected");
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const toAdd = selected.filter((code) => !installedSet.has(code));
|
|
823
|
+
if (toAdd.length === 0) {
|
|
824
|
+
console.log();
|
|
825
|
+
cancel2("All selected countries are already installed");
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
console.log();
|
|
829
|
+
await add(toAdd, {});
|
|
830
|
+
} catch (error) {
|
|
831
|
+
s.stop("Failed");
|
|
832
|
+
process.exitCode = 1;
|
|
833
|
+
p.log.error(errorMessage(error));
|
|
834
|
+
console.log();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// src/commands/remove.ts
|
|
839
|
+
import fs7 from "fs-extra";
|
|
840
|
+
import path6 from "path";
|
|
841
|
+
async function remove(countryCodes, options = {}) {
|
|
842
|
+
const config = await requireConfig("Remove Countries");
|
|
843
|
+
if (!config) {
|
|
844
|
+
process.exitCode = 1;
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
intro2("Remove Countries");
|
|
848
|
+
const codes = countryCodes.map((c) => c.toLowerCase());
|
|
849
|
+
try {
|
|
850
|
+
const toRemove = [];
|
|
851
|
+
const notFound = [];
|
|
852
|
+
for (const code of codes) {
|
|
853
|
+
const filePath = path6.join(config.outputDir, `${code}.json`);
|
|
854
|
+
if (await fs7.pathExists(filePath)) {
|
|
855
|
+
toRemove.push(code);
|
|
856
|
+
} else {
|
|
857
|
+
notFound.push(code);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (notFound.length > 0) {
|
|
861
|
+
for (const code of notFound) {
|
|
862
|
+
p.log.warn(`Not installed: ${code.toUpperCase()}`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (toRemove.length === 0) {
|
|
866
|
+
process.exitCode = 1;
|
|
867
|
+
cancel2("Nothing to remove");
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const registry = await fetchRegistry();
|
|
871
|
+
if (!registry) {
|
|
872
|
+
p.log.error("Could not load registry \u2014 invalid data from remote");
|
|
873
|
+
console.log();
|
|
874
|
+
process.exitCode = 1;
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const list2 = toRemove.map((code) => formatCountryDisplay(code, registry.countries[code])).join("\n");
|
|
878
|
+
if (options.dryRun) {
|
|
879
|
+
p.note(list2, "Would remove (dry run)");
|
|
880
|
+
console.log();
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
p.note(list2, "Will remove");
|
|
884
|
+
if (!options.force) {
|
|
885
|
+
const confirm = await p.confirm({
|
|
886
|
+
message: `Remove ${toRemove.length} ${toRemove.length === 1 ? "country" : "countries"}?`,
|
|
887
|
+
initialValue: false
|
|
888
|
+
});
|
|
889
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
890
|
+
cancel2();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const s = p.spinner();
|
|
895
|
+
for (const code of toRemove) {
|
|
896
|
+
const info = registry.countries[code];
|
|
897
|
+
const label = `${info?.flag || "\u{1F3F3}\uFE0F"} ${info?.name.en || code}`;
|
|
898
|
+
s.start(label);
|
|
899
|
+
const filePath = path6.join(config.outputDir, `${code}.json`);
|
|
900
|
+
await fs7.remove(filePath);
|
|
901
|
+
s.stop(label);
|
|
902
|
+
}
|
|
903
|
+
const ext = config.typescript ? "ts" : "js";
|
|
904
|
+
s.start(`Regenerating index.${ext}`);
|
|
905
|
+
await generateIndex(config);
|
|
906
|
+
s.stop(`Regenerated index.${ext}`);
|
|
907
|
+
outro2(`Removed ${toRemove.length} ${toRemove.length === 1 ? "country" : "countries"}`);
|
|
908
|
+
} catch (error) {
|
|
909
|
+
process.exitCode = 1;
|
|
910
|
+
p.log.error(errorMessage(error));
|
|
911
|
+
console.log();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// src/commands/update.ts
|
|
916
|
+
import fs8 from "fs-extra";
|
|
917
|
+
import path7 from "path";
|
|
918
|
+
async function update(options = {}) {
|
|
919
|
+
const config = await requireConfig("Update Countries");
|
|
920
|
+
if (!config) {
|
|
921
|
+
process.exitCode = 1;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
intro2("Update Countries");
|
|
925
|
+
const s = p.spinner();
|
|
926
|
+
s.start("Checking installed countries");
|
|
927
|
+
try {
|
|
928
|
+
const installed = await getInstalledCountries(config.outputDir);
|
|
929
|
+
if (installed.length === 0) {
|
|
930
|
+
s.stop("No countries found");
|
|
931
|
+
p.log.warn("No countries installed");
|
|
932
|
+
p.log.message("Run: npx geo-data add sa ae");
|
|
933
|
+
console.log();
|
|
934
|
+
process.exitCode = 1;
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const registry = await fetchRegistry();
|
|
938
|
+
if (!registry) {
|
|
939
|
+
s.stop("Failed");
|
|
940
|
+
p.log.error("Could not load registry \u2014 invalid data from remote");
|
|
941
|
+
console.log();
|
|
942
|
+
process.exitCode = 1;
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
s.stop(`Found ${installed.length} countries`);
|
|
946
|
+
if (options.dryRun) {
|
|
947
|
+
const list2 = installed.sort().map((code) => formatCountryDisplay(code, registry.countries[code])).join("\n");
|
|
948
|
+
p.note(list2, "Would update (dry run)");
|
|
949
|
+
console.log();
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
let updated = 0;
|
|
953
|
+
for (const code of installed.sort()) {
|
|
954
|
+
const info = registry.countries[code];
|
|
955
|
+
if (!info) continue;
|
|
956
|
+
s.start(`${info.flag} ${info.name.en}`);
|
|
957
|
+
try {
|
|
958
|
+
const countryData = await fetchCountry(code, config);
|
|
959
|
+
if (!countryData) {
|
|
960
|
+
s.stop(`${info.flag} ${info.name.en} - failed: invalid data`);
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
const outputPath = path7.join(config.outputDir, `${code}.json`);
|
|
964
|
+
await fs8.writeJson(outputPath, countryData, { spaces: 2 });
|
|
965
|
+
const size = (JSON.stringify(countryData).length / 1024).toFixed(1);
|
|
966
|
+
s.stop(`${info.flag} ${info.name.en} (${size} KB)`);
|
|
967
|
+
updated++;
|
|
968
|
+
} catch (error) {
|
|
969
|
+
s.stop(`${info.flag} ${info.name.en} - failed: ${errorMessage(error)}`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const ext = config.typescript ? "ts" : "js";
|
|
973
|
+
s.start(`Regenerating index.${ext}`);
|
|
974
|
+
await generateIndex(config);
|
|
975
|
+
s.stop(`Regenerated index.${ext}`);
|
|
976
|
+
outro2(`Updated ${updated} ${updated === 1 ? "country" : "countries"}`);
|
|
977
|
+
} catch (error) {
|
|
978
|
+
s.stop("Failed");
|
|
979
|
+
process.exitCode = 1;
|
|
980
|
+
p.log.error(errorMessage(error));
|
|
981
|
+
console.log();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/index.ts
|
|
986
|
+
var require2 = createRequire(import.meta.url);
|
|
987
|
+
var { version } = require2("../package.json");
|
|
988
|
+
var program = new Command();
|
|
989
|
+
program.configureOutput({
|
|
990
|
+
outputError: (str, write) => write(chalk3.red(str))
|
|
991
|
+
});
|
|
992
|
+
program.name("geo-data").description("Copy only the countries you need. No bloat.").version(version);
|
|
993
|
+
program.command("init").description("Initialize geo-data in your project").action(init);
|
|
994
|
+
program.command("add").description("Add countries to your project").argument("[countries...]", "Country codes (e.g., sa ae fr)").option("-f, --force", "Overwrite existing without asking").option("-n, --dry-run", "Preview changes without writing").action((countries, options) => {
|
|
995
|
+
if (!countries || countries.length === 0) {
|
|
996
|
+
pick();
|
|
997
|
+
} else {
|
|
998
|
+
add(countries, options);
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
program.command("list").alias("ls").description("List available countries").option("-i, --installed", "Show only installed countries").action((options) => list(options));
|
|
1002
|
+
program.command("update").description("Re-download installed countries").option("-f, --force", "Update without confirmation").option("-n, --dry-run", "Preview what would be updated").action((options) => update(options));
|
|
1003
|
+
program.command("remove").alias("rm").description("Remove countries from your project").argument("<countries...>", "Country codes to remove").option("-f, --force", "Remove without confirmation").option("-n, --dry-run", "Preview what would be removed").action((countries, options) => remove(countries, options));
|
|
1004
|
+
program.command("cache").description("Manage offline cache").argument("[action]", "info (default) or clear", "info").action((action) => cache(action));
|
|
1005
|
+
program.command("pick").alias("select").description("Interactive country picker").action(() => pick());
|
|
1006
|
+
program.showHelpAfterError(chalk3.dim("(use --help for available commands)"));
|
|
1007
|
+
program.showSuggestionAfterError(true);
|
|
1008
|
+
program.addHelpText(
|
|
1009
|
+
"after",
|
|
1010
|
+
`
|
|
1011
|
+
${chalk3.bold("Examples:")}
|
|
1012
|
+
${chalk3.dim("$")} geo-data init
|
|
1013
|
+
${chalk3.dim("$")} geo-data pick
|
|
1014
|
+
${chalk3.dim("$")} geo-data add sa ae
|
|
1015
|
+
${chalk3.dim("$")} geo-data list -i
|
|
1016
|
+
`
|
|
1017
|
+
);
|
|
1018
|
+
program.parseAsync().catch((error) => {
|
|
1019
|
+
console.error(chalk3.red(error instanceof Error ? error.message : "An unexpected error occurred"));
|
|
1020
|
+
process.exitCode = 1;
|
|
1021
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "geo-data-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Copy only the countries you need. No bloat.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"LICENSE"
|
|
9
|
+
],
|
|
10
|
+
"bin": {
|
|
11
|
+
"geo-data": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup src/index.ts --format esm",
|
|
15
|
+
"dev": "tsup src/index.ts --format esm --watch",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"prepublishOnly": "npm run build",
|
|
18
|
+
"lint": "biome lint .",
|
|
19
|
+
"format": "biome format --write .",
|
|
20
|
+
"check": "biome check .",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest",
|
|
23
|
+
"test:coverage": "vitest run --coverage"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"geo",
|
|
27
|
+
"countries",
|
|
28
|
+
"cities",
|
|
29
|
+
"i18n",
|
|
30
|
+
"multilingual",
|
|
31
|
+
"cli",
|
|
32
|
+
"geography",
|
|
33
|
+
"typescript",
|
|
34
|
+
"codegen",
|
|
35
|
+
"states",
|
|
36
|
+
"regions",
|
|
37
|
+
"localization",
|
|
38
|
+
"l10n"
|
|
39
|
+
],
|
|
40
|
+
"author": "Mohammed Fahad",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/H4ck3r-x0/geo-data.git",
|
|
45
|
+
"directory": "packages/cli"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/H4ck3r-x0/geo-data#readme",
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/H4ck3r-x0/geo-data/issues"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@clack/prompts": "^0.8.2",
|
|
56
|
+
"chalk": "^5.3.0",
|
|
57
|
+
"commander": "^12.1.0",
|
|
58
|
+
"figlet": "^1.8.0",
|
|
59
|
+
"fs-extra": "^11.2.0",
|
|
60
|
+
"gradient-string": "^3.0.0",
|
|
61
|
+
"prompts": "^2.4.2",
|
|
62
|
+
"string-similarity": "^4.0.4",
|
|
63
|
+
"zod": "^4.3.6"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@types/figlet": "^1.7.0",
|
|
67
|
+
"@types/fs-extra": "^11.0.4",
|
|
68
|
+
"@types/gradient-string": "^1.1.6",
|
|
69
|
+
"@types/node": "^20.11.0",
|
|
70
|
+
"@types/prompts": "^2.4.9",
|
|
71
|
+
"@types/string-similarity": "^4.0.2",
|
|
72
|
+
"tsup": "^8.0.1",
|
|
73
|
+
"typescript": "^5.3.3",
|
|
74
|
+
"vitest": "^4.0.18"
|
|
75
|
+
}
|
|
76
|
+
}
|