thaizip 0.7.6 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,177 +1,56 @@
1
1
  # thaizip
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/thaizip.svg)](https://www.npmjs.com/package/thaizip)
4
- [![npm downloads](https://img.shields.io/npm/dm/thaizip.svg)](https://www.npmjs.com/package/thaizip)
5
4
  [![license](https://img.shields.io/npm/l/thaizip.svg)](https://github.com/naay99999/thai-zip/blob/main/LICENSE)
6
5
 
7
- Fast fuzzy autocomplete for Thai addresses — subdistrict, district, province, postal code. Thai and English input, zero runtime dependencies, React optional.
6
+ Fast fuzzy autocomplete for Thai addresses — subdistrict, district, province, postal code.
8
7
 
9
- **Docs:** https://naay99999.github.io/thai-zip/ (ไทย / [English](https://naay99999.github.io/thai-zip/en/))
8
+ 📚 **[Documentation](https://naay99999.github.io/thai-zip/)** (ไทย / [English](https://naay99999.github.io/thai-zip/en/)) · [Playground](https://naay99999.github.io/thai-zip/en/playground/)
10
9
 
11
- | Thai input | English input (romanization alias) |
12
- |---|---|
13
- | ![thaizip demo — typing "ลาดพร้าว" and getting fuzzy Thai suggestions](.github/assets/demo.png) | ![thaizip demo — typing "ladprao" and matching the same Thai suggestions](.github/assets/demo-en.png) |
10
+ ![thaizip demo — typing "ลาดพร้าว", "ladprao" and "10230" to get fuzzy Thai address suggestions, then resolving a selection](.github/assets/demo.gif)
11
+
12
+ ## Features
13
+
14
+ - **Fuzzy search** in Thai or English — typos and common spellings like `ladprao` still match
15
+ - **Postal code lookup** — exact or prefix (`10230`, `102`)
16
+ - **Cascade dropdowns** — province → district → subdistrict
17
+ - **Bundled data** — 77 provinces, 918 districts, 7,385 subdistricts, loaded lazily
18
+ - **Zero runtime dependencies**, works anywhere JS runs; optional React hook
19
+
20
+ ## Install
14
21
 
15
22
  ```bash
16
23
  npm install thaizip
17
24
  ```
18
25
 
19
- | import path | contents |
20
- |---|---|
21
- | `thaizip` | core functions + types (no React code, 4.4 KB gzip) |
22
- | `thaizip/react` | `useThaiAddressAutocomplete` (ships `"use client"`) |
23
- | `thaizip/data` | `loadDefaultIndex`, `clearDefaultIndex`, `getDefaultIndexIfLoaded` (2.3 KB gzip, plus a 114 KB gzip data chunk fetched lazily on the first `loadDefaultIndex()` call) |
24
-
25
26
  ## Quick start
26
27
 
27
28
  ```ts
28
29
  import { loadDefaultIndex } from 'thaizip/data'
29
- import { searchThaiAddress, formatThaiAddressSuggestion, resolveThaiAddress } from 'thaizip'
30
+ import { searchThaiAddress, resolveThaiAddress } from 'thaizip'
30
31
 
31
- const index = await loadDefaultIndex() // ~50ms on the measured device, cached after
32
+ const index = await loadDefaultIndex()
32
33
 
33
- searchThaiAddress(index, 'ลาดพร้าว')
34
- searchThaiAddress(index, 'bang rak')
35
- searchThaiAddress(index, '10500')
36
-
37
- formatThaiAddressSuggestion(record) // for the dropdown
38
- formatThaiAddressSuggestion(record, { locale: 'en' })
39
- resolveThaiAddress(record) // for saving
34
+ const [record] = searchThaiAddress(index, 'ลาดพร้าว')
35
+ resolveThaiAddress(record)
36
+ // { subdistrict: 'ลาดพร้าว', district: 'เขตลาดพร้าว', province: 'กรุงเทพมหานคร', postalCode: '10230', ... }
40
37
  ```
41
38
 
42
- Call `loadDefaultIndex()` at mount or route load — deferring it to the user's first keystroke can block the UI briefly.
43
- The cached default index is shared. Ordinary writes to its records and lookup structures throw; build a separate index with `buildThaiAddressIndex` if you need to edit address data. This is protection against accidental changes, not a security boundary against hostile code already running in the same JavaScript realm.
44
-
45
- ## Search
46
-
47
- ```ts
48
- searchThaiAddress(index, query, {
49
- limit: 10, // text queries only
50
- threshold: 0.4, // match quality 0–1
51
- zipLimit: Infinity, // all-digit queries — unlimited by default
52
- romanizationAliases: true, // expand non-RTGS spellings
53
- })
54
- ```
55
-
56
- Results rank by trigram score, then by how well the query matches the subdistrict's **own** name (exact → prefix → substring), then alphabetically. That second key is what keeps ตำบลลาดพร้าว above the other subdistricts that merely sit inside เขตลาดพร้าว.
57
-
58
- > Combined text + zip in one query (`"ลาดพร้าว 10900"`) is not supported — search by name or zip separately.
59
-
60
- ### Postal codes
61
-
62
- One Thai postal code can cover many subdistricts — `45000` covers 33, and 230 of the 953 codes cover more than 10 — so `zipLimit` defaults to unlimited rather than silently truncating at `limit`.
63
-
64
- ```ts
65
- import { lookupByZipCode } from 'thaizip'
66
-
67
- lookupByZipCode(index, '45000') // exact
68
- lookupByZipCode(index, '450') // prefix
69
- ```
70
-
71
- Direct ZIP lookup rejects raw inputs longer than 1000 characters before scanning them. A finite `zipLimit` also bounds how many matching records are collected.
72
-
73
- ### English input
74
-
75
- The dataset indexes official RTGS spellings, so `bang rak` and `chatuchak` work directly. 87 common non-RTGS spellings are mapped on first: `lardprao` → `lat phrao`, `ladkrabang` → `lat krabang`, `krungthep` → `bangkok`. It is a curated dictionary, not a transliterator — unlisted spellings still miss. Opt out with `romanizationAliases: false`.
76
-
77
- ### Cascade dropdowns
78
-
79
- ```ts
80
- import { listProvinces, listAmphures, listTambons } from 'thaizip'
81
-
82
- listProvinces(index) // 77, sorted by Thai name
83
- listAmphures(index, provinceId)
84
- listTambons(index, amphureId) // each with its zipCode
85
- ```
86
-
87
- Backed by pre-built groupings, not scans over all 7,385 records. Unknown ids return `[]`.
88
-
89
- ## React
39
+ React:
90
40
 
91
41
  ```tsx
92
42
  import { useThaiAddressAutocomplete } from 'thaizip/react'
93
43
 
94
- const { query, setQuery, setQuerySilent, suggestions, isOpen, selectSuggestion, clear } =
95
- useThaiAddressAutocomplete({ index })
96
- ```
97
-
98
- Options: `index`, `limit`, `debounce` (200ms), `threshold`, `zipLimit`, `locale`, `initialQuery`, `onSelect`.
99
-
100
- | value | description |
101
- |---|---|
102
- | `query` / `setQuery` | input value; `setQuery` searches |
103
- | `setQuerySilent` | set the value **without** searching or reopening the dropdown |
104
- | `suggestions` | `ThaiAddressSuggestion[]` |
105
- | `isOpen` | `query` is non-empty and suggestions exist |
106
- | `selectSuggestion` | `(item) => ResolvedThaiAddress \| null` — clears suggestions, leaves `query` alone; `null` if the item is stale |
107
- | `clear` | reset query and suggestions |
108
-
109
- ### Controlled forms
110
-
111
- `selectSuggestion` leaves `query` untouched by design. To echo the choice back into the input use `setQuerySilent` — plain `setQuery` would immediately reopen the dropdown:
112
-
113
- ```tsx
114
- const handlePick = (s: ThaiAddressSuggestion) => {
115
- const address = selectSuggestion(s)
116
- if (!address) return
117
- setQuerySilent(s.label)
118
- form.setValue('address', address)
119
- }
120
- ```
121
-
122
- Editing a saved address? Pass `initialQuery` rather than calling `setQuery` in an effect.
123
-
124
- ### Accessibility
125
-
126
- The hook owns query and suggestion state only. Keyboard navigation and ARIA combobox semantics are yours — `npx react-thaizip add autocomplete` scaffolds a component that already has them.
127
-
128
- ## Custom data
129
-
130
- ```ts
131
- import { buildThaiAddressIndex, validateRawData } from 'thaizip'
132
-
133
- const index = buildThaiAddressIndex({ provinces, amphures, tambons }, {
134
- onSkip: (tambon) => console.warn('skipped:', tambon.id),
135
- validate: true, // default
136
- })
137
- ```
138
-
139
- Input is validated by default, so a stray non-string field fails with `[thaizip] tambon 100404: expected string for name_th, got number` instead of crashing inside the normalizer. `validateRawData(data)` runs the same checks standalone.
140
-
141
- Validation also rejects more than 50,000 total rows, names longer than 256 UTF-16 code units, ZIP values longer than 32 code units, or an estimated trigram posting cost above 1,000,000. The estimate counts each province/district's unique trigrams once for every active child subdistrict, so it catches parent-name fan-out. These bounds are suitable for the bundled-size dataset, not a guarantee that arbitrary uploads are safe to process. For larger trusted datasets, pass `{ validate: false }` and apply your own resource limits before building.
142
-
143
- Types: `RawData`, `RawProvince`, `RawAmphure`, `RawTambon`.
144
-
145
- ## Types
146
-
147
- ```ts
148
- type ThaiAddressSuggestion = {
149
- id: string
150
- label: string // follows `locale`, Thai by default
151
- labelTh: string
152
- labelEn: string
153
- tambon: string; tambonEn: string
154
- amphure: string; amphureEn: string
155
- province: string; provinceEn: string
156
- zipCode: string
157
- }
158
-
159
- type ResolvedThaiAddress = {
160
- tambon: string; tambonEn: string // alias: subdistrict / subdistrictEn
161
- amphure: string; amphureEn: string // alias: district / districtEn
162
- province: string; provinceEn: string
163
- zipCode: string // alias: postalCode
164
- subdistrict: string; subdistrictEn: string
165
- district: string; districtEn: string
166
- postalCode: string
167
- }
44
+ const { query, setQuery, suggestions, selectSuggestion } = useThaiAddressAutocomplete({ index })
168
45
  ```
169
46
 
170
- ## Data
47
+ Need a ready-made UI component? [`react-thaizip`](https://github.com/naay99999/react-thai-zip) scaffolds one into your project.
171
48
 
172
- 77 provinces, 918 districts, 7,385 subdistricts. Records whose parent district or province is soft-deleted are excluded automatically.
49
+ ## Learn more
173
50
 
174
- `searchThaiAddress` is pure and framework-free — it works unchanged in Node, Vue, Svelte, or vanilla JS.
51
+ - [Getting started](https://naay99999.github.io/thai-zip/en/getting-started/)
52
+ - [Guides](https://naay99999.github.io/thai-zip/en/guides/search/) — search, postal codes, cascades, English input, React, custom data
53
+ - [API reference](https://naay99999.github.io/thai-zip/en/reference/search/)
175
54
 
176
55
  ## License
177
56
 
package/dist/data.cjs CHANGED
@@ -42,6 +42,12 @@ var MAX_POSTING_BUDGET = 1e6;
42
42
  function typeErr(table, id, field, expected, value) {
43
43
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
44
44
  }
45
+ function assertId(table, id, field, value) {
46
+ if (typeof value !== "number") throw typeErr(table, id, field, "number", value);
47
+ if (!Number.isSafeInteger(value)) {
48
+ throw new TypeError(`[thaizip] ${table} ${id}: expected safe integer for ${field}, got ${value}`);
49
+ }
50
+ }
45
51
  function typeName(value) {
46
52
  return value === null ? "null" : typeof value;
47
53
  }
@@ -68,7 +74,7 @@ function validateRawData(data) {
68
74
  if (p === null || typeof p !== "object") {
69
75
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
70
76
  }
71
- if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
77
+ assertId("province", p.id, "id", p.id);
72
78
  if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
73
79
  provinceById.set(p.id, p);
74
80
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
@@ -82,12 +88,12 @@ function validateRawData(data) {
82
88
  if (a === null || typeof a !== "object") {
83
89
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
84
90
  }
85
- if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
91
+ assertId("amphure", a.id, "id", a.id);
86
92
  if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
87
93
  amphureById.set(a.id, a);
88
94
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
89
95
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
90
- if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
96
+ assertId("amphure", a.id, "province_id", a.province_id);
91
97
  if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
92
98
  if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
93
99
  }
@@ -100,7 +106,7 @@ function validateRawData(data) {
100
106
  if (t === null || typeof t !== "object") {
101
107
  throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
102
108
  }
103
- if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
109
+ assertId("tambon", t.id, "id", t.id);
104
110
  if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
105
111
  seenTambonIds.add(t.id);
106
112
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
@@ -111,7 +117,7 @@ function validateRawData(data) {
111
117
  if (zipType !== "string" && zipType !== "number") {
112
118
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
113
119
  }
114
- if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
120
+ assertId("tambon", t.id, "amphure_id", t.amphure_id);
115
121
  const zipLength = String(t.zip_code).length;
116
122
  if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
117
123
  if (t.deleted_at) continue;
package/dist/data.js CHANGED
@@ -40,6 +40,12 @@ var MAX_POSTING_BUDGET = 1e6;
40
40
  function typeErr(table, id, field, expected, value) {
41
41
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
42
42
  }
43
+ function assertId(table, id, field, value) {
44
+ if (typeof value !== "number") throw typeErr(table, id, field, "number", value);
45
+ if (!Number.isSafeInteger(value)) {
46
+ throw new TypeError(`[thaizip] ${table} ${id}: expected safe integer for ${field}, got ${value}`);
47
+ }
48
+ }
43
49
  function typeName(value) {
44
50
  return value === null ? "null" : typeof value;
45
51
  }
@@ -66,7 +72,7 @@ function validateRawData(data) {
66
72
  if (p === null || typeof p !== "object") {
67
73
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
68
74
  }
69
- if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
75
+ assertId("province", p.id, "id", p.id);
70
76
  if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
71
77
  provinceById.set(p.id, p);
72
78
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
@@ -80,12 +86,12 @@ function validateRawData(data) {
80
86
  if (a === null || typeof a !== "object") {
81
87
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
82
88
  }
83
- if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
89
+ assertId("amphure", a.id, "id", a.id);
84
90
  if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
85
91
  amphureById.set(a.id, a);
86
92
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
87
93
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
88
- if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
94
+ assertId("amphure", a.id, "province_id", a.province_id);
89
95
  if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
90
96
  if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
91
97
  }
@@ -98,7 +104,7 @@ function validateRawData(data) {
98
104
  if (t === null || typeof t !== "object") {
99
105
  throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
100
106
  }
101
- if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
107
+ assertId("tambon", t.id, "id", t.id);
102
108
  if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
103
109
  seenTambonIds.add(t.id);
104
110
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
@@ -109,7 +115,7 @@ function validateRawData(data) {
109
115
  if (zipType !== "string" && zipType !== "number") {
110
116
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
111
117
  }
112
- if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
118
+ assertId("tambon", t.id, "amphure_id", t.amphure_id);
113
119
  const zipLength = String(t.zip_code).length;
114
120
  if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
115
121
  if (t.deleted_at) continue;
package/dist/index.cjs CHANGED
@@ -42,6 +42,12 @@ var MAX_POSTING_BUDGET = 1e6;
42
42
  function typeErr(table, id, field, expected, value) {
43
43
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
44
44
  }
45
+ function assertId(table, id, field, value) {
46
+ if (typeof value !== "number") throw typeErr(table, id, field, "number", value);
47
+ if (!Number.isSafeInteger(value)) {
48
+ throw new TypeError(`[thaizip] ${table} ${id}: expected safe integer for ${field}, got ${value}`);
49
+ }
50
+ }
45
51
  function typeName(value) {
46
52
  return value === null ? "null" : typeof value;
47
53
  }
@@ -68,7 +74,7 @@ function validateRawData(data) {
68
74
  if (p === null || typeof p !== "object") {
69
75
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
70
76
  }
71
- if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
77
+ assertId("province", p.id, "id", p.id);
72
78
  if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
73
79
  provinceById.set(p.id, p);
74
80
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
@@ -82,12 +88,12 @@ function validateRawData(data) {
82
88
  if (a === null || typeof a !== "object") {
83
89
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
84
90
  }
85
- if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
91
+ assertId("amphure", a.id, "id", a.id);
86
92
  if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
87
93
  amphureById.set(a.id, a);
88
94
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
89
95
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
90
- if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
96
+ assertId("amphure", a.id, "province_id", a.province_id);
91
97
  if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
92
98
  if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
93
99
  }
@@ -100,7 +106,7 @@ function validateRawData(data) {
100
106
  if (t === null || typeof t !== "object") {
101
107
  throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
102
108
  }
103
- if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
109
+ assertId("tambon", t.id, "id", t.id);
104
110
  if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
105
111
  seenTambonIds.add(t.id);
106
112
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
@@ -111,7 +117,7 @@ function validateRawData(data) {
111
117
  if (zipType !== "string" && zipType !== "number") {
112
118
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
113
119
  }
114
- if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
120
+ assertId("tambon", t.id, "amphure_id", t.amphure_id);
115
121
  const zipLength = String(t.zip_code).length;
116
122
  if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
117
123
  if (t.deleted_at) continue;
@@ -421,7 +427,7 @@ function searchThaiAddress(index, query, options) {
421
427
  if (ZIP_CODE_RE.test(normalized)) {
422
428
  if (normalized.length < 2) return [];
423
429
  return lookupByZipCode(index, normalized, {
424
- zipLimit: options?.zipLimit ?? Infinity
430
+ zipLimit: options?.zipLimit ?? (normalized.length >= 5 ? Infinity : limit)
425
431
  });
426
432
  }
427
433
  if (normalized.length < 3) return [];
@@ -583,5 +589,3 @@ exports.normalizeThaiAddressText = normalizeThaiAddressText;
583
589
  exports.resolveThaiAddress = resolveThaiAddress;
584
590
  exports.searchThaiAddress = searchThaiAddress;
585
591
  exports.validateRawData = validateRawData;
586
- //# sourceMappingURL=index.cjs.map
587
- //# sourceMappingURL=index.cjs.map
package/dist/index.d.cts CHANGED
@@ -101,9 +101,11 @@ type SearchOptions = {
101
101
  limit?: number;
102
102
  threshold?: number;
103
103
  /**
104
- * Maximum results for all-digit (zip code) queries. A zip code maps to at most
105
- * a few dozen tambons, so this defaults to `Infinity` — capping zip results at
106
- * the text-autocomplete `limit` silently hides valid subdistricts.
104
+ * Maximum results for all-digit (zip code) queries. When omitted, a complete
105
+ * 5-digit zip returns every matching tambon (at most a few dozen — capping
106
+ * those at the text-autocomplete `limit` would silently hide valid
107
+ * subdistricts), while a partial prefix (2–4 digits, which can match hundreds
108
+ * of records) is capped at `limit`. Pass `Infinity` to return every prefix match.
107
109
  */
108
110
  zipLimit?: number;
109
111
  /**
@@ -145,6 +147,10 @@ type BuildIndexOptions = {
145
147
  * Validate that every consumed field has the expected runtime type and throw a
146
148
  * descriptive `TypeError` if not. Defaults to `true`; set to `false` to skip
147
149
  * the checks when the data source is already trusted (e.g. the bundled index).
150
+ *
151
+ * `false` also skips the row, name-length and posting-budget limits that bound
152
+ * memory and CPU use, so never disable it for data you do not control
153
+ * (user uploads, third-party APIs, a CMS editable by others).
148
154
  */
149
155
  validate?: boolean;
150
156
  };
package/dist/index.d.ts CHANGED
@@ -101,9 +101,11 @@ type SearchOptions = {
101
101
  limit?: number;
102
102
  threshold?: number;
103
103
  /**
104
- * Maximum results for all-digit (zip code) queries. A zip code maps to at most
105
- * a few dozen tambons, so this defaults to `Infinity` — capping zip results at
106
- * the text-autocomplete `limit` silently hides valid subdistricts.
104
+ * Maximum results for all-digit (zip code) queries. When omitted, a complete
105
+ * 5-digit zip returns every matching tambon (at most a few dozen — capping
106
+ * those at the text-autocomplete `limit` would silently hide valid
107
+ * subdistricts), while a partial prefix (2–4 digits, which can match hundreds
108
+ * of records) is capped at `limit`. Pass `Infinity` to return every prefix match.
107
109
  */
108
110
  zipLimit?: number;
109
111
  /**
@@ -145,6 +147,10 @@ type BuildIndexOptions = {
145
147
  * Validate that every consumed field has the expected runtime type and throw a
146
148
  * descriptive `TypeError` if not. Defaults to `true`; set to `false` to skip
147
149
  * the checks when the data source is already trusted (e.g. the bundled index).
150
+ *
151
+ * `false` also skips the row, name-length and posting-budget limits that bound
152
+ * memory and CPU use, so never disable it for data you do not control
153
+ * (user uploads, third-party APIs, a CMS editable by others).
148
154
  */
149
155
  validate?: boolean;
150
156
  };
package/dist/index.js CHANGED
@@ -40,6 +40,12 @@ var MAX_POSTING_BUDGET = 1e6;
40
40
  function typeErr(table, id, field, expected, value) {
41
41
  return new TypeError(`[thaizip] ${table} ${id}: expected ${expected} for ${field}, got ${typeof value}`);
42
42
  }
43
+ function assertId(table, id, field, value) {
44
+ if (typeof value !== "number") throw typeErr(table, id, field, "number", value);
45
+ if (!Number.isSafeInteger(value)) {
46
+ throw new TypeError(`[thaizip] ${table} ${id}: expected safe integer for ${field}, got ${value}`);
47
+ }
48
+ }
43
49
  function typeName(value) {
44
50
  return value === null ? "null" : typeof value;
45
51
  }
@@ -66,7 +72,7 @@ function validateRawData(data) {
66
72
  if (p === null || typeof p !== "object") {
67
73
  throw new TypeError(`[thaizip] province[${i}]: expected object, got ${typeName(p)}`);
68
74
  }
69
- if (typeof p.id !== "number") throw typeErr("province", p.id, "id", "number", p.id);
75
+ assertId("province", p.id, "id", p.id);
70
76
  if (provinceById.has(p.id)) throw new TypeError(`[thaizip] duplicate province id: ${p.id}`);
71
77
  provinceById.set(p.id, p);
72
78
  if (typeof p.name_th !== "string") throw typeErr("province", p.id, "name_th", "string", p.name_th);
@@ -80,12 +86,12 @@ function validateRawData(data) {
80
86
  if (a === null || typeof a !== "object") {
81
87
  throw new TypeError(`[thaizip] amphure[${i}]: expected object, got ${typeName(a)}`);
82
88
  }
83
- if (typeof a.id !== "number") throw typeErr("amphure", a.id, "id", "number", a.id);
89
+ assertId("amphure", a.id, "id", a.id);
84
90
  if (amphureById.has(a.id)) throw new TypeError(`[thaizip] duplicate amphure id: ${a.id}`);
85
91
  amphureById.set(a.id, a);
86
92
  if (typeof a.name_th !== "string") throw typeErr("amphure", a.id, "name_th", "string", a.name_th);
87
93
  if (typeof a.name_en !== "string") throw typeErr("amphure", a.id, "name_en", "string", a.name_en);
88
- if (typeof a.province_id !== "number") throw typeErr("amphure", a.id, "province_id", "number", a.province_id);
94
+ assertId("amphure", a.id, "province_id", a.province_id);
89
95
  if (a.name_th.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_th too long`);
90
96
  if (a.name_en.length > MAX_NAME_LENGTH) throw new RangeError(`[thaizip] amphure ${a.id}: name_en too long`);
91
97
  }
@@ -98,7 +104,7 @@ function validateRawData(data) {
98
104
  if (t === null || typeof t !== "object") {
99
105
  throw new TypeError(`[thaizip] tambon[${i}]: expected object, got ${typeName(t)}`);
100
106
  }
101
- if (typeof t.id !== "number") throw typeErr("tambon", t.id, "id", "number", t.id);
107
+ assertId("tambon", t.id, "id", t.id);
102
108
  if (seenTambonIds.has(t.id)) throw new TypeError(`[thaizip] duplicate tambon id: ${t.id}`);
103
109
  seenTambonIds.add(t.id);
104
110
  if (typeof t.name_th !== "string") throw typeErr("tambon", t.id, "name_th", "string", t.name_th);
@@ -109,7 +115,7 @@ function validateRawData(data) {
109
115
  if (zipType !== "string" && zipType !== "number") {
110
116
  throw typeErr("tambon", t.id, "zip_code", "string or number", t.zip_code);
111
117
  }
112
- if (typeof t.amphure_id !== "number") throw typeErr("tambon", t.id, "amphure_id", "number", t.amphure_id);
118
+ assertId("tambon", t.id, "amphure_id", t.amphure_id);
113
119
  const zipLength = String(t.zip_code).length;
114
120
  if (zipLength > MAX_ZIP_LENGTH) throw new RangeError(`[thaizip] tambon ${t.id}: zip_code too long`);
115
121
  if (t.deleted_at) continue;
@@ -419,7 +425,7 @@ function searchThaiAddress(index, query, options) {
419
425
  if (ZIP_CODE_RE.test(normalized)) {
420
426
  if (normalized.length < 2) return [];
421
427
  return lookupByZipCode(index, normalized, {
422
- zipLimit: options?.zipLimit ?? Infinity
428
+ zipLimit: options?.zipLimit ?? (normalized.length >= 5 ? Infinity : limit)
423
429
  });
424
430
  }
425
431
  if (normalized.length < 3) return [];
@@ -571,5 +577,3 @@ function resolveThaiAddress(record) {
571
577
  }
572
578
 
573
579
  export { applyRomanizationAliases, buildThaiAddressIndex, formatThaiAddressSuggestion, listAmphures, listProvinces, listTambons, lookupByZipCode, normalizeThaiAddressText, resolveThaiAddress, searchThaiAddress, validateRawData };
574
- //# sourceMappingURL=index.js.map
575
- //# sourceMappingURL=index.js.map
package/dist/react.cjs CHANGED
@@ -225,7 +225,7 @@ function searchThaiAddress(index, query, options) {
225
225
  if (ZIP_CODE_RE.test(normalized)) {
226
226
  if (normalized.length < 2) return [];
227
227
  return lookupByZipCode(index, normalized, {
228
- zipLimit: options?.zipLimit ?? Infinity
228
+ zipLimit: options?.zipLimit ?? (normalized.length >= 5 ? Infinity : limit)
229
229
  });
230
230
  }
231
231
  if (normalized.length < 3) return [];
@@ -402,5 +402,3 @@ function useThaiAddressAutocomplete(options) {
402
402
  }
403
403
 
404
404
  exports.useThaiAddressAutocomplete = useThaiAddressAutocomplete;
405
- //# sourceMappingURL=react.cjs.map
406
- //# sourceMappingURL=react.cjs.map
package/dist/react.d.cts CHANGED
@@ -79,6 +79,7 @@ type UseThaiAddressAutocompleteOptions = {
79
79
  limit?: number;
80
80
  debounce?: number;
81
81
  threshold?: number;
82
+ /** Maximum results for zip code queries; see `SearchOptions.zipLimit` for the default. */
82
83
  zipLimit?: number;
83
84
  /** Seed value for `query` on first render. Defaults to `''`. */
84
85
  initialQuery?: string;
package/dist/react.d.ts CHANGED
@@ -79,6 +79,7 @@ type UseThaiAddressAutocompleteOptions = {
79
79
  limit?: number;
80
80
  debounce?: number;
81
81
  threshold?: number;
82
+ /** Maximum results for zip code queries; see `SearchOptions.zipLimit` for the default. */
82
83
  zipLimit?: number;
83
84
  /** Seed value for `query` on first render. Defaults to `''`. */
84
85
  initialQuery?: string;
package/dist/react.js CHANGED
@@ -223,7 +223,7 @@ function searchThaiAddress(index, query, options) {
223
223
  if (ZIP_CODE_RE.test(normalized)) {
224
224
  if (normalized.length < 2) return [];
225
225
  return lookupByZipCode(index, normalized, {
226
- zipLimit: options?.zipLimit ?? Infinity
226
+ zipLimit: options?.zipLimit ?? (normalized.length >= 5 ? Infinity : limit)
227
227
  });
228
228
  }
229
229
  if (normalized.length < 3) return [];
@@ -400,5 +400,3 @@ function useThaiAddressAutocomplete(options) {
400
400
  }
401
401
 
402
402
  export { useThaiAddressAutocomplete };
403
- //# sourceMappingURL=react.js.map
404
- //# sourceMappingURL=react.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thaizip",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "Fast, fuzzy Thai address autocomplete library (headless + React)",
5
5
  "keywords": [
6
6
  "thai",