nano-string-utils
Version:
Modern string utilities with zero dependencies. Tree-shakeable (<1KB each), TypeScript-first, type-safe. Validation, XSS prevention, case conversion, fuzzy matching & more.
293 lines (254 loc) • 11.7 kB
JavaScript
#!/usr/bin/env node
import {
runtime,
getArgs,
exit,
readStdin as getStdinData,
resolvePath,
dynamicImport,
log,
error as logError
} from './runtime-utils.js';
// Function registry with metadata
const FUNCTIONS = {
// Case conversions
slugify: { desc: 'Convert to URL-safe slug', example: 'slugify "Hello World"' },
camelCase: { desc: 'Convert to camelCase', example: 'camelCase "hello world"' },
snakeCase: { desc: 'Convert to snake_case', example: 'snakeCase "hello world"' },
kebabCase: { desc: 'Convert to kebab-case', example: 'kebabCase "hello world"' },
pascalCase: { desc: 'Convert to PascalCase', example: 'pascalCase "hello world"' },
constantCase: { desc: 'Convert to CONSTANT_CASE', example: 'constantCase "hello world"' },
dotCase: { desc: 'Convert to dot.case', example: 'dotCase "hello world"' },
pathCase: { desc: 'Convert to path/case', example: 'pathCase "hello world"' },
sentenceCase: { desc: 'Convert to Sentence case', example: 'sentenceCase "hello WORLD"' },
titleCase: { desc: 'Convert to Title Case', example: 'titleCase "hello world"', hasOptions: true },
// String manipulation
capitalize: { desc: 'Capitalize first letter', example: 'capitalize "hello"' },
reverse: { desc: 'Reverse string', example: 'reverse "hello"' },
truncate: { desc: 'Truncate string', example: 'truncate "long text" --length 5', hasOptions: true },
excerpt: { desc: 'Create excerpt', example: 'excerpt "long text" --length 50', hasOptions: true },
pad: { desc: 'Pad string', example: 'pad "hi" --length 5', hasOptions: true },
padStart: { desc: 'Pad start of string', example: 'padStart "hi" --length 5', hasOptions: true },
padEnd: { desc: 'Pad end of string', example: 'padEnd "hi" --length 5', hasOptions: true },
// Text processing
stripHtml: { desc: 'Remove HTML tags', example: 'stripHtml "<p>Hello</p>"' },
escapeHtml: { desc: 'Escape HTML entities', example: 'escapeHtml "<div>"' },
deburr: { desc: 'Remove diacritics', example: 'deburr "héllo"' },
normalizeWhitespace: { desc: 'Normalize whitespace', example: 'normalizeWhitespace "hello world"', hasOptions: true },
removeNonPrintable: { desc: 'Remove non-printable chars', example: 'removeNonPrintable "hello\\x00world"', hasOptions: true },
toASCII: { desc: 'Convert to ASCII', example: 'toASCII "héllo"', hasOptions: true },
// Validation
isEmail: { desc: 'Check if valid email', example: 'isEmail "test@example.com"' },
isUrl: { desc: 'Check if valid URL', example: 'isUrl "https://example.com"' },
isASCII: { desc: 'Check if ASCII only', example: 'isASCII "hello"' },
isHexColor: { desc: 'Check if valid hex color', example: 'isHexColor "#ff5733"' },
isNumeric: { desc: 'Check if numeric string', example: 'isNumeric "42"' },
isAlphanumeric: { desc: 'Check if alphanumeric', example: 'isAlphanumeric "user123"' },
isUUID: { desc: 'Check if valid UUID', example: 'isUUID "550e8400-e29b-41d4-a716-446655440000"' },
// Analysis
wordCount: { desc: 'Count words', example: 'wordCount "hello world"' },
levenshtein: { desc: 'Calculate edit distance', example: 'levenshtein "kitten" "sitting"', needsTwo: true },
levenshteinNormalized: { desc: 'Normalized edit distance', example: 'levenshteinNormalized "kitten" "sitting"', needsTwo: true },
diff: { desc: 'Show string difference', example: 'diff "hello" "hallo"', needsTwo: true },
// Generation
randomString: { desc: 'Generate random string', example: 'randomString --length 10', hasOptions: true },
hashString: { desc: 'Create hash of string', example: 'hashString "hello"' },
// Unicode
graphemes: { desc: 'Split into graphemes', example: 'graphemes "👨👩👧👦"' },
codePoints: { desc: 'Get Unicode code points', example: 'codePoints "hello"' },
// Pluralization
pluralize: { desc: 'Convert to plural', example: 'pluralize "cat"' },
singularize: { desc: 'Convert to singular', example: 'singularize "cats"' },
// Complex functions (with required options)
template: { desc: 'Interpolate template', example: 'template "Hello {{name}}" --data \'{"name":"World"}\'', hasOptions: true },
templateSafe: { desc: 'Safe template interpolation', example: 'templateSafe "Hello {{name}}" --data \'{"name":"World"}\'', hasOptions: true },
highlight: { desc: 'Highlight search terms', example: 'highlight "hello world" --query "world"', hasOptions: true },
fuzzyMatch: { desc: 'Fuzzy string matching', example: 'fuzzyMatch "hello" "helo"', needsTwo: true, hasOptions: true },
};
// Read stdin if available (delegates to runtime-utils)
const readStdin = getStdinData;
// Parse command line options
function parseOptions(args) {
const options = {};
const positional = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const nextArg = args[i + 1];
if (nextArg && !nextArg.startsWith('--')) {
// Try to parse as JSON first, then as string
try {
options[key] = JSON.parse(nextArg);
} catch {
options[key] = nextArg;
}
i++; // Skip next arg
} else {
options[key] = true;
}
} else {
positional.push(arg);
}
}
return { options, positional };
}
// Show help
function showHelp(functionName = null) {
if (functionName && FUNCTIONS[functionName]) {
const fn = FUNCTIONS[functionName];
log(`\nUsage: nano-string ${functionName} ${fn.needsTwo ? '<string1> <string2>' : '<input>'} ${fn.hasOptions ? '[options]' : ''}`);
log(`\nDescription: ${fn.desc}`);
log(`\nExample:\n nano-string ${fn.example}`);
if (fn.hasOptions) {
log('\nNote: Options can be passed as --key value pairs');
log(' Complex data can be passed as JSON: --data \'{"key":"value"}\'');
}
log('\nPipe support:\n echo "text" | nano-string ' + functionName);
log(`\nRuntime: ${runtime}`);
} else {
log('\nUsage: nano-string <function> [input] [options]');
log('\nAvailable functions:\n');
const categories = {
'Case Conversions': ['slugify', 'camelCase', 'snakeCase', 'kebabCase', 'pascalCase', 'constantCase', 'dotCase', 'pathCase', 'sentenceCase', 'titleCase'],
'String Manipulation': ['capitalize', 'reverse', 'truncate', 'excerpt', 'pad', 'padStart', 'padEnd'],
'Text Processing': ['stripHtml', 'escapeHtml', 'deburr', 'normalizeWhitespace', 'removeNonPrintable', 'toASCII'],
'Validation': ['isEmail', 'isUrl', 'isASCII', 'isHexColor', 'isNumeric', 'isAlphanumeric', 'isUUID'],
'Analysis': ['wordCount', 'levenshtein', 'levenshteinNormalized', 'diff'],
'Generation': ['randomString', 'hashString'],
'Unicode': ['graphemes', 'codePoints'],
'Pluralization': ['pluralize', 'singularize'],
'Templates': ['template', 'templateSafe', 'highlight', 'fuzzyMatch']
};
for (const [category, funcs] of Object.entries(categories)) {
log(` ${category}:`);
for (const func of funcs) {
if (FUNCTIONS[func]) {
log(` ${func.padEnd(20)} ${FUNCTIONS[func].desc}`);
}
}
log();
}
log('Examples:');
log(' nano-string slugify "Hello World"');
log(' echo "Hello World" | nano-string kebabCase');
log(' nano-string truncate "Long text here" --length 10');
log(' nano-string template "Hello {{name}}" --data \'{"name":"World"}\'');
log('\nFor help on a specific function:');
log(' nano-string <function> --help');
log(`\nRuntime: ${runtime}`);
}
}
// Main CLI logic
async function main() {
const args = getArgs();
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
showHelp();
exit(0);
}
const functionName = args[0];
// Check if function exists
if (!FUNCTIONS[functionName]) {
logError(`Error: Unknown function "${functionName}"`);
logError('Run "nano-string --help" to see available functions');
exit(1);
}
// Handle function-specific help
if (args.includes('--help') || args.includes('-h')) {
showHelp(functionName);
exit(0);
}
const { options, positional } = parseOptions(args.slice(1));
const fnMeta = FUNCTIONS[functionName];
try {
// Dynamically import the function
const modulePath = await resolvePath(import.meta.url, '..', 'dist', 'index.js');
const nanoStringUtils = await dynamicImport(modulePath);
const fn = nanoStringUtils[functionName];
if (!fn) {
throw new Error(`Function ${functionName} not found in exports`);
}
// Get input
let input = positional[0] || '';
let secondInput = positional[1] || '';
// Try to read from stdin if no input provided
if (!input) {
const stdinData = await readStdin();
if (stdinData) {
input = stdinData;
}
}
// Validate input
if (!input && functionName !== 'randomString') {
logError('Error: No input provided');
logError(`Usage: nano-string ${functionName} <input>`);
exit(1);
}
// Execute function
let result;
if (fnMeta.needsTwo) {
// Functions that need two strings
if (!secondInput) {
logError(`Error: ${functionName} requires two string arguments`);
logError(`Usage: nano-string ${functionName} <string1> <string2>`);
exit(1);
}
result = Object.keys(options).length > 0 ? fn(input, secondInput, options) : fn(input, secondInput);
} else if (functionName === 'randomString') {
// Special case for randomString
const length = options.length || options.size || 10;
result = options.charset ? fn(length, options.charset) : fn(length);
} else if (functionName === 'template' || functionName === 'templateSafe') {
// Template functions need data
if (!options.data) {
logError('Error: template functions require --data option');
logError('Example: nano-string template "Hello {{name}}" --data \'{"name":"World"}\'');
exit(1);
}
result = fn(input, options.data, options);
} else if (functionName === 'highlight') {
// Highlight needs query
if (!options.query && !options.search) {
logError('Error: highlight requires --query option');
logError('Example: nano-string highlight "hello world" --query "world"');
exit(1);
}
result = fn(input, options.query || options.search, options);
} else if (Object.keys(options).length > 0) {
// Functions with options
if (functionName === 'truncate') {
result = fn(input, options.length || 50, options.suffix || options.ellipsis);
} else if (functionName === 'excerpt') {
result = fn(input, options.length || 50, options.suffix || options.ellipsis);
} else if (functionName === 'pad' || functionName === 'padStart' || functionName === 'padEnd') {
result = fn(input, options.length || 0, options.char || ' ');
} else {
result = fn(input, options);
}
} else {
// Simple functions
result = fn(input);
}
// Output result
if (typeof result === 'boolean') {
log(result ? 'true' : 'false');
exit(result ? 0 : 1);
} else if (typeof result === 'object') {
log(JSON.stringify(result, null, 2));
} else {
log(result);
}
} catch (err) {
logError('Error:', err.message);
// Provide helpful error messages
if (err.message.includes('Cannot find module')) {
logError('\nNote: Make sure to run "npm run build" first to generate the dist folder');
}
exit(1);
}
}
// Run the CLI
main().catch(err => {
logError('Unexpected error:', err);
exit(1);
});