thaizip 0.6.0 → 0.7.3

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Narathip Thakham
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 CHANGED
@@ -1,337 +1,166 @@
1
1
  # thaizip
2
2
 
3
- ไลบรารี autocomplete ที่อยู่ไทยแบบ fuzzy search รวดเร็ว รองรับทั้ง Vanilla JS และ React
3
+ Fast fuzzy autocomplete for Thai addresses — subdistrict, district, province, postal code. Thai and English input, zero runtime dependencies, React optional.
4
4
 
5
- - ค้นหาได้ทั้งภาษาไทย ภาษาอังกฤษ และรหัสไปรษณีย์
6
- - Fuzzy search — ไม่ต้องใส่วรรณยุกต์ก็ค้นหาได้ เช่น "ลาดพราว" เจอ "ลาดพร้าว"
7
- - ไม่มี dependency นอกจาก React (optional)
8
- - โหลดข้อมูลแบบ async — ไม่บล็อก initial bundle (~132 KB gzip)
9
- - รองรับ ESM และ CJS
10
-
11
- ---
12
-
13
- ## ติดตั้ง
5
+ **Docs:** https://naay99999.github.io/thai-zip/ (ไทย / [English](https://naay99999.github.io/thai-zip/en/))
14
6
 
15
7
  ```bash
16
8
  npm install thaizip
17
9
  ```
18
10
 
19
- ถ้าใช้กับ React ต้องมี React >= 18 เป็น peer dependency อยู่แล้ว ไม่ต้องติดตั้งเพิ่ม
20
-
21
- ---
22
-
23
- ## Package exports
24
-
25
- | import path | เนื้อหา |
11
+ | import path | contents |
26
12
  |---|---|
27
- | `thaizip` | core functions + types ทั้งหมด (ไม่มี React) |
28
- | `thaizip/react` | `useThaiAddressAutocomplete` hook + types |
29
- | `thaizip/data` | `loadDefaultIndex`, `clearDefaultIndex` |
30
-
31
- ---
13
+ | `thaizip` | core functions + types (no React code, 4.4 KB gzip) |
14
+ | `thaizip/react` | `useThaiAddressAutocomplete` (ships `"use client"`) |
15
+ | `thaizip/data` | `loadDefaultIndex`, `clearDefaultIndex` (132 KB gzip) |
32
16
 
33
- ## การใช้งานพื้นฐาน (Vanilla JS / TypeScript)
34
-
35
- ### โหลด index และค้นหาที่อยู่
17
+ ## Quick start
36
18
 
37
19
  ```ts
38
20
  import { loadDefaultIndex } from 'thaizip/data'
39
- import { searchThaiAddress } from 'thaizip'
40
-
41
- // โหลด index ครั้งแรก ~200ms (cached หลังจากนั้น)
42
- const index = await loadDefaultIndex()
21
+ import { searchThaiAddress, formatThaiAddressSuggestion, resolveThaiAddress } from 'thaizip'
43
22
 
44
- // ค้นหาด้วยชื่อตำบล/อำเภอ/จังหวัด
45
- const results = searchThaiAddress(index, 'ลาดพร้าว')
23
+ const index = await loadDefaultIndex() // ~40ms of synchronous work, cached after
46
24
 
47
- // ค้นหาด้วยรหัสไปรษณีย์
48
- const results2 = searchThaiAddress(index, '10900')
25
+ searchThaiAddress(index, 'ลาดพร้าว')
26
+ searchThaiAddress(index, 'bang rak')
27
+ searchThaiAddress(index, '10500')
49
28
 
50
- // ค้นหาด้วยภาษาอังกฤษ
51
- const results3 = searchThaiAddress(index, 'chiang mai')
52
-
53
- // ⚠️ ไม่รองรับ: ค้นหาแบบรวม text + รหัสไปรษณีย์ในครั้งเดียว (เช่น "ลาดพร้าว 10900")
54
- // ให้ค้นหาด้วยชื่อสถานที่ หรือรหัสไปรษณีย์ แยกกัน
55
-
56
- console.log(results)
57
- // [{ tambonId, tambonNameTh, tambonNameEn, amphureId, amphureNameTh, ... zipCode }, ...]
29
+ formatThaiAddressSuggestion(record) // for the dropdown
30
+ formatThaiAddressSuggestion(record, { locale: 'en' })
31
+ resolveThaiAddress(record) // for saving
58
32
  ```
59
33
 
60
- ### ตัวเลือก (options)
34
+ Call `loadDefaultIndex()` at mount or route load — deferring it to the user's first keystroke costs ~2 dropped frames.
35
+
36
+ ## Search
61
37
 
62
38
  ```ts
63
- const results = searchThaiAddress(index, 'ลาดพร้าว', {
64
- limit: 5, // จำนวนผลลัพธ์สูงสุด (default: 10)
65
- threshold: 0.4, // ความแม่นยำขั้นต่ำ 0–1 (default: 0.4)
39
+ searchThaiAddress(index, query, {
40
+ limit: 10, // text queries only
41
+ threshold: 0.4, // match quality 0–1
42
+ zipLimit: Infinity, // all-digit queries — unlimited by default
43
+ romanizationAliases: true, // expand non-RTGS spellings
66
44
  })
67
45
  ```
68
46
 
69
- ### แปลงผลลัพธ์เป็นรูปแบบต่าง ๆ
47
+ 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 เขตลาดพร้าว.
70
48
 
71
- **`formatThaiAddressSuggestion`** — ใช้แสดงใน dropdown
49
+ > Combined text + zip in one query (`"ลาดพร้าว 10900"`) is not supported — search by name or zip separately.
72
50
 
73
- ```ts
74
- import { formatThaiAddressSuggestion } from 'thaizip'
75
-
76
- const suggestion = formatThaiAddressSuggestion(results[0])
77
- // {
78
- // id: '100101',
79
- // label: 'ลาดพร้าว > ลาดพร้าว > กรุงเทพมหานคร 10230',
80
- // tambon: 'ลาดพร้าว', tambonEn: 'Lat Phrao',
81
- // amphure: 'ลาดพร้าว', amphureEn: 'Lat Phrao',
82
- // province: 'กรุงเทพมหานคร', provinceEn: 'Bangkok',
83
- // zipCode: '10230',
84
- // }
85
- ```
51
+ ### Postal codes
86
52
 
87
- **`resolveThaiAddress`** — ใช้บันทึกข้อมูลหลังผู้ใช้เลือก
53
+ 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`.
88
54
 
89
55
  ```ts
90
- import { resolveThaiAddress } from 'thaizip'
91
-
92
- const resolved = resolveThaiAddress(results[0])
93
- // {
94
- // tambon: 'ลาดพร้าว', tambonEn: 'Lat Phrao',
95
- // amphure: 'ลาดพร้าว', amphureEn: 'Lat Phrao',
96
- // province: 'กรุงเทพมหานคร', provinceEn: 'Bangkok',
97
- // zipCode: '10230',
98
- // subdistrict: 'ลาดพร้าว', subdistrictEn: 'Lat Phrao',
99
- // district: 'ลาดพร้าว', districtEn: 'Lat Phrao',
100
- // postalCode: '10230',
101
- // }
102
- ```
103
-
104
- ---
105
-
106
- ## การใช้งานกับ React
56
+ import { lookupByZipCode } from 'thaizip'
107
57
 
108
- ### `useThaiAddressAutocomplete`
109
-
110
- Hook นี้จัดการ state ของ query, suggestions, debounce ให้ทั้งหมด **import จาก `thaizip/react`**
58
+ lookupByZipCode(index, '45000') // exact
59
+ lookupByZipCode(index, '450') // prefix
60
+ ```
111
61
 
112
- ```tsx
113
- import { useState, useEffect } from 'react'
114
- import { loadDefaultIndex } from 'thaizip/data'
115
- import { useThaiAddressAutocomplete } from 'thaizip/react'
116
- import type { TrigramIndex, ResolvedThaiAddress } from 'thaizip/react'
62
+ ### English input
117
63
 
118
- // แนะนำ: แยก loading และ form ออกจากกัน เพื่อให้ hook เรียกหลัง index พร้อมแล้วเสมอ
119
- function AddressPage() {
120
- const [index, setIndex] = useState<TrigramIndex | null>(null)
64
+ 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`.
121
65
 
122
- useEffect(() => {
123
- loadDefaultIndex().then(setIndex)
124
- }, [])
66
+ ### Cascade dropdowns
125
67
 
126
- if (!index) return <p>กำลังโหลด...</p>
127
- return <AddressForm index={index} />
128
- }
68
+ ```ts
69
+ import { listProvinces, listAmphures, listTambons } from 'thaizip'
129
70
 
130
- function AddressForm({ index }: { index: TrigramIndex }) {
131
- const { query, setQuery, suggestions, isOpen, selectSuggestion, clear } =
132
- useThaiAddressAutocomplete({
133
- index,
134
- limit: 10, // default: 10
135
- debounce: 200, // default: 200ms
136
- threshold: 0.4, // default: 0.4
137
- })
138
-
139
- const [address, setAddress] = useState<ResolvedThaiAddress | null>(null)
140
-
141
- return (
142
- <div>
143
- <input
144
- value={query}
145
- onChange={(e) => setQuery(e.target.value)}
146
- placeholder="พิมพ์ตำบล อำเภอ จังหวัด หรือรหัสไปรษณีย์"
147
- />
148
-
149
- {isOpen && (
150
- <ul>
151
- {suggestions.map((s) => (
152
- <li key={s.id} onClick={() => {
153
- const resolved = selectSuggestion(s)
154
- setAddress(resolved)
155
- }}>
156
- {s.label}
157
- </li>
158
- ))}
159
- </ul>
160
- )}
161
-
162
- {address && (
163
- <div>
164
- <p>ตำบล: {address.tambon} ({address.tambonEn})</p>
165
- <p>อำเภอ: {address.amphure} ({address.amphureEn})</p>
166
- <p>จังหวัด: {address.province} ({address.provinceEn})</p>
167
- <p>รหัสไปรษณีย์: {address.zipCode}</p>
168
- </div>
169
- )}
170
- </div>
171
- )
172
- }
71
+ listProvinces(index) // 77, sorted by Thai name
72
+ listAmphures(index, provinceId)
73
+ listTambons(index, amphureId) // each with its zipCode
173
74
  ```
174
75
 
175
- ### Return ของ hook
76
+ Backed by pre-built groupings, not scans over all 7,385 records. Unknown ids return `[]`.
176
77
 
177
- | ค่า | ประเภท | คำอธิบาย |
178
- |---|---|---|
179
- | `query` | `string` | ข้อความที่ผู้ใช้พิมพ์อยู่ |
180
- | `setQuery` | `(value: string) => void` | อัปเดต query |
181
- | `suggestions` | `ThaiAddressSuggestion[]` | รายการผลลัพธ์ที่แสดงใน dropdown |
182
- | `isOpen` | `boolean` | `true` เมื่อมี query และมี suggestions |
183
- | `selectSuggestion` | `(item) => ResolvedThaiAddress` | เลือก suggestion แล้วได้ข้อมูลที่อยู่ครบ |
184
- | `clear` | `() => void` | ล้าง query และ suggestions ทั้งหมด |
78
+ ## React
185
79
 
186
- ---
187
-
188
- ## การใช้งานกับ Node.js / Express
189
-
190
- ```ts
191
- import express from 'express'
192
- import { loadDefaultIndex } from 'thaizip/data'
193
- import { searchThaiAddress, formatThaiAddressSuggestion } from 'thaizip'
80
+ ```tsx
81
+ import { useThaiAddressAutocomplete } from 'thaizip/react'
194
82
 
195
- const app = express()
83
+ const { query, setQuery, setQuerySilent, suggestions, isOpen, selectSuggestion, clear } =
84
+ useThaiAddressAutocomplete({ index })
85
+ ```
196
86
 
197
- // โหลด index ครั้งเดียวตอน startup
198
- const index = await loadDefaultIndex()
87
+ Options: `index`, `limit`, `debounce` (200ms), `threshold`, `zipLimit`, `locale`, `initialQuery`, `onSelect`.
199
88
 
200
- app.get('/address/search', (req, res) => {
201
- const query = String(req.query.q ?? '')
202
- if (!query) return res.json([])
89
+ | value | description |
90
+ |---|---|
91
+ | `query` / `setQuery` | input value; `setQuery` searches |
92
+ | `setQuerySilent` | set the value **without** searching or reopening the dropdown |
93
+ | `suggestions` | `ThaiAddressSuggestion[]` |
94
+ | `isOpen` | `query` is non-empty and suggestions exist |
95
+ | `selectSuggestion` | `(item) => ResolvedThaiAddress \| null` — clears suggestions, leaves `query` alone; `null` if the item is stale |
96
+ | `clear` | reset query and suggestions |
203
97
 
204
- const results = searchThaiAddress(index, query, { limit: 10 })
205
- res.json(results.map(formatThaiAddressSuggestion))
206
- })
98
+ ### Controlled forms
207
99
 
208
- app.listen(3000)
209
- ```
100
+ `selectSuggestion` leaves `query` untouched by design. To echo the choice back into the input use `setQuerySilent` — plain `setQuery` would immediately reopen the dropdown:
210
101
 
211
- เรียกใช้งาน:
212
- ```
213
- GET /address/search?q=ลาดพร้าว
214
- GET /address/search?q=10900
102
+ ```tsx
103
+ const handlePick = (s: ThaiAddressSuggestion) => {
104
+ const address = selectSuggestion(s)
105
+ if (!address) return
106
+ setQuerySilent(s.label)
107
+ form.setValue('address', address)
108
+ }
215
109
  ```
216
110
 
217
- ---
218
-
219
- ## ใช้ข้อมูล Index ของตัวเอง
111
+ Editing a saved address? Pass `initialQuery` rather than calling `setQuery` in an effect.
220
112
 
221
- หากต้องการสร้าง index จากข้อมูลที่กำหนดเอง
113
+ ### Accessibility
222
114
 
223
- ```ts
224
- import { buildThaiAddressIndex } from 'thaizip'
225
-
226
- const index = buildThaiAddressIndex({
227
- provinces: [...],
228
- amphures: [...],
229
- tambons: [...],
230
- // geographies ไม่จำเป็นต้องระบุ (ไม่ได้ใช้งานโดย indexer)
231
- }, {
232
- // รับ callback เมื่อ tambon ถูกข้ามเนื่องจากไม่พบ amphure/province
233
- onSkip: (tambon) => console.warn('skipped tambon:', tambon.id),
234
- })
235
- ```
115
+ 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.
236
116
 
237
- ### reset cache (สำหรับ test isolation)
117
+ ## Custom data
238
118
 
239
119
  ```ts
240
- import { clearDefaultIndex } from 'thaizip/data'
120
+ import { buildThaiAddressIndex, validateRawData } from 'thaizip'
241
121
 
242
- clearDefaultIndex() // รีเซ็ต singleton cache ของ loadDefaultIndex
122
+ const index = buildThaiAddressIndex({ provinces, amphures, tambons }, {
123
+ onSkip: (tambon) => console.warn('skipped:', tambon.id),
124
+ validate: true, // default
125
+ })
243
126
  ```
244
127
 
245
- รูปแบบข้อมูล raw ดูได้จาก type `RawData`, `RawProvince`, `RawAmphure`, `RawTambon` ที่ export มาจาก `thaizip`
128
+ 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. It costs nothing measurable on a full-size dataset. `validateRawData(data)` runs the same checks standalone.
246
129
 
247
- ---
130
+ `RawData` is meant to be trusted, application-controlled input — your own dataset, not arbitrary end-user uploads. `buildThaiAddressIndex` has no built-in cap on row count or field length; build cost scales linearly with total input size, but an unbounded or maliciously oversized payload (e.g. an admin importer fed directly to this function) can still cost real time and memory with nothing to stop it. If you ever build an index from untrusted input, enforce your own size limits (row count, field length) before calling `buildThaiAddressIndex`.
248
131
 
249
- ## Types หลัก
132
+ Types: `RawData`, `RawProvince`, `RawAmphure`, `RawTambon`.
250
133
 
251
- Types ทั้งหมดสามารถ import ได้จาก `thaizip` หรือ `thaizip/react` (React-specific types)
134
+ ## Types
252
135
 
253
136
  ```ts
254
- // จาก thaizip หรือ thaizip/react
255
137
  type ThaiAddressSuggestion = {
256
138
  id: string
257
- label: string // "ตำบล > อำเภอ > จังหวัด XXXXX"
258
- tambon: string
259
- tambonEn: string
260
- amphure: string
261
- amphureEn: string
262
- province: string
263
- provinceEn: string
139
+ label: string // follows `locale`, Thai by default
140
+ labelTh: string
141
+ labelEn: string
142
+ tambon: string; tambonEn: string
143
+ amphure: string; amphureEn: string
144
+ province: string; provinceEn: string
264
145
  zipCode: string
265
146
  }
266
147
 
267
- // จาก thaizip หรือ thaizip/react
268
148
  type ResolvedThaiAddress = {
269
- tambon: string // alias: subdistrict
270
- tambonEn: string // alias: subdistrictEn
271
- amphure: string // alias: district
272
- amphureEn: string // alias: districtEn
273
- province: string
274
- provinceEn: string
275
- zipCode: string // alias: postalCode
276
- subdistrict: string
277
- subdistrictEn: string
278
- district: string
279
- districtEn: string
149
+ tambon: string; tambonEn: string // alias: subdistrict / subdistrictEn
150
+ amphure: string; amphureEn: string // alias: district / districtEn
151
+ province: string; provinceEn: string
152
+ zipCode: string // alias: postalCode
153
+ subdistrict: string; subdistrictEn: string
154
+ district: string; districtEn: string
280
155
  postalCode: string
281
156
  }
282
157
  ```
283
158
 
284
- ---
285
-
286
- ## Migration จาก v0.5.x → v1.0.0
287
-
288
- ### hook ย้ายไปอยู่ที่ `thaizip/react`
289
-
290
- ```ts
291
- // v0.5.x
292
- import { useThaiAddressAutocomplete } from 'thaizip'
293
- import type { TrigramIndex } from 'thaizip'
294
-
295
- // v1.0.0
296
- import { useThaiAddressAutocomplete } from 'thaizip/react'
297
- import type { TrigramIndex, ResolvedThaiAddress, ThaiAddressSuggestion } from 'thaizip/react'
298
- // หรือยังคง import types จาก 'thaizip' ได้เช่นเดิม
299
- ```
300
-
301
- ### `clearDefaultIndex` ย้ายไปอยู่ที่ `thaizip/data`
302
-
303
- ```ts
304
- // v0.5.x
305
- import { clearDefaultIndex } from 'thaizip'
306
-
307
- // v1.0.0
308
- import { clearDefaultIndex } from 'thaizip/data'
309
- ```
310
-
311
- ---
312
-
313
- ## Migration จาก v0.2.x → v0.3.0+
314
-
315
- ```ts
316
- // v0.2.x (sync, bundle ~630KB)
317
- import { defaultIndex } from 'thaizip/data'
318
- const results = searchThaiAddress(defaultIndex, query)
319
-
320
- // v0.3.0+ (async, bundle ~132KB gzip)
321
- import { loadDefaultIndex } from 'thaizip/data'
322
- const index = await loadDefaultIndex() // cache อัตโนมัติ หลังจากโหลดครั้งแรก
323
- const results = searchThaiAddress(index, query)
324
- ```
325
-
326
- ---
327
-
328
- ## ข้อมูลที่อยู่
329
-
330
- ข้อมูลอ้างอิงจากการแบ่งเขตการปกครองของไทย (77 จังหวัด, 920 อำเภอ, ~7,385 ตำบล)
159
+ ## Data
331
160
 
332
- ตำบลที่ยังมีสถานะ active แต่อ้างอิงอำเภอหรือจังหวัดที่ถูก soft-deleted จะถูกข้ามโดย index โดยอัตโนมัติ ใช้ `onSkip` callback ใน `buildThaiAddressIndex` เพื่อรับแจ้ง
161
+ 77 provinces, 918 districts, 7,385 subdistricts. Records whose parent district or province is soft-deleted are excluded automatically.
333
162
 
334
- ---
163
+ `searchThaiAddress` is pure and framework-free — it works unchanged in Node, Vue, Svelte, or vanilla JS.
335
164
 
336
165
  ## License
337
166