@utilix-tech/sdk 0.9.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  Full release notes for all Utilix surfaces (browser tools, REST API, SDKs, MCP server) live at [utilix.tech/changelog](https://utilix.tech/changelog). This file tracks just this package.
4
4
 
5
+ ## 0.11.0
6
+ - Added IBAN Validator / Formatter (`validateIban`) and Loan / Mortgage Calculator (`calculateLoan`).
7
+ - (npm publish for this version was retried after fixing a CI pin: `npm install -g npm@latest` started pulling npm 12, which requires Node >=22 and broke on this job's pinned Node 20 — now pinned to the npm 11 line.)
8
+
9
+ ## 0.10.0
10
+ - Added robots.txt Validator and Credit Card Number Validator.
11
+
5
12
  ## 0.9.0
6
13
  - Added Sitemap.xml Validator.
7
14
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/sdk
2
2
 
3
- **526 developer utility functions for Node.js: runs locally, no API key required.**
3
+ **533 developer utility functions for Node.js: runs locally, no API key required.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@utilix-tech/sdk?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@utilix-tech/sdk)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/@utilix-tech/sdk?style=flat-square)](https://www.npmjs.com/package/@utilix-tech/sdk)
@@ -242,7 +242,7 @@ getNextRuns("*/5 * * * *", 5); // next 5 run timestamps
242
242
  ### `/units`: Conversions
243
243
 
244
244
  ```ts
245
- import { convertBytes, pxToAll, convert, formatValue } from "@utilix-tech/sdk/units";
245
+ import { convertBytes, pxToAll, convert, formatValue, validateCardNumber, validateIban, calculateLoan } from "@utilix-tech/sdk/units";
246
246
 
247
247
  // File sizes
248
248
  convertBytes(1073741824, "GB"); // 1
@@ -260,6 +260,18 @@ convert("11111111", 2, 10); // "255"
260
260
  // Currency / locale formatting
261
261
  formatValue(1234567.89, { locale: "en-US", currency: "USD" });
262
262
  // "$1,234,567.89"
263
+
264
+ // Credit card Luhn checksum + network detection
265
+ validateCardNumber("4111 1111 1111 1111");
266
+ // { valid: true, network: "Visa", length: 16, formatted: "4111 1111 1111 1111" }
267
+
268
+ // IBAN mod-97 checksum + formatting
269
+ validateIban("DE89 3704 0044 0532 0130 00");
270
+ // { valid: true, countryCode: "DE", checksumValid: true, formatted: "DE89 3704 0044 0532 0130 00", ... }
271
+
272
+ // Loan / mortgage amortization
273
+ calculateLoan(200000, 6.5, 360);
274
+ // { monthlyPayment: 1264.14, totalInterest: 255085.82, termMonths: 360, schedule: [...] }
263
275
  ```
264
276
 
265
277
  ---
@@ -269,7 +281,7 @@ formatValue(1234567.89, { locale: "en-US", currency: "USD" });
269
281
  ```ts
270
282
  import { getStatusCode, isValidIp, isValidIpv4, isValidIpv6,
271
283
  searchStatusCodes, buildGeoUrl, countryCodeToFlag,
272
- parseHar, validateSitemap } from "@utilix-tech/sdk/network";
284
+ parseHar, validateSitemap, validateRobotsTxt } from "@utilix-tech/sdk/network";
273
285
 
274
286
  getStatusCode(404);
275
287
  // { code: 404, text: "Not Found", description: "...", category: "Client Error" }
@@ -290,6 +302,10 @@ parseHar(har);
290
302
  // Validate a sitemap.xml (or sitemap index) against the sitemaps.org protocol
291
303
  validateSitemap('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.com/</loc></url></urlset>');
292
304
  // { type: "urlset", urlCount: 1, entries: [...], issues: [...], valid: true }
305
+
306
+ // Validate a robots.txt file: User-agent groups, rules, and syntax mistakes
307
+ validateRobotsTxt("User-agent: *\nDisallow: /admin\nSitemap: https://example.com/sitemap.xml");
308
+ // { groups: [...], sitemaps: [...], issues: [...], valid: true }
293
309
  ```
294
310
 
295
311
  ---
@@ -16,6 +16,7 @@ __export(units_exports, {
16
16
  bumpVersion: () => bumpVersion,
17
17
  calcAspectRatio: () => calcAspectRatio,
18
18
  calcSubnet: () => calcSubnet,
19
+ calculateLoan: () => calculateLoan,
19
20
  compareSemver: () => compareSemver,
20
21
  convert: () => convert,
21
22
  convertBytes: () => convertBytes,
@@ -60,6 +61,8 @@ __export(units_exports, {
60
61
  sortVersions: () => sortVersions,
61
62
  splitSubnet: () => splitSubnet,
62
63
  symbolicToPermissions: () => symbolicToPermissions,
64
+ validateCardNumber: () => validateCardNumber,
65
+ validateIban: () => validateIban,
63
66
  viewportToPx: () => viewportToPx
64
67
  });
65
68
  function pxToAll(px, baseFontSize = 16, viewportWidth = 1440, viewportHeight = 900) {
@@ -892,5 +895,225 @@ function sortVersions(versions, order = "asc") {
892
895
  return order === "asc" ? cmp : -cmp;
893
896
  });
894
897
  }
898
+ var CARD_NETWORK_RULES = [
899
+ { name: "American Express", lengths: [15], test: (d) => /^3[47]/.test(d) },
900
+ { name: "Diners Club", lengths: [14], test: (d) => /^3(?:0[0-5]|[68])/.test(d) },
901
+ {
902
+ name: "Discover",
903
+ lengths: [16, 19],
904
+ test: (d) => /^(?:6011|65|64[4-9])/.test(d) || /^622(?:12[6-9]|1[3-9]\d|[2-8]\d{2}|9[01]\d|92[0-5])/.test(d)
905
+ },
906
+ { name: "JCB", lengths: [16], test: (d) => /^35(?:2[89]|[3-8]\d)/.test(d) },
907
+ {
908
+ name: "Mastercard",
909
+ lengths: [16],
910
+ test: (d) => {
911
+ const two = parseInt(d.slice(0, 2), 10);
912
+ const four = parseInt(d.slice(0, 4), 10);
913
+ return two >= 51 && two <= 55 || four >= 2221 && four <= 2720;
914
+ }
915
+ },
916
+ { name: "Maestro", lengths: [12, 13, 14, 15, 16, 17, 18, 19], test: (d) => /^(?:50|5[6-8]|6304|6390|67)/.test(d) },
917
+ { name: "UnionPay", lengths: [16, 17, 18, 19], test: (d) => /^62/.test(d) },
918
+ { name: "Visa", lengths: [13, 16, 19], test: (d) => /^4/.test(d) }
919
+ ];
920
+ function luhnCheck(digits) {
921
+ let sum = 0;
922
+ let shouldDouble = false;
923
+ for (let i = digits.length - 1; i >= 0; i--) {
924
+ let d = parseInt(digits[i], 10);
925
+ if (shouldDouble) {
926
+ d *= 2;
927
+ if (d > 9) d -= 9;
928
+ }
929
+ sum += d;
930
+ shouldDouble = !shouldDouble;
931
+ }
932
+ return sum % 10 === 0;
933
+ }
934
+ function detectCardNetwork(digits) {
935
+ for (const rule of CARD_NETWORK_RULES) {
936
+ if (rule.lengths.includes(digits.length) && rule.test(digits)) return rule.name;
937
+ }
938
+ return null;
939
+ }
940
+ function formatCardNumber(digits, network) {
941
+ if (network === "American Express") {
942
+ return `${digits.slice(0, 4)} ${digits.slice(4, 10)} ${digits.slice(10)}`.trim();
943
+ }
944
+ return digits.match(/.{1,4}/g)?.join(" ") ?? digits;
945
+ }
946
+ function validateCardNumber(input) {
947
+ if (!input || !input.trim()) return { error: "Input is empty" };
948
+ const digits = input.replace(/[\s-]/g, "");
949
+ if (!/^\d+$/.test(digits)) return { error: "Card number must contain only digits, spaces, or hyphens" };
950
+ if (digits.length < 12 || digits.length > 19) {
951
+ return { error: `Card number length (${digits.length}) is outside the valid range of 12-19 digits` };
952
+ }
953
+ const valid = luhnCheck(digits);
954
+ const network = detectCardNetwork(digits);
955
+ const formatted = formatCardNumber(digits, network);
956
+ return { valid, network, length: digits.length, formatted };
957
+ }
958
+ var IBAN_COUNTRY_LENGTHS = {
959
+ AD: 24,
960
+ AE: 23,
961
+ AL: 28,
962
+ AT: 20,
963
+ AZ: 28,
964
+ BA: 20,
965
+ BE: 16,
966
+ BG: 22,
967
+ BH: 22,
968
+ BR: 29,
969
+ BY: 28,
970
+ CH: 21,
971
+ CR: 22,
972
+ CY: 28,
973
+ CZ: 24,
974
+ DE: 22,
975
+ DK: 18,
976
+ DO: 28,
977
+ EE: 20,
978
+ EG: 29,
979
+ ES: 24,
980
+ FI: 18,
981
+ FO: 18,
982
+ FR: 27,
983
+ GB: 22,
984
+ GE: 22,
985
+ GI: 23,
986
+ GL: 18,
987
+ GR: 27,
988
+ GT: 28,
989
+ HR: 21,
990
+ HU: 28,
991
+ IE: 22,
992
+ IL: 23,
993
+ IQ: 23,
994
+ IS: 26,
995
+ IT: 27,
996
+ JO: 30,
997
+ KW: 30,
998
+ KZ: 20,
999
+ LB: 28,
1000
+ LC: 32,
1001
+ LI: 21,
1002
+ LT: 20,
1003
+ LU: 20,
1004
+ LV: 21,
1005
+ LY: 25,
1006
+ MC: 27,
1007
+ MD: 24,
1008
+ ME: 22,
1009
+ MK: 19,
1010
+ MR: 27,
1011
+ MT: 31,
1012
+ MU: 30,
1013
+ NL: 18,
1014
+ NO: 15,
1015
+ PK: 24,
1016
+ PL: 28,
1017
+ PS: 29,
1018
+ PT: 25,
1019
+ QA: 29,
1020
+ RO: 24,
1021
+ RS: 22,
1022
+ SA: 24,
1023
+ SC: 31,
1024
+ SE: 24,
1025
+ SI: 19,
1026
+ SK: 24,
1027
+ SM: 27,
1028
+ ST: 25,
1029
+ SV: 28,
1030
+ TL: 23,
1031
+ TN: 24,
1032
+ TR: 26,
1033
+ UA: 29,
1034
+ VA: 22,
1035
+ VG: 24,
1036
+ XK: 20
1037
+ };
1038
+ function ibanMod97(numeric) {
1039
+ let remainder = 0;
1040
+ for (let i = 0; i < numeric.length; i += 7) {
1041
+ remainder = parseInt(String(remainder) + numeric.substr(i, 7), 10) % 97;
1042
+ }
1043
+ return remainder;
1044
+ }
1045
+ function formatIban(cleaned) {
1046
+ return cleaned.match(/.{1,4}/g)?.join(" ") ?? cleaned;
1047
+ }
1048
+ function validateIban(input) {
1049
+ if (!input || !input.trim()) return { error: "Input is empty" };
1050
+ const cleaned = input.replace(/\s/g, "").toUpperCase();
1051
+ if (!/^[A-Z0-9]+$/.test(cleaned)) return { error: "IBAN must contain only letters and digits" };
1052
+ if (cleaned.length < 4 || cleaned.length > 34) {
1053
+ return { error: `IBAN length (${cleaned.length}) is outside the valid range of 4-34 characters` };
1054
+ }
1055
+ if (!/^[A-Z]{2}\d{2}/.test(cleaned)) {
1056
+ return { error: "IBAN must start with a 2-letter country code followed by 2 check digits" };
1057
+ }
1058
+ const countryCode = cleaned.slice(0, 2);
1059
+ const checkDigits = cleaned.slice(2, 4);
1060
+ const bban = cleaned.slice(4);
1061
+ const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
1062
+ const numeric = rearranged.split("").map((ch) => /[A-Z]/.test(ch) ? String(ch.charCodeAt(0) - 55) : ch).join("");
1063
+ const checksumValid = ibanMod97(numeric) === 1;
1064
+ const expectedLength = IBAN_COUNTRY_LENGTHS[countryCode] ?? null;
1065
+ const lengthValid = expectedLength === null ? null : cleaned.length === expectedLength;
1066
+ const valid = checksumValid && lengthValid !== false;
1067
+ return {
1068
+ valid,
1069
+ formatted: formatIban(cleaned),
1070
+ countryCode,
1071
+ checkDigits,
1072
+ bban,
1073
+ length: cleaned.length,
1074
+ expectedLength,
1075
+ checksumValid,
1076
+ lengthValid
1077
+ };
1078
+ }
1079
+ function round2(n) {
1080
+ return Math.round(n * 100) / 100;
1081
+ }
1082
+ function calculateLoan(principal, annualRatePercent, termMonths) {
1083
+ if (!Number.isFinite(principal) || principal <= 0) return { error: "Principal must be a positive number" };
1084
+ if (!Number.isFinite(annualRatePercent) || annualRatePercent < 0) {
1085
+ return { error: "Annual interest rate must be zero or a positive number" };
1086
+ }
1087
+ if (!Number.isInteger(termMonths) || termMonths <= 0) {
1088
+ return { error: "Term must be a positive whole number of months" };
1089
+ }
1090
+ const monthlyRate = annualRatePercent / 100 / 12;
1091
+ let monthlyPayment;
1092
+ if (monthlyRate === 0) {
1093
+ monthlyPayment = principal / termMonths;
1094
+ } else {
1095
+ const factor = Math.pow(1 + monthlyRate, termMonths);
1096
+ monthlyPayment = principal * monthlyRate * factor / (factor - 1);
1097
+ }
1098
+ const schedule = [];
1099
+ let balance = round2(principal);
1100
+ let totalPayment = 0;
1101
+ for (let month = 1; month <= termMonths; month++) {
1102
+ const interest = monthlyRate === 0 ? 0 : round2(balance * monthlyRate);
1103
+ let principalPortion = round2(monthlyPayment) - interest;
1104
+ if (month === termMonths || principalPortion > balance) principalPortion = balance;
1105
+ const payment = round2(principalPortion + interest);
1106
+ balance = round2(balance - principalPortion);
1107
+ totalPayment += payment;
1108
+ schedule.push({ month, payment, principal: round2(principalPortion), interest, balance: Math.max(balance, 0) });
1109
+ }
1110
+ return {
1111
+ monthlyPayment: round2(monthlyPayment),
1112
+ totalPayment: round2(totalPayment),
1113
+ totalInterest: round2(totalPayment - principal),
1114
+ termMonths,
1115
+ schedule
1116
+ };
1117
+ }
895
1118
 
896
- export { BASE_LABELS, BASE_PREFIXES, BYTE_UNITS, COMMON_CURRENCIES, COMMON_LOCALES, COMMON_PERMISSIONS, COMMON_RATIOS, COMMON_SIZES, COMMON_VIEWPORTS, autoFormat, bumpVersion, calcAspectRatio, calcSubnet, compareSemver, convert, convertBytes, convertIp, coverBox, decimalToIp, decimalToIpCidr, describePermissions, fitIntoBox, formatNumber, formatValue, formatVwValue, formatWithSeparator, gcd, ipFromBinary, ipFromDecimal, ipFromHex, ipToBinary, ipToDecimal, ipToDecimalCidr, ipToHex, ipToOctal, isValid, isValidCidr, isValidIp, isValidIpv4, octalToPermissions, parseFileSize, parseInput, parseNumberString, parseSemver, permissionsToChmod, permissionsToOctal, permissionsToSymbolic, ptToPx, pxToAll, pxToViewport, remToPx, satisfiesRange, scaleByHeight, scaleByWidth, sortVersions, splitSubnet, symbolicToPermissions, units_exports, viewportToPx };
1119
+ export { BASE_LABELS, BASE_PREFIXES, BYTE_UNITS, COMMON_CURRENCIES, COMMON_LOCALES, COMMON_PERMISSIONS, COMMON_RATIOS, COMMON_SIZES, COMMON_VIEWPORTS, autoFormat, bumpVersion, calcAspectRatio, calcSubnet, calculateLoan, compareSemver, convert, convertBytes, convertIp, coverBox, decimalToIp, decimalToIpCidr, describePermissions, fitIntoBox, formatNumber, formatValue, formatVwValue, formatWithSeparator, gcd, ipFromBinary, ipFromDecimal, ipFromHex, ipToBinary, ipToDecimal, ipToDecimalCidr, ipToHex, ipToOctal, isValid, isValidCidr, isValidIp, isValidIpv4, octalToPermissions, parseFileSize, parseInput, parseNumberString, parseSemver, permissionsToChmod, permissionsToOctal, permissionsToSymbolic, ptToPx, pxToAll, pxToViewport, remToPx, satisfiesRange, scaleByHeight, scaleByWidth, sortVersions, splitSubnet, symbolicToPermissions, units_exports, validateCardNumber, validateIban, viewportToPx };
@@ -33,6 +33,7 @@ __export(network_exports, {
33
33
  validateDomain: () => validateDomain,
34
34
  validateHeaderName: () => validateHeaderName,
35
35
  validateHeaderValue: () => validateHeaderValue,
36
+ validateRobotsTxt: () => validateRobotsTxt,
36
37
  validateSitemap: () => validateSitemap
37
38
  });
38
39
  var DNS_RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SOA", "PTR", "SRV", "CAA"];
@@ -1531,5 +1532,88 @@ function validateSitemap(xml) {
1531
1532
  const valid = !issues.some((i) => i.severity === "error");
1532
1533
  return { type, urlCount: entries.length, entries, issues, valid };
1533
1534
  }
1535
+ var KNOWN_ROBOTS_DIRECTIVES = /* @__PURE__ */ new Set(["user-agent", "disallow", "allow", "sitemap", "crawl-delay", "host"]);
1536
+ function validateRobotsTxt(text) {
1537
+ if (!text || !text.trim()) return { error: "Input is empty" };
1538
+ const lines = text.split("\n");
1539
+ const issues = [];
1540
+ const groups = [];
1541
+ const sitemaps = [];
1542
+ let currentGroup = null;
1543
+ for (let i = 0; i < lines.length; i++) {
1544
+ const rawLine = lines[i];
1545
+ const lineNum = i + 1;
1546
+ const commentIdx = rawLine.indexOf("#");
1547
+ const line = (commentIdx >= 0 ? rawLine.slice(0, commentIdx) : rawLine).trim();
1548
+ if (!line) continue;
1549
+ const colonIdx = line.indexOf(":");
1550
+ if (colonIdx === -1) {
1551
+ issues.push({ severity: "error", line: lineNum, message: `Malformed line (missing ':'): "${line}"` });
1552
+ continue;
1553
+ }
1554
+ const directive = line.slice(0, colonIdx).trim().toLowerCase();
1555
+ const value = line.slice(colonIdx + 1).trim();
1556
+ if (!KNOWN_ROBOTS_DIRECTIVES.has(directive)) {
1557
+ issues.push({ severity: "warning", line: lineNum, message: `Unknown directive "${directive}"` });
1558
+ continue;
1559
+ }
1560
+ if (directive === "sitemap") {
1561
+ if (!/^https?:\/\//i.test(value)) {
1562
+ issues.push({ severity: "error", line: lineNum, message: `Sitemap URL "${value}" must be absolute (start with http:// or https://)` });
1563
+ }
1564
+ sitemaps.push(value);
1565
+ continue;
1566
+ }
1567
+ if (directive === "user-agent") {
1568
+ if (!value) {
1569
+ issues.push({ severity: "error", line: lineNum, message: "User-agent directive is missing a value" });
1570
+ }
1571
+ if (currentGroup && currentGroup.rules.length === 0 && currentGroup.crawlDelay === void 0) {
1572
+ currentGroup.userAgents.push(value);
1573
+ } else {
1574
+ currentGroup = { userAgents: [value], rules: [], line: lineNum };
1575
+ groups.push(currentGroup);
1576
+ }
1577
+ continue;
1578
+ }
1579
+ if (!currentGroup) {
1580
+ issues.push({ severity: "error", line: lineNum, message: `"${directive}" directive found before any User-agent line` });
1581
+ continue;
1582
+ }
1583
+ if (directive === "disallow" || directive === "allow") {
1584
+ if (value !== "" && !value.startsWith("/") && !value.startsWith("*")) {
1585
+ issues.push({ severity: "warning", line: lineNum, message: `${directive === "disallow" ? "Disallow" : "Allow"} value "${value}" should start with "/" (or be empty)` });
1586
+ }
1587
+ if (currentGroup.rules.some((r) => r.type === directive && r.value === value)) {
1588
+ issues.push({ severity: "warning", line: lineNum, message: `Duplicate ${directive} rule for "${value}" in this group` });
1589
+ }
1590
+ currentGroup.rules.push({ type: directive, value, line: lineNum });
1591
+ } else if (directive === "crawl-delay") {
1592
+ const n = Number(value);
1593
+ if (Number.isNaN(n) || n < 0) {
1594
+ issues.push({ severity: "error", line: lineNum, message: `Crawl-delay "${value}" must be a non-negative number` });
1595
+ } else {
1596
+ currentGroup.crawlDelay = n;
1597
+ }
1598
+ }
1599
+ }
1600
+ if (groups.length === 0) {
1601
+ issues.push({ severity: "error", line: 1, message: "No User-agent groups found" });
1602
+ }
1603
+ const seenUA = /* @__PURE__ */ new Map();
1604
+ for (const g of groups) {
1605
+ for (const ua of g.userAgents) {
1606
+ const key = ua.toLowerCase();
1607
+ const firstLine = seenUA.get(key);
1608
+ if (firstLine !== void 0) {
1609
+ issues.push({ severity: "warning", line: g.line, message: `Duplicate User-agent "${ua}" (first seen on line ${firstLine})` });
1610
+ } else {
1611
+ seenUA.set(key, g.line);
1612
+ }
1613
+ }
1614
+ }
1615
+ const valid = !issues.some((i) => i.severity === "error");
1616
+ return { groups, sitemaps, issues, valid };
1617
+ }
1534
1618
 
1535
- export { DNS_RECORD_TYPES, HTTP_HEADERS, STATUS_CODES, buildDnsQueryUrl, buildGeoUrl, buildMapUrl, countryCodeToFlag, formatCoordinates, formatTtl, getByCategory, getHeader, getHeadersByCategory, getStatusByCategory, getStatusCode, isClientError, isServerError, isSuccess, isValidIp, isValidIpv4, isValidIpv6, network_exports, parseDnsResponse, parseGeoResponse, parseHar, searchHeaders, searchStatusCodes, statusCodeToText, typeNumberToLabel, validateDomain, validateHeaderName, validateHeaderValue, validateSitemap };
1619
+ export { DNS_RECORD_TYPES, HTTP_HEADERS, STATUS_CODES, buildDnsQueryUrl, buildGeoUrl, buildMapUrl, countryCodeToFlag, formatCoordinates, formatTtl, getByCategory, getHeader, getHeadersByCategory, getStatusByCategory, getStatusCode, isClientError, isServerError, isSuccess, isValidIp, isValidIpv4, isValidIpv6, network_exports, parseDnsResponse, parseGeoResponse, parseHar, searchHeaders, searchStatusCodes, statusCodeToText, typeNumberToLabel, validateDomain, validateHeaderName, validateHeaderValue, validateRobotsTxt, validateSitemap };