thaizip 0.7.3 → 0.7.4

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,9 +1,17 @@
1
1
  # thaizip
2
2
 
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
+ [![license](https://img.shields.io/npm/l/thaizip.svg)](https://github.com/naay99999/thai-zip/blob/main/LICENSE)
6
+
3
7
  Fast fuzzy autocomplete for Thai addresses — subdistrict, district, province, postal code. Thai and English input, zero runtime dependencies, React optional.
4
8
 
5
9
  **Docs:** https://naay99999.github.io/thai-zip/ (ไทย / [English](https://naay99999.github.io/thai-zip/en/))
6
10
 
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) |
14
+
7
15
  ```bash
8
16
  npm install thaizip
9
17
  ```
@@ -59,6 +67,8 @@ lookupByZipCode(index, '45000') // exact
59
67
  lookupByZipCode(index, '450') // prefix
60
68
  ```
61
69
 
70
+ Direct ZIP lookup rejects raw inputs longer than 1000 characters before scanning them. A finite `zipLimit` also bounds how many matching records are collected.
71
+
62
72
  ### English input
63
73
 
64
74
  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`.
package/dist/data.cjs CHANGED
@@ -2,8 +2,13 @@
2
2
 
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __esm = (fn, res) => function __init() {
6
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ var __esm = (fn, res, err) => function __init() {
6
+ if (err) throw err[0];
7
+ try {
8
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
9
+ } catch (e) {
10
+ throw err = [e], e;
11
+ }
7
12
  };
8
13
  var __export = (target, all) => {
9
14
  for (var name in all)
package/dist/data.js CHANGED
@@ -1,7 +1,12 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __getOwnPropNames = Object.getOwnPropertyNames;
3
- var __esm = (fn, res) => function __init() {
4
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
3
+ var __esm = (fn, res, err) => function __init() {
4
+ if (err) throw err[0];
5
+ try {
6
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
7
+ } catch (e) {
8
+ throw err = [e], e;
9
+ }
5
10
  };
6
11
  var __export = (target, all) => {
7
12
  for (var name in all)
package/dist/index.cjs CHANGED
@@ -308,14 +308,6 @@ function compareZip(a, b) {
308
308
  if (a > b) return 1;
309
309
  return 0;
310
310
  }
311
- function sortZipMatches(matches, exactZip) {
312
- matches.sort((a, b) => {
313
- if (a.zipCode === exactZip && b.zipCode !== exactZip) return -1;
314
- if (b.zipCode === exactZip && a.zipCode !== exactZip) return 1;
315
- return compareZip(a.zipCode, b.zipCode);
316
- });
317
- return matches;
318
- }
319
311
  function normalizeLimit(value) {
320
312
  if (Number.isNaN(value) || value === -Infinity) return 0;
321
313
  if (value === Infinity) return value;
@@ -323,9 +315,11 @@ function normalizeLimit(value) {
323
315
  }
324
316
  function lookupByZipCode(index, zip, options) {
325
317
  if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
318
+ if (zip.length > 1e3) return [];
319
+ const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
320
+ if (zipLimit === 0) return [];
326
321
  const normalized = zip.trim();
327
322
  if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
328
- const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
329
323
  const matches = [];
330
324
  const keys = index.sortedZipKeys;
331
325
  const postings = index.sortedZipPostings;
@@ -337,17 +331,19 @@ function lookupByZipCode(index, zip, options) {
337
331
  if (keys[mid] < normalized) lo = mid + 1;
338
332
  else hi = mid;
339
333
  }
340
- for (let i = lo; i < keys.length && keys[i].startsWith(normalized); i++) {
334
+ for (let i = lo; i < keys.length && keys[i].startsWith(normalized) && matches.length < zipLimit; i++) {
341
335
  const indices = postings[i];
342
- for (let j = 0; j < indices.length; j++) matches.push(index.records[indices[j]]);
336
+ for (let j = 0; j < indices.length && matches.length < zipLimit; j++) matches.push(index.records[indices[j]]);
343
337
  }
344
338
  } else {
345
- for (const [z, indices] of index.zipIndex) {
346
- if (z.startsWith(normalized)) {
347
- for (const idx of indices) matches.push(index.records[idx]);
339
+ const matchingKeys = [...index.zipIndex.keys()].filter((z) => z.startsWith(normalized)).sort(compareZip);
340
+ for (const z of matchingKeys) {
341
+ if (matches.length >= zipLimit) break;
342
+ for (const idx of index.zipIndex.get(z)) {
343
+ if (matches.length >= zipLimit) break;
344
+ matches.push(index.records[idx]);
348
345
  }
349
346
  }
350
- sortZipMatches(matches, normalized);
351
347
  }
352
348
  return matches.slice(0, zipLimit).map((record) => ({ ...record }));
353
349
  }
@@ -389,6 +385,7 @@ function searchThaiAddress(index, query, options) {
389
385
  });
390
386
  }
391
387
  if (normalized.length < 3) return [];
388
+ if (limit === 0) return [];
392
389
  const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
393
390
  const queryTrigrams = extractTrigramsNormalized(searchText);
394
391
  const { counts, touched } = getScratch(index);
@@ -415,7 +412,11 @@ function searchThaiAddress(index, query, options) {
415
412
  if (b.score !== a.score) return b.score - a.score;
416
413
  return b.matchRank - a.matchRank;
417
414
  });
418
- const windowSize = Math.max(limit * 4, 50);
415
+ let windowSize = Math.min(limit, scored.length);
416
+ const boundary = scored[windowSize - 1];
417
+ while (windowSize < scored.length && scored[windowSize].score === boundary.score && scored[windowSize].matchRank === boundary.matchRank) {
418
+ windowSize++;
419
+ }
419
420
  const window = scored.slice(0, windowSize);
420
421
  window.sort((a, b) => {
421
422
  if (b.score !== a.score) return b.score - a.score;
package/dist/index.d.cts CHANGED
@@ -184,7 +184,8 @@ declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOption
184
184
  * prefix range — O(log n_zips + matches). Ascending key order places an
185
185
  * exact match before any longer prefix match automatically, so no explicit
186
186
  * exact-first sort is needed. Indexes without the sorted arrays (older
187
- * shape, hand-built) fall back to the original O(n_zips) scan + sort.
187
+ * shape, hand-built) scan and sort matching keys before collecting records.
188
+ * Both paths stop collecting when `zipLimit` is reached.
188
189
  *
189
190
  * Returned records are shallow copies: mutating them cannot corrupt the
190
191
  * shared index (see `searchThaiAddress`).
@@ -193,8 +194,8 @@ declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOption
193
194
  * only by `options.zipLimit` (defaults to `Infinity`), since a zip code can
194
195
  * legitimately map to dozens of tambons (e.g. `45000` → 33 records).
195
196
  *
196
- * Malformed runtime input (non-string `zip`, missing index shape) returns
197
- * `[]` rather than crashing deep inside the library.
197
+ * Malformed runtime input (non-string `zip`, missing index shape) and raw
198
+ * strings longer than 1000 characters return `[]`.
198
199
  */
199
200
  declare function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[];
200
201
  declare function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[];
package/dist/index.d.ts CHANGED
@@ -184,7 +184,8 @@ declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOption
184
184
  * prefix range — O(log n_zips + matches). Ascending key order places an
185
185
  * exact match before any longer prefix match automatically, so no explicit
186
186
  * exact-first sort is needed. Indexes without the sorted arrays (older
187
- * shape, hand-built) fall back to the original O(n_zips) scan + sort.
187
+ * shape, hand-built) scan and sort matching keys before collecting records.
188
+ * Both paths stop collecting when `zipLimit` is reached.
188
189
  *
189
190
  * Returned records are shallow copies: mutating them cannot corrupt the
190
191
  * shared index (see `searchThaiAddress`).
@@ -193,8 +194,8 @@ declare function buildThaiAddressIndex(data: RawData, options?: BuildIndexOption
193
194
  * only by `options.zipLimit` (defaults to `Infinity`), since a zip code can
194
195
  * legitimately map to dozens of tambons (e.g. `45000` → 33 records).
195
196
  *
196
- * Malformed runtime input (non-string `zip`, missing index shape) returns
197
- * `[]` rather than crashing deep inside the library.
197
+ * Malformed runtime input (non-string `zip`, missing index shape) and raw
198
+ * strings longer than 1000 characters return `[]`.
198
199
  */
199
200
  declare function lookupByZipCode(index: TrigramIndex, zip: string, options?: SearchOptions): ThaiAddressRecord[];
200
201
  declare function searchThaiAddress(index: TrigramIndex, query: string, options?: SearchOptions): ThaiAddressRecord[];
package/dist/index.js CHANGED
@@ -306,14 +306,6 @@ function compareZip(a, b) {
306
306
  if (a > b) return 1;
307
307
  return 0;
308
308
  }
309
- function sortZipMatches(matches, exactZip) {
310
- matches.sort((a, b) => {
311
- if (a.zipCode === exactZip && b.zipCode !== exactZip) return -1;
312
- if (b.zipCode === exactZip && a.zipCode !== exactZip) return 1;
313
- return compareZip(a.zipCode, b.zipCode);
314
- });
315
- return matches;
316
- }
317
309
  function normalizeLimit(value) {
318
310
  if (Number.isNaN(value) || value === -Infinity) return 0;
319
311
  if (value === Infinity) return value;
@@ -321,9 +313,11 @@ function normalizeLimit(value) {
321
313
  }
322
314
  function lookupByZipCode(index, zip, options) {
323
315
  if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
316
+ if (zip.length > 1e3) return [];
317
+ const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
318
+ if (zipLimit === 0) return [];
324
319
  const normalized = zip.trim();
325
320
  if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
326
- const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
327
321
  const matches = [];
328
322
  const keys = index.sortedZipKeys;
329
323
  const postings = index.sortedZipPostings;
@@ -335,17 +329,19 @@ function lookupByZipCode(index, zip, options) {
335
329
  if (keys[mid] < normalized) lo = mid + 1;
336
330
  else hi = mid;
337
331
  }
338
- for (let i = lo; i < keys.length && keys[i].startsWith(normalized); i++) {
332
+ for (let i = lo; i < keys.length && keys[i].startsWith(normalized) && matches.length < zipLimit; i++) {
339
333
  const indices = postings[i];
340
- for (let j = 0; j < indices.length; j++) matches.push(index.records[indices[j]]);
334
+ for (let j = 0; j < indices.length && matches.length < zipLimit; j++) matches.push(index.records[indices[j]]);
341
335
  }
342
336
  } else {
343
- for (const [z, indices] of index.zipIndex) {
344
- if (z.startsWith(normalized)) {
345
- for (const idx of indices) matches.push(index.records[idx]);
337
+ const matchingKeys = [...index.zipIndex.keys()].filter((z) => z.startsWith(normalized)).sort(compareZip);
338
+ for (const z of matchingKeys) {
339
+ if (matches.length >= zipLimit) break;
340
+ for (const idx of index.zipIndex.get(z)) {
341
+ if (matches.length >= zipLimit) break;
342
+ matches.push(index.records[idx]);
346
343
  }
347
344
  }
348
- sortZipMatches(matches, normalized);
349
345
  }
350
346
  return matches.slice(0, zipLimit).map((record) => ({ ...record }));
351
347
  }
@@ -387,6 +383,7 @@ function searchThaiAddress(index, query, options) {
387
383
  });
388
384
  }
389
385
  if (normalized.length < 3) return [];
386
+ if (limit === 0) return [];
390
387
  const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
391
388
  const queryTrigrams = extractTrigramsNormalized(searchText);
392
389
  const { counts, touched } = getScratch(index);
@@ -413,7 +410,11 @@ function searchThaiAddress(index, query, options) {
413
410
  if (b.score !== a.score) return b.score - a.score;
414
411
  return b.matchRank - a.matchRank;
415
412
  });
416
- const windowSize = Math.max(limit * 4, 50);
413
+ let windowSize = Math.min(limit, scored.length);
414
+ const boundary = scored[windowSize - 1];
415
+ while (windowSize < scored.length && scored[windowSize].score === boundary.score && scored[windowSize].matchRank === boundary.matchRank) {
416
+ windowSize++;
417
+ }
417
418
  const window = scored.slice(0, windowSize);
418
419
  window.sort((a, b) => {
419
420
  if (b.score !== a.score) return b.score - a.score;
package/dist/react.cjs CHANGED
@@ -152,14 +152,6 @@ function compareZip(a, b) {
152
152
  if (a > b) return 1;
153
153
  return 0;
154
154
  }
155
- function sortZipMatches(matches, exactZip) {
156
- matches.sort((a, b) => {
157
- if (a.zipCode === exactZip && b.zipCode !== exactZip) return -1;
158
- if (b.zipCode === exactZip && a.zipCode !== exactZip) return 1;
159
- return compareZip(a.zipCode, b.zipCode);
160
- });
161
- return matches;
162
- }
163
155
  function normalizeLimit(value) {
164
156
  if (Number.isNaN(value) || value === -Infinity) return 0;
165
157
  if (value === Infinity) return value;
@@ -167,9 +159,11 @@ function normalizeLimit(value) {
167
159
  }
168
160
  function lookupByZipCode(index, zip, options) {
169
161
  if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
162
+ if (zip.length > 1e3) return [];
163
+ const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
164
+ if (zipLimit === 0) return [];
170
165
  const normalized = zip.trim();
171
166
  if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
172
- const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
173
167
  const matches = [];
174
168
  const keys = index.sortedZipKeys;
175
169
  const postings = index.sortedZipPostings;
@@ -181,17 +175,19 @@ function lookupByZipCode(index, zip, options) {
181
175
  if (keys[mid] < normalized) lo = mid + 1;
182
176
  else hi = mid;
183
177
  }
184
- for (let i = lo; i < keys.length && keys[i].startsWith(normalized); i++) {
178
+ for (let i = lo; i < keys.length && keys[i].startsWith(normalized) && matches.length < zipLimit; i++) {
185
179
  const indices = postings[i];
186
- for (let j = 0; j < indices.length; j++) matches.push(index.records[indices[j]]);
180
+ for (let j = 0; j < indices.length && matches.length < zipLimit; j++) matches.push(index.records[indices[j]]);
187
181
  }
188
182
  } else {
189
- for (const [z, indices] of index.zipIndex) {
190
- if (z.startsWith(normalized)) {
191
- for (const idx of indices) matches.push(index.records[idx]);
183
+ const matchingKeys = [...index.zipIndex.keys()].filter((z) => z.startsWith(normalized)).sort(compareZip);
184
+ for (const z of matchingKeys) {
185
+ if (matches.length >= zipLimit) break;
186
+ for (const idx of index.zipIndex.get(z)) {
187
+ if (matches.length >= zipLimit) break;
188
+ matches.push(index.records[idx]);
192
189
  }
193
190
  }
194
- sortZipMatches(matches, normalized);
195
191
  }
196
192
  return matches.slice(0, zipLimit).map((record) => ({ ...record }));
197
193
  }
@@ -233,6 +229,7 @@ function searchThaiAddress(index, query, options) {
233
229
  });
234
230
  }
235
231
  if (normalized.length < 3) return [];
232
+ if (limit === 0) return [];
236
233
  const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
237
234
  const queryTrigrams = extractTrigramsNormalized(searchText);
238
235
  const { counts, touched } = getScratch(index);
@@ -259,7 +256,11 @@ function searchThaiAddress(index, query, options) {
259
256
  if (b.score !== a.score) return b.score - a.score;
260
257
  return b.matchRank - a.matchRank;
261
258
  });
262
- const windowSize = Math.max(limit * 4, 50);
259
+ let windowSize = Math.min(limit, scored.length);
260
+ const boundary = scored[windowSize - 1];
261
+ while (windowSize < scored.length && scored[windowSize].score === boundary.score && scored[windowSize].matchRank === boundary.matchRank) {
262
+ windowSize++;
263
+ }
263
264
  const window = scored.slice(0, windowSize);
264
265
  window.sort((a, b) => {
265
266
  if (b.score !== a.score) return b.score - a.score;
package/dist/react.js CHANGED
@@ -150,14 +150,6 @@ function compareZip(a, b) {
150
150
  if (a > b) return 1;
151
151
  return 0;
152
152
  }
153
- function sortZipMatches(matches, exactZip) {
154
- matches.sort((a, b) => {
155
- if (a.zipCode === exactZip && b.zipCode !== exactZip) return -1;
156
- if (b.zipCode === exactZip && a.zipCode !== exactZip) return 1;
157
- return compareZip(a.zipCode, b.zipCode);
158
- });
159
- return matches;
160
- }
161
153
  function normalizeLimit(value) {
162
154
  if (Number.isNaN(value) || value === -Infinity) return 0;
163
155
  if (value === Infinity) return value;
@@ -165,9 +157,11 @@ function normalizeLimit(value) {
165
157
  }
166
158
  function lookupByZipCode(index, zip, options) {
167
159
  if (!index || !index.zipIndex || !index.records || typeof zip !== "string") return [];
160
+ if (zip.length > 1e3) return [];
161
+ const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
162
+ if (zipLimit === 0) return [];
168
163
  const normalized = zip.trim();
169
164
  if (!ZIP_CODE_RE.test(normalized) || normalized.length < 2) return [];
170
- const zipLimit = normalizeLimit(options?.zipLimit ?? Infinity);
171
165
  const matches = [];
172
166
  const keys = index.sortedZipKeys;
173
167
  const postings = index.sortedZipPostings;
@@ -179,17 +173,19 @@ function lookupByZipCode(index, zip, options) {
179
173
  if (keys[mid] < normalized) lo = mid + 1;
180
174
  else hi = mid;
181
175
  }
182
- for (let i = lo; i < keys.length && keys[i].startsWith(normalized); i++) {
176
+ for (let i = lo; i < keys.length && keys[i].startsWith(normalized) && matches.length < zipLimit; i++) {
183
177
  const indices = postings[i];
184
- for (let j = 0; j < indices.length; j++) matches.push(index.records[indices[j]]);
178
+ for (let j = 0; j < indices.length && matches.length < zipLimit; j++) matches.push(index.records[indices[j]]);
185
179
  }
186
180
  } else {
187
- for (const [z, indices] of index.zipIndex) {
188
- if (z.startsWith(normalized)) {
189
- for (const idx of indices) matches.push(index.records[idx]);
181
+ const matchingKeys = [...index.zipIndex.keys()].filter((z) => z.startsWith(normalized)).sort(compareZip);
182
+ for (const z of matchingKeys) {
183
+ if (matches.length >= zipLimit) break;
184
+ for (const idx of index.zipIndex.get(z)) {
185
+ if (matches.length >= zipLimit) break;
186
+ matches.push(index.records[idx]);
190
187
  }
191
188
  }
192
- sortZipMatches(matches, normalized);
193
189
  }
194
190
  return matches.slice(0, zipLimit).map((record) => ({ ...record }));
195
191
  }
@@ -231,6 +227,7 @@ function searchThaiAddress(index, query, options) {
231
227
  });
232
228
  }
233
229
  if (normalized.length < 3) return [];
230
+ if (limit === 0) return [];
234
231
  const searchText = options?.romanizationAliases !== false && HAS_LATIN_RE.test(normalized) ? applyRomanizationAliases(normalized) : normalized;
235
232
  const queryTrigrams = extractTrigramsNormalized(searchText);
236
233
  const { counts, touched } = getScratch(index);
@@ -257,7 +254,11 @@ function searchThaiAddress(index, query, options) {
257
254
  if (b.score !== a.score) return b.score - a.score;
258
255
  return b.matchRank - a.matchRank;
259
256
  });
260
- const windowSize = Math.max(limit * 4, 50);
257
+ let windowSize = Math.min(limit, scored.length);
258
+ const boundary = scored[windowSize - 1];
259
+ while (windowSize < scored.length && scored[windowSize].score === boundary.score && scored[windowSize].matchRank === boundary.matchRank) {
260
+ windowSize++;
261
+ }
261
262
  const window = scored.slice(0, windowSize);
262
263
  window.sort((a, b) => {
263
264
  if (b.score !== a.score) return b.score - a.score;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thaizip",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Fast, fuzzy Thai address autocomplete library (headless + React)",
5
5
  "keywords": [
6
6
  "thai",
@@ -58,14 +58,17 @@
58
58
  "@testing-library/react": "^16.3.2",
59
59
  "@types/node": "^25.5.2",
60
60
  "@types/react": "^19.2.14",
61
- "@vitest/coverage-v8": "^4.1.2",
61
+ "@vitest/coverage-v8": "^4.1.11",
62
62
  "jsdom": "^29.0.1",
63
63
  "react": "^19.2.4",
64
64
  "react-dom": "^19.2.4",
65
65
  "tsup": "^8.5.1",
66
- "tsx": "^4.21.0",
66
+ "tsx": "^4.23.13",
67
67
  "typescript": "^6.0.2",
68
- "vitest": "^4.1.2"
68
+ "vitest": "^4.1.11"
69
+ },
70
+ "overrides": {
71
+ "esbuild": "^0.28.1"
69
72
  },
70
73
  "peerDependencies": {
71
74
  "react": ">=18.0.0"