/*------------------------------------------------------* * Copyright (c) 2023 Dawid Papiewski "SpacingBat3". * * * * All rights reserved. Licensed under the ISC license. * *------------------------------------------------------*/ /** * Sets of chars that are correctly understood by current engine and can be * transformed to valid {@linkcode charset}. * * @since v1.0.0 */ export const parseableRange = Object.freeze(["a-z","A-Z","0-9","---"] as const); /** * Like `unknown`, except it includes some primitive type just so generics can * pick any commonly-used primitive type. */ export type unknownLiteral = null|undefined|object|string|number|bigint; /** * A valid mode for trimming the string. `null` skips trimming entirely. * * @since v1.2.0 */ export type trimMode = null|"left"|"right"|"both"; type stringify = T extends number ? `${T}` : T extends string ? T : string; /** * Splits `T` to the union of chars. If `T` is an empty string, it resolves to * `never`. */ export type charset = ( T extends `${infer C extends string}${infer R extends string}` ? ( C | charset ) : T extends "" ? never : T ); /** * A union of letters from English alphabet (lowercase). */ type char = charset<"abcdefghijklmnopqrstuvwxyz">; /** * A union of arabic numbers (i.e. decimal number system). */ type digit = charset<"0123456789">; type parseableRange = (typeof parseableRange)[number]; /** * Maps given {@link parseableRange} to {@link charset}. */ type extendRange = ( T extends "a-z" ? char : T extends "A-Z" ? Uppercase : ( T extends "0-9" ? digit : T extends "---" ? "-" : never ) ); type range2charset = T extends parseableRange ? extendRange : ( // a-z T extends `${infer F extends "a-z"}${string}` ? ( T extends `${F}${infer R extends string}` ? extendRange|range2charset : never // A-Z ) : T extends `${infer F extends "A-Z"}${string}` ? ( T extends `${F}${infer R extends string}` ? extendRange|range2charset : never // 0-9 ) : T extends `${infer F extends "0-9"}${string}` ? ( T extends `${F}${infer R extends string}` ? extendRange|range2charset : never // any char ) : T extends `${infer F extends string}${infer R extends string}` ? ( charset|range2charset // empty string ) : never ); type _case = ( // Charset contains any letter from English alphabet? C extends `${string}${char}${string}` ? ( // Charset contains any upperCase English letter? C extends `${string}${Uppercase}${string}` ? T : Lowercase ) : Uppercase ); /** * A generic type which modifies literal string to replace `T` string with `R` * replacement character based on `C` charset. It should work exactly the same * as runtime function does when combined with {@link _trimMode}. */ type _replace = ( // F <- T[0], S <- T[1..N] T extends `${infer F extends string}${infer S extends string}` ? ( // Leave valid chars as-is: F extends range2charset ? ( `${F}${_replace}` // Replace invalid chars with replacement character: ) : `${R}${_replace}` // Original type on empty or non-literal strings. ) : T ); /** * A helper generic type to trim string to remove leading chars outside of * range. */ type _trimLeft = ( T extends `${infer F extends string}${infer R extends string}` ? ( F extends range2charset ? T : _trimLeft ) : T ); /** * A helper generic type to trim string to remove trailing chars outside of * range. Does not respect the left side of the string and assumes the first * char found would be part of invalid chars on right side (which leaves empty * string in such scenarios). */ type _trimRightPhase2 = ( T extends `${infer F extends string}${infer R extends string}` ? ( F extends range2charset ? `${F}${_trimRightPhase2}` : "" ) : T ); /** * A helper generic type to trim string to remove trailing chars outside of * range. Unlike to {@link _trimRightPhase2} it leaves leading chars untouched. */ type _trimRight = ( T extends `${infer F extends string}${infer R extends string}` ? ( F extends range2charset ? `${F}${_trimRightPhase2}` : `${F}${_trimRight}` ) : T ); /** * Trims a string based on {@link trimMode} (`M`) parameter. * It should provide the same logic as in runtime function. * * @template T - A value to be trimmed. * @template C - A charset used during sanitization. * @template M - A way of trimming the string. */ type _trimMode = ( M extends "right" ? _trimRight : M extends "both" ? ( _trimRightPhase2<_trimLeft,C> ) : M extends "left" ? _trimLeft : M extends null ? T : never ); /** * Ensures given string is a *char* (i.e. has `length === 1`). It will resolve * to `never` both for strings with multiple characters and empty strings. * * @template T - any `string`. */ type ensureChar = ( T extends `${infer P extends string}${string}` ? ( T extends P ? T : never ) : never ); /** Ensures given string is non-empty. Resolves to `never` otherwise. */ type ensureNonEmpty = T extends "" ? never : T; /** Resolves to last character from the given string. */ type lastChar = ( T extends `${string}${infer R extends string}` ? ( R extends "" ? T : lastChar ) : T ); /** Infers a set of chars from given string as a union type. */ type charGroups = ( T extends `${infer S extends string}-${infer E extends string}${infer R extends string}` ? ( S extends "" ? charGroups : `${lastChar}-${E}`|charGroups ) : never ); /** * Transforms {@link sanitizeLiteral} function parameters to provide an expected * result type for a given set of literals. * * @template V - Value to be sanitized. * @template C - A charset to be used for sanitization. * @template R - A replacement character to be used for sanitization. * @template M - A range of value trimming. */ export type sanitizeResult = ( V extends null|undefined ? V : R extends ensureChar ? ( charGroups extends parseableRange ? V extends string|number ? ( ensureNonEmpty<_replace<_trimMode<_case,C>,C,M>,C,R>> ) : string : never ) : never ); /** * A type-safe string sanitizer supporting any set of chars while being capable * of calculating the expected result as a static type if literal is provided as * a name. Predicts the accurate output based on input types or errors (`never`) * if function is guaranteed to fail in the runtime. * * @remarks * * It is designed to be rather performant at runtime (it uses [`RegExp`] * under-the-hood), however be aware of long compilation times as TypeScript * will have to do heavy calculations for function result in case of complex * strings and character sets (a lot of operations are done in char-by-char * manner, using `infer` and type recursion — there's a real chance that for * very long literal strings TypeScript will just give up at calculations and * end compilation with an error!). * * [`RegExp`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp "RegExp – JavaScript | MDN" * * @privateRemarks * * This function began, for me and anyone digging into the source code, as a * resource for learning advanced TypeScript manipulations on string literals. * I will also use it for my own personal projects. * * @param value - Value to sanitize. Should be a *non-nullish* `string`. * @param charset - A string that represents a set of characters. For ranges, only values from {@linkcode parseableRange} are valid. * @param replacement - A `char` (i.e. `string` with `length === 0`) which should replace invalid characters inside the string. * @param trimMode – Definies how string should be trimmed. Defaults to `left` (compatibility reasons). * * @returns - Original {@link value} for nullish values, sanitized string for anything else. * @throws - [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError "TypeError – JavaScript | MDN") for unresolveable {@link charset} or invalid {@link trimMode}, [`RangeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RangeError "RangeError – JavaScript | MDN") for non-char values in {@link replacement} and [`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error "Error – JavaScript | MDN") for {@link value} which cannot be sanitized to the expected {@link charset}. * * @example * * // (const) regular: "fooBar3" * const regular = "fooBar3" as const; * // (const) mod1: "FOOBAR3" * const mod1 = sanitizeLiteral(regular,"A-Z0-9"); * // (const) mod2: "foobarz" * const mod2 = sanitizeLiteral(regular,"a-z","z"); * // (const) mod3: "oo_ar3" * const mod3 = sanitizeLiteral(regular,"acdeghijklmnopqrstuvwxyz0-9","_"); * * @since v1.0.0 */ export function sanitizeLiteral(value:V, charset="a-z0-9" as C, replacement="-" as R,trimMode="left" as M): sanitizeResult { if(value === null || value === undefined) return value as sanitizeResult; if((charset.match(/([^])-([^])/gm)??[]).find(element => !["a-z","A-Z","0-9","---"].includes(element)) !== undefined) throw new TypeError(`Unrecognized charset: "${charset}"!`); if(replacement.length !== 1) throw new RangeError("Parameter 'replacement' should be a valid character"); charset = charset.replaceAll(/([\]^\\])/g,"\\$1") as C; let valueString:string; const regexp = { valid: new RegExp(`[${charset}]`), invalid: new RegExp(`[^${charset}${replacement.replaceAll("]","\\]")}]`,"g") } if(typeof value !== "string") valueString = String(value); else valueString = value; if(regexp.invalid.test(valueString)||valueString.startsWith(replacement)) { // Try to convert string to uppercase or lowercase based on charset. valueString = !/[A-Z]/.test(charset) ? valueString.toLowerCase() : !/[a-z]/.test(charset) ? valueString.toUpperCase() : valueString; // Trim string based on the trimMode switch(trimMode) { case "both": //@ts-expect-error – fallthrough intended case "left": valueString = valueString.slice(valueString.search(regexp.valid)); if(trimMode === "left") break; //@ts-expect-error – fallthrough intended case "right": valueString = valueString.slice(0,valueString.length-Array.from(valueString).reverse().findIndex(c => regexp.valid.test(c))) case null: break; default: throw new TypeError(`Invalid trim mode: "${trimMode}"`); } // Replace the rest with the replacement character. valueString = valueString.replaceAll(regexp.invalid,replacement) } // Do not accept the empty strings if(valueString.length === 0) throw new Error("Parameter 'name' is not sanitizable!"); return valueString as sanitizeResult; } export default sanitizeLiteral;