@catbee/utils 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/index.mjs CHANGED
@@ -37,6 +37,7 @@ import { fileExists, readFile, readFileSync } from '@catbee/utils/fs';
37
37
  import { isPort, isHostname } from '@catbee/utils/validation';
38
38
  import { optionalRequire } from '@catbee/utils/async';
39
39
  import { uuid } from '@catbee/utils/id';
40
+ import { trimChars } from '@catbee/utils/string';
40
41
  import { HealthzServer } from '@catbee/utils/healthz-server';
41
42
 
42
43
  var __defProp = Object.defineProperty;
@@ -730,8 +731,12 @@ var ExpressServer = class {
730
731
  app;
731
732
  /** Set of active WebSocket connections */
732
733
  connections = /* @__PURE__ */ new Set();
733
- /** Flag indicating if the server is shutting down */
734
+ /** Flag indicating if the server is shutting down (rejects traffic with 503) */
734
735
  isShuttingDown = false;
736
+ /** Flag indicating if the server is in graceful drain delay (sets Connection: close while servicing traffic) */
737
+ isDraining = false;
738
+ /** Internal readiness state (used when HealthzServer is not enabled) */
739
+ internalReady = false;
735
740
  /** Flag indicating if graceful shutdown handlers are registered */
736
741
  gracefulShutdownRegistered = false;
737
742
  /** Map of registered signal listeners for clean teardown */
@@ -742,10 +747,14 @@ var ExpressServer = class {
742
747
  healthzChecks = [];
743
748
  /** Named checks queued for Healthz readiness probe */
744
749
  healthzReadinessChecks = [];
750
+ /** Whether readiness checks were explicitly provided or registered */
751
+ hasExplicitReadinessChecks = false;
745
752
  /** Promise that resolves when initialization (middleware + routes) is complete */
746
753
  initPromise;
747
754
  /** In-flight start promise to protect against concurrent start() calls */
748
755
  startPromise;
756
+ /** In-flight stop promise to protect against concurrent stop() calls */
757
+ stopPromise;
749
758
  /**
750
759
  * Initializes server with intelligent defaults and security best practices.
751
760
  * All settings can be customized via config and hooks.
@@ -786,7 +795,8 @@ var ExpressServer = class {
786
795
  if (this.config.healthzServer.checks) {
787
796
  this.healthzChecks.push(...this.config.healthzServer.checks);
788
797
  }
789
- if (this.config.healthzServer.readinessChecks) {
798
+ if (this.config.healthzServer.readinessChecks !== void 0) {
799
+ this.hasExplicitReadinessChecks = true;
790
800
  this.healthzReadinessChecks.push(...this.config.healthzServer.readinessChecks);
791
801
  }
792
802
  }
@@ -884,6 +894,9 @@ var ExpressServer = class {
884
894
  res.status(HttpStatusCodes.SERVICE_UNAVAILABLE).json(new ServiceUnavailableException("Server is shutting down"));
885
895
  return;
886
896
  }
897
+ if (this.isDraining) {
898
+ res.setHeader("Connection", "close");
899
+ }
887
900
  next();
888
901
  });
889
902
  }
@@ -1197,21 +1210,96 @@ var ExpressServer = class {
1197
1210
  check
1198
1211
  };
1199
1212
  if (probeType === "liveness" || probeType === "both") {
1200
- this.healthzChecks.push(namedCheck);
1213
+ const idx = this.healthzChecks.findIndex((c) => c.name === name);
1214
+ if (idx !== -1) {
1215
+ this.healthzChecks[idx] = namedCheck;
1216
+ } else {
1217
+ this.healthzChecks.push(namedCheck);
1218
+ }
1201
1219
  }
1202
1220
  if (probeType === "readiness" || probeType === "both") {
1203
- this.healthzReadinessChecks.push(namedCheck);
1221
+ this.hasExplicitReadinessChecks = true;
1222
+ const idx = this.healthzReadinessChecks.findIndex((c) => c.name === name);
1223
+ if (idx !== -1) {
1224
+ this.healthzReadinessChecks[idx] = namedCheck;
1225
+ } else {
1226
+ this.healthzReadinessChecks.push(namedCheck);
1227
+ }
1204
1228
  }
1205
1229
  HealthzServer.registerCheck(namedCheck, probeType);
1206
1230
  return this;
1207
1231
  }
1208
1232
  /**
1233
+ * Unregister a health check by name.
1234
+ *
1235
+ * @param name Name of the check to remove
1236
+ * @param options Target probe type ('readiness' | 'liveness' | 'both') or options object
1237
+ * @returns This instance for method chaining
1238
+ */
1239
+ unregisterHealthCheck(name, options) {
1240
+ const probeType = typeof options === "string" ? options : options?.type ?? "both";
1241
+ if (probeType === "liveness" || probeType === "both") {
1242
+ const idx = this.healthzChecks.findIndex((c) => c.name === name);
1243
+ if (idx !== -1) this.healthzChecks.splice(idx, 1);
1244
+ }
1245
+ if (probeType === "readiness" || probeType === "both") {
1246
+ const idx = this.healthzReadinessChecks.findIndex((c) => c.name === name);
1247
+ if (idx !== -1) this.healthzReadinessChecks.splice(idx, 1);
1248
+ }
1249
+ HealthzServer.unregisterCheck(name, probeType);
1250
+ return this;
1251
+ }
1252
+ /**
1253
+ * Get all registered Healthz checks queued for this Express server.
1254
+ */
1255
+ getHealthzChecks() {
1256
+ return {
1257
+ liveness: [
1258
+ ...this.healthzChecks
1259
+ ],
1260
+ readiness: [
1261
+ ...this.healthzReadinessChecks
1262
+ ]
1263
+ };
1264
+ }
1265
+ /**
1266
+ * Register a readiness health check.
1267
+ *
1268
+ * @param name Unique identifier for the check (used in detailed responses)
1269
+ * @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
1270
+ * @returns This instance for method chaining
1271
+ */
1272
+ registerReadinessCheck(name, check) {
1273
+ return this.registerHealthCheck(name, check, "readiness");
1274
+ }
1275
+ /**
1276
+ * Register a liveness health check.
1277
+ *
1278
+ * @param name Unique identifier for the check (used in detailed responses)
1279
+ * @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
1280
+ * @returns This instance for method chaining
1281
+ */
1282
+ registerLivenessCheck(name, check) {
1283
+ return this.registerHealthCheck(name, check, "liveness");
1284
+ }
1285
+ /**
1286
+ * Register a readiness and liveness health check.
1287
+ *
1288
+ * @param name Unique identifier for the check (used in detailed responses)
1289
+ * @param check Function returning boolean or Promise<boolean> indicating health (supports optional AbortSignal)
1290
+ * @returns This instance for method chaining
1291
+ */
1292
+ registerReadinessAndLivenessChecks(name, check) {
1293
+ return this.registerHealthCheck(name, check, "both");
1294
+ }
1295
+ /**
1209
1296
  * Mark the service as ready / not-ready for traffic on the Healthz probe server.
1210
1297
  *
1211
1298
  * @param ready Whether the service is ready to receive traffic
1212
1299
  * @returns This instance for method chaining
1213
1300
  */
1214
1301
  setReady(ready) {
1302
+ this.internalReady = ready;
1215
1303
  HealthzServer.setReady(ready);
1216
1304
  return this;
1217
1305
  }
@@ -1219,7 +1307,28 @@ var ExpressServer = class {
1219
1307
  * Whether the service is currently marked as ready for traffic on the Healthz probe server.
1220
1308
  */
1221
1309
  isReady() {
1222
- return HealthzServer.isReady();
1310
+ if (this.isHealthzServerEnabled()) {
1311
+ return HealthzServer.isReady();
1312
+ }
1313
+ return this.internalReady && this.isRunning();
1314
+ }
1315
+ /**
1316
+ * Mark application startup as completed on the Healthz probe server (switches `/startupz` to 200).
1317
+ *
1318
+ * @returns This instance for method chaining
1319
+ */
1320
+ markStartupComplete() {
1321
+ HealthzServer.markStartupComplete();
1322
+ return this;
1323
+ }
1324
+ /**
1325
+ * Whether application startup has completed on the Healthz probe server.
1326
+ */
1327
+ isStartupComplete() {
1328
+ if (this.isHealthzServerEnabled()) {
1329
+ return HealthzServer.isStartupComplete();
1330
+ }
1331
+ return this.isRunning();
1223
1332
  }
1224
1333
  /**
1225
1334
  * Get the running HealthzServer instance (if started).
@@ -1240,7 +1349,7 @@ var ExpressServer = class {
1240
1349
  * @returns `true` when ready, otherwise `false`.
1241
1350
  */
1242
1351
  ready() {
1243
- return HealthzServer.isReady();
1352
+ return this.isReady();
1244
1353
  }
1245
1354
  /**
1246
1355
  * Get the underlying Express application instance.
@@ -1297,92 +1406,108 @@ var ExpressServer = class {
1297
1406
  */
1298
1407
  async doStart() {
1299
1408
  await this.initPromise;
1300
- await this.runHook("beforeStart", this.app);
1301
- const server = this.createServerInstance();
1302
- this.server = server;
1303
- return new Promise((resolve, reject) => {
1304
- let isListening = false;
1305
- server.on("error", (err) => {
1306
- if (!isListening) {
1307
- getLogger().error({
1308
- err
1309
- }, "Server failed to start");
1310
- try {
1311
- server.removeAllListeners();
1312
- server.close();
1313
- if (HealthzServer.isStarted()) {
1314
- HealthzServer.stop().catch(() => {
1315
- });
1316
- }
1317
- } catch {
1318
- }
1319
- this.server = null;
1320
- this.connections.clear();
1321
- reject(err);
1322
- } else {
1323
- getLogger().error({
1324
- err
1325
- }, "Server runtime error");
1326
- }
1327
- });
1328
- this.setupConnectionTracking();
1329
- Promise.resolve(this.runHook("onServerCreated", server)).then(() => {
1330
- const onListening = /* @__PURE__ */ __name(async () => {
1331
- try {
1332
- if (this.isHealthzServerEnabled()) {
1333
- const healthzConfig = {
1334
- ...typeof this.config.healthzServer === "object" ? this.config.healthzServer : {},
1335
- handleSignals: false,
1336
- checks: [
1337
- ...this.healthzChecks
1338
- ],
1339
- readinessChecks: [
1340
- ...this.healthzReadinessChecks
1341
- ]
1342
- };
1343
- const addr = await HealthzServer.start(healthzConfig);
1344
- if (!addr) {
1345
- throw new Error("Healthz probe server failed to start (already running in this process)");
1346
- }
1347
- this.healthzAddress = addr;
1348
- }
1349
- this.logServerStartInfo();
1350
- await this.runHook("afterStart", server);
1351
- if (this.isHealthzServerEnabled()) {
1352
- HealthzServer.setReady(true);
1353
- }
1354
- isListening = true;
1355
- resolve(server);
1356
- } catch (err) {
1357
- const error = err instanceof Error ? err : new Error(String(err));
1409
+ if (this.isHealthzServerEnabled()) {
1410
+ const healthzConfig = {
1411
+ ...typeof this.config.healthzServer === "object" ? this.config.healthzServer : {},
1412
+ handleSignals: false,
1413
+ checks: [
1414
+ ...this.healthzChecks
1415
+ ],
1416
+ ...this.hasExplicitReadinessChecks ? {
1417
+ readinessChecks: [
1418
+ ...this.healthzReadinessChecks
1419
+ ]
1420
+ } : {}
1421
+ };
1422
+ const addr = await HealthzServer.start(healthzConfig);
1423
+ if (!addr) {
1424
+ throw new Error("Healthz probe server failed to start (already running in this process)");
1425
+ }
1426
+ this.healthzAddress = addr;
1427
+ }
1428
+ try {
1429
+ await this.runHook("beforeStart", this.app);
1430
+ const server = this.createServerInstance();
1431
+ this.server = server;
1432
+ return await new Promise((resolve, reject) => {
1433
+ let isListening = false;
1434
+ server.on("error", async (err) => {
1435
+ if (!isListening) {
1358
1436
  getLogger().error({
1359
- err: error
1360
- }, "Server startup failed");
1361
- if (HealthzServer.isStarted()) {
1362
- await HealthzServer.stop().catch(() => {
1363
- });
1364
- }
1437
+ err
1438
+ }, "Server failed to start");
1365
1439
  try {
1366
1440
  server.removeAllListeners();
1367
1441
  server.close();
1442
+ if (HealthzServer.isRunning()) {
1443
+ await HealthzServer.stop().catch(() => {
1444
+ });
1445
+ }
1368
1446
  } catch {
1369
1447
  }
1370
1448
  this.server = null;
1371
1449
  this.healthzAddress = null;
1372
1450
  this.connections.clear();
1373
- reject(error);
1451
+ reject(err);
1452
+ } else {
1453
+ getLogger().error({
1454
+ err
1455
+ }, "Server runtime error");
1374
1456
  }
1375
- }, "onListening");
1376
- const listenArgs = [
1377
- this.config.port,
1378
- this.config.host,
1379
- onListening
1380
- ];
1381
- server.listen(...listenArgs);
1382
- }).catch((err) => {
1383
- server.emit("error", err instanceof Error ? err : new Error(String(err)));
1457
+ });
1458
+ this.setupConnectionTracking();
1459
+ Promise.resolve(this.runHook("onServerCreated", server)).then(() => {
1460
+ const onListening = /* @__PURE__ */ __name(async () => {
1461
+ try {
1462
+ this.logServerStartInfo();
1463
+ await this.runHook("afterStart", server);
1464
+ this.internalReady = true;
1465
+ if (this.isHealthzServerEnabled()) {
1466
+ HealthzServer.markStartupComplete();
1467
+ HealthzServer.setReady(true);
1468
+ }
1469
+ isListening = true;
1470
+ resolve(server);
1471
+ } catch (err) {
1472
+ const error = err instanceof Error ? err : new Error(String(err));
1473
+ getLogger().error({
1474
+ err: error
1475
+ }, "Server startup failed");
1476
+ if (HealthzServer.isRunning()) {
1477
+ await HealthzServer.stop().catch(() => {
1478
+ });
1479
+ }
1480
+ try {
1481
+ server.removeAllListeners();
1482
+ server.close();
1483
+ } catch {
1484
+ }
1485
+ this.server = null;
1486
+ this.healthzAddress = null;
1487
+ this.connections.clear();
1488
+ reject(error);
1489
+ }
1490
+ }, "onListening");
1491
+ const listenArgs = [
1492
+ this.config.port,
1493
+ this.config.host,
1494
+ onListening
1495
+ ];
1496
+ server.listen(...listenArgs);
1497
+ }).catch((err) => {
1498
+ server.emit("error", err instanceof Error ? err : new Error(String(err)));
1499
+ });
1384
1500
  });
1385
- });
1501
+ } catch (err) {
1502
+ if (HealthzServer.isRunning()) {
1503
+ await HealthzServer.stop().catch(() => {
1504
+ });
1505
+ }
1506
+ this.healthzAddress = null;
1507
+ this.server = null;
1508
+ this.connections.clear();
1509
+ throw err;
1510
+ }
1386
1511
  }
1387
1512
  /**
1388
1513
  * Create HTTP or HTTPS server instance (without listening).
@@ -1423,7 +1548,8 @@ var ExpressServer = class {
1423
1548
  const url = `${protocol}://${host}:${port}`;
1424
1549
  getLogger().info(`Server running on ${url}`);
1425
1550
  if (this.healthzAddress) {
1426
- getLogger().info(`Healthz probe server running on http://${this.healthzAddress.address}:${this.healthzAddress.port}`);
1551
+ const healthzHost = this.formatHostForUrl(this.healthzAddress.address);
1552
+ getLogger().info(`Healthz server running on http://${healthzHost}:${this.healthzAddress.port}`);
1427
1553
  }
1428
1554
  if (this.config.openApi?.enable) {
1429
1555
  getLogger().info(`API docs available at ${url}${this.normalizePath(this.config.openApi.mountPath, this.config.openApi.withGlobalPrefix)}`);
@@ -1446,16 +1572,26 @@ var ExpressServer = class {
1446
1572
  * - Monitoring systems are notified
1447
1573
  */
1448
1574
  async stop(force = false) {
1449
- if (!this.server && !HealthzServer.isStarted()) {
1575
+ if (!this.server && !HealthzServer.isRunning()) {
1450
1576
  getLogger().warn("Stop called but server is not running");
1451
1577
  return;
1452
1578
  }
1453
- if (this.isShuttingDown) {
1454
- getLogger().warn("Shutdown already in progress");
1455
- return;
1579
+ if (this.stopPromise) {
1580
+ return this.stopPromise;
1581
+ }
1582
+ this.stopPromise = this.doStop(force);
1583
+ try {
1584
+ await this.stopPromise;
1585
+ } finally {
1586
+ this.stopPromise = void 0;
1456
1587
  }
1457
- this.isShuttingDown = true;
1458
- if (HealthzServer.isStarted()) {
1588
+ }
1589
+ /**
1590
+ * Internal implementation of server shutdown.
1591
+ */
1592
+ async doStop(force = false) {
1593
+ this.internalReady = false;
1594
+ if (HealthzServer.isRunning()) {
1459
1595
  HealthzServer.setReady(false);
1460
1596
  }
1461
1597
  if (this.server) {
@@ -1463,19 +1599,23 @@ var ExpressServer = class {
1463
1599
  }
1464
1600
  try {
1465
1601
  const shutdownDelay = this.getHealthzShutdownDelay();
1466
- if (shutdownDelay > 0 && !force && HealthzServer.isStarted()) {
1602
+ if (shutdownDelay > 0 && !force && HealthzServer.isRunning()) {
1467
1603
  getLogger().info(`Waiting ${shutdownDelay}ms for load balancer to drain traffic...`);
1604
+ this.isDraining = true;
1468
1605
  await new Promise((resolve) => setTimeout(resolve, shutdownDelay));
1469
1606
  }
1607
+ this.isDraining = false;
1608
+ this.isShuttingDown = true;
1470
1609
  if (this.server) {
1471
1610
  await this.gracefulShutdown(force);
1472
1611
  }
1473
1612
  } finally {
1474
- if (HealthzServer.isStarted()) {
1613
+ if (HealthzServer.isRunning()) {
1475
1614
  await HealthzServer.stop();
1476
1615
  this.healthzAddress = null;
1477
1616
  }
1478
1617
  this.isShuttingDown = false;
1618
+ this.isDraining = false;
1479
1619
  }
1480
1620
  }
1481
1621
  /**
@@ -1562,10 +1702,11 @@ var ExpressServer = class {
1562
1702
  signalHandled = true;
1563
1703
  getLogger().info(`Received ${signal}, initiating graceful shutdown...`);
1564
1704
  try {
1565
- this.disableGracefulShutdown();
1566
1705
  await this.stop(false);
1706
+ this.disableGracefulShutdown();
1567
1707
  process.exit(0);
1568
1708
  } catch (err) {
1709
+ this.disableGracefulShutdown();
1569
1710
  getLogger().fatal({
1570
1711
  err
1571
1712
  }, "Shutdown failed");
@@ -1923,7 +2064,8 @@ var ExpressServer = class {
1923
2064
  }
1924
2065
  normalizePath(path, withGlobalPrefix = false) {
1925
2066
  const sanitize = /* @__PURE__ */ __name((p) => {
1926
- return "/" + p.trim().replace(/^\/+/, "").replace(/\/{2,}/g, "/").replace(/\/+$/, "");
2067
+ const inner = trimChars(p.trim(), "/");
2068
+ return inner ? "/" + inner.replace(/\/{2,}/g, "/") : "/";
1927
2069
  }, "sanitize");
1928
2070
  const prefix = withGlobalPrefix && this.globalPrefix ? sanitize(this.globalPrefix) : "";
1929
2071
  if (typeof path !== "string" || !path.trim()) {
package/string/index.cjs CHANGED
@@ -44,7 +44,8 @@ function toCamelCase(str) {
44
44
  }
45
45
  __name(toCamelCase, "toCamelCase");
46
46
  function slugify(str) {
47
- return str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
47
+ const slug = str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-");
48
+ return trimChars(slug, "-");
48
49
  }
49
50
  __name(slugify, "slugify");
50
51
  function truncate(str, len) {
@@ -69,7 +70,13 @@ function mask(str, visibleStart = 0, visibleEnd = 0, maskChar = "*") {
69
70
  }
70
71
  __name(mask, "mask");
71
72
  function stripHtml(str) {
72
- return str.replace(/<[^>]*>/g, "");
73
+ let prev;
74
+ let result = str;
75
+ do {
76
+ prev = result;
77
+ result = result.replace(/<[^<>]*>/g, "");
78
+ } while (result !== prev);
79
+ return result;
73
80
  }
74
81
  __name(stripHtml, "stripHtml");
75
82
  function equalsIgnoreCase(a, b) {
@@ -122,6 +129,38 @@ function ellipsis(str, maxLength, suffix = "...") {
122
129
  return (lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated) + suffix;
123
130
  }
124
131
  __name(ellipsis, "ellipsis");
132
+ function trimChars(str, chars = " ") {
133
+ if (!str) return "";
134
+ let start = 0;
135
+ let end = str.length;
136
+ while (start < end && chars.includes(str[start])) {
137
+ start++;
138
+ }
139
+ while (end > start && chars.includes(str[end - 1])) {
140
+ end--;
141
+ }
142
+ return str.slice(start, end);
143
+ }
144
+ __name(trimChars, "trimChars");
145
+ function trimLeadingChars(str, chars = " ") {
146
+ if (!str) return "";
147
+ let start = 0;
148
+ const end = str.length;
149
+ while (start < end && chars.includes(str[start])) {
150
+ start++;
151
+ }
152
+ return str.slice(start, end);
153
+ }
154
+ __name(trimLeadingChars, "trimLeadingChars");
155
+ function trimTrailingChars(str, chars = " ") {
156
+ if (!str) return "";
157
+ let end = str.length;
158
+ while (end > 0 && chars.includes(str[end - 1])) {
159
+ end--;
160
+ }
161
+ return str.slice(0, end);
162
+ }
163
+ __name(trimTrailingChars, "trimTrailingChars");
125
164
 
126
165
  exports.capitalize = capitalize;
127
166
  exports.countOccurrences = countOccurrences;
@@ -138,5 +177,8 @@ exports.toKebabCase = toKebabCase;
138
177
  exports.toPascalCase = toPascalCase;
139
178
  exports.toSnakeCase = toSnakeCase;
140
179
  exports.toTitleCase = toTitleCase;
180
+ exports.trimChars = trimChars;
181
+ exports.trimLeadingChars = trimLeadingChars;
182
+ exports.trimTrailingChars = trimTrailingChars;
141
183
  exports.truncate = truncate;
142
184
  exports.unescapeHtml = unescapeHtml;
package/string/index.d.ts CHANGED
@@ -165,5 +165,40 @@ declare function isBlank(str: string): boolean;
165
165
  * ellipsis('The quick brown fox', 10); // 'The quick...'
166
166
  */
167
167
  declare function ellipsis(str: string, maxLength: number, suffix?: string): string;
168
+ /**
169
+ * Trims leading and trailing occurrences of specified characters in linear O(n) time
170
+ * without using regular expressions (immune to polynomial ReDoS).
171
+ *
172
+ * @param {string} str - The input string.
173
+ * @param {string} [chars=' '] - Character or set of characters to trim.
174
+ * @returns {string} The trimmed string.
175
+ *
176
+ * @example
177
+ * trimChars('///api/users///', '/'); // 'api/users'
178
+ * trimChars('---hello-world---', '-'); // 'hello-world'
179
+ */
180
+ declare function trimChars(str: string, chars?: string): string;
181
+ /**
182
+ * Trims leading occurrences of specified characters in linear O(n) time.
183
+ *
184
+ * @param {string} str - The input string.
185
+ * @param {string} [chars=' '] - Character or set of characters to trim from start.
186
+ * @returns {string} The trimmed string.
187
+ *
188
+ * @example
189
+ * trimLeadingChars('///api/users', '/'); // 'api/users'
190
+ */
191
+ declare function trimLeadingChars(str: string, chars?: string): string;
192
+ /**
193
+ * Trims trailing occurrences of specified characters in linear O(n) time.
194
+ *
195
+ * @param {string} str - The input string.
196
+ * @param {string} [chars=' '] - Character or set of characters to trim from end.
197
+ * @returns {string} The trimmed string.
198
+ *
199
+ * @example
200
+ * trimTrailingChars('https://example.com///', '/'); // 'https://example.com'
201
+ */
202
+ declare function trimTrailingChars(str: string, chars?: string): string;
168
203
 
169
- export { capitalize, countOccurrences, ellipsis, equalsIgnoreCase, escapeRegex, isBlank, mask, reverse, slugify, stripHtml, toCamelCase, toKebabCase, toPascalCase, toSnakeCase, toTitleCase, truncate, unescapeHtml };
204
+ export { capitalize, countOccurrences, ellipsis, equalsIgnoreCase, escapeRegex, isBlank, mask, reverse, slugify, stripHtml, toCamelCase, toKebabCase, toPascalCase, toSnakeCase, toTitleCase, trimChars, trimLeadingChars, trimTrailingChars, truncate, unescapeHtml };
package/string/index.mjs CHANGED
@@ -42,7 +42,8 @@ function toCamelCase(str) {
42
42
  }
43
43
  __name(toCamelCase, "toCamelCase");
44
44
  function slugify(str) {
45
- return str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
45
+ const slug = str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-");
46
+ return trimChars(slug, "-");
46
47
  }
47
48
  __name(slugify, "slugify");
48
49
  function truncate(str, len) {
@@ -67,7 +68,13 @@ function mask(str, visibleStart = 0, visibleEnd = 0, maskChar = "*") {
67
68
  }
68
69
  __name(mask, "mask");
69
70
  function stripHtml(str) {
70
- return str.replace(/<[^>]*>/g, "");
71
+ let prev;
72
+ let result = str;
73
+ do {
74
+ prev = result;
75
+ result = result.replace(/<[^<>]*>/g, "");
76
+ } while (result !== prev);
77
+ return result;
71
78
  }
72
79
  __name(stripHtml, "stripHtml");
73
80
  function equalsIgnoreCase(a, b) {
@@ -120,5 +127,37 @@ function ellipsis(str, maxLength, suffix = "...") {
120
127
  return (lastSpace > 0 ? truncated.slice(0, lastSpace) : truncated) + suffix;
121
128
  }
122
129
  __name(ellipsis, "ellipsis");
130
+ function trimChars(str, chars = " ") {
131
+ if (!str) return "";
132
+ let start = 0;
133
+ let end = str.length;
134
+ while (start < end && chars.includes(str[start])) {
135
+ start++;
136
+ }
137
+ while (end > start && chars.includes(str[end - 1])) {
138
+ end--;
139
+ }
140
+ return str.slice(start, end);
141
+ }
142
+ __name(trimChars, "trimChars");
143
+ function trimLeadingChars(str, chars = " ") {
144
+ if (!str) return "";
145
+ let start = 0;
146
+ const end = str.length;
147
+ while (start < end && chars.includes(str[start])) {
148
+ start++;
149
+ }
150
+ return str.slice(start, end);
151
+ }
152
+ __name(trimLeadingChars, "trimLeadingChars");
153
+ function trimTrailingChars(str, chars = " ") {
154
+ if (!str) return "";
155
+ let end = str.length;
156
+ while (end > 0 && chars.includes(str[end - 1])) {
157
+ end--;
158
+ }
159
+ return str.slice(0, end);
160
+ }
161
+ __name(trimTrailingChars, "trimTrailingChars");
123
162
 
124
- export { capitalize, countOccurrences, ellipsis, equalsIgnoreCase, escapeRegex, isBlank, mask, reverse, slugify, stripHtml, toCamelCase, toKebabCase, toPascalCase, toSnakeCase, toTitleCase, truncate, unescapeHtml };
163
+ export { capitalize, countOccurrences, ellipsis, equalsIgnoreCase, escapeRegex, isBlank, mask, reverse, slugify, stripHtml, toCamelCase, toKebabCase, toPascalCase, toSnakeCase, toTitleCase, trimChars, trimLeadingChars, trimTrailingChars, truncate, unescapeHtml };