@logtape/logtape 1.2.0-dev.354 → 1.2.0-dev.359

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/logger.ts CHANGED
@@ -1381,11 +1381,309 @@ export class LoggerCtx implements Logger {
1381
1381
  */
1382
1382
  const metaLogger = LoggerImpl.getLogger(["logtape", "meta"]);
1383
1383
 
1384
+ /**
1385
+ * Check if a property access key contains nested access patterns.
1386
+ * @param key The property key to check.
1387
+ * @returns True if the key contains nested access patterns.
1388
+ */
1389
+ function isNestedAccess(key: string): boolean {
1390
+ return key.includes(".") || key.includes("[") || key.includes("?.");
1391
+ }
1392
+
1393
+ /**
1394
+ * Safely access an own property from an object, blocking prototype pollution.
1395
+ *
1396
+ * @param obj The object to access the property from.
1397
+ * @param key The property key to access.
1398
+ * @returns The property value or undefined if not accessible.
1399
+ */
1400
+ function getOwnProperty(obj: unknown, key: string): unknown {
1401
+ // Block dangerous prototype keys
1402
+ if (key === "__proto__" || key === "prototype" || key === "constructor") {
1403
+ return undefined;
1404
+ }
1405
+
1406
+ if ((typeof obj === "object" || typeof obj === "function") && obj !== null) {
1407
+ return Object.prototype.hasOwnProperty.call(obj, key)
1408
+ ? (obj as Record<string, unknown>)[key]
1409
+ : undefined;
1410
+ }
1411
+
1412
+ return undefined;
1413
+ }
1414
+
1415
+ /**
1416
+ * Result of parsing a single segment from a property path.
1417
+ */
1418
+ interface ParseSegmentResult {
1419
+ segment: string | number;
1420
+ nextIndex: number;
1421
+ }
1422
+
1423
+ /**
1424
+ * Parse the next segment from a property path string.
1425
+ *
1426
+ * @param path The full property path string.
1427
+ * @param fromIndex The index to start parsing from.
1428
+ * @returns The parsed segment and next index, or null if parsing fails.
1429
+ */
1430
+ function parseNextSegment(
1431
+ path: string,
1432
+ fromIndex: number,
1433
+ ): ParseSegmentResult | null {
1434
+ const len = path.length;
1435
+ let i = fromIndex;
1436
+
1437
+ if (i >= len) return null;
1438
+
1439
+ let segment: string | number;
1440
+
1441
+ if (path[i] === "[") {
1442
+ // Bracket notation: [0] or ["prop"]
1443
+ i++;
1444
+ if (i >= len) return null;
1445
+
1446
+ if (path[i] === '"' || path[i] === "'") {
1447
+ // Quoted property name: ["prop-name"]
1448
+ const quote = path[i];
1449
+ i++;
1450
+ // Build segment with proper escape handling
1451
+ let segmentStr = "";
1452
+ while (i < len && path[i] !== quote) {
1453
+ if (path[i] === "\\") {
1454
+ i++; // Skip backslash
1455
+ if (i < len) {
1456
+ // Handle escape sequences according to JavaScript spec
1457
+ const escapeChar = path[i];
1458
+ switch (escapeChar) {
1459
+ case "n":
1460
+ segmentStr += "\n";
1461
+ break;
1462
+ case "t":
1463
+ segmentStr += "\t";
1464
+ break;
1465
+ case "r":
1466
+ segmentStr += "\r";
1467
+ break;
1468
+ case "b":
1469
+ segmentStr += "\b";
1470
+ break;
1471
+ case "f":
1472
+ segmentStr += "\f";
1473
+ break;
1474
+ case "v":
1475
+ segmentStr += "\v";
1476
+ break;
1477
+ case "0":
1478
+ segmentStr += "\0";
1479
+ break;
1480
+ case "\\":
1481
+ segmentStr += "\\";
1482
+ break;
1483
+ case '"':
1484
+ segmentStr += '"';
1485
+ break;
1486
+ case "'":
1487
+ segmentStr += "'";
1488
+ break;
1489
+ case "u":
1490
+ // Unicode escape: \uXXXX
1491
+ if (i + 4 < len) {
1492
+ const hex = path.slice(i + 1, i + 5);
1493
+ const codePoint = Number.parseInt(hex, 16);
1494
+ if (!Number.isNaN(codePoint)) {
1495
+ segmentStr += String.fromCharCode(codePoint);
1496
+ i += 4; // Skip the 4 hex digits
1497
+ } else {
1498
+ // Invalid unicode escape, keep as-is
1499
+ segmentStr += escapeChar;
1500
+ }
1501
+ } else {
1502
+ // Not enough characters for unicode escape
1503
+ segmentStr += escapeChar;
1504
+ }
1505
+ break;
1506
+ default:
1507
+ // For any other character after \, just add it as-is
1508
+ segmentStr += escapeChar;
1509
+ }
1510
+ i++;
1511
+ }
1512
+ } else {
1513
+ segmentStr += path[i];
1514
+ i++;
1515
+ }
1516
+ }
1517
+ if (i >= len) return null;
1518
+ segment = segmentStr;
1519
+ i++; // Skip closing quote
1520
+ } else {
1521
+ // Array index: [0]
1522
+ const startIndex = i;
1523
+ while (
1524
+ i < len && path[i] !== "]" && path[i] !== "'" && path[i] !== '"'
1525
+ ) {
1526
+ i++;
1527
+ }
1528
+ if (i >= len) return null;
1529
+ const indexStr = path.slice(startIndex, i);
1530
+ // Empty bracket is invalid
1531
+ if (indexStr.length === 0) return null;
1532
+ const indexNum = Number(indexStr);
1533
+ segment = Number.isNaN(indexNum) ? indexStr : indexNum;
1534
+ }
1535
+
1536
+ // Skip closing bracket
1537
+ while (i < len && path[i] !== "]") i++;
1538
+ if (i < len) i++;
1539
+ } else {
1540
+ // Dot notation: prop
1541
+ const startIndex = i;
1542
+ while (
1543
+ i < len && path[i] !== "." && path[i] !== "[" && path[i] !== "?" &&
1544
+ path[i] !== "]"
1545
+ ) {
1546
+ i++;
1547
+ }
1548
+ segment = path.slice(startIndex, i);
1549
+ // Empty segment is invalid (e.g., leading dot, double dot, trailing dot)
1550
+ if (segment.length === 0) return null;
1551
+ }
1552
+
1553
+ // Skip dot separator
1554
+ if (i < len && path[i] === ".") i++;
1555
+
1556
+ return { segment, nextIndex: i };
1557
+ }
1558
+
1559
+ /**
1560
+ * Access a property or index on an object or array.
1561
+ *
1562
+ * @param obj The object or array to access.
1563
+ * @param segment The property key or array index.
1564
+ * @returns The accessed value or undefined if not accessible.
1565
+ */
1566
+ function accessProperty(obj: unknown, segment: string | number): unknown {
1567
+ if (typeof segment === "string") {
1568
+ return getOwnProperty(obj, segment);
1569
+ }
1570
+
1571
+ // Numeric index for arrays
1572
+ if (Array.isArray(obj) && segment >= 0 && segment < obj.length) {
1573
+ return obj[segment];
1574
+ }
1575
+
1576
+ return undefined;
1577
+ }
1578
+
1579
+ /**
1580
+ * Resolve a nested property path from an object.
1581
+ *
1582
+ * There are two types of property access patterns:
1583
+ * 1. Array/index access: [0] or ["prop"]
1584
+ * 2. Property access: prop or prop?.next
1585
+ *
1586
+ * @param obj The object to traverse.
1587
+ * @param path The property path (e.g., "user.name", "users[0].email", "user['full-name']").
1588
+ * @returns The resolved value or undefined if path doesn't exist.
1589
+ */
1590
+ function resolvePropertyPath(obj: unknown, path: string): unknown {
1591
+ if (obj == null) return undefined;
1592
+
1593
+ // Check for invalid paths
1594
+ if (path.length === 0 || path.endsWith(".")) return undefined;
1595
+
1596
+ let current: unknown = obj;
1597
+ let i = 0;
1598
+ const len = path.length;
1599
+
1600
+ while (i < len) {
1601
+ // Handle optional chaining
1602
+ const isOptional = path.slice(i, i + 2) === "?.";
1603
+ if (isOptional) {
1604
+ i += 2;
1605
+ if (current == null) return undefined;
1606
+ } else if (current == null) {
1607
+ return undefined;
1608
+ }
1609
+
1610
+ // Parse the next segment
1611
+ const result = parseNextSegment(path, i);
1612
+ if (result === null) return undefined;
1613
+
1614
+ const { segment, nextIndex } = result;
1615
+ i = nextIndex;
1616
+
1617
+ // Access the property/index
1618
+ current = accessProperty(current, segment);
1619
+ if (current === undefined) {
1620
+ return undefined;
1621
+ }
1622
+ }
1623
+
1624
+ return current;
1625
+ }
1626
+
1384
1627
  /**
1385
1628
  * Parse a message template into a message template array and a values array.
1386
- * @param template The message template.
1629
+ *
1630
+ * Placeholders to be replaced with `values` are indicated by keys in curly braces
1631
+ * (e.g., `{value}`). The system supports both simple property access and nested
1632
+ * property access patterns:
1633
+ *
1634
+ * **Simple property access:**
1635
+ * ```ts
1636
+ * parseMessageTemplate("Hello, {user}!", { user: "foo" })
1637
+ * // Returns: ["Hello, ", "foo", "!"]
1638
+ * ```
1639
+ *
1640
+ * **Nested property access (dot notation):**
1641
+ * ```ts
1642
+ * parseMessageTemplate("Hello, {user.name}!", {
1643
+ * user: { name: "foo", email: "foo@example.com" }
1644
+ * })
1645
+ * // Returns: ["Hello, ", "foo", "!"]
1646
+ * ```
1647
+ *
1648
+ * **Array indexing:**
1649
+ * ```ts
1650
+ * parseMessageTemplate("First: {users[0]}", {
1651
+ * users: ["foo", "bar", "baz"]
1652
+ * })
1653
+ * // Returns: ["First: ", "foo", ""]
1654
+ * ```
1655
+ *
1656
+ * **Bracket notation for special property names:**
1657
+ * ```ts
1658
+ * parseMessageTemplate("Name: {user[\"full-name\"]}", {
1659
+ * user: { "full-name": "foo bar" }
1660
+ * })
1661
+ * // Returns: ["Name: ", "foo bar", ""]
1662
+ * ```
1663
+ *
1664
+ * **Optional chaining for safe navigation:**
1665
+ * ```ts
1666
+ * parseMessageTemplate("Email: {user?.profile?.email}", {
1667
+ * user: { name: "foo" }
1668
+ * })
1669
+ * // Returns: ["Email: ", undefined, ""]
1670
+ * ```
1671
+ *
1672
+ * **Wildcard patterns:**
1673
+ * - `{*}` - Replaced with the entire properties object
1674
+ * - `{ key-with-whitespace }` - Whitespace is trimmed when looking up keys
1675
+ *
1676
+ * **Escaping:**
1677
+ * - `{{` and `}}` are escaped literal braces
1678
+ *
1679
+ * **Error handling:**
1680
+ * - Non-existent paths return `undefined`
1681
+ * - Malformed expressions resolve to `undefined` without throwing errors
1682
+ * - Out of bounds array access returns `undefined`
1683
+ *
1684
+ * @param template The message template string containing placeholders.
1387
1685
  * @param properties The values to replace placeholders with.
1388
- * @returns The message template array and the values array.
1686
+ * @returns The message template array with values interleaved between text segments.
1389
1687
  */
1390
1688
  export function parseMessageTemplate(
1391
1689
  template: string,
@@ -1447,6 +1745,11 @@ export function parseMessageTemplate(
1447
1745
  // Key has no leading/trailing whitespace
1448
1746
  prop = properties[key];
1449
1747
  }
1748
+
1749
+ // If property not found directly and this looks like nested access, try nested resolution
1750
+ if (prop === undefined && isNestedAccess(trimmedKey)) {
1751
+ prop = resolvePropertyPath(properties, trimmedKey);
1752
+ }
1450
1753
  }
1451
1754
 
1452
1755
  message.push(prop);