Skip to content

Commit a243e8f

Browse files
authored
Various Improvements - v0.20.0 (#43)
* chore: bump version to 0.20.0 and update package metadata feat: enhance documentation with examples for string utility functions - Added detailed examples for `capitalize`, `codePoints`, `deburr`, `detectScript`, `escapeHtml`, `fuzzyMatch`, `graphemes`, `hashString`, `pad`, `padEnd`, `padStart`, `pluralize`, `randomString`, `reverse`, `singularize`, `smartSplit`, `stripHtml`, `toASCII`, and `wordCount`. feat: introduce SafeHTML type and sanitization functions - Added `SafeHTML` branded type for sanitized HTML. - Implemented `toSafeHTML` for sanitizing strings into SafeHTML. - Added `unsafeSafeHTML` for casting strings to SafeHTML without sanitization. fix: update size limits in package.json for better performance - Adjusted size limits for `dist/index.js` and `dist/index.cjs`. test: add unit tests for new SafeHTML functionality - Created tests for `toSafeHTML` and `unsafeSafeHTML` to ensure proper sanitization and type safety. * update package json
1 parent f2d5ebe commit a243e8f

39 files changed

+988
-727
lines changed

CHANGELOG.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.20.0] - 2025-10-09
11+
12+
### Added
13+
14+
- **New Branded Type: `SafeHTML`** - Compile-time XSS prevention with type-safe HTML sanitization
15+
16+
- New `toSafeHTML()` builder function creates sanitized HTML with opinionated secure defaults
17+
- Strips all HTML by default for maximum security
18+
- Removes script tags and dangerous content
19+
- Removes non-printable characters
20+
- Optional configuration to allow specific safe tags
21+
- New `unsafeSafeHTML()` for trusted input bypass (use with caution)
22+
- Zero runtime overhead - type safety enforced at compile time
23+
- Full test coverage with 11 comprehensive tests
24+
- Integrates with existing branded type system alongside `Email`, `URL`, and `Slug`
25+
26+
- **Brand Pattern Documentation** - Comprehensive guide for extending the type system
27+
28+
- Added "Extending with Custom Branded Types" section to README
29+
- Examples showing how to create custom branded types (PhoneNumber, PostalCode, CreditCard)
30+
- Pattern demonstrates type-safe constructors and validation without bloating the library
31+
- Shows composability of custom types with built-in branded types
32+
33+
- **Enhanced JSDoc Examples** - Real-world usage examples for better developer experience
34+
- Added comprehensive TypeScript examples to 20+ utility functions
35+
- All examples now use proper ```ts code blocks for syntax highlighting
36+
- Includes practical use cases: form validation, search normalization, file naming, etc.
37+
- Functions enhanced: `capitalize`, `codePoints`, `deburr`, `detectScript`, `escapeHtml`, `graphemes`, `hashString`, `pad`, `padEnd`, `padStart`, `pluralize`, `randomString`, `reverse`, `singularize`, `smartSplit`, `stripHtml`, `toASCII`
38+
39+
### Fixed
40+
41+
- **Type Safety** - Resolved TypeScript strict mode violations with array access
42+
- Added non-null assertions for mathematically guaranteed valid array access in `levenshtein`, `pad`, `fuzzyMatch`, `smartSplit`
43+
- Fixed `randomString` to handle empty charset edge case (early return)
44+
- Zero runtime cost fixes - all type assertions validated by algorithm guarantees
45+
- All 1207 tests passing
46+
47+
### Changed
48+
49+
- **Bundle Size Limits** - Increased to accommodate SafeHTML feature
50+
- ESM: 9.1 KB → 9.5 KB (+400 bytes headroom)
51+
- CJS: 9.5 KB → 10 KB (+500 bytes headroom)
52+
- Current actual size: 8.84 KB ESM / 9.36 KB CJS (well under new limits)
53+
- SafeHTML adds ~500 bytes for compile-time XSS prevention
54+
55+
### Security
56+
57+
- **XSS Prevention** - SafeHTML branded type provides compile-time protection against XSS attacks
58+
- Functions accepting user input can now require `SafeHTML` type
59+
- Compiler enforces sanitization before rendering untrusted content
60+
- Type system prevents accidentally using unvalidated strings in unsafe contexts
61+
1062
## [0.19.1] - 2025-10-07
1163

1264
### Changed
@@ -510,6 +562,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
510562
- 100% test coverage for utility functions
511563
- Modern build tooling with tsup and Vitest
512564

565+
[0.20.0]: https://github.com/Zheruel/nano-string-utils/releases/tag/v0.20.0
513566
[0.19.1]: https://github.com/Zheruel/nano-string-utils/releases/tag/v0.19.1
514567
[0.19.0]: https://github.com/Zheruel/nano-string-utils/releases/tag/v0.19.0
515568
[0.18.0]: https://github.com/Zheruel/nano-string-utils/releases/tag/v0.18.0

README.md

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,62 @@ if (validated) {
13101310
}
13111311
```
13121312

1313+
#### Extending with Custom Branded Types
1314+
1315+
The `Brand<T, K>` utility allows you to create your own custom branded types beyond the built-in ones. This is perfect for domain-specific validation without bloating the library.
1316+
1317+
```typescript
1318+
import type { Brand } from "nano-string-utils";
1319+
import { sanitize } from "nano-string-utils";
1320+
1321+
// Create custom branded types for your domain
1322+
type PhoneNumber = Brand<string, "PhoneNumber">;
1323+
type PostalCode = Brand<string, "PostalCode">;
1324+
type CreditCard = Brand<string, "CreditCard">;
1325+
1326+
// Build type-safe constructors
1327+
function toPhoneNumber(str: string): PhoneNumber | null {
1328+
const cleaned = str.replace(/\D/g, "");
1329+
if (cleaned.length === 10 || cleaned.length === 11) {
1330+
return cleaned as PhoneNumber;
1331+
}
1332+
return null;
1333+
}
1334+
1335+
function toPostalCode(str: string): PostalCode | null {
1336+
// US ZIP code validation
1337+
if (/^\d{5}(-\d{4})?$/.test(str)) {
1338+
return str as PostalCode;
1339+
}
1340+
return null;
1341+
}
1342+
1343+
// Type guards for runtime checking
1344+
function isPhoneNumber(str: string): str is PhoneNumber {
1345+
return toPhoneNumber(str) !== null;
1346+
}
1347+
1348+
// Use in your application
1349+
function sendSMS(phone: PhoneNumber, message: string) {
1350+
// Can only be called with validated phone numbers
1351+
console.log(`Sending to ${phone}: ${message}`);
1352+
}
1353+
1354+
const userInput = "(555) 123-4567";
1355+
const phone = toPhoneNumber(userInput);
1356+
if (phone) {
1357+
sendSMS(phone); // ✅ Type safe!
1358+
}
1359+
// sendSMS(userInput); // ❌ Type error - string is not PhoneNumber
1360+
```
1361+
1362+
This pattern gives you:
1363+
1364+
- **Compile-time safety** - Prevent using unvalidated data
1365+
- **Zero bundle cost** - Only import what you use from the library
1366+
- **Domain modeling** - Express business rules in the type system
1367+
- **Composability** - Mix custom types with built-in branded types
1368+
13131369
### Template Literal Types (TypeScript)
13141370

13151371
Case conversion functions now provide precise type inference for literal strings at compile time. This feature enhances IDE support with exact type transformations while maintaining full backward compatibility.
@@ -1534,14 +1590,11 @@ We continuously benchmark nano-string-utils against popular alternatives (lodash
15341590
### Running Benchmarks
15351591

15361592
```bash
1537-
# Run all benchmarks
1538-
npm run bench:all
1539-
1540-
# Run performance benchmarks only
1541-
npm run bench:perf
1593+
# Run vitest benchmark tests
1594+
npm run bench
15421595

1543-
# Run bundle size analysis only
1544-
npm run bench:size
1596+
# Generate benchmark data for docs website
1597+
npm run bench:data
15451598
```
15461599

15471600
### Latest Results
@@ -1578,7 +1631,7 @@ npm run bench:size
15781631

15791632
- 🏆 **Smallest bundle sizes**: nano-string-utils wins 47 out of 48 tested functions (98% win rate)
15801633
-**Competitive performance**: Wins 10 out of 14 benchmarked functions against es-toolkit
1581-
- 📊 **Detailed benchmarks**: See [benchmark-results.md](./benchmarks/benchmark-results.md) for full comparison
1634+
- 📊 **[View full interactive benchmarks](https://zheruel.github.io/nano-string-utils/#bundle-size)** with detailed comparison
15821635
-**Optimized performance**:
15831636
- **Case conversions**: 3.4M-4.3M ops/s, competitive with es-toolkit
15841637
- **Truncate**: 23.4M ops/s for fast string truncation

benchmarks/benchmark-results.md

Lines changed: 0 additions & 56 deletions
This file was deleted.

benchmarks/bundle-size-results.md

Lines changed: 0 additions & 62 deletions
This file was deleted.

benchmarks/bundle-size.ts

Lines changed: 19 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -280,66 +280,6 @@ function formatBytes(bytes: number): string {
280280
return kb < 10 ? `${kb.toFixed(1)}KB` : `${Math.round(kb)}KB`;
281281
}
282282

283-
function generateMarkdownTable(metrics: DetailedSizeMetrics[]): string {
284-
let markdown = "# Bundle Size Comparison\n\n";
285-
markdown += "## Overview\n\n";
286-
287-
// Summary stats
288-
const totalNanoWins = metrics.filter((m) => m.winner === "nano").length;
289-
const avgSavings =
290-
metrics
291-
.filter((m) => m.percentSavings !== undefined)
292-
.reduce((sum, m) => sum + (m.percentSavings || 0), 0) / metrics.length;
293-
294-
markdown += `- **Total Functions**: ${metrics.length}\n`;
295-
markdown += `- **Nano Wins**: ${totalNanoWins}/${metrics.length}\n`;
296-
markdown += `- **Average Size Reduction**: ${Math.round(avgSavings)}%\n\n`;
297-
298-
markdown += "## Detailed Comparison\n\n";
299-
markdown +=
300-
"Sizes shown are minified (gzipped). For nano-string-utils, tree-shaken size is shown when different from bundled.\n\n";
301-
markdown +=
302-
"| Function | nano-string-utils | lodash | es-toolkit | Winner | Savings |\n";
303-
markdown +=
304-
"|----------|-------------------|--------|------------|--------|----------|\n";
305-
306-
for (const metric of metrics.sort((a, b) =>
307-
a.function.localeCompare(b.function)
308-
)) {
309-
const nanoSize =
310-
metric.nano.treeShaken.gzipped !== metric.nano.gzipped
311-
? `${formatBytes(metric.nano.minified)} (${formatBytes(
312-
metric.nano.gzipped
313-
)}) → ${formatBytes(metric.nano.treeShaken.minified)} (${formatBytes(
314-
metric.nano.treeShaken.gzipped
315-
)})`
316-
: `${formatBytes(metric.nano.minified)} (${formatBytes(
317-
metric.nano.gzipped
318-
)})`;
319-
320-
const lodashSize = metric.lodash
321-
? `${formatBytes(metric.lodash.minified)} (${formatBytes(
322-
metric.lodash.gzipped
323-
)})`
324-
: "-";
325-
326-
const esToolkitSize = metric.esToolkit
327-
? `${formatBytes(metric.esToolkit.minified)} (${formatBytes(
328-
metric.esToolkit.gzipped
329-
)})`
330-
: "-";
331-
332-
const savingsStr =
333-
metric.percentSavings !== undefined ? `${metric.percentSavings}%` : "-";
334-
335-
const winnerIcon = metric.winner === "nano" ? "🏆" : "";
336-
337-
markdown += `| ${metric.function} | ${nanoSize} | ${lodashSize} | ${esToolkitSize} | ${metric.winner} ${winnerIcon} | ${savingsStr} |\n`;
338-
}
339-
340-
return markdown;
341-
}
342-
343283
async function generateJSONReport(metrics: DetailedSizeMetrics[]) {
344284
const outputPath = path.join(__dirname, "bundle-sizes.json");
345285

@@ -401,17 +341,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
401341
console.log("🚀 nano-string-utils Bundle Size Analysis\n");
402342
generateBundleSizeReport()
403343
.then(async ({ results, detailedMetrics }) => {
404-
// Generate and save markdown report
405-
const markdown = generateMarkdownTable(detailedMetrics);
406-
console.log("\n" + markdown);
407-
408-
const mdPath = path.join(__dirname, "bundle-size-results.md");
409-
await fs.writeFile(mdPath, markdown);
410-
console.log(`\n✅ Markdown report saved to ${mdPath}`);
411-
412344
// Generate and save JSON report
413345
const jsonPath = await generateJSONReport(detailedMetrics);
414-
console.log(`✅ JSON report saved to ${jsonPath}`);
346+
console.log(`\n✅ JSON report saved to ${jsonPath}`);
415347

416348
// Copy to public directory for serving in documentation site
417349
const publicJsonPath = path.join(
@@ -423,9 +355,25 @@ if (import.meta.url === `file://${process.argv[1]}`) {
423355
);
424356
await fs.mkdir(path.dirname(publicJsonPath), { recursive: true });
425357
await fs.copyFile(jsonPath, publicJsonPath);
426-
console.log(`✅ JSON copied to public at ${publicJsonPath}`);
358+
console.log(`✅ JSON copied to docs-src/public/`);
359+
360+
// Summary
361+
const totalNanoWins = detailedMetrics.filter(
362+
(m) => m.winner === "nano"
363+
).length;
364+
const avgSavings = Math.round(
365+
detailedMetrics
366+
.filter((m) => m.percentSavings !== undefined)
367+
.reduce((sum, m) => sum + (m.percentSavings || 0), 0) /
368+
detailedMetrics.length
369+
);
370+
371+
console.log(`\n📊 Summary:`);
372+
console.log(` - Total functions: ${detailedMetrics.length}`);
373+
console.log(` - Nano wins: ${totalNanoWins}/${detailedMetrics.length}`);
374+
console.log(` - Average size reduction: ${avgSavings}%`);
427375
})
428376
.catch(console.error);
429377
}
430378

431-
export { measureBundleSize, generateBundleSizeReport, generateMarkdownTable };
379+
export { measureBundleSize, generateBundleSizeReport, formatBytes };

0 commit comments

Comments
 (0)