@codady/utils 0.0.38 → 0.0.39

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/src/ajax.ts ADDED
@@ -0,0 +1,450 @@
1
+ /**
2
+ * @since Last modified: 2026/01/20 16:38:21
3
+ * Sends an asynchronous HTTP request (AJAX).
4
+ * @function ajax
5
+ * @param {AjaxOptions} options - Configuration for the request.
6
+ * @returns {Promise<AjaxResponse>} Returns a promise that resolves with the response context.
7
+ * @example
8
+ * ajax({ url: '/api/data', method: 'GET' }).then(res => console.log(res.content));
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import isEmpty from './isEmpty';
14
+ import getDataType from './getDataType';
15
+ import getBodyHTML from './getBodyHTML';
16
+ import getUrlHash from './getUrlHash';
17
+ import capitalize from './capitalize';
18
+ import buildUrl from './buildUrl';
19
+ import cleanQueryString from './cleanQueryString';
20
+
21
+ /**
22
+ * Interface for the AJAX configuration options.
23
+ */
24
+ interface AjaxOptions {
25
+ url: string; // Request URL
26
+ method?: string | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD' | 'TRACE';
27
+ async?: boolean; // Whether the request is asynchronous
28
+ data?: any; // Data to be sent
29
+ selector?: string; // Selector to extract specific content from HTML response
30
+ timeout?: number; // Request timeout in milliseconds
31
+ headers?: Record<string, string>; // HTTP headers
32
+ responseType?: XMLHttpRequestResponseType; // Expected response type (json, blob, etc.)
33
+ catchError?: boolean; // Whether to reject the promise on error/timeout
34
+ signal?: AbortSignal; // AbortSignal for canceling the request
35
+ xhrFields?: Record<string, any>; // Additional fields to set on the XHR object
36
+ cacheBustKey?: string,
37
+ // Callbacks
38
+ onAbort?: ((resp: AjaxResponse) => void) | null;
39
+ onTimeout?: ((resp: AjaxResponse) => void) | null;
40
+ onOpened?: ((resp: AjaxResponse) => void) | null;
41
+ onHeadersReceived?: ((resp: AjaxResponse) => void) | null;
42
+ onLoading?: ((resp: AjaxResponse) => void) | null;
43
+ onBeforeSend?: ((resp: AjaxResponse) => void) | null;
44
+ onDownload?: ((resp: AjaxResponse) => void) | null;
45
+ onUpload?: ((resp: AjaxResponse) => void) | null;
46
+ onComplete?: ((resp: AjaxResponse) => void) | null; // Called when upload/download reaches 100%
47
+ onSuccess?: ((resp: AjaxResponse) => void) | null;
48
+ onFailure?: ((resp: AjaxResponse) => void) | null;
49
+ onInformation?: ((resp: AjaxResponse) => void) | null;
50
+ onRedirection?: ((resp: AjaxResponse) => void) | null;
51
+ onUnknownError?: ((resp: AjaxResponse) => void) | null;
52
+ onClientError?: ((resp: AjaxResponse) => void) | null;
53
+ onServerError?: ((resp: AjaxResponse) => void) | null;
54
+ onError?: ((resp: AjaxResponse) => void) | null;
55
+ onFinish?: ((resp: AjaxResponse) => void) | null; // Called on both success and failure
56
+ onCreated?: ((resp: AjaxResponse) => void) | null;
57
+ }
58
+
59
+ /**
60
+ * Interface for the response object passed to callbacks.
61
+ */
62
+ interface AjaxResponse {
63
+ xhr: XMLHttpRequest;
64
+ data: any; // The processed data sent in the request
65
+ abort: () => void; // Function to manually abort the request
66
+ status: number | string;
67
+ content: any; // The response body (parsed JSON, DOM string, etc.)
68
+ stage: number; // XHR readyState (0-4)
69
+ type: string; // Current stage name (e.g., 'success', 'timeout')
70
+ progress: {
71
+ name?: 'upload' | 'download';
72
+ loaded?: number;
73
+ total?: number;
74
+ timestamp?: number;
75
+ ratio?: number;
76
+ percent?: number;
77
+ };
78
+ }
79
+
80
+ const ajax = (options: AjaxOptions) => {
81
+ // Validation
82
+ if (isEmpty(options)) {
83
+ return Promise.reject(new Error('Options are required'));
84
+ }
85
+
86
+ if (!options.url || typeof options.url !== 'string') {
87
+ return Promise.reject(new Error('URL is required and must be a string'));
88
+ }
89
+
90
+ // Default configuration
91
+ const config: Required<AjaxOptions> = {
92
+ url: '',
93
+ method: 'POST',
94
+ async: true,
95
+ selector: '',
96
+ data: null,
97
+ timeout: 3600000,
98
+ headers: {},
99
+ responseType: '',
100
+ catchError: false,
101
+ signal: null as any,
102
+ xhrFields: {},
103
+ cacheBustKey: '_t',
104
+ //
105
+ onAbort: null,
106
+ onTimeout: null,
107
+ //
108
+ onBeforeSend: null,
109
+ //
110
+ onCreated: null,
111
+ onOpened: null,
112
+ onHeadersReceived: null,
113
+ onLoading: null,
114
+ //
115
+ onSuccess: null,
116
+ onFailure: null,
117
+ onInformation: null,
118
+ onRedirection: null,
119
+ onClientError: null,
120
+ onServerError: null,
121
+ onUnknownError: null,
122
+ onError: null,
123
+ onFinish: null,
124
+ //
125
+ onDownload: null,
126
+ onUpload: null,
127
+ onComplete: null,
128
+ };
129
+
130
+ //合并参数
131
+ Object.assign(config, options);
132
+
133
+ //
134
+ const method = config.method.toUpperCase() || 'POST',
135
+ methodsWithoutBody = ['GET', 'HEAD', 'TRACE'];
136
+
137
+ //创建XMLHttpRequest
138
+ let xhr: any = new XMLHttpRequest(),
139
+ //设置发送数据和预设请求头
140
+ requestData: any = null,
141
+ headerContentType = config?.headers?.['Content-Type'] || config?.headers?.['content-type'],
142
+ removeHeader = () => {
143
+ if (headerContentType) {
144
+ delete config.headers['Content-Type'];
145
+ delete config.headers['content-type'];
146
+ }
147
+ }
148
+ if (!isEmpty(config.data)) {
149
+
150
+ let dataType = getDataType(config.data)
151
+ if (dataType === 'FormData') {
152
+ //如果是new FormData格式,直接相等
153
+ requestData = config.data;
154
+ // 不需要手动设置Content-Type,浏览器会自动设置
155
+ //config.contType = 'multipart/form-data';
156
+ removeHeader();
157
+ } else if (dataType === 'Object') {
158
+ //如果是对象格式{name:'',age:''}
159
+ //并且此时已经设置了contType
160
+ if (!headerContentType) {
161
+ //如果未设置则默认设为如下contType
162
+ //Content-Type=application/x-www-form-urlencoded
163
+ /* for (let k in config.data) {
164
+ requestData += '&' + k + '=' + config.data[k];
165
+ } */
166
+ requestData = new URLSearchParams(config.data).toString();
167
+ //URLSearchParams.toString => `a=1&b=3`
168
+ //非get、head方法修正content-type
169
+ if (!methodsWithoutBody.includes(method)) {
170
+ config.headers['Content-Type'] = 'application/x-www-form-urlencoded';
171
+ }
172
+ } else if (headerContentType?.includes('application/json')) {
173
+ //Content-Type=application/json或contentType=application/json
174
+ requestData = JSON.stringify(config.data);
175
+ } else {
176
+ requestData = config.data;
177
+ }
178
+ } else if (dataType === 'String') {
179
+ //未设置或,已经设置了Content-Type=application/x-www-form-urlencoded
180
+ if (!headerContentType || headerContentType.includes('urlencoded')) {
181
+ //如果是name=''&age=''字符串
182
+ //?name=''&age=''或&name=''&age=''统一去掉第一个&/?
183
+ requestData = cleanQueryString(config.data.trim());
184
+ //非get、head方法修正content-type
185
+ if (!methodsWithoutBody.includes(method) && !headerContentType) {
186
+ config.headers['Content-Type'] = 'application/x-www-form-urlencoded';
187
+ }
188
+ } else {
189
+ requestData = config.data;
190
+ }
191
+ } else {
192
+ requestData = config.data;
193
+ }
194
+ }
195
+
196
+ //设置超时时间
197
+ xhr.timeout = config.timeout;
198
+ // 响应类型
199
+ if (config.responseType) {
200
+ xhr.responseType = config.responseType as XMLHttpRequestResponseType;
201
+ }
202
+
203
+ //返回promise
204
+
205
+ const result = new Promise((resolve, reject) => {
206
+
207
+ //超时监听
208
+ const timeoutHandler = () => {
209
+ cleanup();
210
+ let resp = { ...context, status: xhr.status, content: xhr.response, type: 'timeout' };
211
+ //回调,status和content在此确认
212
+ config?.onTimeout?.(resp);
213
+ //reject只能接受一个参数
214
+ config.catchError ? reject(resp) : resolve(resp);
215
+ },
216
+ //报错监听
217
+ errorHandler = (resp: any) => {
218
+ //这几个错误来自xhr.onreadystatechange
219
+ if (resp.type === 'client-error') {
220
+ config?.onClientError?.({ ...context });
221
+ } else if (resp.type === 'server-error') {
222
+ config?.onServerError?.({ ...context });
223
+ } else if (resp.type === 'unknown-error') {
224
+ config?.onUnknownError?.({ ...context });
225
+ }
226
+ //此外还会有xhr.onerror的错误,所以需要统一使用onError监听
227
+
228
+ config?.onError?.(resp);
229
+ //reject只能接受一个参数
230
+ config.catchError ? reject(resp) : resolve(resp);
231
+ },
232
+ //取消监听
233
+ abortHandler = () => {
234
+ cleanup();
235
+ const resp = { ...context, status: xhr.status, type: 'abort' }
236
+ config.catchError ? reject(resp) : resolve(resp);
237
+ //回调,status和content在此确认
238
+ config?.onAbort?.(resp);
239
+ },
240
+ abortHandlerWithSignal = () => {
241
+ //先中止请求,防止触发其他 readystate 事件
242
+ xhr.abort();
243
+ abortHandler();
244
+ },
245
+ //成功监听
246
+ successHandler = (resp: any) => {
247
+ //成功回调
248
+ config?.onSuccess?.(resp);
249
+ //resolve只能接受一个参数
250
+ resolve(resp);
251
+ },
252
+ //统一处理abort
253
+ cleanup = () => {
254
+ // 如果使用了AbortSignal,则移除它的事件监听器
255
+ config.signal && config.signal.removeEventListener('abort', abortHandlerWithSignal);
256
+ // 移除各类事件监听器
257
+ config.onError && xhr.removeEventListener('error', errorHandler);
258
+ config.onTimeout && xhr.removeEventListener('timeout', timeoutHandler);
259
+ // 解绑上传/下载进度事件
260
+ config.onUpload && xhr.upload.removeEventListener('progress', uploadProgressHandler);
261
+ config.onDownload && xhr.removeEventListener('progress', downloadProgressHandler);
262
+ //销毁
263
+ xhr.onreadystatechange = null;
264
+ },
265
+ // Context object to track state
266
+ context: AjaxResponse = {
267
+ //原始xhr
268
+ xhr,
269
+ //发送的数据
270
+ data: requestData,
271
+ //可取消的函数
272
+ abort: abortHandler,
273
+ //xhr.status
274
+ status: '',
275
+ //响应的内容
276
+ content: null,
277
+ //0~4阶段编号
278
+ stage: 0,
279
+ //阶段名称
280
+ type: 'unset',
281
+ //上传和下载进度
282
+ progress: {}
283
+ },
284
+ //定义进度函数
285
+ progressHandler = (name: 'upload' | 'download', data: any, callback: Function) => {
286
+ if (data.lengthComputable) {
287
+ const resp = { ...context, status: xhr.status },
288
+ ratio = data.loaded / data.total
289
+ resp.progress = {
290
+ name,
291
+ loaded: data.loaded,
292
+ total: data.total,
293
+ timestamp: (new Date(data.timeStamp)).getTime(),
294
+ ratio,
295
+ percent: Math.round(ratio * 100),
296
+ }
297
+ callback?.(resp);
298
+ //到达100%执行complete
299
+ if ((resp.progress.percent as number) >= 100) {
300
+ resp.progress.percent = 100;
301
+ config?.onComplete?.(resp);
302
+ }
303
+ }
304
+ }, uploadProgressHandler = (data: any) => {
305
+ progressHandler('upload', data, (resp: any) => (config.onUpload as Function)(resp));
306
+ },
307
+ downloadProgressHandler = (data: any) => {
308
+ progressHandler('download', data, (resp: any) => (config.onDownload as Function)(resp));
309
+ };
310
+
311
+
312
+ //使用AbortSignal
313
+ if (config.signal) {
314
+ if (config.signal.aborted) return abortHandlerWithSignal();
315
+ config.signal.addEventListener('abort', abortHandlerWithSignal);
316
+ }
317
+
318
+ //监听上传进度
319
+ config.onUpload && xhr.upload.addEventListener('progress', uploadProgressHandler);
320
+
321
+ //监听下载进度
322
+ config.onDownload && xhr.addEventListener('progress', downloadProgressHandler);
323
+
324
+ // 事件监听器
325
+ config.onError && xhr.addEventListener('error', errorHandler);
326
+ config.onTimeout && xhr.addEventListener('timeout', timeoutHandler);
327
+ config.onAbort && xhr.addEventListener('abort', abortHandler);
328
+
329
+ // 手动触发 Created 状态
330
+ config.onCreated?.({ ...context, type: 'created' });
331
+
332
+
333
+
334
+ //状态判断
335
+ xhr.onreadystatechange = function () {
336
+ context.stage = xhr.readyState;
337
+ context.status = xhr.status;
338
+ const statusMap: Record<number, string> = { 1: 'opened', 2: 'headersReceived', 3: 'loading' };
339
+ //0=created放在外侧确保能触发,如果放在.onreadystatechange可能触发不了
340
+ if (xhr.readyState < 4) {
341
+ if (!xhr.readyState) return;
342
+ context.type = statusMap[xhr.readyState];
343
+ (config as any)[`on${capitalize(context.type)}`]?.({ ...context });
344
+ return;
345
+ }
346
+ //已经请求成功,不会有timeout事件,也不需要abort了,所以移除abort事件
347
+ cleanup();
348
+
349
+ const isInformation = xhr.status >= 100 && xhr.status < 200,
350
+ isSuccess = (xhr.status >= 200 && xhr.status < 300) || xhr.status === 304,
351
+ isRedirection = xhr.status >= 300 && xhr.status < 400,
352
+ isClientError = xhr.status >= 400 && xhr.status < 500,
353
+ isServerError = xhr.status >= 500 && xhr.status < 600;
354
+
355
+
356
+ //已经获得返回数据
357
+ if (isSuccess) {
358
+ if (!config.responseType || xhr.responseType === 'text') {
359
+ //可能返回字符串类型的对象,wordpress的REST API
360
+ let trim = xhr.responseText.trim(),
361
+ content = '';
362
+ if ((trim.startsWith('[') && trim.endsWith(']')) || (trim.startsWith('{') && trim.endsWith('}'))) {
363
+ //通过判断开头字符是{或[来确定异步页面是否是JSON内容,如果是则转成JSON对象
364
+ try {
365
+ content = JSON.parse(trim);
366
+ } catch {
367
+ console.warn('Malformed JSON detected, falling back to text.');
368
+ content = xhr.responseText;
369
+ }
370
+ } else if (/(<\/html>|<\/body>)/i.test(trim)) {
371
+ //请求了一个HTML页面
372
+ //返回文本类型DOMstring
373
+ let urlHash = getUrlHash(config.url);
374
+ content = getBodyHTML(trim, config.selector || urlHash);
375
+ } else {
376
+ //普通文本,不做任何处理
377
+ content = xhr.responseText;
378
+ }
379
+ //content=文本字符串/json
380
+ context.content = content;
381
+ } else {
382
+ //content=json、blob、document、arraybuffer等类型,如果知道服务器返回的XML, xhr.responseType应该为document
383
+ context.content = xhr.response;
384
+ }
385
+ context.type = 'success';
386
+ successHandler({ ...context });
387
+ } else {
388
+ //失败回调
389
+ context.content = xhr.response;
390
+ context.type = isInformation ? 'infomation' : isRedirection ? 'redirection' : isClientError ? 'client-error' : isServerError ? 'server-error' : 'unknown-error';
391
+ //
392
+ if (isInformation) {
393
+ config?.onInformation?.({ ...context });
394
+ } else if (isRedirection) {
395
+ config?.onRedirection?.({ ...context });
396
+ } else {
397
+ errorHandler({ ...context });
398
+ }
399
+ //
400
+ config?.onFailure?.({ ...context });
401
+ }
402
+ config?.onFinish?.({ ...context });
403
+ };
404
+
405
+ //发送异步请求
406
+ let openParams: (string | boolean)[] = [method, config.url, config.async];
407
+ if (methodsWithoutBody.includes(method)) {
408
+ // 拼接url => xxx.com?a=0&b=1#hello
409
+ const url = buildUrl({
410
+ url: config.url,
411
+ data: requestData,
412
+ cacheBustKey: config.cacheBustKey,
413
+ appendCacheBust: true,
414
+ });
415
+ openParams = [method, url, config.async];
416
+ }
417
+
418
+ //设置xhr其他字段
419
+ for (let k in config.xhrFields) {
420
+ config.xhrFields.hasOwnProperty(k) && (xhr[k] = config.xhrFields[k]);
421
+ }
422
+ //与服务器建立连接
423
+ xhr.open(...openParams);
424
+
425
+ //有则设置,仅跳过空内容
426
+ for (let k in config.headers) {
427
+ config.headers.hasOwnProperty(k) && !isEmpty(config.headers[k]) && xhr.setRequestHeader(k, config.headers[k]);
428
+ }
429
+
430
+ config?.onBeforeSend?.(({ ...context, status: xhr.status, type: 'beforeSend' }));
431
+ //发送请求,get和head不需要发送数据
432
+ xhr.send(methodsWithoutBody.includes(method) ? null : (requestData || null));
433
+ //open和send阶段已经是异步了,无法使用try+catch捕获错误
434
+ });
435
+ //绑定xhr和abort
436
+ (result as any).xhr = xhr;
437
+ (result as any).abort = () => xhr.abort();
438
+ return result;
439
+ };
440
+ // Static Helper Methods
441
+ //get、head、trace是不需要发送数据的,data将被转为url参数处理
442
+ ['post', 'put', 'delete', 'patch', 'options', 'get', 'head', 'trace'].forEach(method => {
443
+ (ajax as any)[method] = (url: string, data: any, options: AjaxOptions = { url: '' }) =>
444
+ ajax({ ...options, method, url, data });
445
+ });
446
+
447
+ ajax.all = (requests: AjaxOptions[]): Promise<AjaxResponse[]> => Promise.all(requests.map(ajax) as any);
448
+
449
+
450
+ export default ajax;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @since Last modified: 2026/01/20 13:56:36
3
+ * Builds a URL with query parameters, an optional cache-busting key, and preserves the hash.
4
+ * This function processes the base URL, appends query parameters, and optionally adds a cache-busting parameter.
5
+ * It also keeps the hash fragment intact if it exists in the original URL.
6
+ *
7
+ * @param {string} url - The original URL string that may or may not contain query parameters and a hash.
8
+ * @param {string | Record<string, string> | URLSearchParams} [data] - The data to be appended as query parameters.
9
+ * This can be a query string, an object, or a `URLSearchParams` object.
10
+ * @param {string} [cacheBustKey='_t'] - The cache-busting query parameter key to be appended to the URL. Default is '_t'.
11
+ * @param {boolean} [appendCacheBust=true] - A flag to indicate whether to append the cache-busting parameter. Default is true.
12
+ *
13
+ * @returns {string} The final constructed URL with the original URL, query parameters, and hash (if any).
14
+ *
15
+ * @example
16
+ * buildUrl({
17
+ * url: '/api/data',
18
+ * data: { key: 'value' },
19
+ * cacheBustKey: '_cache',
20
+ * appendCacheBust: true
21
+ * });
22
+ * // Returns: '/api/data?key=value&_cache=1638311234567'
23
+ */
24
+ import cleanQueryString from "./cleanQueryString";
25
+ import getDataType from "./getDataType";
26
+ import isEmpty from "./isEmpty";
27
+ const buildUrl = ({ url, data, cacheBustKey = '_t', appendCacheBust = true }) => {
28
+ // 1. Extract and remove the hash (e.g., /page#section -> hash="#section")
29
+ const hashIndex = url.indexOf('#');
30
+ let hash = '', pureUrl = url;
31
+ // If a hash exists, separate it from the base URL
32
+ if (hashIndex !== -1) {
33
+ hash = url.slice(hashIndex);
34
+ pureUrl = url.slice(0, hashIndex);
35
+ }
36
+ // 2. Use the URL object to handle the base URL and existing query parameters.
37
+ // `window.location.origin` ensures the support for relative paths (e.g., '/api/list').
38
+ const urlObj = new URL(pureUrl, window.location.origin);
39
+ // 3. Append business data (query parameters) to the URL if data is not empty
40
+ if (!isEmpty(data)) {
41
+ let params, dataType = getDataType(data);
42
+ // If the data is a URLSearchParams object, directly use it
43
+ if (dataType === 'URLSearchParams') {
44
+ params = data;
45
+ }
46
+ else if (dataType === 'object') {
47
+ // If the data is an object, convert it to URLSearchParams
48
+ params = new URLSearchParams(data);
49
+ }
50
+ else {
51
+ // If the data is a string, clean it up (remove leading '?' or '&')
52
+ params = new URLSearchParams(cleanQueryString(data));
53
+ }
54
+ // Append new parameters to the existing URL search parameters
55
+ params.forEach((value, key) => {
56
+ urlObj.searchParams.append(key, value);
57
+ });
58
+ }
59
+ // 4. Optionally add the cache-busting parameter if the flag is set
60
+ appendCacheBust && cacheBustKey && urlObj.searchParams.set(cacheBustKey, Date.now().toString());
61
+ // 5. Return the final URL: base URL + query parameters + original hash (if any)
62
+ return urlObj.toString() + hash;
63
+ };
64
+ export default buildUrl;
@@ -0,0 +1,86 @@
1
+ 
2
+
3
+ /**
4
+ * @since Last modified: 2026/01/20 13:56:36
5
+ * Builds a URL with query parameters, an optional cache-busting key, and preserves the hash.
6
+ * This function processes the base URL, appends query parameters, and optionally adds a cache-busting parameter.
7
+ * It also keeps the hash fragment intact if it exists in the original URL.
8
+ *
9
+ * @param {string} url - The original URL string that may or may not contain query parameters and a hash.
10
+ * @param {string | Record<string, string> | URLSearchParams} [data] - The data to be appended as query parameters.
11
+ * This can be a query string, an object, or a `URLSearchParams` object.
12
+ * @param {string} [cacheBustKey='_t'] - The cache-busting query parameter key to be appended to the URL. Default is '_t'.
13
+ * @param {boolean} [appendCacheBust=true] - A flag to indicate whether to append the cache-busting parameter. Default is true.
14
+ *
15
+ * @returns {string} The final constructed URL with the original URL, query parameters, and hash (if any).
16
+ *
17
+ * @example
18
+ * buildUrl({
19
+ * url: '/api/data',
20
+ * data: { key: 'value' },
21
+ * cacheBustKey: '_cache',
22
+ * appendCacheBust: true
23
+ * });
24
+ * // Returns: '/api/data?key=value&_cache=1638311234567'
25
+ */
26
+
27
+ import cleanQueryString from "./cleanQueryString";
28
+ import getDataType from "./getDataType";
29
+ import isEmpty from "./isEmpty";
30
+
31
+ const buildUrl = ({
32
+ url,
33
+ data,
34
+ cacheBustKey = '_t',
35
+ appendCacheBust = true
36
+ }: {
37
+ url: string;
38
+ data?: string | Record<string, string> | URLSearchParams;
39
+ cacheBustKey?: string;
40
+ appendCacheBust?: boolean;
41
+ }): string => {
42
+ // 1. Extract and remove the hash (e.g., /page#section -> hash="#section")
43
+ const hashIndex = url.indexOf('#');
44
+ let hash = '',
45
+ pureUrl = url;
46
+
47
+ // If a hash exists, separate it from the base URL
48
+ if (hashIndex !== -1) {
49
+ hash = url.slice(hashIndex);
50
+ pureUrl = url.slice(0, hashIndex);
51
+ }
52
+
53
+ // 2. Use the URL object to handle the base URL and existing query parameters.
54
+ // `window.location.origin` ensures the support for relative paths (e.g., '/api/list').
55
+ const urlObj = new URL(pureUrl, window.location.origin);
56
+
57
+ // 3. Append business data (query parameters) to the URL if data is not empty
58
+ if (!isEmpty(data)) {
59
+ let params: URLSearchParams,
60
+ dataType = getDataType(data);
61
+
62
+ // If the data is a URLSearchParams object, directly use it
63
+ if (dataType === 'URLSearchParams') {
64
+ params = data as URLSearchParams;
65
+ } else if (dataType === 'object') {
66
+ // If the data is an object, convert it to URLSearchParams
67
+ params = new URLSearchParams(data);
68
+ } else {
69
+ // If the data is a string, clean it up (remove leading '?' or '&')
70
+ params = new URLSearchParams(cleanQueryString(data as string));
71
+ }
72
+
73
+ // Append new parameters to the existing URL search parameters
74
+ params.forEach((value, key) => {
75
+ urlObj.searchParams.append(key, value);
76
+ });
77
+ }
78
+
79
+ // 4. Optionally add the cache-busting parameter if the flag is set
80
+ appendCacheBust && cacheBustKey && urlObj.searchParams.set(cacheBustKey, Date.now().toString());
81
+
82
+ // 5. Return the final URL: base URL + query parameters + original hash (if any)
83
+ return urlObj.toString() + hash;
84
+ };
85
+
86
+ export default buildUrl;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @since Last modified: 2026/01/20 11:52:35
3
+ * Capitalizes the first letter of the given string.
4
+ *
5
+ * This function takes a string as input and returns a new string with the first letter
6
+ * capitalized, while leaving the rest of the string unchanged. If the input string is
7
+ * empty or undefined, it returns the input string as is.
8
+ *
9
+ * @param str - The string whose first letter will be capitalized.
10
+ * @returns A new string with the first letter capitalized, or the input string if it's empty.
11
+ */
12
+ const capitalize = (str) => {
13
+ // Check if the input string is empty or undefined
14
+ if (!str)
15
+ return str;
16
+ // Capitalize the first letter and return the new string
17
+ return str.charAt(0).toUpperCase() + str.slice(1);
18
+ };
19
+ export default capitalize;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @since Last modified: 2026/01/20 11:52:35
3
+ * Capitalizes the first letter of the given string.
4
+ *
5
+ * This function takes a string as input and returns a new string with the first letter
6
+ * capitalized, while leaving the rest of the string unchanged. If the input string is
7
+ * empty or undefined, it returns the input string as is.
8
+ *
9
+ * @param str - The string whose first letter will be capitalized.
10
+ * @returns A new string with the first letter capitalized, or the input string if it's empty.
11
+ */
12
+ const capitalize = (str) => {
13
+ // Check if the input string is empty or undefined
14
+ if (!str)
15
+ return str;
16
+ // Capitalize the first letter and return the new string
17
+ return str.charAt(0).toUpperCase() + str.slice(1);
18
+ };
19
+ export default capitalize;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @since Last modified: 2026/01/20 11:52:35
3
+ * Capitalizes the first letter of the given string.
4
+ *
5
+ * This function takes a string as input and returns a new string with the first letter
6
+ * capitalized, while leaving the rest of the string unchanged. If the input string is
7
+ * empty or undefined, it returns the input string as is.
8
+ *
9
+ * @param str - The string whose first letter will be capitalized.
10
+ * @returns A new string with the first letter capitalized, or the input string if it's empty.
11
+ */
12
+ const capitalize = (str: string): string => {
13
+ // Check if the input string is empty or undefined
14
+ if (!str) return str;
15
+
16
+ // Capitalize the first letter and return the new string
17
+ return str.charAt(0).toUpperCase() + str.slice(1);
18
+ }
19
+
20
+ export default capitalize;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @since Last modified: 2026/01/20 13:55:13
3
+ * Cleans a query string by removing the leading '?' or '&' character if it exists.
4
+ * This ensures that the string is in a valid format for use in URLSearchParams.
5
+ *
6
+ * @param {string} data - The query string to clean.
7
+ * @returns {string} The cleaned query string without leading '?' or '&'.
8
+ *
9
+ * @example
10
+ * cleanQueryString('?key=value&name=John'); // Returns 'key=value&name=John'
11
+ * cleanQueryString('&key=value&name=John'); // Returns 'key=value&name=John'
12
+ * cleanQueryString('key=value&name=John'); // Returns 'key=value&name=John'
13
+ */
14
+ const cleanQueryString = (data) => {
15
+ return typeof data === 'string' && (data.startsWith('?') || data.startsWith('&'))
16
+ ? data.slice(1) // Remove the leading '?' or '&'
17
+ : data; // Return the string as-is if no leading character is present
18
+ };
19
+ export default cleanQueryString;