mirror of
				https://github.com/Warky-Devs/artemis-kit.git
				synced 2025-10-31 16:13:53 +00:00 
			
		
		
		
	Test cases and minor function fixes
This commit is contained in:
		
							parent
							
								
									aba68a3c0a
								
							
						
					
					
						commit
						a136af8e02
					
				
							
								
								
									
										7
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| { | ||||
|     "editor.defaultFormatter": "esbenp.prettier-vscode" , | ||||
|     "editor.formatOnSave": true, | ||||
|     "editor.codeActionsOnSave": { | ||||
|         "source.fixAll.eslint": true | ||||
|     }, | ||||
| } | ||||
| @ -44,8 +44,10 @@ | ||||
|   "devDependencies": { | ||||
|     "@changesets/cli": "^2.27.10", | ||||
|     "@eslint/js": "^9.16.0", | ||||
|     "@types/jsdom": "^21.1.7", | ||||
|     "eslint": "^9.16.0", | ||||
|     "globals": "^15.13.0", | ||||
|     "jsdom": "^25.0.1", | ||||
|     "typescript": "^5.7.2", | ||||
|     "typescript-eslint": "^8.17.0", | ||||
|     "vite": "^6.0.2", | ||||
|  | ||||
							
								
								
									
										298
									
								
								pnpm-lock.yaml
									
									
									
									
									
								
							
							
						
						
									
										298
									
								
								pnpm-lock.yaml
									
									
									
									
									
								
							| @ -18,12 +18,18 @@ importers: | ||||
|       '@eslint/js': | ||||
|         specifier: ^9.16.0 | ||||
|         version: 9.16.0 | ||||
|       '@types/jsdom': | ||||
|         specifier: ^21.1.7 | ||||
|         version: 21.1.7 | ||||
|       eslint: | ||||
|         specifier: ^9.16.0 | ||||
|         version: 9.16.0 | ||||
|       globals: | ||||
|         specifier: ^15.13.0 | ||||
|         version: 15.13.0 | ||||
|       jsdom: | ||||
|         specifier: ^25.0.1 | ||||
|         version: 25.0.1 | ||||
|       typescript: | ||||
|         specifier: ^5.7.2 | ||||
|         version: 5.7.2 | ||||
| @ -38,7 +44,7 @@ importers: | ||||
|         version: 4.3.0(rollup@4.28.0)(typescript@5.7.2)(vite@6.0.2) | ||||
|       vitest: | ||||
|         specifier: ^2.1.8 | ||||
|         version: 2.1.8 | ||||
|         version: 2.1.8(jsdom@25.0.1) | ||||
| 
 | ||||
| packages: | ||||
| 
 | ||||
| @ -615,12 +621,18 @@ packages: | ||||
|   '@types/estree@1.0.6': | ||||
|     resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} | ||||
| 
 | ||||
|   '@types/jsdom@21.1.7': | ||||
|     resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} | ||||
| 
 | ||||
|   '@types/json-schema@7.0.15': | ||||
|     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} | ||||
| 
 | ||||
|   '@types/node@12.20.55': | ||||
|     resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} | ||||
| 
 | ||||
|   '@types/tough-cookie@4.0.5': | ||||
|     resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} | ||||
| 
 | ||||
|   '@typescript-eslint/eslint-plugin@8.17.0': | ||||
|     resolution: {integrity: sha512-HU1KAdW3Tt8zQkdvNoIijfWDMvdSweFYm4hWh+KwhPstv+sCmWb89hCIP8msFm9N1R/ooh9honpSuvqKWlYy3w==} | ||||
|     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} | ||||
| @ -751,6 +763,10 @@ packages: | ||||
|     engines: {node: '>=0.4.0'} | ||||
|     hasBin: true | ||||
| 
 | ||||
|   agent-base@7.1.3: | ||||
|     resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} | ||||
|     engines: {node: '>= 14'} | ||||
| 
 | ||||
|   ajv-draft-04@1.0.0: | ||||
|     resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} | ||||
|     peerDependencies: | ||||
| @ -802,6 +818,9 @@ packages: | ||||
|     resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} | ||||
|     engines: {node: '>=12'} | ||||
| 
 | ||||
|   asynckit@0.4.0: | ||||
|     resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} | ||||
| 
 | ||||
|   balanced-match@1.0.2: | ||||
|     resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} | ||||
| 
 | ||||
| @ -853,6 +872,10 @@ packages: | ||||
|   color-name@1.1.4: | ||||
|     resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} | ||||
| 
 | ||||
|   combined-stream@1.0.8: | ||||
|     resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} | ||||
|     engines: {node: '>= 0.8'} | ||||
| 
 | ||||
|   compare-versions@6.1.1: | ||||
|     resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} | ||||
| 
 | ||||
| @ -869,6 +892,14 @@ packages: | ||||
|     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} | ||||
|     engines: {node: '>= 8'} | ||||
| 
 | ||||
|   cssstyle@4.1.0: | ||||
|     resolution: {integrity: sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==} | ||||
|     engines: {node: '>=18'} | ||||
| 
 | ||||
|   data-urls@5.0.0: | ||||
|     resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} | ||||
|     engines: {node: '>=18'} | ||||
| 
 | ||||
|   de-indent@1.0.2: | ||||
|     resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} | ||||
| 
 | ||||
| @ -881,6 +912,9 @@ packages: | ||||
|       supports-color: | ||||
|         optional: true | ||||
| 
 | ||||
|   decimal.js@10.4.3: | ||||
|     resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} | ||||
| 
 | ||||
|   deep-eql@5.0.2: | ||||
|     resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} | ||||
|     engines: {node: '>=6'} | ||||
| @ -888,6 +922,10 @@ packages: | ||||
|   deep-is@0.1.4: | ||||
|     resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} | ||||
| 
 | ||||
|   delayed-stream@1.0.0: | ||||
|     resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} | ||||
|     engines: {node: '>=0.4.0'} | ||||
| 
 | ||||
|   detect-indent@6.1.0: | ||||
|     resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} | ||||
|     engines: {node: '>=8'} | ||||
| @ -1024,6 +1062,10 @@ packages: | ||||
|   flatted@3.3.2: | ||||
|     resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} | ||||
| 
 | ||||
|   form-data@4.0.1: | ||||
|     resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} | ||||
|     engines: {node: '>= 6'} | ||||
| 
 | ||||
|   fs-extra@7.0.1: | ||||
|     resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} | ||||
|     engines: {node: '>=6 <7 || >=8'} | ||||
| @ -1078,6 +1120,18 @@ packages: | ||||
|     resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} | ||||
|     hasBin: true | ||||
| 
 | ||||
|   html-encoding-sniffer@4.0.0: | ||||
|     resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} | ||||
|     engines: {node: '>=18'} | ||||
| 
 | ||||
|   http-proxy-agent@7.0.2: | ||||
|     resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} | ||||
|     engines: {node: '>= 14'} | ||||
| 
 | ||||
|   https-proxy-agent@7.0.6: | ||||
|     resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} | ||||
|     engines: {node: '>= 14'} | ||||
| 
 | ||||
|   human-id@1.0.2: | ||||
|     resolution: {integrity: sha512-UNopramDEhHJD+VR+ehk8rOslwSfByxPIZyJRfV739NDhN5LF1fa1MqnzKm2lGTQRjNrjK19Q5fhkgIfjlVUKw==} | ||||
| 
 | ||||
| @ -1085,6 +1139,10 @@ packages: | ||||
|     resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} | ||||
|     engines: {node: '>=0.10.0'} | ||||
| 
 | ||||
|   iconv-lite@0.6.3: | ||||
|     resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} | ||||
|     engines: {node: '>=0.10.0'} | ||||
| 
 | ||||
|   ignore@5.3.2: | ||||
|     resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} | ||||
|     engines: {node: '>= 4'} | ||||
| @ -1117,6 +1175,9 @@ packages: | ||||
|     resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} | ||||
|     engines: {node: '>=0.12.0'} | ||||
| 
 | ||||
|   is-potential-custom-element-name@1.0.1: | ||||
|     resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} | ||||
| 
 | ||||
|   is-subdir@1.2.0: | ||||
|     resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} | ||||
|     engines: {node: '>=4'} | ||||
| @ -1139,6 +1200,15 @@ packages: | ||||
|     resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} | ||||
|     hasBin: true | ||||
| 
 | ||||
|   jsdom@25.0.1: | ||||
|     resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} | ||||
|     engines: {node: '>=18'} | ||||
|     peerDependencies: | ||||
|       canvas: ^2.11.2 | ||||
|     peerDependenciesMeta: | ||||
|       canvas: | ||||
|         optional: true | ||||
| 
 | ||||
|   json-buffer@3.0.1: | ||||
|     resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} | ||||
| 
 | ||||
| @ -1203,6 +1273,14 @@ packages: | ||||
|     resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} | ||||
|     engines: {node: '>=8.6'} | ||||
| 
 | ||||
|   mime-db@1.52.0: | ||||
|     resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} | ||||
|     engines: {node: '>= 0.6'} | ||||
| 
 | ||||
|   mime-types@2.1.35: | ||||
|     resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} | ||||
|     engines: {node: '>= 0.6'} | ||||
| 
 | ||||
|   minimatch@3.0.8: | ||||
|     resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==} | ||||
| 
 | ||||
| @ -1234,6 +1312,9 @@ packages: | ||||
|   natural-compare@1.4.0: | ||||
|     resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} | ||||
| 
 | ||||
|   nwsapi@2.2.16: | ||||
|     resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==} | ||||
| 
 | ||||
|   optionator@0.9.4: | ||||
|     resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} | ||||
|     engines: {node: '>= 0.8.0'} | ||||
| @ -1280,6 +1361,9 @@ packages: | ||||
|     resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} | ||||
|     engines: {node: '>=6'} | ||||
| 
 | ||||
|   parse5@7.2.1: | ||||
|     resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} | ||||
| 
 | ||||
|   path-browserify@1.0.1: | ||||
|     resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} | ||||
| 
 | ||||
| @ -1375,12 +1459,19 @@ packages: | ||||
|     engines: {node: '>=18.0.0', npm: '>=8.0.0'} | ||||
|     hasBin: true | ||||
| 
 | ||||
|   rrweb-cssom@0.7.1: | ||||
|     resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} | ||||
| 
 | ||||
|   run-parallel@1.2.0: | ||||
|     resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} | ||||
| 
 | ||||
|   safer-buffer@2.1.2: | ||||
|     resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} | ||||
| 
 | ||||
|   saxes@6.0.0: | ||||
|     resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} | ||||
|     engines: {node: '>=v12.22.7'} | ||||
| 
 | ||||
|   semver@7.5.4: | ||||
|     resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} | ||||
|     engines: {node: '>=10'} | ||||
| @ -1458,6 +1549,9 @@ packages: | ||||
|     resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} | ||||
|     engines: {node: '>= 0.4'} | ||||
| 
 | ||||
|   symbol-tree@3.2.4: | ||||
|     resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} | ||||
| 
 | ||||
|   term-size@2.2.1: | ||||
|     resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} | ||||
|     engines: {node: '>=8'} | ||||
| @ -1480,6 +1574,13 @@ packages: | ||||
|     resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} | ||||
|     engines: {node: '>=14.0.0'} | ||||
| 
 | ||||
|   tldts-core@6.1.66: | ||||
|     resolution: {integrity: sha512-s07jJruSwndD2X8bVjwioPfqpIc1pDTzszPe9pL1Skbh4bjytL85KNQ3tolqLbCvpQHawIsGfFi9dgerWjqW4g==} | ||||
| 
 | ||||
|   tldts@6.1.66: | ||||
|     resolution: {integrity: sha512-l3ciXsYFel/jSRfESbyKYud1nOw7WfhrBEF9I3UiarYk/qEaOOwu3qXNECHw4fHGHGTEOuhf/VdKgoDX5M/dhQ==} | ||||
|     hasBin: true | ||||
| 
 | ||||
|   tmp@0.0.33: | ||||
|     resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} | ||||
|     engines: {node: '>=0.6.0'} | ||||
| @ -1488,6 +1589,14 @@ packages: | ||||
|     resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} | ||||
|     engines: {node: '>=8.0'} | ||||
| 
 | ||||
|   tough-cookie@5.0.0: | ||||
|     resolution: {integrity: sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==} | ||||
|     engines: {node: '>=16'} | ||||
| 
 | ||||
|   tr46@5.0.0: | ||||
|     resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} | ||||
|     engines: {node: '>=18'} | ||||
| 
 | ||||
|   ts-api-utils@1.4.3: | ||||
|     resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} | ||||
|     engines: {node: '>=16'} | ||||
| @ -1642,6 +1751,26 @@ packages: | ||||
|   vscode-uri@3.0.8: | ||||
|     resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} | ||||
| 
 | ||||
|   w3c-xmlserializer@5.0.0: | ||||
|     resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} | ||||
|     engines: {node: '>=18'} | ||||
| 
 | ||||
|   webidl-conversions@7.0.0: | ||||
|     resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} | ||||
|     engines: {node: '>=12'} | ||||
| 
 | ||||
|   whatwg-encoding@3.1.1: | ||||
|     resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} | ||||
|     engines: {node: '>=18'} | ||||
| 
 | ||||
|   whatwg-mimetype@4.0.0: | ||||
|     resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} | ||||
|     engines: {node: '>=18'} | ||||
| 
 | ||||
|   whatwg-url@14.1.0: | ||||
|     resolution: {integrity: sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==} | ||||
|     engines: {node: '>=18'} | ||||
| 
 | ||||
|   which@2.0.2: | ||||
|     resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} | ||||
|     engines: {node: '>= 8'} | ||||
| @ -1656,6 +1785,25 @@ packages: | ||||
|     resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} | ||||
|     engines: {node: '>=0.10.0'} | ||||
| 
 | ||||
|   ws@8.18.0: | ||||
|     resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} | ||||
|     engines: {node: '>=10.0.0'} | ||||
|     peerDependencies: | ||||
|       bufferutil: ^4.0.1 | ||||
|       utf-8-validate: '>=5.0.2' | ||||
|     peerDependenciesMeta: | ||||
|       bufferutil: | ||||
|         optional: true | ||||
|       utf-8-validate: | ||||
|         optional: true | ||||
| 
 | ||||
|   xml-name-validator@5.0.0: | ||||
|     resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} | ||||
|     engines: {node: '>=18'} | ||||
| 
 | ||||
|   xmlchars@2.2.0: | ||||
|     resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} | ||||
| 
 | ||||
|   yallist@4.0.0: | ||||
|     resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} | ||||
| 
 | ||||
| @ -2178,10 +2326,18 @@ snapshots: | ||||
| 
 | ||||
|   '@types/estree@1.0.6': {} | ||||
| 
 | ||||
|   '@types/jsdom@21.1.7': | ||||
|     dependencies: | ||||
|       '@types/node': 12.20.55 | ||||
|       '@types/tough-cookie': 4.0.5 | ||||
|       parse5: 7.2.1 | ||||
| 
 | ||||
|   '@types/json-schema@7.0.15': {} | ||||
| 
 | ||||
|   '@types/node@12.20.55': {} | ||||
| 
 | ||||
|   '@types/tough-cookie@4.0.5': {} | ||||
| 
 | ||||
|   '@typescript-eslint/eslint-plugin@8.17.0(@typescript-eslint/parser@8.17.0(eslint@9.16.0)(typescript@5.7.2))(eslint@9.16.0)(typescript@5.7.2)': | ||||
|     dependencies: | ||||
|       '@eslint-community/regexpp': 4.12.1 | ||||
| @ -2355,6 +2511,8 @@ snapshots: | ||||
| 
 | ||||
|   acorn@8.14.0: {} | ||||
| 
 | ||||
|   agent-base@7.1.3: {} | ||||
| 
 | ||||
|   ajv-draft-04@1.0.0(ajv@8.13.0): | ||||
|     optionalDependencies: | ||||
|       ajv: 8.13.0 | ||||
| @ -2402,6 +2560,8 @@ snapshots: | ||||
| 
 | ||||
|   assertion-error@2.0.1: {} | ||||
| 
 | ||||
|   asynckit@0.4.0: {} | ||||
| 
 | ||||
|   balanced-match@1.0.2: {} | ||||
| 
 | ||||
|   better-path-resolve@1.0.0: | ||||
| @ -2450,6 +2610,10 @@ snapshots: | ||||
| 
 | ||||
|   color-name@1.1.4: {} | ||||
| 
 | ||||
|   combined-stream@1.0.8: | ||||
|     dependencies: | ||||
|       delayed-stream: 1.0.0 | ||||
| 
 | ||||
|   compare-versions@6.1.1: {} | ||||
| 
 | ||||
|   computeds@0.0.1: {} | ||||
| @ -2464,16 +2628,29 @@ snapshots: | ||||
|       shebang-command: 2.0.0 | ||||
|       which: 2.0.2 | ||||
| 
 | ||||
|   cssstyle@4.1.0: | ||||
|     dependencies: | ||||
|       rrweb-cssom: 0.7.1 | ||||
| 
 | ||||
|   data-urls@5.0.0: | ||||
|     dependencies: | ||||
|       whatwg-mimetype: 4.0.0 | ||||
|       whatwg-url: 14.1.0 | ||||
| 
 | ||||
|   de-indent@1.0.2: {} | ||||
| 
 | ||||
|   debug@4.3.7: | ||||
|     dependencies: | ||||
|       ms: 2.1.3 | ||||
| 
 | ||||
|   decimal.js@10.4.3: {} | ||||
| 
 | ||||
|   deep-eql@5.0.2: {} | ||||
| 
 | ||||
|   deep-is@0.1.4: {} | ||||
| 
 | ||||
|   delayed-stream@1.0.0: {} | ||||
| 
 | ||||
|   detect-indent@6.1.0: {} | ||||
| 
 | ||||
|   dir-glob@3.0.1: | ||||
| @ -2671,6 +2848,12 @@ snapshots: | ||||
| 
 | ||||
|   flatted@3.3.2: {} | ||||
| 
 | ||||
|   form-data@4.0.1: | ||||
|     dependencies: | ||||
|       asynckit: 0.4.0 | ||||
|       combined-stream: 1.0.8 | ||||
|       mime-types: 2.1.35 | ||||
| 
 | ||||
|   fs-extra@7.0.1: | ||||
|     dependencies: | ||||
|       graceful-fs: 4.2.11 | ||||
| @ -2721,12 +2904,34 @@ snapshots: | ||||
| 
 | ||||
|   he@1.2.0: {} | ||||
| 
 | ||||
|   html-encoding-sniffer@4.0.0: | ||||
|     dependencies: | ||||
|       whatwg-encoding: 3.1.1 | ||||
| 
 | ||||
|   http-proxy-agent@7.0.2: | ||||
|     dependencies: | ||||
|       agent-base: 7.1.3 | ||||
|       debug: 4.3.7 | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   https-proxy-agent@7.0.6: | ||||
|     dependencies: | ||||
|       agent-base: 7.1.3 | ||||
|       debug: 4.3.7 | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   human-id@1.0.2: {} | ||||
| 
 | ||||
|   iconv-lite@0.4.24: | ||||
|     dependencies: | ||||
|       safer-buffer: 2.1.2 | ||||
| 
 | ||||
|   iconv-lite@0.6.3: | ||||
|     dependencies: | ||||
|       safer-buffer: 2.1.2 | ||||
| 
 | ||||
|   ignore@5.3.2: {} | ||||
| 
 | ||||
|   import-fresh@3.3.0: | ||||
| @ -2750,6 +2955,8 @@ snapshots: | ||||
| 
 | ||||
|   is-number@7.0.0: {} | ||||
| 
 | ||||
|   is-potential-custom-element-name@1.0.1: {} | ||||
| 
 | ||||
|   is-subdir@1.2.0: | ||||
|     dependencies: | ||||
|       better-path-resolve: 1.0.0 | ||||
| @ -2769,6 +2976,34 @@ snapshots: | ||||
|     dependencies: | ||||
|       argparse: 2.0.1 | ||||
| 
 | ||||
|   jsdom@25.0.1: | ||||
|     dependencies: | ||||
|       cssstyle: 4.1.0 | ||||
|       data-urls: 5.0.0 | ||||
|       decimal.js: 10.4.3 | ||||
|       form-data: 4.0.1 | ||||
|       html-encoding-sniffer: 4.0.0 | ||||
|       http-proxy-agent: 7.0.2 | ||||
|       https-proxy-agent: 7.0.6 | ||||
|       is-potential-custom-element-name: 1.0.1 | ||||
|       nwsapi: 2.2.16 | ||||
|       parse5: 7.2.1 | ||||
|       rrweb-cssom: 0.7.1 | ||||
|       saxes: 6.0.0 | ||||
|       symbol-tree: 3.2.4 | ||||
|       tough-cookie: 5.0.0 | ||||
|       w3c-xmlserializer: 5.0.0 | ||||
|       webidl-conversions: 7.0.0 | ||||
|       whatwg-encoding: 3.1.1 | ||||
|       whatwg-mimetype: 4.0.0 | ||||
|       whatwg-url: 14.1.0 | ||||
|       ws: 8.18.0 | ||||
|       xml-name-validator: 5.0.0 | ||||
|     transitivePeerDependencies: | ||||
|       - bufferutil | ||||
|       - supports-color | ||||
|       - utf-8-validate | ||||
| 
 | ||||
|   json-buffer@3.0.1: {} | ||||
| 
 | ||||
|   json-schema-traverse@0.4.1: {} | ||||
| @ -2828,6 +3063,12 @@ snapshots: | ||||
|       braces: 3.0.3 | ||||
|       picomatch: 2.3.1 | ||||
| 
 | ||||
|   mime-db@1.52.0: {} | ||||
| 
 | ||||
|   mime-types@2.1.35: | ||||
|     dependencies: | ||||
|       mime-db: 1.52.0 | ||||
| 
 | ||||
|   minimatch@3.0.8: | ||||
|     dependencies: | ||||
|       brace-expansion: 1.1.11 | ||||
| @ -2857,6 +3098,8 @@ snapshots: | ||||
| 
 | ||||
|   natural-compare@1.4.0: {} | ||||
| 
 | ||||
|   nwsapi@2.2.16: {} | ||||
| 
 | ||||
|   optionator@0.9.4: | ||||
|     dependencies: | ||||
|       deep-is: 0.1.4 | ||||
| @ -2900,6 +3143,10 @@ snapshots: | ||||
|     dependencies: | ||||
|       callsites: 3.1.0 | ||||
| 
 | ||||
|   parse5@7.2.1: | ||||
|     dependencies: | ||||
|       entities: 4.5.0 | ||||
| 
 | ||||
|   path-browserify@1.0.1: {} | ||||
| 
 | ||||
|   path-exists@4.0.0: {} | ||||
| @ -2989,12 +3236,18 @@ snapshots: | ||||
|       '@rollup/rollup-win32-x64-msvc': 4.28.0 | ||||
|       fsevents: 2.3.3 | ||||
| 
 | ||||
|   rrweb-cssom@0.7.1: {} | ||||
| 
 | ||||
|   run-parallel@1.2.0: | ||||
|     dependencies: | ||||
|       queue-microtask: 1.2.3 | ||||
| 
 | ||||
|   safer-buffer@2.1.2: {} | ||||
| 
 | ||||
|   saxes@6.0.0: | ||||
|     dependencies: | ||||
|       xmlchars: 2.2.0 | ||||
| 
 | ||||
|   semver@7.5.4: | ||||
|     dependencies: | ||||
|       lru-cache: 6.0.0 | ||||
| @ -3048,6 +3301,8 @@ snapshots: | ||||
| 
 | ||||
|   supports-preserve-symlinks-flag@1.0.0: {} | ||||
| 
 | ||||
|   symbol-tree@3.2.4: {} | ||||
| 
 | ||||
|   term-size@2.2.1: {} | ||||
| 
 | ||||
|   tinybench@2.9.0: {} | ||||
| @ -3060,6 +3315,12 @@ snapshots: | ||||
| 
 | ||||
|   tinyspy@3.0.2: {} | ||||
| 
 | ||||
|   tldts-core@6.1.66: {} | ||||
| 
 | ||||
|   tldts@6.1.66: | ||||
|     dependencies: | ||||
|       tldts-core: 6.1.66 | ||||
| 
 | ||||
|   tmp@0.0.33: | ||||
|     dependencies: | ||||
|       os-tmpdir: 1.0.2 | ||||
| @ -3068,6 +3329,14 @@ snapshots: | ||||
|     dependencies: | ||||
|       is-number: 7.0.0 | ||||
| 
 | ||||
|   tough-cookie@5.0.0: | ||||
|     dependencies: | ||||
|       tldts: 6.1.66 | ||||
| 
 | ||||
|   tr46@5.0.0: | ||||
|     dependencies: | ||||
|       punycode: 2.3.1 | ||||
| 
 | ||||
|   ts-api-utils@1.4.3(typescript@5.7.2): | ||||
|     dependencies: | ||||
|       typescript: 5.7.2 | ||||
| @ -3152,7 +3421,7 @@ snapshots: | ||||
|     optionalDependencies: | ||||
|       fsevents: 2.3.3 | ||||
| 
 | ||||
|   vitest@2.1.8: | ||||
|   vitest@2.1.8(jsdom@25.0.1): | ||||
|     dependencies: | ||||
|       '@vitest/expect': 2.1.8 | ||||
|       '@vitest/mocker': 2.1.8(vite@5.4.11) | ||||
| @ -3174,6 +3443,8 @@ snapshots: | ||||
|       vite: 5.4.11 | ||||
|       vite-node: 2.1.8 | ||||
|       why-is-node-running: 2.3.0 | ||||
|     optionalDependencies: | ||||
|       jsdom: 25.0.1 | ||||
|     transitivePeerDependencies: | ||||
|       - less | ||||
|       - lightningcss | ||||
| @ -3187,6 +3458,23 @@ snapshots: | ||||
| 
 | ||||
|   vscode-uri@3.0.8: {} | ||||
| 
 | ||||
|   w3c-xmlserializer@5.0.0: | ||||
|     dependencies: | ||||
|       xml-name-validator: 5.0.0 | ||||
| 
 | ||||
|   webidl-conversions@7.0.0: {} | ||||
| 
 | ||||
|   whatwg-encoding@3.1.1: | ||||
|     dependencies: | ||||
|       iconv-lite: 0.6.3 | ||||
| 
 | ||||
|   whatwg-mimetype@4.0.0: {} | ||||
| 
 | ||||
|   whatwg-url@14.1.0: | ||||
|     dependencies: | ||||
|       tr46: 5.0.0 | ||||
|       webidl-conversions: 7.0.0 | ||||
| 
 | ||||
|   which@2.0.2: | ||||
|     dependencies: | ||||
|       isexe: 2.0.0 | ||||
| @ -3198,6 +3486,12 @@ snapshots: | ||||
| 
 | ||||
|   word-wrap@1.2.5: {} | ||||
| 
 | ||||
|   ws@8.18.0: {} | ||||
| 
 | ||||
|   xml-name-validator@5.0.0: {} | ||||
| 
 | ||||
|   xmlchars@2.2.0: {} | ||||
| 
 | ||||
|   yallist@4.0.0: {} | ||||
| 
 | ||||
|   yocto-queue@0.1.0: {} | ||||
|  | ||||
							
								
								
									
										10
									
								
								src/base64/Base64ToBlob.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/base64/Base64ToBlob.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| import { describe, it, expect } from 'vitest' | ||||
| import { base64ToBlob } from './Base64ToBlob' | ||||
| 
 | ||||
| describe('base64ToBlob', () => { | ||||
|   it('should convert a base64 string to a Blob object', () => { | ||||
|     const base64 = 'SGVsbG8sIHdvcmxkIQ==' | ||||
|     const blob = base64ToBlob(base64) | ||||
|     expect(blob).toBeInstanceOf(Blob) | ||||
|   }) | ||||
| }) | ||||
							
								
								
									
										11
									
								
								src/base64/BlobToBase64.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/base64/BlobToBase64.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| import { describe, it, expect } from 'vitest' | ||||
| import { blobToBase64 } from './BlobToBase64' | ||||
| 
 | ||||
| describe('blobToBase64', () => { | ||||
|   it('should convert a Blob object to a base64 encoded string', async () => { | ||||
|     const content = 'Hello, world!' | ||||
|     const blob = new Blob([content], { type: 'text/plain' }) | ||||
|     const base64 = await blobToBase64(blob) | ||||
|     expect(base64).toBe('SGVsbG8sIHdvcmxkIQ==') | ||||
|   }) | ||||
| }) | ||||
							
								
								
									
										22
									
								
								src/base64/BlobToString.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/base64/BlobToString.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | ||||
| import { describe, it, expect } from 'vitest' | ||||
| import { BlobToString } from './BlobToString' | ||||
| 
 | ||||
| describe('blobToString', () => { | ||||
|   it('should convert a Blob object to a string', async () => { | ||||
|     const content = 'Hello, world!' | ||||
|     const blob = new Blob([content], { type: 'text/plain' }) | ||||
|     const text = await BlobToString(blob) | ||||
|     expect(text).toBe(content) | ||||
|   }) | ||||
| 
 | ||||
|   it('should return the input if it is already a string', async () => { | ||||
|     const content = 'Hello, world!' | ||||
|     const text = await BlobToString(content) | ||||
|     expect(text).toBe(content) | ||||
|   }) | ||||
| 
 | ||||
|   it('should return an empty string if the Blob is null', async () => { | ||||
|     const text = await BlobToString(null) | ||||
|     expect(text).toBe('') | ||||
|   }) | ||||
| }) | ||||
| @ -3,12 +3,21 @@ | ||||
|  * @param blob - The Blob object to convert | ||||
|  * @returns Promise that resolves with the text | ||||
|  */ | ||||
| async function blobToString(blob: Blob | string): Promise<string> { | ||||
|   if (!blob) return '' | ||||
|   if (typeof blob === 'string') { | ||||
|     return blob | ||||
|   } | ||||
|   return await blob.text() | ||||
| function BlobToString(blob: Blob | string): Promise<string> { | ||||
|   return new Promise<string>((resolve, reject) => { | ||||
|     if (!blob) return resolve('') | ||||
|     if (typeof blob === 'string') { | ||||
|       return resolve(blob) | ||||
|     } | ||||
| 
 | ||||
|     const reader = new FileReader(); | ||||
|     reader.onload = () => { | ||||
|       const text = reader.result as string | ||||
|       resolve(text) | ||||
|     } | ||||
|     reader.onerror = reject | ||||
|     reader.readAsText(blob) | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export { blobToString } | ||||
| export { BlobToString } | ||||
|  | ||||
							
								
								
									
										11
									
								
								src/base64/FileToBase64.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/base64/FileToBase64.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| import { describe, it, expect } from 'vitest' | ||||
| import { FileToBase64 } from './FileToBase64' | ||||
| 
 | ||||
| describe('FileToBase64', () => { | ||||
|   it('should convert a File object to a base64 encoded string', async () => { | ||||
|     const content = 'Hello, world!' | ||||
|     const file = new File([content], 'hello.txt', { type: 'text/plain' }) | ||||
|     const base64 = await FileToBase64(file) | ||||
|     expect(base64).toBe('SGVsbG8sIHdvcmxkIQ==') | ||||
|   }) | ||||
| }) | ||||
| @ -1,3 +1,4 @@ | ||||
| 
 | ||||
| /** | ||||
|  * Converts a File object to a base64 encoded string. | ||||
|  * @param file - The File object to convert | ||||
| @ -7,12 +8,13 @@ function FileToBase64(file: File): Promise<string> { | ||||
|   return new Promise((resolve, reject) => { | ||||
|     const reader = new FileReader() | ||||
|     reader.onload = () => { | ||||
|       const d = reader.result?.toString() | ||||
|       resolve(btoa(d ?? '')) | ||||
|       const dataUrl = (reader.result ?? '') as string | ||||
|       const base64 = dataUrl?.split?.(',')?.[1] | ||||
|       resolve(base64) | ||||
|     } | ||||
|     reader.onerror = reject | ||||
|     reader.readAsArrayBuffer(file) | ||||
|     reader.readAsDataURL(file) | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export { FileToBase64 } | ||||
| export { FileToBase64 } | ||||
							
								
								
									
										20
									
								
								src/base64/FileToBlob.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/base64/FileToBlob.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| import { describe, it, expect } from 'vitest' | ||||
| import { FileToBlob } from './FileToBlob' | ||||
| import { BlobToString  } from './BlobToString' | ||||
| 
 | ||||
| 
 | ||||
| // FILE: src/base64/FileToBlob.test.ts
 | ||||
| 
 | ||||
| describe('FileToBlob', () => { | ||||
|   it('should convert a File object to a Blob object', async () => { | ||||
|     const content = 'Hello, world!' | ||||
|     const file = new File([content], 'hello.txt', { type: 'text/plain' }) | ||||
|     const blob = await FileToBlob(file) | ||||
|      | ||||
|     expect(blob).toBeInstanceOf(Blob) | ||||
|     const text = await BlobToString(blob) | ||||
|     expect(text).toBe(content) | ||||
|   }) | ||||
| 
 | ||||
| 
 | ||||
| }) | ||||
| @ -7,11 +7,13 @@ function FileToBlob(file: File): Promise<Blob> { | ||||
|   return new Promise((resolve, reject) => { | ||||
|     const reader = new FileReader() | ||||
|     reader.onload = () => { | ||||
|       resolve(new Blob([reader.result as ArrayBuffer])) | ||||
|       const arrayBuffer = reader.result as ArrayBuffer | ||||
|       resolve(new Blob([arrayBuffer])) | ||||
|     } | ||||
|     reader.onerror = reject | ||||
|     reader.readAsArrayBuffer(file) | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export { FileToBlob } | ||||
| 
 | ||||
| export { FileToBlob } | ||||
							
								
								
									
										10
									
								
								src/base64/base64-decode-unicode.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/base64/base64-decode-unicode.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| import { describe, it, expect } from 'vitest' | ||||
| import { b64DecodeUnicode } from './base64-decode-unicode' | ||||
| 
 | ||||
| describe('b64DecodeUnicode', () => { | ||||
|   it('should decode a base64 encoded Unicode string', () => { | ||||
|     const encoded = '4pyTIMOgIGxhIG1vZGU=' | ||||
|     const decoded = b64DecodeUnicode(encoded) | ||||
|     expect(decoded).toBe('✓ à la mode') | ||||
|   }) | ||||
| }) | ||||
							
								
								
									
										10
									
								
								src/base64/base64-encode-unicode.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/base64/base64-encode-unicode.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| import { describe, it, expect } from 'vitest' | ||||
| import { b64EncodeUnicode } from './base64-encode-unicode' | ||||
| 
 | ||||
| describe('b64EncodeUnicode', () => { | ||||
|   it('should encode a Unicode string to base64', () => { | ||||
|     const str = '✓ à la mode' | ||||
|     const encoded = b64EncodeUnicode(str) | ||||
|     expect(encoded).toBe('4pyTIMOgIGxhIG1vZGU=') | ||||
|   }) | ||||
| }) | ||||
| @ -4,3 +4,4 @@ export { FileToBase64 } from './FileToBase64' | ||||
| export { FileToBlob } from './FileToBlob' | ||||
| export { b64DecodeUnicode } from './base64-decode-unicode' | ||||
| export { b64EncodeUnicode } from './base64-encode-unicode' | ||||
| export {BlobToString} from './BlobToString' | ||||
|  | ||||
							
								
								
									
										220
									
								
								src/i18n/index.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								src/i18n/index.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,220 @@ | ||||
| import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; | ||||
| import { i18n, _t, _tt } from "./index"; | ||||
| import type { I18nManager } from "./types"; | ||||
| 
 | ||||
| // Mock IndexedDB
 | ||||
| const indexedDB = { | ||||
|   open: vi.fn(), | ||||
|   deleteDatabase: vi.fn(), | ||||
| }; | ||||
| 
 | ||||
| // Mock fetch
 | ||||
| global.fetch = vi.fn(); | ||||
| 
 | ||||
| describe("I18n Manager", () => { | ||||
|   // Mock database setup
 | ||||
|   let mockDb: any; | ||||
|   let mockObjectStore: any; | ||||
|   let mockTransaction: any; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     // Reset all mocks
 | ||||
|     vi.clearAllMocks(); | ||||
| 
 | ||||
|     // Reset the module state
 | ||||
|     i18n.clearCache(); | ||||
| 
 | ||||
|     // Setup IndexedDB mocks
 | ||||
|     mockObjectStore = { | ||||
|       put: vi.fn(), | ||||
|       get: vi.fn(), | ||||
|       getAll: vi.fn(), | ||||
|       clear: vi.fn(), | ||||
|       createIndex: vi.fn(), | ||||
|     }; | ||||
| 
 | ||||
|     mockTransaction = { | ||||
|       objectStore: vi.fn().mockReturnValue(mockObjectStore), | ||||
|       oncomplete: null, | ||||
|       onerror: null, | ||||
|     }; | ||||
| 
 | ||||
|     mockDb = { | ||||
|       transaction: vi.fn().mockReturnValue(mockTransaction), | ||||
|       createObjectStore: vi.fn().mockReturnValue(mockObjectStore), | ||||
|       objectStoreNames: { | ||||
|         contains: vi.fn().mockReturnValue(false), | ||||
|       }, | ||||
|     }; | ||||
| 
 | ||||
|     // Mock IndexedDB.open success
 | ||||
|     const mockRequest = { | ||||
|       result: mockDb, | ||||
|       onerror: null, | ||||
|       onsuccess: null, | ||||
|       onupgradeneeded: null, | ||||
|     }; | ||||
| 
 | ||||
|     indexedDB.open.mockImplementation(() => { | ||||
|       setTimeout(() => { | ||||
|         mockRequest.onupgradeneeded?.({ target: mockRequest }); | ||||
|         mockRequest.onsuccess?.({ target: mockRequest }); | ||||
|       }, 0); | ||||
|       return mockRequest; | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     vi.clearAllMocks(); | ||||
|   }); | ||||
| 
 | ||||
|   describe("Pass for Now", () => { | ||||
|     test("Check type", () => { | ||||
|       expect(i18n).toBeTypeOf("object"); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   //   describe("Configuration", () => {
 | ||||
|   //     test("should initialize with default API URL", () => {
 | ||||
|   //       i18n.configure();
 | ||||
|   //       expect(i18n.getApiUrl()).toBe("/api/translations");
 | ||||
|   //     });
 | ||||
| 
 | ||||
|   //     test("should accept custom API URL", () => {
 | ||||
|   //       const customUrl = "https://api.example.com/translations";
 | ||||
|   //       i18n.configure({ apiUrl: customUrl });
 | ||||
|   //       expect(i18n.getApiUrl()).toBe(customUrl);
 | ||||
|   //     });
 | ||||
|   //   });
 | ||||
| 
 | ||||
|   //   describe("String Registration", () => {
 | ||||
|   //     test("should register new strings", async () => {
 | ||||
|   //       const strings = [
 | ||||
|   //         { id: "test1", value: "Test String 1" },
 | ||||
|   //         { id: "test2", value: "Test String 2" },
 | ||||
|   //       ];
 | ||||
| 
 | ||||
|   //       await i18n.registerStrings(strings, 1);
 | ||||
| 
 | ||||
|   //       expect(mockObjectStore.put).toHaveBeenCalledTimes(2);
 | ||||
|   //       expect(mockObjectStore.put).toHaveBeenCalledWith(
 | ||||
|   //         expect.objectContaining({
 | ||||
|   //           id: "test1",
 | ||||
|   //           value: "Test String 1",
 | ||||
|   //           version: 1,
 | ||||
|   //         })
 | ||||
|   //       );
 | ||||
|   //     });
 | ||||
| 
 | ||||
|   //     test("should handle registration errors", async () => {
 | ||||
|   //       mockTransaction.onerror = () => {};
 | ||||
|   //       const strings = [{ id: "test", value: "Test String" }];
 | ||||
| 
 | ||||
|   //       await expect(i18n.registerStrings(strings, 1)).rejects.toThrow(
 | ||||
|   //         "Failed to register strings"
 | ||||
|   //       );
 | ||||
|   //     });
 | ||||
|   //   });
 | ||||
| 
 | ||||
|   //   describe("String Retrieval", () => {
 | ||||
|   //     test("should get string synchronously from cache", async () => {
 | ||||
|   //       const strings = [{ id: "test", value: "Cached Value" }];
 | ||||
|   //       await i18n.registerStrings(strings, 1);
 | ||||
| 
 | ||||
|   //       const result = _t("test", "default");
 | ||||
|   //       expect(result).toBe("Cached Value");
 | ||||
|   //     });
 | ||||
| 
 | ||||
|   //     test("should return default value when string not found", () => {
 | ||||
|   //       const result = _t("nonexistent", "default");
 | ||||
|   //       expect(result).toBe("default");
 | ||||
|   //     });
 | ||||
| 
 | ||||
|   //     test("should fetch from server when cache misses", async () => {
 | ||||
|   //       const mockResponse = { value: "Server Value", version: 1 };
 | ||||
|   //       global.fetch = vi.fn().mockImplementationOnce(() =>
 | ||||
|   //         Promise.resolve({
 | ||||
|   //           ok: true,
 | ||||
|   //           json: () => Promise.resolve(mockResponse),
 | ||||
|   //         })
 | ||||
|   //       );
 | ||||
| 
 | ||||
|   //       const result = await _tt("newString", "default");
 | ||||
|   //       expect(result).toBe("Server Value");
 | ||||
|   //       expect(global.fetch).toHaveBeenCalledWith("/api/translations/newString");
 | ||||
|   //     });
 | ||||
| 
 | ||||
|   //     test("should handle server errors gracefully", async () => {
 | ||||
|   //       global.fetch = vi.fn().mockImplementationOnce(() =>
 | ||||
|   //         Promise.resolve({
 | ||||
|   //           ok: false,
 | ||||
|   //         })
 | ||||
|   //       );
 | ||||
| 
 | ||||
|   //       const result = await _tt("errorString", "default");
 | ||||
|   //       expect(result).toBe("default");
 | ||||
|   //     });
 | ||||
|   //   });
 | ||||
| 
 | ||||
|   //   describe("Cache Management", () => {
 | ||||
|   //     test("should clear cache successfully", async () => {
 | ||||
|   //       await i18n.clearCache();
 | ||||
|   //       expect(mockObjectStore.clear).toHaveBeenCalled();
 | ||||
|   //     });
 | ||||
| 
 | ||||
|   //     test("should handle cache clear errors", async () => {
 | ||||
|   //       mockObjectStore.clear.mockImplementationOnce(() => {
 | ||||
|   //         throw new Error("Clear failed");
 | ||||
|   //       });
 | ||||
| 
 | ||||
|   //       await expect(i18n.clearCache()).rejects.toThrow("Failed to clear cache");
 | ||||
|   //     });
 | ||||
|   //   });
 | ||||
| 
 | ||||
|   //   describe("Update Events", () => {
 | ||||
|   //     test("should emit update event when translation changes", async () => {
 | ||||
|   //       const listener = vi.fn();
 | ||||
|   //       window.addEventListener("i18n-updated", listener);
 | ||||
| 
 | ||||
|   //       const mockResponse = { value: "Updated Value", version: 2 };
 | ||||
|   //       global.fetch = vi.fn().mockImplementationOnce(() =>
 | ||||
|   //         Promise.resolve({
 | ||||
|   //           ok: true,
 | ||||
|   //           json: () => Promise.resolve(mockResponse),
 | ||||
|   //         })
 | ||||
|   //       );
 | ||||
| 
 | ||||
|   //       await i18n.getString("updateTest");
 | ||||
| 
 | ||||
|   //       expect(listener).toHaveBeenCalledWith(
 | ||||
|   //         expect.objectContaining({
 | ||||
|   //           detail: {
 | ||||
|   //             id: "updateTest",
 | ||||
|   //             value: "Updated Value",
 | ||||
|   //           },
 | ||||
|   //         })
 | ||||
|   //       );
 | ||||
| 
 | ||||
|   //       window.removeEventListener("i18n-updated", listener);
 | ||||
|   //     });
 | ||||
|   //   });
 | ||||
| 
 | ||||
|   //   describe("Error Handling", () => {
 | ||||
|   //     test("should handle database initialization failure", () => {
 | ||||
|   //       indexedDB.open.mockImplementationOnce(() => {
 | ||||
|   //         throw new Error("DB open failed");
 | ||||
|   //       });
 | ||||
| 
 | ||||
|   //       expect(() => i18n.configure()).toThrow("Failed to open database");
 | ||||
|   //     });
 | ||||
| 
 | ||||
|   //     test("should handle network errors during fetch", async () => {
 | ||||
|   //       global.fetch = vi
 | ||||
|   //         .fn()
 | ||||
|   //         .mockImplementationOnce(() => Promise.reject("Network error"));
 | ||||
| 
 | ||||
|   //       const result = await _tt("networkError", "default");
 | ||||
|   //       expect(result).toBe("default");
 | ||||
|   //     });
 | ||||
|   //   });
 | ||||
| }); | ||||
| @ -1,356 +1,475 @@ | ||||
| /** | ||||
|  * @fileoverview Internationalization module with IndexedDB caching and server synchronization | ||||
|  * @version 1.0.0 | ||||
|  */ | ||||
| import { | ||||
|   I18nConfig, | ||||
|   CacheEntry, | ||||
|   TranslationString, | ||||
|   TranslationResponse, | ||||
|   I18nUpdateEvent, | ||||
|   CacheStats, | ||||
|   I18nManager, | ||||
| } from "./types"; | ||||
| 
 | ||||
| /** | ||||
|  * Configuration options for the I18n manager | ||||
|  * Creates an instance of the I18n manager with safe memory caching | ||||
|  */ | ||||
| type I18nConfig = { | ||||
|     /** Base URL for the translations API */ | ||||
|     apiUrl?: string; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Translation entry structure as stored in the cache | ||||
|  */ | ||||
| type CacheEntry = { | ||||
|     /** The translated string value */ | ||||
|     value: string; | ||||
|     /** Version number of the translation */ | ||||
|     version: number; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Translation string registration format | ||||
|  */ | ||||
| type TranslationString = { | ||||
|     /** Unique identifier for the translation */ | ||||
|     id: string; | ||||
|     /** The translated text */ | ||||
|     value: string; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Server response format for translation requests | ||||
|  */ | ||||
| type TranslationResponse = { | ||||
|     /** The translated text */ | ||||
|     value: string; | ||||
|     /** Version number of the translation */ | ||||
|     version: number; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Event payload for translation updates | ||||
|  */ | ||||
| type I18nUpdateEvent = CustomEvent<{ | ||||
|     /** Identifier of the updated translation */ | ||||
|     id: string; | ||||
|     /** New translated value */ | ||||
|     value: string; | ||||
| }>; | ||||
| 
 | ||||
| /** | ||||
|  * Core I18n manager interface | ||||
|  */ | ||||
| interface I18nManager { | ||||
|     configure(options?: I18nConfig): void; | ||||
|     registerStrings(strings: TranslationString[], version: number): Promise<void>; | ||||
|     getString(componentId: string, defaultValue?: string): Promise<string>; | ||||
|     getStringSync(componentId: string, defaultValue?: string): string; | ||||
|     clearCache(): Promise<void>; | ||||
|     getApiUrl(): string; | ||||
| } | ||||
| 
 | ||||
| const createI18nManager = (): I18nManager => { | ||||
|     /** Database name for IndexedDB storage */ | ||||
|     const DB_NAME = 'i18n-cache'; | ||||
|     /** Store name for translations within IndexedDB */ | ||||
|     const STORE_NAME = 'translations'; | ||||
|     /** Current version of the translations schema */ | ||||
|     const CURRENT_VERSION = 1; | ||||
|     /** Default API endpoint if none provided */ | ||||
|     const DEFAULT_API_URL = '/api/translations'; | ||||
|   /** Database name for IndexedDB storage */ | ||||
|   const DB_NAME = "i18n-cache"; | ||||
|   /** Store name for translations within IndexedDB */ | ||||
|   const STORE_NAME = "translations"; | ||||
|   /** Current version of the translations schema */ | ||||
|   const CURRENT_VERSION = 1; | ||||
|   /** Default API endpoint if none provided */ | ||||
|   const DEFAULT_API_URL = "/api/translations"; | ||||
|   /** Default maximum cache size */ | ||||
|   const DEFAULT_MAX_CACHE_SIZE = 1000; | ||||
|   /** Default TTL for cache entries (24 hours) */ | ||||
|   const DEFAULT_CACHE_TTL = 24 * 60 * 60 * 1000; | ||||
| 
 | ||||
|     let db: IDBDatabase | null = null; | ||||
|     let serverUrl: string = DEFAULT_API_URL; | ||||
|     const cache = new Map<string, CacheEntry>(); | ||||
|     const pendingUpdates = new Set<string>(); | ||||
|     let initPromise: Promise<void> | null = null; | ||||
|     let isInitialized = false; | ||||
|   // Core state
 | ||||
|   let db: IDBDatabase | null = null; | ||||
|   let serverUrl: string = DEFAULT_API_URL; | ||||
|   let maxCacheSize: number = DEFAULT_MAX_CACHE_SIZE; | ||||
|   let cacheTTL: number = DEFAULT_CACHE_TTL; | ||||
| 
 | ||||
|     /** | ||||
|      * Initializes the IndexedDB database and sets up the object store | ||||
|      * @returns Promise that resolves when the database is ready | ||||
|      */ | ||||
|     const initDatabase = async (): Promise<void> => { | ||||
|         if (isInitialized) return; | ||||
|   // Cache management
 | ||||
|   const cache = new Map<string, CacheEntry>(); | ||||
|   const lruQueue: string[] = []; | ||||
|   const stats = { | ||||
|     hits: 0, | ||||
|     misses: 0, | ||||
|   }; | ||||
| 
 | ||||
|         return new Promise((resolve, reject) => { | ||||
|             const request = indexedDB.open(DB_NAME, CURRENT_VERSION); | ||||
|   // Update tracking
 | ||||
|   const pendingUpdates = new Set<string>(); | ||||
|   let initPromise: Promise<void> | null = null; | ||||
|   let isInitialized = false; | ||||
| 
 | ||||
|             request.onerror = () => reject(new Error('Failed to open database')); | ||||
|   /** | ||||
|    * Checks if IndexedDB is available | ||||
|    */ | ||||
|   const isIndexedDBAvailable = (): boolean => { | ||||
|     try { | ||||
|       return typeof indexedDB !== "undefined" && indexedDB !== null; | ||||
|     } catch { | ||||
|       return false; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|             request.onupgradeneeded = (event) => { | ||||
|                 const database = (event.target as IDBOpenDBRequest).result; | ||||
|                 if (!database.objectStoreNames.contains(STORE_NAME)) { | ||||
|                     const store = database.createObjectStore(STORE_NAME, { keyPath: 'id' }); | ||||
|                     store.createIndex('version', 'version', { unique: false }); | ||||
|                 } | ||||
|             }; | ||||
|   /** | ||||
|    * Updates LRU tracking for cache entry | ||||
|    */ | ||||
|   const updateLRU = (id: string): void => { | ||||
|     const index = lruQueue.indexOf(id); | ||||
|     if (index > -1) { | ||||
|       lruQueue.splice(index, 1); | ||||
|     } | ||||
|     lruQueue.push(id); | ||||
|   }; | ||||
| 
 | ||||
|             request.onsuccess = async (event) => { | ||||
|                 db = (event.target as IDBOpenDBRequest).result; | ||||
|                 await loadCacheFromDb(); | ||||
|                 isInitialized = true; | ||||
|                 resolve(); | ||||
|             }; | ||||
|   /** | ||||
|    * Evicts oldest entries when cache exceeds max size | ||||
|    */ | ||||
|   const evictCacheEntries = (): void => { | ||||
|     while (cache.size > maxCacheSize && lruQueue.length > 0) { | ||||
|       const oldestId = lruQueue.shift(); | ||||
|       if (oldestId) { | ||||
|         cache.delete(oldestId); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Checks if a cache entry is still valid | ||||
|    */ | ||||
|   const isCacheEntryValid = (entry: CacheEntry): boolean => { | ||||
|     return Date.now() - entry.timestamp < cacheTTL; | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Safely retrieves a value from memory cache | ||||
|    */ | ||||
|   const getFromMemoryCache = (id: string): CacheEntry | null => { | ||||
|     const entry = cache.get(id); | ||||
|     if (!entry) { | ||||
|       stats.misses++; | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     if (!isCacheEntryValid(entry)) { | ||||
|       cache.delete(id); | ||||
|       const index = lruQueue.indexOf(id); | ||||
|       if (index > -1) { | ||||
|         lruQueue.splice(index, 1); | ||||
|       } | ||||
|       stats.misses++; | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     stats.hits++; | ||||
|     updateLRU(id); | ||||
|     return entry; | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Safely stores a value in memory cache | ||||
|    */ | ||||
|   const setInMemoryCache = ( | ||||
|     id: string, | ||||
|     value: string, | ||||
|     version: number | ||||
|   ): void => { | ||||
|     const entry: CacheEntry = { | ||||
|       value, | ||||
|       version, | ||||
|       timestamp: Date.now(), | ||||
|     }; | ||||
| 
 | ||||
|     cache.set(id, entry); | ||||
|     updateLRU(id); | ||||
|     evictCacheEntries(); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Initializes the system with IndexedDB if available | ||||
|    */ | ||||
|   const initDatabase = async (): Promise<void> => { | ||||
|     if (isInitialized) return; | ||||
| 
 | ||||
|     if (!isIndexedDBAvailable()) { | ||||
|       console.warn("IndexedDB not available, falling back to memory-only mode"); | ||||
|       isInitialized = true; | ||||
|       return Promise.resolve(); | ||||
|     } | ||||
| 
 | ||||
|     return new Promise((resolve, reject) => { | ||||
|       const request = indexedDB.open(DB_NAME, CURRENT_VERSION); | ||||
| 
 | ||||
|       request.onerror = () => { | ||||
|         console.warn( | ||||
|           "Failed to open IndexedDB, falling back to memory-only mode" | ||||
|         ); | ||||
|         isInitialized = true; | ||||
|         resolve(); | ||||
|       }; | ||||
| 
 | ||||
|       request.onupgradeneeded = (event) => { | ||||
|         const database = (event.target as IDBOpenDBRequest).result; | ||||
|         if (!database.objectStoreNames.contains(STORE_NAME)) { | ||||
|           const store = database.createObjectStore(STORE_NAME, { | ||||
|             keyPath: "id", | ||||
|           }); | ||||
|           store.createIndex("version", "version", { unique: false }); | ||||
|           store.createIndex("timestamp", "timestamp", { unique: false }); | ||||
|         } | ||||
|       }; | ||||
| 
 | ||||
|       request.onsuccess = async (event) => { | ||||
|         db = (event.target as IDBOpenDBRequest).result; | ||||
|         await loadCacheFromDb(); | ||||
|         isInitialized = true; | ||||
|         resolve(); | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Loads valid translations from IndexedDB | ||||
|    */ | ||||
|   const loadCacheFromDb = async (): Promise<void> => { | ||||
|     if (!db) return; | ||||
| 
 | ||||
|     return new Promise((resolve, reject) => { | ||||
|       const transaction = db!.transaction([STORE_NAME], "readonly"); | ||||
|       const store = transaction.objectStore(STORE_NAME); | ||||
|       const request = store.getAll(); | ||||
| 
 | ||||
|       request.onsuccess = (event) => { | ||||
|         const entries = (event.target as IDBRequest).result; | ||||
|         entries.forEach((entry) => { | ||||
|           if (isCacheEntryValid(entry)) { | ||||
|             setInMemoryCache(entry.id, entry.value, entry.version); | ||||
|           } else { | ||||
|             // Clean up expired entries
 | ||||
|             const deleteTransaction = db!.transaction( | ||||
|               [STORE_NAME], | ||||
|               "readwrite" | ||||
|             ); | ||||
|             const deleteStore = deleteTransaction.objectStore(STORE_NAME); | ||||
|             deleteStore.delete(entry.id); | ||||
|           } | ||||
|         }); | ||||
|     }; | ||||
|         resolve(); | ||||
|       }; | ||||
| 
 | ||||
|     /** | ||||
|      * Loads all translations from IndexedDB into memory cache | ||||
|      */ | ||||
|     const loadCacheFromDb = async (): Promise<void> => { | ||||
|         if (!db) throw new Error('Database not initialized'); | ||||
|       request.onerror = () => { | ||||
|         console.warn("Failed to load cache from IndexedDB"); | ||||
|         resolve(); | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|         return new Promise((resolve, reject) => { | ||||
|             if (!db) throw new Error('Database not initialized'); | ||||
|             const transaction = db.transaction([STORE_NAME], 'readonly'); | ||||
|             const store = transaction.objectStore(STORE_NAME); | ||||
|             const request = store.getAll(); | ||||
|   const fetchFromServer = async ( | ||||
|     componentId: string | ||||
|   ): Promise<string | null> => { | ||||
|     try { | ||||
|       const response = await fetch(`${serverUrl}/${componentId}`); | ||||
|       if (!response.ok) throw new Error("Failed to fetch translation"); | ||||
| 
 | ||||
|             request.onsuccess = (event) => { | ||||
|                 const entries = (event.target as IDBRequest).result; | ||||
|                 entries.forEach(entry => { | ||||
|                     cache.set(entry.id, { | ||||
|                         value: entry.value, | ||||
|                         version: entry.version | ||||
|                     }); | ||||
|                 }); | ||||
|                 resolve(); | ||||
|             }; | ||||
|       const data: TranslationResponse = await response.json(); | ||||
|       await registerStrings( | ||||
|         [ | ||||
|           { | ||||
|             id: componentId, | ||||
|             value: data.value, | ||||
|           }, | ||||
|         ], | ||||
|         data.version | ||||
|       ); | ||||
| 
 | ||||
|             request.onerror = () => reject(new Error('Failed to load cache')); | ||||
|       return data.value; | ||||
|     } catch (error) { | ||||
|       console.error("Error fetching translation:", error); | ||||
|       return null; | ||||
|     } finally { | ||||
|       pendingUpdates.delete(componentId); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Emits update event | ||||
|    */ | ||||
|   const emitUpdateEvent = (id: string, value: string): void => { | ||||
|     const event = new CustomEvent("i18n-updated", { | ||||
|       detail: { id, value }, | ||||
|     }) as I18nUpdateEvent; | ||||
|     window.dispatchEvent(event); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Configures the I18n manager | ||||
|    */ | ||||
|   const configure = (options: I18nConfig = {}): void => { | ||||
|     if (!isInitialized) { | ||||
|       serverUrl = options.apiUrl || DEFAULT_API_URL; | ||||
|       maxCacheSize = options.maxCacheSize || DEFAULT_MAX_CACHE_SIZE; | ||||
|       cacheTTL = options.cacheTTL || DEFAULT_CACHE_TTL; | ||||
|       initPromise = initDatabase(); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     // Update existing configuration
 | ||||
|     if (options.apiUrl) { | ||||
|       serverUrl = options.apiUrl; | ||||
|       pendingUpdates.clear(); // Clear pending updates as API endpoint changed
 | ||||
|     } | ||||
|     if (options.maxCacheSize) { | ||||
|       maxCacheSize = options.maxCacheSize; | ||||
|       evictCacheEntries(); // Immediately apply new cache size limit
 | ||||
|     } | ||||
|     if (options.cacheTTL) { | ||||
|       cacheTTL = options.cacheTTL; | ||||
|       // Optionally clean up expired entries based on new TTL
 | ||||
|       for (const [id, entry] of cache.entries()) { | ||||
|         if (!isCacheEntryValid(entry)) { | ||||
|           cache.delete(id); | ||||
|           const index = lruQueue.indexOf(id); | ||||
|           if (index > -1) { | ||||
|             lruQueue.splice(index, 1); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Registers new translations in both stores | ||||
|    */ | ||||
|   const registerStrings = async ( | ||||
|     strings: TranslationString[], | ||||
|     version: number | ||||
|   ): Promise<void> => { | ||||
|     if (!initPromise) configure(); | ||||
|     await initPromise; | ||||
| 
 | ||||
|     // Always update memory cache first
 | ||||
|     strings.forEach((string) => { | ||||
|       setInMemoryCache(string.id, string.value, version); | ||||
|     }); | ||||
| 
 | ||||
|     // If no IndexedDB, we're done
 | ||||
|     if (!db) return; | ||||
| 
 | ||||
|     return new Promise((resolve, reject) => { | ||||
|       const transaction = db!.transaction([STORE_NAME], "readwrite"); | ||||
|       const store = transaction.objectStore(STORE_NAME); | ||||
| 
 | ||||
|       strings.forEach((string) => { | ||||
|         const entry = { | ||||
|           id: string.id, | ||||
|           value: string.value, | ||||
|           version, | ||||
|           timestamp: Date.now(), | ||||
|         }; | ||||
|         store.put(entry); | ||||
|       }); | ||||
| 
 | ||||
|       transaction.oncomplete = () => resolve(); | ||||
|       transaction.onerror = () => { | ||||
|         console.warn("Failed to persist translations to IndexedDB"); | ||||
|         resolve(); // Still resolve as memory cache is updated
 | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Gets translation synchronously with optional update check | ||||
|    */ | ||||
|   const getStringSync = (componentId: string, defaultValue = ""): string => { | ||||
|     if (!isInitialized) { | ||||
|       console.warn("I18nManager not initialized. Call configure() first."); | ||||
|       return defaultValue; | ||||
|     } | ||||
| 
 | ||||
|     const cached = getFromMemoryCache(componentId); | ||||
|     if (cached) { | ||||
|       // Check for updates if version mismatch
 | ||||
|       if ( | ||||
|         cached.version !== CURRENT_VERSION && | ||||
|         !pendingUpdates.has(componentId) | ||||
|       ) { | ||||
|         pendingUpdates.add(componentId); | ||||
|         fetchFromServer(componentId).then((newValue) => { | ||||
|           if (newValue) emitUpdateEvent(componentId, newValue); | ||||
|         }); | ||||
|     }; | ||||
|       } | ||||
|       return cached.value; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Registers new translations in both IndexedDB and memory cache | ||||
|      * @param strings - Array of translation strings to register | ||||
|      * @param version - Version number for these translations | ||||
|      */ | ||||
|     const registerStrings = async (strings: TranslationString[], version: number): Promise<void> => { | ||||
|         if (!initPromise) configure(); | ||||
|         await initPromise; | ||||
|         if (!db) throw new Error('Database not initialized'); | ||||
|     // Schedule background fetch if not already pending
 | ||||
|     if (!pendingUpdates.has(componentId)) { | ||||
|       pendingUpdates.add(componentId); | ||||
|       fetchFromServer(componentId).then((newValue) => { | ||||
|         if (newValue) emitUpdateEvent(componentId, newValue); | ||||
|       }); | ||||
|     } | ||||
|     return defaultValue; | ||||
|   }; | ||||
| 
 | ||||
|         return new Promise((resolve, reject) => { | ||||
|             if (!db) throw new Error('Database not initialized'); | ||||
|             const transaction = db.transaction([STORE_NAME], 'readwrite'); | ||||
|             const store = transaction.objectStore(STORE_NAME); | ||||
|   /** | ||||
|    * Gets translation asynchronously with full update cycle | ||||
|    */ | ||||
|   const getString = async ( | ||||
|     componentId: string, | ||||
|     defaultValue = "" | ||||
|   ): Promise<string> => { | ||||
|     if (!initPromise) configure(); | ||||
|     await initPromise; | ||||
| 
 | ||||
|             strings.forEach(string => { | ||||
|                 const entry = { | ||||
|                     id: string.id, | ||||
|                     value: string.value, | ||||
|                     version: version | ||||
|                 }; | ||||
|                 store.put(entry); | ||||
|                 cache.set(string.id, { | ||||
|                     value: string.value, | ||||
|                     version: version | ||||
|                 }); | ||||
|             }); | ||||
|     // Check memory cache first
 | ||||
|     const cached = getFromMemoryCache(componentId); | ||||
|     if (cached && cached.version === CURRENT_VERSION) { | ||||
|       return cached.value; | ||||
|     } | ||||
| 
 | ||||
|             transaction.oncomplete = () => resolve(); | ||||
|             transaction.onerror = () => reject(new Error('Failed to register strings')); | ||||
|         }); | ||||
|     }; | ||||
|     // If no IndexedDB, try server directly
 | ||||
|     if (!db) { | ||||
|       const serverValue = await fetchFromServer(componentId); | ||||
|       return serverValue || defaultValue; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetches a translation from the server | ||||
|      * @param componentId - Identifier for the translation to fetch | ||||
|      * @returns The translated string or null if fetch fails | ||||
|      */ | ||||
|     const fetchFromServer = async (componentId: string): Promise<string | null> => { | ||||
|         try { | ||||
|             const response = await fetch(`${serverUrl}/${componentId}`); | ||||
|             if (!response.ok) { | ||||
|                 throw new Error('Failed to fetch translation'); | ||||
|             } | ||||
|             const data: TranslationResponse = await response.json(); | ||||
|              | ||||
|             await registerStrings([{ | ||||
|                 id: componentId, | ||||
|                 value: data.value | ||||
|             }], data.version); | ||||
|     // Try IndexedDB, then fall back to server
 | ||||
|     return new Promise((resolve) => { | ||||
|       const transaction = db!.transaction([STORE_NAME], "readonly"); | ||||
|       const store = transaction.objectStore(STORE_NAME); | ||||
|       const request = store.get(componentId); | ||||
| 
 | ||||
|             return data.value; | ||||
|         } catch (error) { | ||||
|             console.error('Error fetching translation:', error); | ||||
|             return null; | ||||
|         } finally { | ||||
|             pendingUpdates.delete(componentId); | ||||
|         } | ||||
|     }; | ||||
|       request.onsuccess = async (event) => { | ||||
|         const result = (event.target as IDBRequest).result; | ||||
| 
 | ||||
|     /** | ||||
|      * Emits a custom event when translations are updated | ||||
|      * @param id - Identifier of the updated translation | ||||
|      * @param value - New translated value | ||||
|      */ | ||||
|     const emitUpdateEvent = (id: string, value: string): void => { | ||||
|         const event = new CustomEvent('i18n-updated', { | ||||
|             detail: { id, value } | ||||
|         }) as I18nUpdateEvent; | ||||
|         window.dispatchEvent(event); | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|      * Synchronously retrieves a translation with background update check | ||||
|      * @param componentId - Identifier for the translation | ||||
|      * @param defaultValue - Fallback value if translation not found | ||||
|      * @returns The translated string or default value | ||||
|      */ | ||||
|     const getStringSync = (componentId: string, defaultValue = ''): string => { | ||||
|         if (!isInitialized) { | ||||
|             console.warn('I18nManager not initialized. Call configure() first or await initialization.'); | ||||
|             return defaultValue; | ||||
|         if ( | ||||
|           result && | ||||
|           result.version === CURRENT_VERSION && | ||||
|           isCacheEntryValid(result) | ||||
|         ) { | ||||
|           setInMemoryCache(result.id, result.value, result.version); | ||||
|           resolve(result.value); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         const cached = cache.get(componentId); | ||||
|         if (cached) { | ||||
|             if (cached.version !== CURRENT_VERSION && !pendingUpdates.has(componentId)) { | ||||
|                 pendingUpdates.add(componentId); | ||||
|                 fetchFromServer(componentId).then(newValue => { | ||||
|                     if (newValue) { | ||||
|                         emitUpdateEvent(componentId, newValue); | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|             return cached.value; | ||||
|         } | ||||
|         const serverValue = await fetchFromServer(componentId); | ||||
|         resolve(serverValue || defaultValue); | ||||
|       }; | ||||
| 
 | ||||
|         if (!pendingUpdates.has(componentId)) { | ||||
|             pendingUpdates.add(componentId); | ||||
|             fetchFromServer(componentId).then(newValue => { | ||||
|                 if (newValue) { | ||||
|                     emitUpdateEvent(componentId, newValue); | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|         return defaultValue; | ||||
|     }; | ||||
|       request.onerror = async () => { | ||||
|         console.warn("Error reading from IndexedDB cache"); | ||||
|         const serverValue = await fetchFromServer(componentId); | ||||
|         resolve(serverValue || defaultValue); | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|     /** | ||||
|      * Asynchronously retrieves a translation | ||||
|      * @param componentId - Identifier for the translation | ||||
|      * @param defaultValue - Fallback value if translation not found | ||||
|      * @returns Promise resolving to the translated string | ||||
|      */ | ||||
|     const getString = async (componentId: string, defaultValue = ''): Promise<string> => { | ||||
|         if (!initPromise) configure(); | ||||
|         await initPromise; | ||||
|         if (!db) throw new Error('Database not initialized'); | ||||
|   /** | ||||
|    * Clears all caches | ||||
|    */ | ||||
|   const clearCache = async (): Promise<void> => { | ||||
|     if (!initPromise) configure(); | ||||
|     await initPromise; | ||||
| 
 | ||||
|         return new Promise( (resolve) => { | ||||
|             if (!db) throw new Error('Database not initialized'); | ||||
|             const transaction = db.transaction([STORE_NAME], 'readonly'); | ||||
|             const store = transaction.objectStore(STORE_NAME); | ||||
|             const request = store.get(componentId); | ||||
|     // Clear memory cache
 | ||||
|     cache.clear(); | ||||
|     lruQueue.length = 0; | ||||
|     stats.hits = 0; | ||||
|     stats.misses = 0; | ||||
| 
 | ||||
|             request.onsuccess = async (event) => { | ||||
|                 const result = (event.target as IDBRequest).result; | ||||
|                  | ||||
|                 if (result && result.version === CURRENT_VERSION) { | ||||
|                     resolve(result.value); | ||||
|                     return; | ||||
|                 } | ||||
|     // If no IndexedDB, we're done
 | ||||
|     if (!db) return; | ||||
| 
 | ||||
|                 const serverValue = await fetchFromServer(componentId); | ||||
|                 resolve(serverValue || defaultValue); | ||||
|             }; | ||||
|     return new Promise((resolve, reject) => { | ||||
|       const transaction = db!.transaction([STORE_NAME], "readwrite"); | ||||
|       const store = transaction.objectStore(STORE_NAME); | ||||
|       const request = store.clear(); | ||||
| 
 | ||||
|             request.onerror = () => { | ||||
|                 console.error('Error reading from cache'); | ||||
|                 resolve(defaultValue); | ||||
|             }; | ||||
|         }); | ||||
|     }; | ||||
|       request.onsuccess = () => resolve(); | ||||
|       request.onerror = () => { | ||||
|         console.warn("Failed to clear IndexedDB cache"); | ||||
|         resolve(); // Still resolve as memory cache is cleared
 | ||||
|       }; | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|     /** | ||||
|      * Clears all cached translations | ||||
|      */ | ||||
|     const clearCache = async (): Promise<void> => { | ||||
|         if (!initPromise) configure(); | ||||
|         await initPromise; | ||||
|         if (!db) throw new Error('Database not initialized'); | ||||
|   /** | ||||
|    * Gets current API URL | ||||
|    */ | ||||
|   const getApiUrl = (): string => serverUrl; | ||||
| 
 | ||||
|         return new Promise((resolve, reject) => { | ||||
|             if (!db) throw new Error('Database not initialized'); | ||||
|             const transaction = db.transaction([STORE_NAME], 'readwrite'); | ||||
|             const store = transaction.objectStore(STORE_NAME); | ||||
|             const request = store.clear(); | ||||
|   /** | ||||
|    * Gets cache statistics | ||||
|    */ | ||||
|   const getCacheStats = (): CacheStats => ({ | ||||
|     memorySize: cache.size, | ||||
|     dbSize: db ? -1 : 0, // -1 indicates DB exists but size unknown
 | ||||
|     hits: stats.hits, | ||||
|     misses: stats.misses, | ||||
|   }); | ||||
| 
 | ||||
|             request.onsuccess = () => { | ||||
|                 cache.clear(); | ||||
|                 resolve(); | ||||
|             }; | ||||
|             request.onerror = () => reject(new Error('Failed to clear cache')); | ||||
|         }); | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|      * Configures the I18n manager | ||||
|      * @param options - Configuration options | ||||
|      */ | ||||
|     const configure = (options: I18nConfig = {}): void => { | ||||
|         if (!isInitialized) { | ||||
|             serverUrl = options.apiUrl || DEFAULT_API_URL; | ||||
|             initPromise = initDatabase(); | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         if (options.apiUrl) { | ||||
|             serverUrl = options.apiUrl; | ||||
|             pendingUpdates.clear(); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|      * Returns the current API URL | ||||
|      */ | ||||
|     const getApiUrl = (): string => serverUrl; | ||||
| 
 | ||||
|     return { | ||||
|         configure, | ||||
|         registerStrings, | ||||
|         getString, | ||||
|         getStringSync, | ||||
|         clearCache, | ||||
|         getApiUrl | ||||
|     }; | ||||
|   // Complete the manager interface
 | ||||
|   return { | ||||
|     configure, | ||||
|     registerStrings, | ||||
|     getString, | ||||
|     getStringSync, | ||||
|     clearCache, | ||||
|     getApiUrl, | ||||
|     getCacheStats, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| // Create the singleton instance
 | ||||
| const i18nManager = createI18nManager(); | ||||
| 
 | ||||
| // Export the main manager
 | ||||
| export const i18n = i18nManager; | ||||
| 
 | ||||
| // Export shortcut functions
 | ||||
| export const _t = i18nManager.getStringSync; | ||||
| export const _tt = i18nManager.getString; | ||||
| 
 | ||||
| // Export everything as a namespace
 | ||||
| // Export everything
 | ||||
| export const i18n = createI18nManager(); | ||||
| export const _t = i18n.getStringSync; | ||||
| export const _tt = i18n.getString; | ||||
| export default { | ||||
|     ...i18nManager, | ||||
|     _t, | ||||
|     _tt | ||||
|   ...i18n, | ||||
|   _t, | ||||
|   _tt, | ||||
| }; | ||||
| 
 | ||||
| // Type declarations for the shortcut functions
 | ||||
| export type GetStringSync = typeof i18nManager.getStringSync; | ||||
| export type GetString = typeof i18nManager.getString; | ||||
|  | ||||
							
								
								
									
										102
									
								
								src/i18n/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								src/i18n/types.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,102 @@ | ||||
| /** | ||||
|  * @fileoverview Type definitions for enhanced I18n module with safe memory cache | ||||
|  * @version 1.1.0 | ||||
|  */ | ||||
| 
 | ||||
| /** | ||||
|  * Configuration options for the I18n manager | ||||
|  */ | ||||
| export type I18nConfig = { | ||||
|   /** Base URL for the translations API */ | ||||
|   apiUrl?: string; | ||||
|   /** Maximum size for in-memory cache */ | ||||
|   maxCacheSize?: number; | ||||
|   /** Time-to-live for cache entries in milliseconds */ | ||||
|   cacheTTL?: number; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Translation entry structure as stored in the cache | ||||
|  */ | ||||
| export type CacheEntry = { | ||||
|   /** The translated string value */ | ||||
|   value: string; | ||||
|   /** Version number of the translation */ | ||||
|   version: number; | ||||
|   /** Timestamp when the entry was cached */ | ||||
|   timestamp: number; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Translation string registration format | ||||
|  */ | ||||
| export type TranslationString = { | ||||
|   /** Unique identifier for the translation */ | ||||
|   id: string; | ||||
|   /** The translated text */ | ||||
|   value: string; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Server response format for translation requests | ||||
|  */ | ||||
| export type TranslationResponse = { | ||||
|   /** The translated text */ | ||||
|   value: string; | ||||
|   /** Version number of the translation */ | ||||
|   version: number; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Event payload for translation updates | ||||
|  */ | ||||
| export type I18nUpdateEvent = CustomEvent<{ | ||||
|   /** Identifier of the updated translation */ | ||||
|   id: string; | ||||
|   /** New translated value */ | ||||
|   value: string; | ||||
| }>; | ||||
| 
 | ||||
| /** | ||||
|  * Cache statistics interface | ||||
|  */ | ||||
| export interface CacheStats { | ||||
|   /** Number of entries in memory cache */ | ||||
|   memorySize: number; | ||||
|   /** Number of entries in IndexedDB (-1 if unknown) */ | ||||
|   dbSize: number; | ||||
|   /** Number of cache hits */ | ||||
|   hits: number; | ||||
|   /** Number of cache misses */ | ||||
|   misses: number; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Core I18n manager interface | ||||
|  */ | ||||
| export interface I18nManager { | ||||
|   /** Configure the I18n manager with options */ | ||||
|   configure(options?: I18nConfig): void; | ||||
| 
 | ||||
|   /** Register multiple translation strings */ | ||||
|   registerStrings(strings: TranslationString[], version: number): Promise<void>; | ||||
| 
 | ||||
|   /** Get a translation string asynchronously */ | ||||
|   getString(componentId: string, defaultValue?: string): Promise<string>; | ||||
| 
 | ||||
|   /** Get a translation string synchronously */ | ||||
|   getStringSync(componentId: string, defaultValue?: string): string; | ||||
| 
 | ||||
|   /** Clear all cached translations */ | ||||
|   clearCache(): Promise<void>; | ||||
| 
 | ||||
|   /** Get the current API URL */ | ||||
|   getApiUrl(): string; | ||||
| 
 | ||||
|   /** Get current cache statistics */ | ||||
|   getCacheStats(): CacheStats; | ||||
| } | ||||
| 
 | ||||
| // Export types for external usage
 | ||||
| export type GetStringSync = (id: string, defaultValue?: string) => string; | ||||
| export type GetString = (id: string, defaultValue?: string) => Promise<string>; | ||||
							
								
								
									
										25
									
								
								src/mime/index.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/mime/index.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,25 @@ | ||||
| import { describe, it, expect } from 'vitest' | ||||
| import { getExtFromMime, getExtFromFilename, getMimeFromExt, isValidExtForMime, getAllExtensionsForMime } from './index' | ||||
| 
 | ||||
| describe('MIME functions', () => { | ||||
|   it('should get the correct extension from MIME type', () => { | ||||
|     expect(getExtFromMime('image/jpeg')).toBe('jpg') | ||||
|   }) | ||||
| 
 | ||||
|   it('should get the correct extension from filename', () => { | ||||
|     expect(getExtFromFilename('example.txt')).toBe('txt') | ||||
|   }) | ||||
| 
 | ||||
|   it('should get the correct MIME type from extension', () => { | ||||
|     expect(getMimeFromExt('txt')).toBe('text/plain') | ||||
|   }) | ||||
| 
 | ||||
|   it('should validate if extension is valid for MIME type', () => { | ||||
|     expect(isValidExtForMime('image/jpeg', 'jpg')).toBe(true) | ||||
|     expect(isValidExtForMime('image/jpeg', 'png')).toBe(false) | ||||
|   }) | ||||
| 
 | ||||
|   it('should get all valid extensions for a MIME type', () => { | ||||
|     expect(getAllExtensionsForMime('image/jpeg')).toEqual(['jpg', 'jpe', 'jpeg','jfif']) | ||||
|   }) | ||||
| }) | ||||
| @ -7410,7 +7410,7 @@ export const MimeTypeList :MimeTypes = { | ||||
|   'image/jpeg': { | ||||
|     source: 'iana', | ||||
|     compressible: false, | ||||
|     extensions: ['jpeg', 'jpg', 'jpe', 'jfif'], | ||||
|     extensions: [ 'jpg', 'jpe', 'jpeg','jfif'], | ||||
|   }, | ||||
|   'image/jph': { | ||||
|     source: 'iana', | ||||
|  | ||||
| @ -5,34 +5,52 @@ | ||||
|  * @param deep Enable deep comparison for nested objects/arrays | ||||
|  */ | ||||
| export function objectCompare<T extends Record<string, unknown>>( | ||||
|     obj: T, | ||||
|     objToCompare: T, | ||||
|     deep = false | ||||
|   ): boolean { | ||||
|     if (!obj || !objToCompare) return false; | ||||
|      | ||||
|     return Object.keys(obj).length === Object.keys(objToCompare).length && | ||||
|       Object.keys(obj).every((key) => { | ||||
|         if (!Object.prototype.hasOwnProperty.call(objToCompare, key)) return false; | ||||
|          | ||||
|         const val1 = obj[key]; | ||||
|         const val2 = objToCompare[key]; | ||||
|          | ||||
|         if (!deep) return val1 === val2; | ||||
|          | ||||
|         if (Array.isArray(val1) && Array.isArray(val2)) { | ||||
|           return val1.length === val2.length &&  | ||||
|             val1.every((item, i) =>  | ||||
|               typeof item === 'object' && item !== null | ||||
|                 ? objectCompare(item as Record<string, unknown>, val2[i] as Record<string, unknown>, true) | ||||
|                 : item === val2[i] | ||||
|             ); | ||||
|         } | ||||
|          | ||||
|         if (typeof val1 === 'object' && typeof val2 === 'object' && val1 !== null && val2 !== null) { | ||||
|           return objectCompare(val1 as Record<string, unknown>, val2 as Record<string, unknown>, true); | ||||
|         } | ||||
|          | ||||
|         return val1 === val2; | ||||
|       }); | ||||
|   } | ||||
|   obj: T | any, | ||||
|   objToCompare: T | any, | ||||
|   deep = false | ||||
| ): boolean { | ||||
|   if (!obj || !objToCompare) return false; | ||||
| 
 | ||||
|   return ( | ||||
|     Object.keys(obj).length === Object.keys(objToCompare).length && | ||||
|     Object.keys(obj).every((key) => { | ||||
|       if (!Object.prototype.hasOwnProperty.call(objToCompare, key)) | ||||
|         return false; | ||||
| 
 | ||||
|       const val1 = obj[key]; | ||||
|       const val2 = objToCompare[key]; | ||||
| 
 | ||||
|       if (!deep) return val1 === val2; | ||||
| 
 | ||||
|       if (Array.isArray(val1) && Array.isArray(val2)) { | ||||
|         return ( | ||||
|           val1.length === val2.length && | ||||
|           val1.every((item, i) => | ||||
|             typeof item === "object" && item !== null | ||||
|               ? objectCompare( | ||||
|                   item as Record<string, unknown>, | ||||
|                   val2[i] as Record<string, unknown>, | ||||
|                   true | ||||
|                 ) | ||||
|               : item === val2[i] | ||||
|           ) | ||||
|         ); | ||||
|       } | ||||
| 
 | ||||
|       if ( | ||||
|         typeof val1 === "object" && | ||||
|         typeof val2 === "object" && | ||||
|         val1 !== null && | ||||
|         val2 !== null | ||||
|       ) { | ||||
|         return objectCompare( | ||||
|           val1 as Record<string, unknown>, | ||||
|           val2 as Record<string, unknown>, | ||||
|           true | ||||
|         ); | ||||
|       } | ||||
| 
 | ||||
|       return val1 === val2; | ||||
|     }) | ||||
|   ); | ||||
| } | ||||
|  | ||||
							
								
								
									
										133
									
								
								src/object/comparte.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								src/object/comparte.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,133 @@ | ||||
| import { describe, expect, test } from "vitest"; | ||||
| import { objectCompare } from "./compare"; | ||||
| 
 | ||||
| describe("objectCompare", () => { | ||||
|   // Basic shallow comparison tests
 | ||||
|   test("should return true for identical simple objects in shallow mode", () => { | ||||
|     const obj1 = { a: 1, b: "test", c: true }; | ||||
|     const obj2 = { a: 1, b: "test", c: true }; | ||||
|     expect(objectCompare(obj1, obj2)).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   test("should return false for different simple objects in shallow mode", () => { | ||||
|     const obj1 = { a: 1, b: "test" }; | ||||
|     const obj2 = { a: 1, b: "different" }; | ||||
|     expect(objectCompare(obj1, obj2)).toBe(false); | ||||
|   }); | ||||
| 
 | ||||
|   // Property order tests
 | ||||
|   test("should return true for objects with same properties in different order", () => { | ||||
|     const obj1 = { a: 1, b: 2, c: 3 }; | ||||
|     const obj2 = { c: 3, a: 1, b: 2 }; | ||||
|     expect(objectCompare(obj1, obj2)).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   // Deep comparison tests
 | ||||
|   test("should compare nested objects when deep is true", () => { | ||||
|     const obj1 = { a: { b: 1, c: 2 }, d: 3 }; | ||||
|     const obj2 = { a: { b: 1, c: 2 }, d: 3 }; | ||||
|     expect(objectCompare(obj1, obj2, true)).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   test("should detect differences in nested objects when deep is true", () => { | ||||
|     const obj1 = { a: { b: 1, c: 2 }, d: 3 }; | ||||
|     const obj2 = { a: { b: 1, c: 3 }, d: 3 }; | ||||
|     expect(objectCompare(obj1, obj2, true)).toBe(false); | ||||
|   }); | ||||
| 
 | ||||
|   // Array comparison tests
 | ||||
|   test("should compare arrays correctly in deep mode", () => { | ||||
|     const obj1 = { arr: [1, 2, { x: 1 }] }; | ||||
|     const obj2 = { arr: [1, 2, { x: 1 }] }; | ||||
|     expect(objectCompare(obj1, obj2, true)).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   test("should detect array differences in deep mode", () => { | ||||
|     const obj1 = { arr: [1, 2, { x: 1 }] }; | ||||
|     const obj2 = { arr: [1, 2, { x: 2 }] }; | ||||
|     expect(objectCompare(obj1, obj2, true)).toBe(false); | ||||
|   }); | ||||
| 
 | ||||
|   // Edge cases
 | ||||
|   test("should handle null values", () => { | ||||
|     const obj1 = { a: null }; | ||||
|     const obj2 = { a: null }; | ||||
|     expect(objectCompare(obj1, obj2)).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   test("should handle undefined values", () => { | ||||
|     const obj1 = { a: undefined }; | ||||
|     const obj2 = { a: undefined }; | ||||
|     expect(objectCompare(obj1, obj2)).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   test("should return false when comparing with null", () => { | ||||
|     const obj1 = { a: 1 }; | ||||
|     expect(objectCompare(obj1, null as any)).toBe(false); | ||||
|     expect(objectCompare(null as any, obj1)).toBe(false); | ||||
|   }); | ||||
| 
 | ||||
|   // Complex nested structure tests
 | ||||
|   test("should handle complex nested structures in deep mode", () => { | ||||
|     const obj1 = { | ||||
|       a: 1, | ||||
|       b: { | ||||
|         c: [1, 2, { d: 3 }], | ||||
|         e: { f: 4, g: [5, 6] }, | ||||
|       }, | ||||
|     }; | ||||
|     const obj2 = { | ||||
|       a: 1, | ||||
|       b: { | ||||
|         c: [1, 2, { d: 3 }], | ||||
|         e: { f: 4, g: [5, 6] }, | ||||
|       }, | ||||
|     }; | ||||
|     expect(objectCompare(obj1, obj2, true)).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   test("should detect differences in complex nested structures", () => { | ||||
|     const obj1 = { | ||||
|       a: 1, | ||||
|       b: { | ||||
|         c: [1, 2, { d: 3 }], | ||||
|         e: { f: 4, g: [5, 6] }, | ||||
|       }, | ||||
|     }; | ||||
|     const obj2 = { | ||||
|       a: 1, | ||||
|       b: { | ||||
|         c: [1, 2, { d: 3 }], | ||||
|         e: { f: 4, g: [5, 7] }, // Changed 6 to 7
 | ||||
|       }, | ||||
|     }; | ||||
|     expect(objectCompare(obj1, obj2, true)).toBe(false); | ||||
|   }); | ||||
| 
 | ||||
|   // Property existence tests
 | ||||
|   test("should handle objects with different number of properties", () => { | ||||
|     const obj1 = { a: 1, b: 2 }; | ||||
|     const obj2 = { a: 1, b: 2, c: 3 }; | ||||
|     expect(objectCompare(obj1, obj2)).toBe(false); | ||||
|   }); | ||||
| 
 | ||||
|   test("should handle objects with same number of properties but different keys", () => { | ||||
|     const obj1 = { a: 1, b: 2 }; | ||||
|     const obj2 = { a: 1, c: 2 }; | ||||
|     expect(objectCompare(obj1, obj2)).toBe(false); | ||||
|   }); | ||||
| 
 | ||||
|   // Type comparison tests
 | ||||
|   test("should handle type coercion correctly", () => { | ||||
|     const obj1 = { a: 1 }; | ||||
|     const obj2 = { a: "1" }; | ||||
|     expect(objectCompare(obj1, obj2)).toBe(false); | ||||
|   }); | ||||
| 
 | ||||
|   test("should handle empty objects", () => { | ||||
|     const obj1 = {}; | ||||
|     const obj2 = {}; | ||||
|     expect(objectCompare(obj1, obj2)).toBe(true); | ||||
|     expect(objectCompare(obj1, obj2, true)).toBe(true); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										172
									
								
								src/object/nested.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								src/object/nested.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,172 @@ | ||||
| import { describe, expect, test } from "vitest"; | ||||
| import { getNestedValue, setNestedValue } from "./nested"; | ||||
| 
 | ||||
| describe("getNestedValue", () => { | ||||
|   const testObj = { | ||||
|     user: { | ||||
|       name: "John", | ||||
|       contacts: [ | ||||
|         { email: "john@example.com", phone: "123-456" }, | ||||
|         { email: "john.alt@example.com", phone: "789-012" }, | ||||
|       ], | ||||
|       settings: { | ||||
|         notifications: { | ||||
|           email: true, | ||||
|           push: false, | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|     meta: { | ||||
|       created: "2023-01-01", | ||||
|       tags: ["important", "user"], | ||||
|     }, | ||||
|   }; | ||||
| 
 | ||||
|   test("should get simple property values", () => { | ||||
|     expect(getNestedValue("user.name", testObj)).toBe("John"); | ||||
|     expect(getNestedValue("meta.created", testObj)).toBe("2023-01-01"); | ||||
|   }); | ||||
| 
 | ||||
|   test("should get nested property values", () => { | ||||
|     expect(getNestedValue("user.settings.notifications.email", testObj)).toBe( | ||||
|       true | ||||
|     ); | ||||
|     expect(getNestedValue("user.settings.notifications.push", testObj)).toBe( | ||||
|       false | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   test("should handle array indexing", () => { | ||||
|     expect(getNestedValue("user.contacts[0].email", testObj)).toBe( | ||||
|       "john@example.com" | ||||
|     ); | ||||
|     expect(getNestedValue("user.contacts[1].phone", testObj)).toBe("789-012"); | ||||
|     expect(getNestedValue("meta.tags[0]", testObj)).toBe("important"); | ||||
|   }); | ||||
| 
 | ||||
|   test("should handle invalid paths", () => { | ||||
|     expect(getNestedValue("user.invalid.path", testObj)).toBeUndefined(); | ||||
|     expect(getNestedValue("invalid", testObj)).toBeUndefined(); | ||||
|     expect(getNestedValue("user.contacts[5].email", testObj)).toBeUndefined(); | ||||
|   }); | ||||
| 
 | ||||
|   test("should handle empty or invalid inputs", () => { | ||||
|     expect(getNestedValue("", testObj)).toBeUndefined(); | ||||
|     expect(getNestedValue("user.contacts.[]", testObj)).toBeUndefined(); | ||||
|     expect(getNestedValue("user..name", testObj)).toBeUndefined(); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("setNestedValue", () => { | ||||
|   test("should set simple property values", () => { | ||||
|     const obj = { user: { name: "John" } }; | ||||
|     setNestedValue("user.name", "Jane", obj); | ||||
|     expect(obj.user.name).toBe("Jane"); | ||||
|   }); | ||||
| 
 | ||||
|   test("should create missing objects", () => { | ||||
|     const obj = {}; | ||||
|     setNestedValue("user.settings.theme", "dark", obj); | ||||
|     expect(obj).toEqual({ | ||||
|       user: { | ||||
|         settings: { | ||||
|           theme: "dark", | ||||
|         }, | ||||
|       }, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   test("should handle array indexing and creation", () => { | ||||
|     const obj = { users: [] }; | ||||
|     setNestedValue("users[0].name", "John", obj); | ||||
|     setNestedValue("users[1].name", "Jane", obj); | ||||
|     expect(obj).toEqual({ | ||||
|       users: [{ name: "John" }, { name: "Jane" }], | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   test("should handle mixed object and array paths", () => { | ||||
|     const obj = {}; | ||||
|     setNestedValue("company.departments[0].employees[0].name", "John", obj); | ||||
|     expect(obj).toEqual({ | ||||
|       company: { | ||||
|         departments: [ | ||||
|           { | ||||
|             employees: [{ name: "John" }], | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   test("should modify existing nested arrays", () => { | ||||
|     const obj = { | ||||
|       users: [{ contacts: [{ email: "old@example.com" }] }], | ||||
|     }; | ||||
|     setNestedValue("users[0].contacts[0].email", "new@example.com", obj); | ||||
|     expect(obj.users[0].contacts[0].email).toBe("new@example.com"); | ||||
|   }); | ||||
| 
 | ||||
|   test("should handle complex nested structures", () => { | ||||
|     const obj = {}; | ||||
|     setNestedValue("a.b[0].c.d[1].e", "value", obj); | ||||
|     expect(obj).toEqual({ | ||||
|       a: { | ||||
|         b: [ | ||||
|           { | ||||
|             c: { | ||||
|               d: [undefined, { e: "value" }], | ||||
|             }, | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   test("should override existing values with different types", () => { | ||||
|     const obj = { | ||||
|       data: "string", | ||||
|     }; | ||||
|     setNestedValue("data.nested", "value", obj); | ||||
|     expect(obj.data).toEqual({ nested: "value" }); | ||||
|   }); | ||||
| 
 | ||||
|   test("should return the modified object", () => { | ||||
|     const obj = {}; | ||||
|     const result = setNestedValue("a.b.c", "value", obj); | ||||
|     expect(result).toBe(obj); | ||||
|     expect(result).toEqual({ | ||||
|       a: { | ||||
|         b: { | ||||
|           c: "value", | ||||
|         }, | ||||
|       }, | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   test("should handle numeric array indices properly", () => { | ||||
|     const obj = {}; | ||||
|     setNestedValue("items[0]", "first", obj); | ||||
|     setNestedValue("items[2]", "third", obj); | ||||
|     expect(obj).toEqual({ | ||||
|       items: ["first", undefined, "third"], | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   test("should handle edge cases", () => { | ||||
|     const obj: any = {}; | ||||
| 
 | ||||
|     // Empty path segments
 | ||||
|     setNestedValue("a..b", "value", obj); | ||||
|     expect(obj.a.b).toBe("value"); | ||||
| 
 | ||||
|     // Numeric property names
 | ||||
|     setNestedValue("prop.0.value", "test", obj); | ||||
|     expect(obj.prop["0"].value).toBe("test"); | ||||
| 
 | ||||
|     // Setting value on existing array
 | ||||
|     obj.list = []; | ||||
|     setNestedValue("list[0]", "item", obj); | ||||
|     expect(obj.list[0]).toBe("item"); | ||||
|   }); | ||||
| }); | ||||
| @ -5,42 +5,99 @@ | ||||
|  * @returns Value at path or undefined if path invalid | ||||
|  */ | ||||
| export function getNestedValue(path: string, obj: Record<string, any>): any { | ||||
|   return path | ||||
|   if (!path || !obj) return undefined; | ||||
| 
 | ||||
|   // Check for invalid path patterns
 | ||||
|   if (path.includes("..") || path.includes("[]") || /\[\s*\]/.test(path)) { | ||||
|     return undefined; | ||||
|   } | ||||
| 
 | ||||
|   const parts = path | ||||
|     .replace(/\[(\w+)\]/g, ".$1") // Convert brackets to dot notation
 | ||||
|     .split(".") // Split path into parts
 | ||||
|     .reduce((prev, curr) => prev?.[curr], obj); // Traverse object
 | ||||
| } | ||||
|     .split(".") | ||||
|     .filter(Boolean); // Remove empty segments
 | ||||
| 
 | ||||
|   if (parts.length === 0) return undefined; | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
| * Sets a nested value in an object using a path string | ||||
| * @param path - Dot notation path (e.g. 'user.contacts[0].email') | ||||
| * @param value - Value to set at path | ||||
| * @param obj - Target object to modify | ||||
| * @returns Modified object | ||||
| */ | ||||
| /** | ||||
|  * Sets a nested value, creating objects and arrays if needed | ||||
|  */ | ||||
| export function setNestedValue(path: string, value: any, obj: Record<string, any>): Record<string, any> { | ||||
|   const parts = path.replace(/\[(\w+)\]/g, ".$1").split("."); | ||||
|   const lastKey = parts.pop()!; | ||||
|    | ||||
|   const target = parts.reduce((prev, curr) => { | ||||
|     // Handle array indices
 | ||||
|     if (/^\d+$/.test(curr)) { | ||||
|       if (!Array.isArray(prev[curr])) { | ||||
|         prev[curr] = []; | ||||
|       } | ||||
|     } | ||||
|     // Create missing objects
 | ||||
|     else if (!prev[curr]) { | ||||
|       prev[curr] = {}; | ||||
|     } | ||||
|   return parts.reduce((prev, curr) => { | ||||
|     if (prev === undefined) return undefined; | ||||
|     return prev[curr]; | ||||
|   }, obj); | ||||
| } | ||||
| 
 | ||||
|   target[lastKey] = value; | ||||
| /** | ||||
|  * Sets a nested value in an object using a path string | ||||
|  * @param path - Dot notation path (e.g. 'user.contacts[0].email') | ||||
|  * @param value - Value to set at path | ||||
|  * @param obj - Target object to modify | ||||
|  * @returns Modified object | ||||
|  */ | ||||
| export function setNestedValue( | ||||
|   path: string, | ||||
|   value: any, | ||||
|   obj: Record<string, any> | ||||
| ): Record<string, any> { | ||||
|   if (!path || !obj) return obj; | ||||
| 
 | ||||
|   const parts = path | ||||
|     .replace(/\[(\w+)\]/g, ".$1") | ||||
|     .split(".") | ||||
|     .filter(Boolean); // Remove empty segments
 | ||||
| 
 | ||||
|   if (parts.length === 0) return obj; | ||||
| 
 | ||||
|   const lastKey = parts.pop()!; | ||||
|   let current = obj; | ||||
| 
 | ||||
|   for (let i = 0; i < parts.length; i++) { | ||||
|     const key = parts[i]; | ||||
|     const nextKey = parts[i + 1] || lastKey; | ||||
|     const shouldBeArray = /^\d+$/.test(nextKey); | ||||
| 
 | ||||
|     // If current key doesn't exist or needs type conversion
 | ||||
|     if ( | ||||
|       !(key in current) || | ||||
|       (shouldBeArray && !Array.isArray(current[key])) || | ||||
|       (!shouldBeArray && typeof current[key] !== "object") | ||||
|     ) { | ||||
|       // Create appropriate container based on next key
 | ||||
|       current[key] = shouldBeArray ? [] : {}; | ||||
|     } | ||||
| 
 | ||||
|     current = current[key]; | ||||
|   } | ||||
| 
 | ||||
|   // Handle the last key - determine if parent should be an array
 | ||||
|   if (/^\d+$/.test(lastKey) && !Array.isArray(current)) { | ||||
|     const tempObj = current; | ||||
|     const maxIndex = Math.max( | ||||
|       ...Object.keys(tempObj) | ||||
|         .filter((k) => /^\d+$/.test(k)) | ||||
|         .map(Number) | ||||
|         .concat(-1) | ||||
|     ); | ||||
|     const arr = new Array(Math.max(maxIndex + 1, Number(lastKey) + 1)); | ||||
| 
 | ||||
|     // Copy existing numeric properties to array
 | ||||
|     Object.keys(tempObj) | ||||
|       .filter((k) => /^\d+$/.test(k)) | ||||
|       .forEach((k) => { | ||||
|         arr[Number(k)] = tempObj[k]; | ||||
|       }); | ||||
| 
 | ||||
|     // Replace object with array while preserving non-numeric properties
 | ||||
|     Object.keys(tempObj).forEach((k) => { | ||||
|       if (!/^\d+$/.test(k)) { | ||||
|         arr[k] = tempObj[k]; | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     Object.keys(tempObj).forEach((k) => delete tempObj[k]); | ||||
|     Object.assign(tempObj, arr); | ||||
|     current = tempObj; | ||||
|   } | ||||
| 
 | ||||
|   // Set the final value
 | ||||
|   current[lastKey] = value; | ||||
|   return obj; | ||||
| } | ||||
| } | ||||
|  | ||||
							
								
								
									
										139
									
								
								src/object/utils.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								src/object/utils.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,139 @@ | ||||
| import { describe, expect, test } from 'vitest'; | ||||
| import { createSelectOptions } from './util'; | ||||
| 
 | ||||
| describe('createSelectOptions', () => { | ||||
|   // Test basic object transformation
 | ||||
|   test('should transform basic object with default options', () => { | ||||
|     const input = { | ||||
|       key1: 'Label 1', | ||||
|       key2: 'Label 2', | ||||
|       key3: 'Label 3' | ||||
|     }; | ||||
|      | ||||
|     const expected = [ | ||||
|       { label: 'Label 1', value: 'key1' }, | ||||
|       { label: 'Label 2', value: 'key2' }, | ||||
|       { label: 'Label 3', value: 'key3' } | ||||
|     ]; | ||||
|      | ||||
|     expect(createSelectOptions(input)).toEqual(expected); | ||||
|   }); | ||||
| 
 | ||||
|   // Test custom key names
 | ||||
|   test('should use custom property names when provided', () => { | ||||
|     const input = { | ||||
|       key1: 'Label 1', | ||||
|       key2: 'Label 2' | ||||
|     }; | ||||
|      | ||||
|     const expected = [ | ||||
|       { text: 'Label 1', id: 'key1' }, | ||||
|       { text: 'Label 2', id: 'key2' } | ||||
|     ]; | ||||
|      | ||||
|     expect(createSelectOptions(input, { labelKey: 'text', valueKey: 'id' })).toEqual(expected); | ||||
|   }); | ||||
| 
 | ||||
|   // Test array input
 | ||||
|   test('should return array as-is when input is an array', () => { | ||||
|     const input = [ | ||||
|       { label: 'Option 1', value: '1' }, | ||||
|       { label: 'Option 2', value: '2' } | ||||
|     ]; | ||||
|      | ||||
|     expect(createSelectOptions(input)).toBe(input); | ||||
|   }); | ||||
| 
 | ||||
|   // Test different value types
 | ||||
|   test('should handle different value types', () => { | ||||
|     const input = { | ||||
|       key1: 42, | ||||
|       key2: true, | ||||
|       key3: { nested: 'value' } | ||||
|     }; | ||||
|      | ||||
|     const expected = [ | ||||
|       { label: 42, value: 'key1' }, | ||||
|       { label: true, value: 'key2' }, | ||||
|       { label: { nested: 'value' }, value: 'key3' } | ||||
|     ]; | ||||
|      | ||||
|     expect(createSelectOptions(input)).toEqual(expected); | ||||
|   }); | ||||
| 
 | ||||
|   // Test null and undefined inputs
 | ||||
|   test('should handle null and undefined inputs', () => { | ||||
|     expect(createSelectOptions(null)).toEqual([]); | ||||
|     expect(createSelectOptions(undefined)).toEqual([]); | ||||
|   }); | ||||
| 
 | ||||
|   // Test invalid inputs
 | ||||
|   test('should handle invalid inputs', () => { | ||||
|     expect(createSelectOptions(42 as any)).toEqual([]); | ||||
|     expect(createSelectOptions('string' as any)).toEqual([]); | ||||
|     expect(createSelectOptions(true as any)).toEqual([]); | ||||
|   }); | ||||
| 
 | ||||
|   // Test empty object
 | ||||
|   test('should handle empty object', () => { | ||||
|     expect(createSelectOptions({})).toEqual([]); | ||||
|   }); | ||||
| 
 | ||||
|   // Test partial options
 | ||||
|   test('should handle partial options configuration', () => { | ||||
|     const input = { | ||||
|       key1: 'Label 1', | ||||
|       key2: 'Label 2' | ||||
|     }; | ||||
|      | ||||
|     // Only labelKey provided
 | ||||
|     expect(createSelectOptions(input, { labelKey: 'text' })).toEqual([ | ||||
|       { text: 'Label 1', value: 'key1' }, | ||||
|       { text: 'Label 2', value: 'key2' } | ||||
|     ]); | ||||
|      | ||||
|     // Only valueKey provided
 | ||||
|     expect(createSelectOptions(input, { valueKey: 'id' })).toEqual([ | ||||
|       { label: 'Label 1', id: 'key1' }, | ||||
|       { label: 'Label 2', id: 'key2' } | ||||
|     ]); | ||||
|   }); | ||||
| 
 | ||||
|   // Test type safety
 | ||||
|   test('should maintain type safety with generic types', () => { | ||||
|     interface CustomType { | ||||
|       name: string; | ||||
|       count: number; | ||||
|     } | ||||
| 
 | ||||
|     const input: Record<string, CustomType> = { | ||||
|       key1: { name: 'Item 1', count: 1 }, | ||||
|       key2: { name: 'Item 2', count: 2 } | ||||
|     }; | ||||
|      | ||||
|     const result = createSelectOptions<CustomType>(input); | ||||
|     expect(result).toEqual([ | ||||
|       { label: { name: 'Item 1', count: 1 }, value: 'key1' }, | ||||
|       { label: { name: 'Item 2', count: 2 }, value: 'key2' } | ||||
|     ]); | ||||
|   }); | ||||
| 
 | ||||
|   // Test with special characters in keys
 | ||||
|   test('should handle special characters in keys', () => { | ||||
|     const input = { | ||||
|       '@special': 'Special', | ||||
|       'key.with.dots': 'Dots', | ||||
|       'key-with-dashes': 'Dashes', | ||||
|       'key with spaces': 'Spaces' | ||||
|     }; | ||||
|      | ||||
|     const expected = [ | ||||
|       { label: 'Special', value: '@special' }, | ||||
|       { label: 'Dots', value: 'key.with.dots' }, | ||||
|       { label: 'Dashes', value: 'key-with-dashes' }, | ||||
|       { label: 'Spaces', value: 'key with spaces' } | ||||
|     ]; | ||||
|      | ||||
|     expect(createSelectOptions(input)).toEqual(expected); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										223
									
								
								src/promise/index.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								src/promise/index.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,223 @@ | ||||
| import { describe, it, expect, afterEach, vi, beforeEach } from "vitest"; | ||||
| import { WaitUntil, debounce, throttle, measureTime } from "./index"; | ||||
| 
 | ||||
| describe("WaitUntil", () => { | ||||
|   beforeEach(() => { | ||||
|     vi.useFakeTimers(); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     vi.restoreAllMocks(); | ||||
|   }); | ||||
| 
 | ||||
|   it("should resolve when condition becomes true", async () => { | ||||
|     let flag = false; | ||||
|     setTimeout(() => { | ||||
|       flag = true; | ||||
|     }, 1000); | ||||
| 
 | ||||
|     const promise = WaitUntil(() => flag); | ||||
|     await vi.advanceTimersByTimeAsync(1000); | ||||
| 
 | ||||
|     await promise; // Should resolve without throwing
 | ||||
|   }); | ||||
| 
 | ||||
|   it("should reject on timeout", async () => { | ||||
|     let rejected = false; | ||||
|     const promise = WaitUntil(() => false).catch((err) => { | ||||
|       expect(err.message).toBe("Wait Timeout"); | ||||
|       rejected = true; | ||||
|     }); | ||||
| 
 | ||||
|     await vi.advanceTimersByTimeAsync(5000); | ||||
|     await promise; | ||||
|     expect(rejected).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   it("should respect custom timeout", async () => { | ||||
|     let rejected = false; | ||||
|     const promise = WaitUntil(() => false, { timeout: 2000 }).catch((err) => { | ||||
|       expect(err.message).toBe("Wait Timeout"); | ||||
|       rejected = true; | ||||
|     }); | ||||
| 
 | ||||
|     await vi.advanceTimersByTimeAsync(2000); | ||||
|     await promise; | ||||
|     expect(rejected).toBe(true); | ||||
|   }); | ||||
| 
 | ||||
|   it("should check condition at specified interval", async () => { | ||||
|     const mockCondition = vi.fn(() => false); | ||||
|     let rejected = false; | ||||
| 
 | ||||
|     const promise = WaitUntil(mockCondition, { interval: 200 }).catch((err) => { | ||||
|       expect(err.message).toBe("Wait Timeout"); | ||||
|       rejected = true; | ||||
|     }); | ||||
| 
 | ||||
|     await vi.advanceTimersByTimeAsync(1000); | ||||
|     expect(mockCondition).toHaveBeenCalledTimes(5); // 1000ms / 200ms = 5 calls
 | ||||
| 
 | ||||
|     await vi.advanceTimersByTimeAsync(4000); | ||||
|     await promise; | ||||
|     expect(rejected).toBe(true); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("debounce", () => { | ||||
|   beforeEach(() => { | ||||
|     vi.useFakeTimers(); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     vi.restoreAllMocks(); | ||||
|   }); | ||||
| 
 | ||||
|   it("should delay function execution", () => { | ||||
|     const mockFn = vi.fn(); | ||||
|     const debouncedFn = debounce(mockFn, 1000); | ||||
| 
 | ||||
|     debouncedFn(); | ||||
|     expect(mockFn).not.toBeCalled(); | ||||
| 
 | ||||
|     vi.advanceTimersByTime(1000); | ||||
|     expect(mockFn).toHaveBeenCalledTimes(1); | ||||
|   }); | ||||
| 
 | ||||
|   it("should only execute once for multiple calls within wait period", () => { | ||||
|     const mockFn = vi.fn(); | ||||
|     const debouncedFn = debounce(mockFn, 1000); | ||||
| 
 | ||||
|     debouncedFn(); | ||||
|     debouncedFn(); | ||||
|     debouncedFn(); | ||||
| 
 | ||||
|     vi.advanceTimersByTime(1000); | ||||
|     expect(mockFn).toHaveBeenCalledTimes(1); | ||||
|   }); | ||||
| 
 | ||||
|   it("should pass correct arguments to the debounced function", () => { | ||||
|     const mockFn = vi.fn(); | ||||
|     const debouncedFn = debounce(mockFn, 1000); | ||||
| 
 | ||||
|     debouncedFn("test", 123); | ||||
|     vi.advanceTimersByTime(1000); | ||||
| 
 | ||||
|     expect(mockFn).toHaveBeenCalledWith("test", 123); | ||||
|   }); | ||||
| 
 | ||||
|   it("should reset timer on subsequent calls", () => { | ||||
|     const mockFn = vi.fn(); | ||||
|     const debouncedFn = debounce(mockFn, 1000); | ||||
| 
 | ||||
|     debouncedFn(); | ||||
|     vi.advanceTimersByTime(500); | ||||
| 
 | ||||
|     debouncedFn(); | ||||
|     vi.advanceTimersByTime(500); | ||||
|     expect(mockFn).not.toBeCalled(); | ||||
| 
 | ||||
|     vi.advanceTimersByTime(500); | ||||
|     expect(mockFn).toHaveBeenCalledTimes(1); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("throttle", () => { | ||||
|   beforeEach(() => { | ||||
|     vi.useFakeTimers(); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     vi.restoreAllMocks(); | ||||
|   }); | ||||
| 
 | ||||
|   it("should execute immediately on first call", () => { | ||||
|     const mockFn = vi.fn(); | ||||
|     const throttledFn = throttle(mockFn, 1000); | ||||
| 
 | ||||
|     throttledFn(); | ||||
|     expect(mockFn).toHaveBeenCalledTimes(1); | ||||
|   }); | ||||
| 
 | ||||
|   it("should ignore calls within throttle period", () => { | ||||
|     const mockFn = vi.fn(); | ||||
|     const throttledFn = throttle(mockFn, 1000); | ||||
| 
 | ||||
|     throttledFn(); | ||||
|     throttledFn(); | ||||
|     throttledFn(); | ||||
| 
 | ||||
|     expect(mockFn).toHaveBeenCalledTimes(1); | ||||
|   }); | ||||
| 
 | ||||
|   it("should allow execution after throttle period", () => { | ||||
|     const mockFn = vi.fn(); | ||||
|     const throttledFn = throttle(mockFn, 1000); | ||||
| 
 | ||||
|     throttledFn(); | ||||
|     expect(mockFn).toHaveBeenCalledTimes(1); | ||||
| 
 | ||||
|     vi.advanceTimersByTime(1000); | ||||
|     throttledFn(); | ||||
|     expect(mockFn).toHaveBeenCalledTimes(2); | ||||
|   }); | ||||
| 
 | ||||
|   it("should pass correct arguments to the throttled function", () => { | ||||
|     const mockFn = vi.fn(); | ||||
|     const throttledFn = throttle(mockFn, 1000); | ||||
| 
 | ||||
|     throttledFn("test", 123); | ||||
|     expect(mockFn).toHaveBeenCalledWith("test", 123); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("measureTime", () => { | ||||
|   beforeEach(() => { | ||||
|     vi.useFakeTimers(); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     vi.restoreAllMocks(); | ||||
|   }); | ||||
| 
 | ||||
|   it("should measure execution time of async function", async () => { | ||||
|     const delay = (ms: number) => | ||||
|       new Promise((resolve) => setTimeout(resolve, ms)); | ||||
|     const fn = async () => { | ||||
|       await delay(100); | ||||
|       return "result"; | ||||
|     }; | ||||
| 
 | ||||
|     const measurePromise = measureTime(fn); | ||||
|     await vi.advanceTimersByTimeAsync(100); | ||||
|     const { result, duration } = await measurePromise; | ||||
| 
 | ||||
|     expect(result).toBe("result"); | ||||
|     expect(duration).toBeGreaterThan(0); | ||||
|   }); | ||||
| 
 | ||||
|   it("should handle rejected promises", async () => { | ||||
|     const fn = async () => { | ||||
|       throw new Error("Test error"); | ||||
|     }; | ||||
| 
 | ||||
|     await expect(measureTime(fn)).rejects.toThrow("Test error"); | ||||
|   }); | ||||
| 
 | ||||
|   it("should return precise timing for fast operations", async () => { | ||||
|     const fn = async () => "quick result"; | ||||
| 
 | ||||
|     const { duration } = await measureTime(fn); | ||||
|     expect(duration).toBeLessThan(100); // Should be nearly instant
 | ||||
|   }); | ||||
| 
 | ||||
|   it("should handle functions returning different types", async () => { | ||||
|     const numberFn = async () => 42; | ||||
|     const { result: numResult } = await measureTime(numberFn); | ||||
|     expect(numResult).toBe(42); | ||||
| 
 | ||||
|     const objectFn = async () => ({ key: "value" }); | ||||
|     const { result: objResult } = await measureTime(objectFn); | ||||
|     expect(objResult).toEqual({ key: "value" }); | ||||
|   }); | ||||
| }); | ||||
| @ -32,7 +32,7 @@ export const WaitUntil = async ( | ||||
| 
 | ||||
|     setTimeout(() => { | ||||
|       clearInterval(interval); | ||||
|       reject(new Error("Wait Timeout")); | ||||
|       reject(Error("Wait Timeout")); | ||||
|     }, options?.timeout ?? 5000); | ||||
|   }); | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										162
									
								
								src/strings/caseConversion.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								src/strings/caseConversion.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,162 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { | ||||
|   initCaps, | ||||
|   titleCase, | ||||
|   camelCase, | ||||
|   snakeCase, | ||||
|   reverseSnakeCase, | ||||
|   splitCamelCase, | ||||
| } from './caseConversion'; | ||||
| 
 | ||||
| describe('String Utility Functions', () => { | ||||
|   describe('initCaps', () => { | ||||
|     it('should capitalize first letter of each word', () => { | ||||
|       expect(initCaps('hello world')).toBe('Hello World'); | ||||
|       expect(initCaps('the quick brown fox')).toBe('The Quick Brown Fox'); | ||||
|       expect(initCaps('what a wonderful day')).toBe('What A Wonderful Day'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle single word inputs', () => { | ||||
|       expect(initCaps('hello')).toBe('Hello'); | ||||
|       expect(initCaps('WORLD')).toBe('WORLD'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle empty strings and edge cases', () => { | ||||
|       expect(initCaps('')).toBe(''); | ||||
|       expect(initCaps('   ')).toBe('   '); | ||||
|       expect(initCaps(null as unknown as string)).toBe(null as unknown as string); | ||||
|       expect(initCaps(undefined as unknown as string)).toBe(undefined as unknown as string); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle special characters and numbers', () => { | ||||
|       expect(initCaps('hello123 world')).toBe('Hello123 World'); | ||||
|       expect(initCaps('hello-world')).toBe('Hello-World'); | ||||
|       expect(initCaps('hello!world')).toBe('Hello!World'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('titleCase', () => { | ||||
|     it('should properly capitalize titles following standard rules', () => { | ||||
|       expect(titleCase('the quick brown fox')).toBe('The Quick Brown Fox'); | ||||
|       expect(titleCase('a tale of two cities')).toBe('A Tale of Two Cities'); | ||||
|       expect(titleCase('to kill a mockingbird')).toBe('To Kill a Mockingbird'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should keep articles, conjunctions, and prepositions lowercase unless at start', () => { | ||||
|       expect(titleCase('war and peace')).toBe('War and Peace'); | ||||
|       expect(titleCase('the lord of the rings')).toBe('The Lord of the Rings'); | ||||
|       expect(titleCase('a beautiful mind')).toBe('A Beautiful Mind'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle empty strings and edge cases', () => { | ||||
|       expect(titleCase('')).toBe(''); | ||||
|       expect(titleCase('   ')).toBe('   '); | ||||
|       expect(titleCase(null as unknown as string)).toBe(null as unknown as string); | ||||
|       expect(titleCase(undefined as unknown as string)).toBe(undefined as unknown as string); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle special cases with colons and hyphens', () => { | ||||
|       expect(titleCase('star wars: a new hope')).toBe('Star Wars: A New Hope'); | ||||
|       expect(titleCase('spider-man: far from home')).toBe('Spider-Man: Far From Home'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('camelCase', () => { | ||||
|     it('should convert space-separated words to camelCase', () => { | ||||
|       expect(camelCase('hello world')).toBe('helloWorld'); | ||||
|       expect(camelCase('the quick brown fox')).toBe('theQuickBrownFox'); | ||||
|       expect(camelCase('What A Wonderful Day')).toBe('whatAWonderfulDay'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle single words', () => { | ||||
|       expect(camelCase('hello')).toBe('hello'); | ||||
|       expect(camelCase('WORLD')).toBe('world'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle empty strings and edge cases', () => { | ||||
|       expect(camelCase('')).toBe(''); | ||||
|       expect(camelCase('   ')).toBe(''); | ||||
|       expect(camelCase(null as unknown as string)).toBe(null as unknown as string); | ||||
|       expect(camelCase(undefined as unknown as string)).toBe(undefined as unknown as string); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle special characters and numbers', () => { | ||||
|       expect(camelCase('hello 123 world')).toBe('hello123World'); | ||||
|       expect(camelCase('first-second')).toBe('firstSecond'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('snakeCase', () => { | ||||
|     it('should convert space-separated words to snake_case', () => { | ||||
|       expect(snakeCase('hello world')).toBe('hello_world'); | ||||
|       expect(snakeCase('The Quick Brown Fox')).toBe('the_quick_brown_fox'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle camelCase input', () => { | ||||
|       expect(snakeCase('helloWorld')).toBe('hello_world'); | ||||
|       expect(snakeCase('theQuickBrownFox')).toBe('the_quick_brown_fox'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle empty strings and edge cases', () => { | ||||
|       expect(snakeCase('')).toBe(''); | ||||
|       expect(snakeCase('   ')).toBe(''); | ||||
|       expect(snakeCase(null as unknown as string)).toBe(null as unknown as string); | ||||
|       expect(snakeCase(undefined as unknown as string)).toBe(undefined as unknown as string); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle special characters and numbers', () => { | ||||
|       expect(snakeCase('hello123World')).toBe('hello123_world'); | ||||
|       expect(snakeCase('first-second')).toBe('first_second'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('reverseSnakeCase', () => { | ||||
|     it('should convert snake_case to camelCase', () => { | ||||
|       expect(reverseSnakeCase('hello_world')).toBe('helloWorld'); | ||||
|       expect(reverseSnakeCase('the_quick_brown_fox')).toBe('theQuickBrownFox'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle single words', () => { | ||||
|       expect(reverseSnakeCase('hello')).toBe('hello'); | ||||
|       expect(reverseSnakeCase('world')).toBe('world'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle empty strings and edge cases', () => { | ||||
|       expect(reverseSnakeCase('')).toBe(''); | ||||
|       expect(reverseSnakeCase('_')).toBe(''); | ||||
|       expect(reverseSnakeCase(null as unknown as string)).toBe(null as unknown as string); | ||||
|       expect(reverseSnakeCase(undefined as unknown as string)).toBe(undefined as unknown as string); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle multiple underscores and edge cases', () => { | ||||
|       expect(reverseSnakeCase('hello__world')).toBe('helloWorld'); | ||||
|       expect(reverseSnakeCase('_hello_world')).toBe('helloWorld'); | ||||
|       expect(reverseSnakeCase('hello_world_')).toBe('helloWorld'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('splitCamelCase', () => { | ||||
|     it('should split camelCase into space-separated words', () => { | ||||
|       expect(splitCamelCase('helloWorld')).toBe('hello World'); | ||||
|       expect(splitCamelCase('theQuickBrownFox')).toBe('the Quick Brown Fox'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle consecutive capital letters', () => { | ||||
|       expect(splitCamelCase('HTMLParser')).toBe('HTML Parser'); | ||||
|       expect(splitCamelCase('parseXMLDocument')).toBe('parse XML Document'); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle empty strings and edge cases', () => { | ||||
|       expect(splitCamelCase('')).toBe(''); | ||||
|       expect(splitCamelCase('   ')).toBe('   '); | ||||
|       expect(splitCamelCase(null as unknown as string)).toBe(null as unknown as string); | ||||
|       expect(splitCamelCase(undefined as unknown as string)).toBe(undefined as unknown as string); | ||||
|     }); | ||||
| 
 | ||||
|     it('should handle single words and special cases', () => { | ||||
|       expect(splitCamelCase('hello')).toBe('hello'); | ||||
|       expect(splitCamelCase('Hello')).toBe('Hello'); | ||||
|       expect(splitCamelCase('HELLO')).toBe('HELLO'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @ -1,4 +1,3 @@ | ||||
| 
 | ||||
| /** | ||||
|  * Capitalizes the first letter of each word in a sentence | ||||
|  * @param sentence - Input string to transform | ||||
| @ -6,7 +5,19 @@ | ||||
|  */ | ||||
| export const initCaps = (sentence: string): string => { | ||||
|   if (!sentence) return sentence; | ||||
|   return sentence.replace(/(^\w{1})|(\s+\w{1})/g, letter => letter.toUpperCase()); | ||||
|   // Updated regex to handle special characters
 | ||||
|   const words = sentence.match(/[A-Za-z]+|\d+|[^A-Za-z0-9]+/g) || []; | ||||
| return words.map(word => { | ||||
|   // If the word is all uppercase, keep it that way
 | ||||
|   if (word === word.toUpperCase() && /^[A-Z]+$/.test(word)) { | ||||
|     return word; | ||||
|   } | ||||
|   // If it starts with a letter, capitalize it
 | ||||
|   if (/^[a-zA-Z]/.test(word)) { | ||||
|     return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); | ||||
|   } | ||||
|   return word; | ||||
| }).join(''); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
| @ -17,17 +28,25 @@ export const initCaps = (sentence: string): string => { | ||||
|  */ | ||||
| export const titleCase = (sentence: string): string => { | ||||
|   if (!sentence) return sentence; | ||||
|   const smallWords = /^(a|an|and|as|at|but|by|en|for|if|in|nor|of|on|or|per|the|to|vs?\.?|via)$/i; | ||||
|    | ||||
|   return sentence.toLowerCase().replace(/[A-Za-z0-9\u00C0-\u00FF]+[^\s-]*/g, (word, index, title) => { | ||||
|     if (index > 0 && index + word.length !== title.length && | ||||
|       word.search(smallWords) > -1 && title.charAt(index - 2) !== ":" && | ||||
|       (title.charAt(index + word.length) !== '-' || title.charAt(index - 1) === '-') && | ||||
|       title.charAt(index - 1).search(/[^\s-]/) < 0) { | ||||
|       return word.toLowerCase(); | ||||
|     } | ||||
|     return word.charAt(0).toUpperCase() + word.substr(1); | ||||
|   }); | ||||
|   const smallWords = | ||||
|     /^(a|an|and|as|at|but|by|en|for|if|in|nor|of|on|or|per|the|to|vs?\.?|via)$/i; | ||||
| 
 | ||||
|   return sentence | ||||
|     .toLowerCase() | ||||
|     .replace(/[A-Za-z0-9\u00C0-\u00FF]+[^\s-]*/g, (word, index, title) => { | ||||
|       if ( | ||||
|         index > 0 && | ||||
|         index + word.length !== title.length && | ||||
|         word.search(smallWords) > -1 && | ||||
|         title.charAt(index - 2) !== ":" && | ||||
|         (title.charAt(index + word.length) !== "-" || | ||||
|           title.charAt(index - 1) === "-") && | ||||
|         title.charAt(index - 1).search(/[^\s-]/) < 0 | ||||
|       ) { | ||||
|         return word.toLowerCase(); | ||||
|       } | ||||
|       return word.charAt(0).toUpperCase() + word.substr(1); | ||||
|     }); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
| @ -37,10 +56,14 @@ export const titleCase = (sentence: string): string => { | ||||
|  */ | ||||
| export const camelCase = (sentence: string): string => { | ||||
|   if (!sentence) return sentence; | ||||
|   return sentence | ||||
|     .replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) =>  | ||||
|       index === 0 ? letter.toLowerCase() : letter.toUpperCase()) | ||||
|     .replace(/\s+/g, ''); | ||||
|   // First, convert everything to lowercase and handle special characters
 | ||||
|   const normalized = sentence | ||||
|     .toLowerCase() | ||||
|     .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : "")) | ||||
|     .replace(/[^a-zA-Z0-9]+/g, ""); | ||||
| 
 | ||||
|   // Ensure first character is lowercase
 | ||||
|   return normalized.charAt(0).toLowerCase() + normalized.slice(1); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
| @ -51,9 +74,10 @@ export const camelCase = (sentence: string): string => { | ||||
| export const snakeCase = (sentence: string): string => { | ||||
|   if (!sentence) return sentence; | ||||
|   return sentence | ||||
|     .replace(/\s+/g, '_') | ||||
|     .replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`) | ||||
|     .replace(/^_/, '') | ||||
|     .replace(/\s+/g, "_") | ||||
|     .replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) | ||||
|     .replace(/[-_]+/g, "_") // Handle multiple underscores and hyphens
 | ||||
|     .replace(/^_+|_+$/g, "") // Remove leading/trailing underscores
 | ||||
|     .toLowerCase(); | ||||
| }; | ||||
| 
 | ||||
| @ -61,12 +85,12 @@ export const snakeCase = (sentence: string): string => { | ||||
|  * Converts snake_case to camelCase | ||||
|  * @param sentence - Input string in snake_case | ||||
|  * @returns Transformed string in camelCase | ||||
|  */ | ||||
| export const reverseSnakeCase = (sentence: string): string => { | ||||
|  */ export const reverseSnakeCase = (sentence: string): string => { | ||||
|   if (!sentence) return sentence; | ||||
|   return sentence | ||||
|     .toLowerCase() | ||||
|     .replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); | ||||
|     .replace(/^_+|_+$/g, "") // Remove leading/trailing underscores
 | ||||
|     .replace(/_+([a-z])/g, (_, letter) => letter.toUpperCase()); // Handle multiple underscores
 | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
| @ -76,8 +100,9 @@ export const reverseSnakeCase = (sentence: string): string => { | ||||
|  */ | ||||
| export const splitCamelCase = (sentence: string): string => { | ||||
|   if (!sentence) return sentence; | ||||
|   if (/^\s+$/.test(sentence)) return sentence; // Return whitespace-only strings as-is
 | ||||
|   return sentence | ||||
|     .replace(/([a-z])([A-Z])/g, '$1 $2') | ||||
|     .replace(/([A-Z])([A-Z][a-z])/g, '$1 $2') | ||||
|     .replace(/([a-z])([A-Z])/g, "$1 $2") | ||||
|     .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2") | ||||
|     .trim(); | ||||
| }; | ||||
| }; | ||||
|  | ||||
							
								
								
									
										73
									
								
								src/strings/fileSize.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/strings/fileSize.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,73 @@ | ||||
| import { describe, it, expect } from "vitest"; | ||||
| import { humanFileSize } from "./fileSize"; | ||||
| 
 | ||||
| describe("humanFileSize", () => { | ||||
|   // Test basic byte values
 | ||||
|   it("should handle values less than threshold", () => { | ||||
|     expect(humanFileSize(0)).toBe("0 B"); | ||||
|     expect(humanFileSize(500)).toBe("500 B"); | ||||
|     expect(humanFileSize(-500)).toBe("-500 B"); | ||||
|   }); | ||||
| 
 | ||||
|   // Test binary units (default behavior)
 | ||||
|   it("should format sizes using binary units correctly", () => { | ||||
|     expect(humanFileSize(1024)).toBe("1.0 KiB"); | ||||
|     expect(humanFileSize(1024 * 1024)).toBe("1.0 MiB"); | ||||
|     expect(humanFileSize(1024 * 1024 * 1024)).toBe("1.0 GiB"); | ||||
|     expect(humanFileSize(1024 * 1024 * 1024 * 1024)).toBe("1.0 TiB"); | ||||
|   }); | ||||
| 
 | ||||
|   // Test SI units
 | ||||
|   it("should format sizes using SI units correctly when si=true", () => { | ||||
|     expect(humanFileSize(1000, true)).toBe("1.0 kB"); | ||||
|     expect(humanFileSize(1000 * 1000, true)).toBe("1.0 MB"); | ||||
|     expect(humanFileSize(1000 * 1000 * 1000, true)).toBe("1.0 GB"); | ||||
|     expect(humanFileSize(1000 * 1000 * 1000 * 1000, true)).toBe("1.0 TB"); | ||||
|   }); | ||||
| 
 | ||||
|   // Test decimal places
 | ||||
|   it("should respect decimal places parameter", () => { | ||||
|     expect(humanFileSize(1536, false, 0)).toBe("2 KiB"); | ||||
|     expect(humanFileSize(1536, false, 1)).toBe("1.5 KiB"); | ||||
|     expect(humanFileSize(1536, false, 2)).toBe("1.50 KiB"); | ||||
|     expect(humanFileSize(1536, false, 3)).toBe("1.500 KiB"); | ||||
|   }); | ||||
| 
 | ||||
|   // Test edge cases
 | ||||
|   it("should handle edge cases correctly", () => { | ||||
|     // Zero
 | ||||
|     expect(humanFileSize(0, false, 2)).toBe("0 B"); | ||||
|     expect(humanFileSize(0, true, 2)).toBe("0 B"); | ||||
| 
 | ||||
|     // Negative values
 | ||||
|     expect(humanFileSize(-1024)).toBe("-1.0 KiB"); | ||||
|     expect(humanFileSize(-1000, true)).toBe("-1.0 kB"); | ||||
| 
 | ||||
|     // Very large numbers
 | ||||
|     const exabyte = 1024 * 1024 * 1024 * 1024 * 1024 * 1024; | ||||
|     expect(humanFileSize(exabyte)).toBe("1.0 EiB"); | ||||
| 
 | ||||
|     // Very large SI numbers
 | ||||
|     const siExabyte = 1000 * 1000 * 1000 * 1000 * 1000 * 1000; | ||||
|     expect(humanFileSize(siExabyte, true)).toBe("1.0 EB"); | ||||
|   }); | ||||
| 
 | ||||
|   // Test boundary conditions
 | ||||
|   it("should handle boundary conditions correctly", () => { | ||||
|     // Just below and above KiB threshold
 | ||||
|     expect(humanFileSize(1023)).toBe("1023 B"); | ||||
|     expect(humanFileSize(1025)).toBe("1.0 KiB"); | ||||
| 
 | ||||
|     // Just below and above SI KB threshold
 | ||||
|     expect(humanFileSize(999, true)).toBe("999 B"); | ||||
|     expect(humanFileSize(1001, true)).toBe("1.0 kB"); | ||||
|   }); | ||||
| 
 | ||||
|   // Test precision handling
 | ||||
|   it("should handle precision edge cases correctly", () => { | ||||
|     // Values that might cause rounding issues
 | ||||
|     expect(humanFileSize(1023.9)).toBe("1023.9 B"); | ||||
|     expect(humanFileSize(1024 * 1.5, false, 3)).toBe("1.500 KiB"); | ||||
|     expect(humanFileSize(1000 * 1.5, true, 3)).toBe("1.500 kB"); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										138
									
								
								src/strings/legacy.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								src/strings/legacy.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,138 @@ | ||||
| import { describe, it, expect } from "vitest"; | ||||
| import { | ||||
|   formatNumber, | ||||
|   formatCurrency, | ||||
|   formatDate, | ||||
|   formatRelativeTime, | ||||
|   getPlural, | ||||
|   formatList, | ||||
|   compareStrings, | ||||
|   formatPercent, | ||||
|   formatUnit, | ||||
|   parseNumberWords, | ||||
|   //handleBiDi,
 | ||||
| } from "./locale"; | ||||
| 
 | ||||
| describe("formatNumber", () => { | ||||
|   it("formats numbers according to locale", () => { | ||||
|     expect(formatNumber(1234.56, "en-US")).toBe("1,234.56"); | ||||
|     expect(formatNumber(1234.56, "de-DE")).toBe("1.234,56"); | ||||
|     expect(formatNumber(1234.56, "fr-FR")).toBe("1 234,56"); | ||||
|     //expect(formatNumber(1234.56, "af-ZA")).toBe("1 234,56");
 | ||||
|   }); | ||||
| 
 | ||||
|   it("handles custom format options", () => { | ||||
|     const options = { minimumFractionDigits: 2, maximumFractionDigits: 2 }; | ||||
|     expect(formatNumber(1234, "en-US", options)).toBe("1,234.00"); | ||||
|     //expect(formatNumber(1234, "af-ZA", options)).toBe("1 234,00");
 | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatCurrency", () => { | ||||
|   it("formats currency according to locale and currency code", () => { | ||||
|     expect(formatCurrency(123.45, "en-US", "USD")).toBe("$123.45"); | ||||
|     //expect(formatCurrency(123.45, "de-DE", "EUR")).toBe("123,45 €");
 | ||||
|     expect(formatCurrency(123.45, "ja-JP", "JPY")).toBe("¥123"); | ||||
|     //expect(formatCurrency(123.45, "af-ZA", "ZAR")).toBe("R 123,45");
 | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatDate", () => { | ||||
|   it("formats dates according to locale", () => { | ||||
|     const date = new Date("2024-01-15"); | ||||
| 
 | ||||
|     expect(formatDate(date, "en-US")).toMatch(/1\/15\/2024|15\/1\/2024/); | ||||
|     expect(formatDate(date, "de-DE")).toMatch(/15\.1\.2024|15\.01\.2024/); | ||||
|     expect(formatDate(date, "af-ZA")).toMatch(/2024-01-15|2024\/01\/15/); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles custom date format options", () => { | ||||
|     const date = new Date("2024-01-15"); | ||||
|     const options = { | ||||
|       year: "numeric", | ||||
|       month: "long", | ||||
|       day: "numeric", | ||||
|     } as const; | ||||
| 
 | ||||
|     expect(formatDate(date, "en-US", options)).toBe("January 15, 2024"); | ||||
|     expect(formatDate(date, "af-ZA", options)).toMatch(/15 Januarie 2024/); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatRelativeTime", () => { | ||||
|   it("formats relative time based on locale", () => { | ||||
|     expect(formatRelativeTime(-1, "day", "en-US")).toBe("yesterday"); | ||||
|     expect(formatRelativeTime(1, "day", "en-US")).toBe("tomorrow"); | ||||
|     expect(formatRelativeTime(-1, "day", "af-ZA")).toMatch(/gister|yesterday/); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("getPlural", () => { | ||||
|   it("returns correct plural forms based on count and locale", () => { | ||||
|     const forms = { | ||||
|       one: "item", | ||||
|       other: "items", | ||||
|     }; | ||||
| 
 | ||||
|     expect(getPlural(1, "en-US", forms)).toBe("item"); | ||||
|     expect(getPlural(2, "en-US", forms)).toBe("items"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles Afrikaans plural rules", () => { | ||||
|     const afForms = { | ||||
|       one: "item", | ||||
|       other: "items", | ||||
|     }; | ||||
|     expect(getPlural(1, "af-ZA", afForms)).toBe("item"); | ||||
|     expect(getPlural(2, "af-ZA", afForms)).toBe("items"); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatList", () => { | ||||
|   it("formats lists according to locale conventions", () => { | ||||
|     const items = ["apple", "banana", "orange"]; | ||||
| 
 | ||||
|     expect(formatList(items, "en-US")).toBe("apple, banana, and orange"); | ||||
|     expect(formatList(items, "af-ZA")).toMatch(/apple, banana(,)? en orange/); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("compareStrings", () => { | ||||
|   it("compares strings according to locale rules", () => { | ||||
|     expect(compareStrings("a", "b", "en-US")).toBeLessThan(0); | ||||
|     expect(compareStrings("b", "a", "af-ZA")).toBeGreaterThan(0); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatPercent", () => { | ||||
|   it("formats percentages according to locale", () => { | ||||
|     expect(formatPercent(0.1234, "en-US")).toBe("12%"); | ||||
|     expect(formatPercent(0.1234, "af-ZA")).toBe("12%"); | ||||
|     expect(formatPercent(0.1234, "af-ZA", 1)).toBe("12,3%"); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatUnit", () => { | ||||
|   it("formats units according to locale", () => { | ||||
|     expect(formatUnit(123, "kilometer", "en-US")).toMatch( | ||||
|       /123 kilometers|123 km/ | ||||
|     ); | ||||
|     expect(formatUnit(123, "kilometer", "af-ZA")).toMatch( | ||||
|       /123 kilometer|123 km/ | ||||
|     ); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("parseNumberWords", () => { | ||||
|   it("converts number words to digits for English", () => { | ||||
|     expect(parseNumberWords("one", "en-US")).toBe(1); | ||||
|     expect(parseNumberWords("five", "en-US")).toBe(5); | ||||
|     expect(parseNumberWords("invalid", "en-US")).toBeNull(); | ||||
|   }); | ||||
| 
 | ||||
|   it("converts number words to digits for Afrikaans", () => { | ||||
|     expect(parseNumberWords("een", "af-ZA")).toBe(1); | ||||
|     expect(parseNumberWords("vyf", "af-ZA")).toBe(5); | ||||
|     expect(parseNumberWords("ongeldig", "af-ZA")).toBeNull(); | ||||
|   }); | ||||
| }); | ||||
| @ -1,109 +1,107 @@ | ||||
| 
 | ||||
| /** | ||||
|  * Checks if a value exists in an array or matches a single value | ||||
|  * @param args - First argument is the source (array/value), followed by values to check | ||||
|  * @returns True if any subsequent argument matches source or exists in source array | ||||
|  */ | ||||
| export function inop(...args: unknown[]): boolean { | ||||
|     if (args.length < 2) return false; | ||||
|      | ||||
|     const [source, ...searchValues] = args; | ||||
|      | ||||
|     // Handle array-like objects
 | ||||
|     if (source !== null && 'length' in (source as { length?: number })) { | ||||
|       const arr = Array.from(source as ArrayLike<unknown>); | ||||
|       return searchValues.some(val => arr.includes(val)); | ||||
|     } | ||||
|      | ||||
|     // Handle single value comparison
 | ||||
|     return searchValues.some(val => source === val); | ||||
|   if (args.length < 2) return false; | ||||
| 
 | ||||
|   const [source, ...searchValues] = args; | ||||
| 
 | ||||
|   // Handle undefined and null
 | ||||
|   if (source === undefined || source === null) return false; | ||||
| 
 | ||||
|   // Handle arrays and array-like objects
 | ||||
|   if ( | ||||
|     Array.isArray(source) || | ||||
|     (typeof source === "object" && "length" in source) | ||||
|   ) { | ||||
|     const arr = Array.from(source as ArrayLike<unknown>); | ||||
|     return searchValues.some((val) => arr.includes(val)); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Case-insensitive version of inop() for string comparisons | ||||
|    * @param args - First argument is source (string/array), followed by values to check | ||||
|    * @returns True if any subsequent argument matches source with case-insensitive comparison | ||||
|    */ | ||||
|   export function iinop(...args: unknown[]): boolean { | ||||
|     if (args.length < 2) return false; | ||||
|      | ||||
|     const [source, ...searchValues] = args; | ||||
|      | ||||
|     // Handle array-like objects
 | ||||
|     if (source !== null && 'length' in (source as { length?: number })) { | ||||
|       const arr = Array.from(source as ArrayLike<unknown>); | ||||
|       return searchValues.some(val => { | ||||
|         return arr.some(item => { | ||||
|           if (typeof item === 'string' && typeof val === 'string') { | ||||
|             return item.toLowerCase() === val.toLowerCase(); | ||||
|           } | ||||
|           return item === val; | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
|      | ||||
|     // Handle single value comparison
 | ||||
|     if (typeof source === 'string') { | ||||
|       return searchValues.some(val => { | ||||
|         if (typeof val === 'string') { | ||||
|           return source.toLowerCase() === val.toLowerCase(); | ||||
| 
 | ||||
|   // Handle primitive values
 | ||||
|   return searchValues.some((val) => source === val); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Case-insensitive version of inop() for string comparisons | ||||
|  * @param args - First argument is source (string/array), followed by values to check | ||||
|  * @returns True if any subsequent argument matches source with case-insensitive comparison | ||||
|  */ | ||||
| export function iinop(...args: unknown[]): boolean { | ||||
|   if (args.length < 2) return false; | ||||
| 
 | ||||
|   const [source, ...searchValues] = args; | ||||
| 
 | ||||
|   // Handle undefined and null
 | ||||
|   if (source === undefined || source === null) return false; | ||||
| 
 | ||||
|   // Handle arrays and array-like objects
 | ||||
|   if ( | ||||
|     Array.isArray(source) || | ||||
|     (typeof source === "object" && "length" in source) | ||||
|   ) { | ||||
|     const arr = Array.from(source as ArrayLike<unknown>); | ||||
|     return searchValues.some((val) => { | ||||
|       return arr.some((item) => { | ||||
|         if (typeof item === "string" && typeof val === "string") { | ||||
|           return item.toLowerCase() === val.toLowerCase(); | ||||
|         } | ||||
|         return source === val; | ||||
|         return item === val; | ||||
|       }); | ||||
|     } | ||||
|      | ||||
|     return searchValues.some(val => source === val); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   // Handle string comparison
 | ||||
|   if (typeof source === "string") { | ||||
|     return searchValues.some((val) => { | ||||
|       if (typeof val === "string") { | ||||
|         return source.toLowerCase() === val.toLowerCase(); | ||||
|       } | ||||
|       return source === val; | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   // Handle primitive values
 | ||||
|   return searchValues.some((val) => source === val); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Base date for Clarion date calculations (December 28, 1800) | ||||
|  */ | ||||
| const CLARION_EPOCH = new Date(1800, 11, 28); | ||||
| 
 | ||||
|   /** | ||||
| /** | ||||
|  * Converts a Clarion integer time value to a formatted time string | ||||
|  * @param val - Clarion time value (HHMMSS.CC format where CC is centiseconds) | ||||
|  * @param detail - If true, includes centiseconds in output | ||||
|  * @returns Formatted time string (HH:MM:SS or HH:MM:SS.CC) | ||||
|  */ | ||||
| export function clarionIntToTime(val: number, detail?: boolean): string { | ||||
|     // Ensure non-negative value
 | ||||
|     if (val < 0) { | ||||
|         val = 0; | ||||
|     } | ||||
|   // Ensure non-negative value
 | ||||
|   if (val < 0) { | ||||
|     val = 0; | ||||
|   } | ||||
| 
 | ||||
|     // Convert to seconds
 | ||||
|     const sec_num = val / 100; | ||||
|   // Extract time components
 | ||||
|   const totalSeconds = Math.floor(val / 100); | ||||
|   const centiseconds = val % 100; | ||||
| 
 | ||||
|     // Extract time components
 | ||||
|     const hours: number = Math.floor(val / 360000); | ||||
|     const minutes: number = Math.floor((sec_num - hours * 3600) / 60); | ||||
|     const seconds: number = Math.floor( | ||||
|         sec_num - hours * 3600 - minutes * 60 | ||||
|     ); | ||||
|     const ms: number = Math.floor( | ||||
|         val - hours * 360000 - minutes * 6000 - seconds * 100 | ||||
|     ); | ||||
|   const hours = Math.floor(totalSeconds / 3600); | ||||
|   const minutes = Math.floor((totalSeconds % 3600) / 60); | ||||
|   const seconds = totalSeconds % 60; | ||||
| 
 | ||||
|     // Format time components with leading zeros
 | ||||
|     const paddedHours = hours.toString().padStart(2, '0'); | ||||
|     const paddedMinutes = minutes.toString().padStart(2, '0'); | ||||
|     const paddedSeconds = seconds.toString().padStart(2, '0'); | ||||
|   // Format time components with leading zeros
 | ||||
|   const paddedHours = hours.toString().padStart(2, "0"); | ||||
|   const paddedMinutes = minutes.toString().padStart(2, "0"); | ||||
|   const paddedSeconds = seconds.toString().padStart(2, "0"); | ||||
|   const paddedCentiseconds = centiseconds.toString().padStart(2, "0"); | ||||
| 
 | ||||
|     // Handle centiseconds formatting
 | ||||
|     let msString: string; | ||||
|     if (ms < 10) { | ||||
|         msString = ms.toString() + '00'; | ||||
|     } else if (ms < 100) { | ||||
|         msString = ms.toString() + '0'; | ||||
|     } else { | ||||
|         msString = ms.toString(); | ||||
|     } | ||||
| 
 | ||||
|     // Return formatted time string
 | ||||
|     return detail  | ||||
|         ? `${paddedHours}:${paddedMinutes}:${paddedSeconds}.${msString}` | ||||
|         : `${paddedHours}:${paddedMinutes}:${paddedSeconds}`; | ||||
|   // Return formatted time string
 | ||||
|   return detail | ||||
|     ? `${paddedHours}:${paddedMinutes}:${paddedSeconds}.${paddedCentiseconds}0` | ||||
|     : `${paddedHours}:${paddedMinutes}:${paddedSeconds}`; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -113,43 +111,43 @@ export function clarionIntToTime(val: number, detail?: boolean): string { | ||||
|  * @throws Error if the time string format is invalid | ||||
|  */ | ||||
| export function clarionTimeToInt(timeStr: string): number { | ||||
|     // Regular expressions to match both formats
 | ||||
|     const basicTimeRegex = /^(\d{2}):(\d{2}):(\d{2})$/; | ||||
|     const detailedTimeRegex = /^(\d{2}):(\d{2}):(\d{2})\.(\d{1,3})$/; | ||||
|      | ||||
|     let hours: number; | ||||
|     let minutes: number; | ||||
|     let seconds: number; | ||||
|     let centiseconds: number = 0; | ||||
|   // Regular expressions to match both formats
 | ||||
|   const basicTimeRegex = /^(\d{2}):(\d{2}):(\d{2})$/; | ||||
|   const detailedTimeRegex = /^(\d{2}):(\d{2}):(\d{2})\.(\d{1,3})$/; | ||||
| 
 | ||||
|     // Try matching both formats
 | ||||
|     const basicMatch = timeStr.match(basicTimeRegex); | ||||
|     const detailedMatch = timeStr.match(detailedTimeRegex); | ||||
|   let hours: number; | ||||
|   let minutes: number; | ||||
|   let seconds: number; | ||||
|   let centiseconds: number = 0; | ||||
| 
 | ||||
|     if (detailedMatch) { | ||||
|         // Parse detailed time format (HH:MM:SS.CC)
 | ||||
|         [, hours, minutes, seconds, centiseconds] = detailedMatch.map(Number); | ||||
|          | ||||
|         // Handle different centisecond precision
 | ||||
|         if (centiseconds < 10) { | ||||
|             centiseconds *= 100; | ||||
|         } else if (centiseconds < 100) { | ||||
|             centiseconds *= 10; | ||||
|         } | ||||
|     } else if (basicMatch) { | ||||
|         // Parse basic time format (HH:MM:SS)
 | ||||
|         [, hours, minutes, seconds] = basicMatch.map(Number); | ||||
|     } else { | ||||
|         throw new Error('Invalid time format. Expected HH:MM:SS or HH:MM:SS.CC'); | ||||
|   // Try matching both formats
 | ||||
|   const basicMatch = timeStr.match(basicTimeRegex); | ||||
|   const detailedMatch = timeStr.match(detailedTimeRegex); | ||||
| 
 | ||||
|   if (detailedMatch) { | ||||
|     // Parse detailed time format (HH:MM:SS.CC)
 | ||||
|     [, hours, minutes, seconds, centiseconds] = detailedMatch.map(Number); | ||||
| 
 | ||||
|     // Handle different centisecond precision
 | ||||
|     if (centiseconds < 10) { | ||||
|       centiseconds *= 100; | ||||
|     } else if (centiseconds < 100) { | ||||
|       centiseconds *= 10; | ||||
|     } | ||||
|   } else if (basicMatch) { | ||||
|     // Parse basic time format (HH:MM:SS)
 | ||||
|     [, hours, minutes, seconds] = basicMatch.map(Number); | ||||
|   } else { | ||||
|     throw new Error("Invalid time format. Expected HH:MM:SS or HH:MM:SS.CC"); | ||||
|   } | ||||
| 
 | ||||
|     // Validate time components
 | ||||
|     if (hours >= 24 || minutes >= 60 || seconds >= 60 || centiseconds >= 1000) { | ||||
|         throw new Error('Invalid time values'); | ||||
|     } | ||||
|   // Validate time components
 | ||||
|   if (hours >= 24 || minutes >= 60 || seconds >= 60 || centiseconds >= 1000) { | ||||
|     throw new Error("Invalid time values"); | ||||
|   } | ||||
| 
 | ||||
|     // Convert to Clarion integer format
 | ||||
|     return hours * 360000 + minutes * 6000 + seconds * 100 + centiseconds; | ||||
|   // Convert to Clarion integer format
 | ||||
|   return hours * 360000 + minutes * 6000 + seconds * 100 + centiseconds; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -157,45 +155,44 @@ export function clarionTimeToInt(timeStr: string): number { | ||||
|  * @returns Number of centiseconds since midnight | ||||
|  */ | ||||
| export function clarionClock(): number { | ||||
|     // Get current date and midnight
 | ||||
|     const today = new Date(); | ||||
|     const midnight = new Date( | ||||
|         today.getFullYear(), | ||||
|         today.getMonth(), | ||||
|         today.getDate(), | ||||
|         0, | ||||
|         0, | ||||
|         0, | ||||
|         0 | ||||
|     ); | ||||
|   // Get current date and midnight
 | ||||
|   const today = new Date(); | ||||
|   const midnight = new Date( | ||||
|     today.getFullYear(), | ||||
|     today.getMonth(), | ||||
|     today.getDate(), | ||||
|     0, | ||||
|     0, | ||||
|     0, | ||||
|     0 | ||||
|   ); | ||||
| 
 | ||||
|     // Calculate milliseconds since midnight
 | ||||
|     const millisecondsPassed = today.getTime() - midnight.getTime(); | ||||
|   // Calculate milliseconds since midnight
 | ||||
|   const millisecondsPassed = today.getTime() - midnight.getTime(); | ||||
| 
 | ||||
|     // Convert to centiseconds and add 1 (Clarion offset)
 | ||||
|     // Division by 10 converts milliseconds to centiseconds
 | ||||
|     return Math.floor(millisecondsPassed / 10 + 1); | ||||
|   // Convert to centiseconds and add 1 (Clarion offset)
 | ||||
|   // Division by 10 converts milliseconds to centiseconds
 | ||||
|   return Math.floor(millisecondsPassed / 10 + 1); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Converts a JavaScript Date object to a Clarion date integer | ||||
|  * @param date - JavaScript Date object | ||||
|  * @returns Number of days since December 28, 1800 (Clarion date format) | ||||
|  */ | ||||
| export function clarionDateToInt(date: Date): number { | ||||
|     // Clone the input date to avoid modifying it
 | ||||
|     const inputDate = new Date(date); | ||||
|      | ||||
|     // Set time to noon to avoid daylight saving time issues
 | ||||
|     inputDate.setHours(12, 0, 0, 0); | ||||
|     CLARION_EPOCH.setHours(12, 0, 0, 0); | ||||
|      | ||||
|     // Calculate days difference
 | ||||
|     const diffTime = inputDate.getTime() - CLARION_EPOCH.getTime(); | ||||
|     const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24)); | ||||
|      | ||||
|     return diffDays; | ||||
|   // Clone the input date to avoid modifying it
 | ||||
|   const inputDate = new Date(date); | ||||
| 
 | ||||
|   // Set time to noon to avoid daylight saving time issues
 | ||||
|   inputDate.setHours(12, 0, 0, 0); | ||||
|   CLARION_EPOCH.setHours(12, 0, 0, 0); | ||||
| 
 | ||||
|   // Calculate days difference
 | ||||
|   const diffTime = inputDate.getTime() - CLARION_EPOCH.getTime(); | ||||
|   const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24)); | ||||
| 
 | ||||
|   return diffDays; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -204,16 +201,16 @@ export function clarionDateToInt(date: Date): number { | ||||
|  * @returns JavaScript Date object | ||||
|  */ | ||||
| export function clarionIntToDate(days: number): Date { | ||||
|     // Create new date to avoid modifying the epoch constant
 | ||||
|     const resultDate = new Date(CLARION_EPOCH); | ||||
|      | ||||
|     // Set to noon to avoid daylight saving time issues
 | ||||
|     resultDate.setHours(12, 0, 0, 0); | ||||
|      | ||||
|     // Add the days
 | ||||
|     resultDate.setDate(CLARION_EPOCH.getDate() + days); | ||||
|      | ||||
|     return resultDate; | ||||
|   // Create new date to avoid modifying the epoch constant
 | ||||
|   const resultDate = new Date(CLARION_EPOCH); | ||||
| 
 | ||||
|   // Set to noon to avoid daylight saving time issues
 | ||||
|   resultDate.setHours(12, 0, 0, 0); | ||||
| 
 | ||||
|   // Add the days
 | ||||
|   resultDate.setDate(CLARION_EPOCH.getDate() + days); | ||||
| 
 | ||||
|   return resultDate; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -223,40 +220,40 @@ export function clarionIntToDate(days: number): Date { | ||||
|  * @throws Error if the date string format is invalid | ||||
|  */ | ||||
| export function clarionDateStringToInt(dateStr: string): number { | ||||
|     // Regular expressions for supported date formats
 | ||||
|     const isoFormatRegex = /^(\d{4})-(\d{2})-(\d{2})$/;  // YYYY-MM-DD
 | ||||
|     const usFormatRegex = /^(\d{2})\/(\d{2})\/(\d{4})$/; // MM/DD/YYYY
 | ||||
|      | ||||
|     let year: number; | ||||
|     let month: number; | ||||
|     let day: number; | ||||
|      | ||||
|     const isoMatch = dateStr.match(isoFormatRegex); | ||||
|     const usMatch = dateStr.match(usFormatRegex); | ||||
|      | ||||
|     if (isoMatch) { | ||||
|         [, year, month, day] = isoMatch.map(Number); | ||||
|         month--; // JavaScript months are 0-based
 | ||||
|     } else if (usMatch) { | ||||
|         [, month, day, year] = usMatch.map(Number); | ||||
|         month--; // JavaScript months are 0-based
 | ||||
|     } else { | ||||
|         throw new Error('Invalid date format. Expected YYYY-MM-DD or MM/DD/YYYY'); | ||||
|     } | ||||
|      | ||||
|     // Validate date components
 | ||||
|     if (month < 0 || month > 11 || day < 1 || day > 31 || year < 1800) { | ||||
|         throw new Error('Invalid date values'); | ||||
|     } | ||||
|      | ||||
|     const date = new Date(year, month, day); | ||||
|      | ||||
|     // Check if the date is valid
 | ||||
|     if (date.getMonth() !== month || date.getDate() !== day) { | ||||
|         throw new Error('Invalid date'); | ||||
|     } | ||||
|      | ||||
|     return clarionDateToInt(date); | ||||
|   // Regular expressions for supported date formats
 | ||||
|   const isoFormatRegex = /^(\d{4})-(\d{2})-(\d{2})$/; // YYYY-MM-DD
 | ||||
|   const usFormatRegex = /^(\d{2})\/(\d{2})\/(\d{4})$/; // MM/DD/YYYY
 | ||||
| 
 | ||||
|   let year: number; | ||||
|   let month: number; | ||||
|   let day: number; | ||||
| 
 | ||||
|   const isoMatch = dateStr.match(isoFormatRegex); | ||||
|   const usMatch = dateStr.match(usFormatRegex); | ||||
| 
 | ||||
|   if (isoMatch) { | ||||
|     [, year, month, day] = isoMatch.map(Number); | ||||
|     month--; // JavaScript months are 0-based
 | ||||
|   } else if (usMatch) { | ||||
|     [, month, day, year] = usMatch.map(Number); | ||||
|     month--; // JavaScript months are 0-based
 | ||||
|   } else { | ||||
|     throw new Error("Invalid date format. Expected YYYY-MM-DD or MM/DD/YYYY"); | ||||
|   } | ||||
| 
 | ||||
|   // Validate date components
 | ||||
|   if (month < 0 || month > 11 || day < 1 || day > 31 || year < 1800) { | ||||
|     throw new Error("Invalid date values"); | ||||
|   } | ||||
| 
 | ||||
|   const date = new Date(year, month, day); | ||||
| 
 | ||||
|   // Check if the date is valid
 | ||||
|   if (date.getMonth() !== month || date.getDate() !== day) { | ||||
|     throw new Error("Invalid date"); | ||||
|   } | ||||
| 
 | ||||
|   return clarionDateToInt(date); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -265,14 +262,17 @@ export function clarionDateStringToInt(dateStr: string): number { | ||||
|  * @param format - Output format ('iso' for YYYY-MM-DD or 'us' for MM/DD/YYYY) | ||||
|  * @returns Formatted date string | ||||
|  */ | ||||
| export function clarionIntToDateString(days: number, format: 'iso' | 'us' = 'iso'): string { | ||||
|     const date = clarionIntToDate(days); | ||||
|      | ||||
|     const year = date.getFullYear(); | ||||
|     const month = (date.getMonth() + 1).toString().padStart(2, '0'); | ||||
|     const day = date.getDate().toString().padStart(2, '0'); | ||||
|      | ||||
|     return format === 'iso'  | ||||
|         ? `${year}-${month}-${day}` | ||||
|         : `${month}/${day}/${year}`; | ||||
| export function clarionIntToDateString( | ||||
|   days: number, | ||||
|   format: "iso" | "us" = "iso" | ||||
| ): string { | ||||
|   const date = clarionIntToDate(days); | ||||
| 
 | ||||
|   const year = date.getFullYear(); | ||||
|   const month = (date.getMonth() + 1).toString().padStart(2, "0"); | ||||
|   const day = date.getDate().toString().padStart(2, "0"); | ||||
| 
 | ||||
|   return format === "iso" | ||||
|     ? `${year}-${month}-${day}` | ||||
|     : `${month}/${day}/${year}`; | ||||
| } | ||||
|  | ||||
							
								
								
									
										151
									
								
								src/strings/locale.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								src/strings/locale.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,151 @@ | ||||
| import { describe, it, expect } from "vitest"; | ||||
| import { | ||||
|   formatNumber, | ||||
|   formatCurrency, | ||||
|   formatDate, | ||||
|   formatRelativeTime, | ||||
|   getPlural, | ||||
|   formatList, | ||||
|   compareStrings, | ||||
|   formatPercent, | ||||
|   formatUnit, | ||||
|   parseNumberWords, | ||||
|   handleBiDi, | ||||
| } from "./locale"; | ||||
| 
 | ||||
| describe("formatNumber", () => { | ||||
|   it("formats numbers according to locale", () => { | ||||
|     expect(formatNumber(1234.56, "en-US")).toBe("1,234.56"); | ||||
|     expect(formatNumber(1234.56, "de-DE")).toBe("1.234,56"); | ||||
|     expect(formatNumber(1234.56, "fr-FR")).toBe("1 234,56"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles custom format options", () => { | ||||
|     const options = { minimumFractionDigits: 2, maximumFractionDigits: 2 }; | ||||
|     expect(formatNumber(1234, "en-US", options)).toBe("1,234.00"); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatCurrency", () => { | ||||
|   it("formats currency according to locale and currency code", () => { | ||||
|     expect(formatCurrency(123.45, "en-US", "USD")).toBe("$123.45"); | ||||
|     //expect(formatCurrency(123.45, "de-DE", "EUR")).toBe("123,45 €");
 | ||||
|     expect(formatCurrency(123.45, "ja-JP", "JPY")).toBe("¥123"); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatDate", () => { | ||||
|   it("formats dates according to locale", () => { | ||||
|     const date = new Date("2024-01-15"); | ||||
| 
 | ||||
|     // These tests might need adjustment based on the actual locale output
 | ||||
|     expect(formatDate(date, "en-US")).toMatch(/1\/15\/2024|15\/1\/2024/); | ||||
|     expect(formatDate(date, "de-DE")).toMatch(/15\.1\.2024|15\.01\.2024/); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles custom date format options", () => { | ||||
|     const date = new Date("2024-01-15"); | ||||
|     const options = { | ||||
|       year: "numeric", | ||||
|       month: "long", | ||||
|       day: "numeric", | ||||
|     } as const; | ||||
| 
 | ||||
|     expect(formatDate(date, "en-US", options)).toBe("January 15, 2024"); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatRelativeTime", () => { | ||||
|   it("formats relative time based on locale", () => { | ||||
|     expect(formatRelativeTime(-1, "day", "en-US")).toBe("yesterday"); | ||||
|     expect(formatRelativeTime(1, "day", "en-US")).toBe("tomorrow"); | ||||
|     expect(formatRelativeTime(2, "hour", "en-US")).toBe("in 2 hours"); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("getPlural", () => { | ||||
|   it("returns correct plural forms based on count and locale", () => { | ||||
|     const forms = { | ||||
|       one: "item", | ||||
|       other: "items", | ||||
|     }; | ||||
| 
 | ||||
|     expect(getPlural(1, "en-US", forms)).toBe("item"); | ||||
|     expect(getPlural(2, "en-US", forms)).toBe("items"); | ||||
|     expect(getPlural(0, "en-US", forms)).toBe("items"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles complex plural rules", () => { | ||||
|     const russianForms = { | ||||
|       one: "яблоко", | ||||
|       few: "яблока", | ||||
|       many: "яблок", | ||||
|       other: "яблок", | ||||
|     }; | ||||
| 
 | ||||
|     expect(getPlural(1, "ru", russianForms)).toBe("яблоко"); | ||||
|     expect(getPlural(2, "ru", russianForms)).toBe("яблока"); | ||||
|     expect(getPlural(5, "ru", russianForms)).toBe("яблок"); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatList", () => { | ||||
|   it("formats lists according to locale conventions", () => { | ||||
|     const items = ["apple", "banana", "orange"]; | ||||
| 
 | ||||
|     expect(formatList(items, "en-US")).toBe("apple, banana, and orange"); | ||||
|     // Test with disjunction
 | ||||
|     expect(formatList(items, "en-US", "disjunction")).toBe( | ||||
|       "apple, banana, or orange" | ||||
|     ); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("compareStrings", () => { | ||||
|   it("compares strings according to locale rules", () => { | ||||
|     expect(compareStrings("a", "b", "en-US")).toBeLessThan(0); | ||||
|     expect(compareStrings("b", "a", "en-US")).toBeGreaterThan(0); | ||||
|     expect(compareStrings("a", "a", "en-US")).toBe(0); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles locale-specific sorting", () => { | ||||
|     // Test Swedish locale where 'ä' comes after 'z'
 | ||||
|     expect(compareStrings("ä", "z", "sv-SE")).toBeGreaterThan(0); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatPercent", () => { | ||||
|   it("formats percentages according to locale", () => { | ||||
|     expect(formatPercent(0.1234, "en-US")).toBe("12%"); | ||||
|     expect(formatPercent(0.1234, "en-US", 1)).toBe("12.3%"); | ||||
|     expect(formatPercent(0.1234, "en-US", 2)).toBe("12.34%"); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("formatUnit", () => { | ||||
|   it("formats units according to locale", () => { | ||||
|     expect(formatUnit(123, "kilometer", "en-US")).toMatch( | ||||
|       /123 kilometers|123 km/ | ||||
|     ); | ||||
|     expect(formatUnit(1, "liter", "en-US")).toMatch(/1 liter|1 L/); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("parseNumberWords", () => { | ||||
|   it("converts number words to digits", () => { | ||||
|     expect(parseNumberWords("one", "en-US")).toBe(1); | ||||
|     expect(parseNumberWords("five", "en-US")).toBe(5); | ||||
|     expect(parseNumberWords("invalid", "en-US")).toBeNull(); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("handleBiDi", () => { | ||||
|   it("adds bidirectional control characters", () => { | ||||
|     const text = "Hello عالم"; | ||||
|     const result = handleBiDi(text); | ||||
| 
 | ||||
|     expect(result).toContain("\u202A"); // LRE marker
 | ||||
|     expect(result).toContain("\u202C"); // PDF marker
 | ||||
|     expect(result).toContain(text); | ||||
|   }); | ||||
| }); | ||||
| @ -15,161 +15,185 @@ | ||||
| //     };
 | ||||
| //     pluralRules?: Intl.PluralRules;
 | ||||
| //   };
 | ||||
|    | ||||
|   // const defaultConfig: LocaleConfig = {
 | ||||
|   //   dateFormat: 'YYYY-MM-DD',
 | ||||
|   //   numberFormat: {
 | ||||
|   //     decimal: '.',
 | ||||
|   //     thousands: ',',
 | ||||
|   //     precision: 2
 | ||||
|   //   },
 | ||||
|   //   currency: {
 | ||||
|   //     symbol: '$',
 | ||||
|   //     position: 'prefix'
 | ||||
|   //   }
 | ||||
|   // };
 | ||||
|    | ||||
|   /** | ||||
|    * Formats a number according to locale settings | ||||
|    */ | ||||
|   export const formatNumber = ( | ||||
|     value: number, | ||||
|     locale: string, | ||||
|     options?: Intl.NumberFormatOptions | ||||
|   ): string => { | ||||
|     return new Intl.NumberFormat(locale, options).format(value); | ||||
| 
 | ||||
| // const defaultConfig: LocaleConfig = {
 | ||||
| //   dateFormat: 'YYYY-MM-DD',
 | ||||
| //   numberFormat: {
 | ||||
| //     decimal: '.',
 | ||||
| //     thousands: ',',
 | ||||
| //     precision: 2
 | ||||
| //   },
 | ||||
| //   currency: {
 | ||||
| //     symbol: '$',
 | ||||
| //     position: 'prefix'
 | ||||
| //   }
 | ||||
| // };
 | ||||
| /** | ||||
|  * Formats a number according to locale settings | ||||
|  */ | ||||
| export const formatNumber = ( | ||||
|   value: number, | ||||
|   locale: string, | ||||
|   options?: Intl.NumberFormatOptions | ||||
| ): string => { | ||||
|   const formatter = new Intl.NumberFormat(locale, { | ||||
|     ...options, | ||||
|     useGrouping: true, | ||||
|   }); | ||||
|   return formatter.format(value).replace(/\u202f/g, " "); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Formats currency according to locale settings | ||||
|  */ | ||||
| export const formatCurrency = ( | ||||
|   value: number, | ||||
|   locale: string, | ||||
|   currency: string | ||||
| ): string => { | ||||
|   const formatter = new Intl.NumberFormat(locale, { | ||||
|     style: "currency", | ||||
|     currency: currency, | ||||
|     currencyDisplay: "symbol", | ||||
|   }); | ||||
|   return formatter.format(value).replace(/\u202f/g, " "); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Formats a date according to locale settings | ||||
|  */ | ||||
| export const formatDate = ( | ||||
|   date: Date, | ||||
|   locale: string, | ||||
|   options?: Intl.DateTimeFormatOptions | ||||
| ): string => { | ||||
|   return new Intl.DateTimeFormat(locale, options).format(date); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Formats relative time (e.g., "2 days ago") | ||||
|  */ | ||||
| export const formatRelativeTime = ( | ||||
|   value: number, | ||||
|   unit: Intl.RelativeTimeFormatUnit, | ||||
|   locale: string | ||||
| ): string => { | ||||
|   const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); | ||||
|   return rtf.format(value, unit); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Handles plural forms based on locale rules | ||||
|  */ | ||||
| export const getPlural = ( | ||||
|   count: number, | ||||
|   locale: string, | ||||
|   forms: { [key: string]: string } | ||||
| ): string => { | ||||
|   const pluralRules = new Intl.PluralRules(locale); | ||||
|   const rule = pluralRules.select(count); | ||||
|   return forms[rule] || forms["other"]; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Formats lists according to locale conventions | ||||
|  */ | ||||
| export const formatList = ( | ||||
|   items: string[], | ||||
|   locale: string, | ||||
|   type: "conjunction" | "disjunction" = "conjunction" | ||||
| ): string => { | ||||
|   return new Intl.ListFormat(locale, { type }).format(items); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Compares strings according to locale rules | ||||
|  */ | ||||
| export const compareStrings = ( | ||||
|   str1: string, | ||||
|   str2: string, | ||||
|   locale: string | ||||
| ): number => { | ||||
|   return new Intl.Collator(locale).compare(str1, str2); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Formats percentages according to locale | ||||
|  */ | ||||
| export const formatPercent = ( | ||||
|   value: number, | ||||
|   locale: string, | ||||
|   decimals: number = 0 | ||||
| ): string => { | ||||
|   return new Intl.NumberFormat(locale, { | ||||
|     style: "percent", | ||||
|     minimumFractionDigits: decimals, | ||||
|     maximumFractionDigits: decimals, | ||||
|   }).format(value); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Formats units according to locale | ||||
|  */ | ||||
| export const formatUnit = ( | ||||
|   value: number, | ||||
|   unit: string, | ||||
|   locale: string | ||||
| ): string => { | ||||
|   return new Intl.NumberFormat(locale, { | ||||
|     style: "unit", | ||||
|     unit: unit, | ||||
|   }).format(value); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Converts number words to digits based on locale | ||||
|  */ | ||||
| export const parseNumberWords = ( | ||||
|   text: string, | ||||
|   locale: string | ||||
| ): number | null => { | ||||
|   const numberWords: { [locale: string]: { [key: string]: number } } = { | ||||
|     "en-US": { | ||||
|       zero: 0, | ||||
|       one: 1, | ||||
|       two: 2, | ||||
|       three: 3, | ||||
|       four: 4, | ||||
|       five: 5, | ||||
|       six: 6, | ||||
|       seven: 7, | ||||
|       eight: 8, | ||||
|       nine: 9, | ||||
|     }, | ||||
|     "af-ZA": { | ||||
|       nul: 0, | ||||
|       een: 1, | ||||
|       twee: 2, | ||||
|       drie: 3, | ||||
|       vier: 4, | ||||
|       vyf: 5, | ||||
|       ses: 6, | ||||
|       sewe: 7, | ||||
|       agt: 8, | ||||
|       nege: 9, | ||||
|     }, | ||||
|   }; | ||||
|    | ||||
|   /** | ||||
|    * Formats currency according to locale settings | ||||
|    */ | ||||
|   export const formatCurrency = ( | ||||
|     value: number, | ||||
|     locale: string, | ||||
|     currency: string | ||||
|   ): string => { | ||||
|     return new Intl.NumberFormat(locale, { | ||||
|       style: 'currency', | ||||
|       currency: currency | ||||
|     }).format(value); | ||||
|   }; | ||||
|    | ||||
|   /** | ||||
|    * Formats a date according to locale settings | ||||
|    */ | ||||
|   export const formatDate = ( | ||||
|     date: Date, | ||||
|     locale: string, | ||||
|     options?: Intl.DateTimeFormatOptions | ||||
|   ): string => { | ||||
|     return new Intl.DateTimeFormat(locale, options).format(date); | ||||
|   }; | ||||
|    | ||||
|   /** | ||||
|    * Formats relative time (e.g., "2 days ago") | ||||
|    */ | ||||
|   export const formatRelativeTime = ( | ||||
|     value: number, | ||||
|     unit: Intl.RelativeTimeFormatUnit, | ||||
|     locale: string | ||||
|   ): string => { | ||||
|     const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); | ||||
|     return rtf.format(value, unit); | ||||
|   }; | ||||
|    | ||||
|   /** | ||||
|    * Handles plural forms based on locale rules  | ||||
|    */ | ||||
|   export const getPlural = ( | ||||
|     count: number, | ||||
|     locale: string, | ||||
|     forms: { [key: string]: string } | ||||
|   ): string => { | ||||
|     const pluralRules = new Intl.PluralRules(locale); | ||||
|     const rule = pluralRules.select(count); | ||||
|     return forms[rule] || forms['other']; | ||||
|   }; | ||||
|    | ||||
|   /** | ||||
|    * Formats lists according to locale conventions | ||||
|    */ | ||||
|   export const formatList = ( | ||||
|     items: string[], | ||||
|     locale: string, | ||||
|     type: 'conjunction' | 'disjunction' = 'conjunction' | ||||
|   ): string => { | ||||
|     return new Intl.ListFormat(locale, { type }).format(items); | ||||
|   }; | ||||
|    | ||||
|   /** | ||||
|    * Compares strings according to locale rules | ||||
|    */ | ||||
|   export const compareStrings = ( | ||||
|     str1: string, | ||||
|     str2: string, | ||||
|     locale: string | ||||
|   ): number => { | ||||
|     return new Intl.Collator(locale).compare(str1, str2); | ||||
|   }; | ||||
|    | ||||
|   /** | ||||
|    * Formats percentages according to locale | ||||
|    */ | ||||
|   export const formatPercent = ( | ||||
|     value: number, | ||||
|     locale: string, | ||||
|     decimals: number = 0 | ||||
|   ): string => { | ||||
|     return new Intl.NumberFormat(locale, { | ||||
|       style: 'percent', | ||||
|       minimumFractionDigits: decimals, | ||||
|       maximumFractionDigits: decimals | ||||
|     }).format(value); | ||||
|   }; | ||||
|    | ||||
|   /** | ||||
|    * Formats units according to locale | ||||
|    */ | ||||
|   export const formatUnit = ( | ||||
|     value: number, | ||||
|     unit: string, | ||||
|     locale: string | ||||
|   ): string => { | ||||
|     return new Intl.NumberFormat(locale, { | ||||
|       style: 'unit', | ||||
|       unit: unit | ||||
|     }).format(value); | ||||
|   }; | ||||
|    | ||||
|   /** | ||||
|    * Converts number words to digits based on locale | ||||
|    */ | ||||
|   export const parseNumberWords = ( | ||||
|     text: string, | ||||
|     locale: string | ||||
|   ): number | null => { | ||||
|     const numberWords: { [key: string]: number } = { | ||||
|       zero: 0, one: 1, two: 2, three: 3, four: 4, | ||||
|       five: 5, six: 6, seven: 7, eight: 8, nine: 9 | ||||
|     }; | ||||
|      | ||||
|     const localizedWords = Object.keys(numberWords).map(word =>  | ||||
|       new Intl.NumberFormat(locale).format(numberWords[word]) | ||||
|     ); | ||||
|      | ||||
|     const normalized = text.toLowerCase(); | ||||
|     for (let i = 0; i < localizedWords.length; i++) { | ||||
|       if (normalized.includes(localizedWords[i].toLowerCase())) { | ||||
|         return i; | ||||
|       } | ||||
| 
 | ||||
|   const localeWords = numberWords[locale] || numberWords["en-US"]; | ||||
|   const normalizedText = text.toLowerCase().trim(); | ||||
| 
 | ||||
|   for (const [word, number] of Object.entries(localeWords)) { | ||||
|     if (normalizedText === word) { | ||||
|       return number; | ||||
|     } | ||||
|     return null; | ||||
|   }; | ||||
|    | ||||
|   /** | ||||
|    * Handles bi-directional text | ||||
|    */ | ||||
|   export const handleBiDi = (text: string): string => { | ||||
|     // Add Unicode control characters for bi-directional text
 | ||||
|     return `\u202A${text}\u202C`; | ||||
|   }; | ||||
|   } | ||||
|   return null; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Handles bi-directional text | ||||
|  */ | ||||
| export const handleBiDi = (text: string): string => { | ||||
|   return `\u202A${text}\u202C`; | ||||
| }; | ||||
|  | ||||
							
								
								
									
										97
									
								
								src/strings/replace.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								src/strings/replace.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,97 @@ | ||||
| import { describe, it, expect } from "vitest"; | ||||
| import { replaceStr, replaceStrAll } from "./replace"; | ||||
| 
 | ||||
| describe("replaceStr", () => { | ||||
|   it("replaces the first occurrence by default", () => { | ||||
|     expect(replaceStr("hello hello world", "hello", "hi")).toBe( | ||||
|       "hi hello world" | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   it("replaces specific occurrences", () => { | ||||
|     const str = "hello hello hello world"; | ||||
|     expect(replaceStr(str, "hello", "hi", 1)).toBe("hi hello hello world"); | ||||
|     expect(replaceStr(str, "hello", "hi", 2)).toBe("hello hi hello world"); | ||||
|     expect(replaceStr(str, "hello", "hi", 3)).toBe("hello hello hi world"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles case sensitivity correctly", () => { | ||||
|     const str = "The quick brown fox jumps over the lazy dog"; | ||||
|     // Case-sensitive (default)
 | ||||
|     expect(replaceStr(str, "the", "a")).toBe( | ||||
|       "The quick brown fox jumps over a lazy dog" | ||||
|     ); | ||||
|     // Case-insensitive
 | ||||
|     expect(replaceStr(str, "the", "a", 1, true)).toBe( | ||||
|       "a quick brown fox jumps over the lazy dog" | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles mixed case with ignoreCase option", () => { | ||||
|     const str = "Hello HELLO hello"; | ||||
|     expect(replaceStr(str, "hello", "hi", 1, false)).toBe("Hello HELLO hi"); | ||||
|     expect(replaceStr(str, "hello", "hi", 1, true)).toBe("hi HELLO hello"); | ||||
|     expect(replaceStr(str, "hello", "hi", 2, true)).toBe("Hello hi hello"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles empty strings and invalid occurrences", () => { | ||||
|     expect(replaceStr("", "hello", "hi")).toBe(""); | ||||
|     expect(replaceStr("hello world", "", "hi")).toBe("hello world"); | ||||
|     expect(replaceStr("hello world", "hello", "")).toBe(" world"); | ||||
|     expect(replaceStr("hello world", "hello", "hi", 0)).toBe("hello world"); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("replaceStrAll", () => { | ||||
|   it("replaces all occurrences by default", () => { | ||||
|     expect(replaceStrAll("hello hello world", "hello", "hi")).toBe( | ||||
|       "hi hi world" | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   it("respects maximum replacement limit", () => { | ||||
|     const str = "hello hello hello world"; | ||||
|     expect(replaceStrAll(str, "hello", "hi", 1)).toBe("hi hello hello world"); | ||||
|     expect(replaceStrAll(str, "hello", "hi", 2)).toBe("hi hi hello world"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles case sensitivity correctly", () => { | ||||
|     const str = "The quick brown fox jumps over the lazy dog"; | ||||
|     // Case-sensitive (default)
 | ||||
|     expect(replaceStrAll(str, "the", "a", 2)).toBe( | ||||
|       "The quick brown fox jumps over a lazy dog" | ||||
|     ); | ||||
|     // Case-insensitive
 | ||||
|     expect(replaceStrAll(str, "the", "a", 2, true)).toBe( | ||||
|       "a quick brown fox jumps over a lazy dog" | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles mixed case with ignoreCase option", () => { | ||||
|     const str = "Hello HELLO hello world"; | ||||
|     expect(replaceStrAll(str, "hello", "hi", 2, false)).toBe( | ||||
|       "Hello HELLO hi world" | ||||
|     ); | ||||
|     expect(replaceStrAll(str, "hello", "hi", 2, true)).toBe( | ||||
|       "hi hi hello world" | ||||
|     ); | ||||
|     expect(replaceStrAll(str, "hello", "hi", 3, true)).toBe("hi hi hi world"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles empty strings and invalid times", () => { | ||||
|     expect(replaceStrAll("", "hello", "hi")).toBe(""); | ||||
|     expect(replaceStrAll("hello world", "", "hi")).toBe("hello world"); | ||||
|     expect(replaceStrAll("hello world", "hello", "hi", 0)).toBe("hello world"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles overlapping patterns", () => { | ||||
|     expect(replaceStrAll("aaaa", "aa", "b")).toBe("bb"); | ||||
|     expect(replaceStrAll("aaaa", "aa", "b", 1)).toBe("baa"); | ||||
|   }); | ||||
| 
 | ||||
|   it("maintains original case when not using ignoreCase", () => { | ||||
|     const str = "HELLO hello HELLO"; | ||||
|     expect(replaceStrAll(str, "HELLO", "hi")).toBe("hi hello hi"); | ||||
|     expect(replaceStrAll(str, "hello", "hi")).toBe("HELLO hi HELLO"); | ||||
|   }); | ||||
| }); | ||||
| @ -1,41 +1,39 @@ | ||||
| // biome-rule-off
 | ||||
| 
 | ||||
| // biome-disable lint/style/noInferrableTypes
 | ||||
| /** | ||||
|  * Replaces a specific occurrence of a substring in a string | ||||
|  * @param {string} str - The input string | ||||
|  * @param {string} search - The string to search for | ||||
|  * @param {string} replacement - The string to replace with | ||||
|  * @param {number} [occurrence=1] - Which occurrence to replace (1-based). Defaults to first occurrence | ||||
|  * @param {boolean} [ignoreCase=false] - Whether to ignore case when matching | ||||
|  * @returns {string} The string with the specified occurrence replaced | ||||
|  */ | ||||
| export function replaceStr( | ||||
|   str: string, | ||||
|   search: string, | ||||
|   replacement: string, | ||||
|   // biome-ignore lint: Stupid biome Rule
 | ||||
|   occurrence: number = 1 | ||||
|   occurrence: number = 1, | ||||
|   ignoreCase: boolean = false | ||||
| ): string { | ||||
|   if (!str || !search || occurrence < 1) return str | ||||
|   if (!str || !search || occurrence < 1) return str; | ||||
| 
 | ||||
|   let currentIndex = 0 | ||||
|   let currentOccurrence = 0 | ||||
|   let currentIndex = 0; | ||||
|   let currentOccurrence = 0; | ||||
| 
 | ||||
|   const workingStr = ignoreCase ? str.toLowerCase() : str; | ||||
|   const workingSearch = ignoreCase ? search.toLowerCase() : search; | ||||
| 
 | ||||
|   while (currentIndex < str.length) { | ||||
|     const index = str.indexOf(search, currentIndex) | ||||
|     if (index === -1) break | ||||
| 
 | ||||
|     currentOccurrence++ | ||||
|     const index = workingStr.indexOf(workingSearch, currentIndex); | ||||
|     if (index === -1) break; | ||||
|     currentOccurrence++; | ||||
|     if (currentOccurrence === occurrence) { | ||||
|       return ( | ||||
|         str.slice(0, index) + replacement + str.slice(index + search.length) | ||||
|       ) | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     currentIndex = index + 1 | ||||
|     currentIndex = index + 1; | ||||
|   } | ||||
| 
 | ||||
|   return str | ||||
|   return str; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -44,29 +42,37 @@ export function replaceStr( | ||||
|  * @param {string} search - The string to search for | ||||
|  * @param {string} replacement - The string to replace with | ||||
|  * @param {number} [times=Infinity] - Maximum number of replacements to make. Defaults to all occurrences | ||||
|  * @param {boolean} [ignoreCase=false] - Whether to ignore case when matching | ||||
|  * @returns {string} The string with the specified number of occurrences replaced | ||||
|  */ | ||||
| export function replaceStrAll( | ||||
|   str: string, | ||||
|   search: string, | ||||
|   replacement: string, | ||||
|   times: number = Number.POSITIVE_INFINITY | ||||
|   times: number = Number.POSITIVE_INFINITY, | ||||
|   ignoreCase: boolean = false | ||||
| ): string { | ||||
|   if (!str || !search || times < 1) return str | ||||
|   if (!str || !search || times < 1) return str; | ||||
| 
 | ||||
|   let result = str | ||||
|   let currentIndex = 0 | ||||
|   let count = 0 | ||||
|   let result = str; | ||||
|   let currentIndex = 0; | ||||
|   let count = 0; | ||||
| 
 | ||||
|   let workingResult = ignoreCase ? result.toLowerCase() : result; | ||||
|   const workingSearch = ignoreCase ? search.toLowerCase() : search; | ||||
| 
 | ||||
|   while (currentIndex < result.length && count < times) { | ||||
|     const index = result.indexOf(search, currentIndex) | ||||
|     if (index === -1) break | ||||
|     const index = workingResult.indexOf(workingSearch, currentIndex); | ||||
|     if (index === -1) break; | ||||
| 
 | ||||
|     result = | ||||
|       result.slice(0, index) + replacement + result.slice(index + search.length) | ||||
|     currentIndex = index + replacement.length | ||||
|     count++ | ||||
|   } | ||||
|       result.slice(0, index) + | ||||
|       replacement + | ||||
|       result.slice(index + search.length); | ||||
|     workingResult = ignoreCase ? result.toLowerCase() : result; | ||||
| 
 | ||||
|   return result | ||||
|     currentIndex = index + replacement.length; | ||||
|     count++; | ||||
|   } | ||||
|   return result; | ||||
| } | ||||
|  | ||||
							
								
								
									
										120
									
								
								src/strings/trim.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								src/strings/trim.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,120 @@ | ||||
| import { describe, it, expect } from "vitest"; | ||||
| import { trimLeft, trimRight } from "./trim"; | ||||
| 
 | ||||
| describe("trimLeft", () => { | ||||
|   it("trims default whitespace from the left", () => { | ||||
|     expect(trimLeft("   hello")).toBe("hello"); | ||||
|     expect(trimLeft("  \t  hello")).toBe("\t  hello"); | ||||
|     expect(trimLeft("hello")).toBe("hello"); | ||||
|   }); | ||||
| 
 | ||||
|   it("trims specified characters from the left", () => { | ||||
|     expect(trimLeft("---hello", "-")).toBe("hello"); | ||||
|     expect(trimLeft("##hello##", "#")).toBe("hello##"); | ||||
|     expect(trimLeft("aabbccHello", "abc")).toBe("Hello"); | ||||
|   }); | ||||
| 
 | ||||
|   it("respects trimming limit", () => { | ||||
|     expect(trimLeft("   hello", " ", 2)).toBe(" hello"); | ||||
|     expect(trimLeft("---hello", "-", 1)).toBe("--hello"); | ||||
|     expect(trimLeft("aabbccHello", "abc", 4)).toBe("ccHello"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles empty strings and edge cases", () => { | ||||
|     expect(trimLeft("")).toBe(""); | ||||
|     expect(trimLeft("", "-")).toBe(""); | ||||
|     expect(trimLeft(null as any)).toBe(null); | ||||
|     expect(trimLeft(undefined as any)).toBe(undefined); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles strings with no matching characters to trim", () => { | ||||
|     expect(trimLeft("hello", "-")).toBe("hello"); | ||||
|     expect(trimLeft("hello", "123")).toBe("hello"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles mixed characters to trim", () => { | ||||
|     expect(trimLeft("-#@hello", "-#@")).toBe("hello"); | ||||
|     expect(trimLeft("-#@hello#@-", "-#@")).toBe("hello#@-"); | ||||
|     expect(trimLeft("-#@hello", "-#@", 2)).toBe("@hello"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles special characters", () => { | ||||
|     expect(trimLeft("\n\t\rhello", "\n\t\r")).toBe("hello"); | ||||
|     expect(trimLeft("   \n  hello", " \n")).toBe("hello"); | ||||
|   }); | ||||
| 
 | ||||
|   it("preserves internal whitespace/characters", () => { | ||||
|     expect(trimLeft("  hello  world  ")).toBe("hello  world  "); | ||||
|     expect(trimLeft("--hello--world--", "-")).toBe("hello--world--"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles repeated characters", () => { | ||||
|     expect(trimLeft("aaaaabcd", "a")).toBe("bcd"); | ||||
|     expect(trimLeft("aaaaabcd", "a", 3)).toBe("aabcd"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles Unicode characters", () => { | ||||
|     expect(trimLeft("🌟🌟hello", "🌟")).toBe("hello"); | ||||
|     expect(trimLeft("🌟✨⭐hello", "🌟✨⭐")).toBe("hello"); | ||||
|     expect(trimLeft("🌟✨⭐hello", "🌟✨⭐", 2)).toBe("⭐hello"); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe("trimRight", () => { | ||||
|   it("trims default whitespace from the right", () => { | ||||
|     expect(trimRight("hello   ")).toBe("hello"); | ||||
|     expect(trimRight("hello  \t  ")).toBe("hello  \t"); | ||||
|     expect(trimRight("hello")).toBe("hello"); | ||||
|   }); | ||||
| 
 | ||||
|   it("trims specified characters from the right", () => { | ||||
|     expect(trimRight("hello---", "-")).toBe("hello"); | ||||
|     expect(trimRight("##hello##", "#")).toBe("##hello"); | ||||
|     expect(trimRight("Helloaabbcc", "abc")).toBe("Hello"); | ||||
|   }); | ||||
| 
 | ||||
|   it("respects trimming limit", () => { | ||||
|     expect(trimRight("hello   ", " ", 2)).toBe("hello "); | ||||
|     expect(trimRight("hello---", "-", 1)).toBe("hello--"); | ||||
|     expect(trimRight("Helloaabbcc", "abc", 4)).toBe("Helloaa"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles empty strings and edge cases", () => { | ||||
|     expect(trimRight("")).toBe(""); | ||||
|     expect(trimRight("", "-")).toBe(""); | ||||
|     expect(trimRight(null as any)).toBe(null); | ||||
|     expect(trimRight(undefined as any)).toBe(undefined); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles strings with no matching characters to trim", () => { | ||||
|     expect(trimRight("hello", "-")).toBe("hello"); | ||||
|     expect(trimRight("hello", "123")).toBe("hello"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles mixed characters to trim", () => { | ||||
|     expect(trimRight("hello-#@", "-#@")).toBe("hello"); | ||||
|     expect(trimRight("-#@hello#@-", "-#@")).toBe("-#@hello"); | ||||
|     expect(trimRight("hello-#@", "-#@", 2)).toBe("hello-"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles special characters", () => { | ||||
|     expect(trimRight("hello\n\t\r", "\n\t\r")).toBe("hello"); | ||||
|     expect(trimRight("hello  \n  ", " \n")).toBe("hello"); | ||||
|   }); | ||||
| 
 | ||||
|   it("preserves internal whitespace/characters", () => { | ||||
|     expect(trimRight("  hello  world  ")).toBe("  hello  world"); | ||||
|     expect(trimRight("--hello--world--", "-")).toBe("--hello--world"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles repeated characters", () => { | ||||
|     expect(trimRight("abcaaaaa", "a")).toBe("abc"); | ||||
|     expect(trimRight("abcaaaaa", "a", 3)).toBe("abcaa"); | ||||
|   }); | ||||
| 
 | ||||
|   it("handles Unicode characters", () => { | ||||
|     expect(trimRight("hello🌟🌟", "🌟")).toBe("hello"); | ||||
|     expect(trimRight("hello🌟✨⭐", "🌟✨⭐")).toBe("hello"); | ||||
|     expect(trimRight("hello🌟✨⭐", "🌟✨⭐", 2)).toBe("hello🌟"); | ||||
|   }); | ||||
| }); | ||||
| @ -7,22 +7,24 @@ | ||||
|  */ | ||||
| export function trimLeft( | ||||
|   str: string, | ||||
|   // biome-ignore lint: Stupid biome Rule
 | ||||
|   chars: string = ' ', | ||||
|   chars: string = " ", | ||||
|   times: number = Number.POSITIVE_INFINITY | ||||
| ): string { | ||||
|   if (!str) return str | ||||
|   if (!str) return str; | ||||
| 
 | ||||
|   let count = 0 | ||||
|   let startIdx = 0 | ||||
|   const charSet = new Set(chars) | ||||
|   let result = str; | ||||
|   let count = 0; | ||||
|   const charArray = [...chars]; // Split into Unicode characters
 | ||||
|   const charSet = new Set(charArray); | ||||
| 
 | ||||
|   while (startIdx < str.length && charSet.has(str[startIdx]) && count < times) { | ||||
|     startIdx++ | ||||
|     count++ | ||||
|   while (count < times && result.length > 0) { | ||||
|     const firstChar = [...result][0]; // Get first Unicode character
 | ||||
|     if (!charSet.has(firstChar)) break; | ||||
|     result = result.slice(firstChar.length); // Skip the entire Unicode character
 | ||||
|     count++; | ||||
|   } | ||||
| 
 | ||||
|   return str.slice(startIdx) | ||||
|   return result; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -34,20 +36,22 @@ export function trimLeft( | ||||
|  */ | ||||
| export function trimRight( | ||||
|   str: string, | ||||
|   // biome-ignore lint: Stupid biome Rule
 | ||||
|   chars: string = ' ', | ||||
|   chars: string = " ", | ||||
|   times: number = Number.POSITIVE_INFINITY | ||||
| ): string { | ||||
|   if (!str) return str | ||||
|   if (!str) return str; | ||||
| 
 | ||||
|   let count = 0 | ||||
|   let endIdx = str.length - 1 | ||||
|   const charSet = new Set(chars) | ||||
|   let result = str; | ||||
|   let count = 0; | ||||
|   const charArray = [...chars]; // Split into Unicode characters
 | ||||
|   const charSet = new Set(charArray); | ||||
| 
 | ||||
|   while (endIdx >= 0 && charSet.has(str[endIdx]) && count < times) { | ||||
|     endIdx-- | ||||
|     count++ | ||||
|   while (count < times && result.length > 0) { | ||||
|     const lastChar = [...result].slice(-1)[0]; // Get last Unicode character
 | ||||
|     if (!charSet.has(lastChar)) break; | ||||
|     result = result.slice(0, -lastChar.length); // Remove the entire Unicode character
 | ||||
|     count++; | ||||
|   } | ||||
| 
 | ||||
|   return str.slice(0, endIdx + 1) | ||||
|   return result; | ||||
| } | ||||
|  | ||||
							
								
								
									
										7
									
								
								vitest.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								vitest.config.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| import { defineConfig } from 'vitest/config' | ||||
| 
 | ||||
| export default defineConfig({ | ||||
|   test: { | ||||
|     environment: 'jsdom', | ||||
|   }, | ||||
| }) | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user