diff --git a/package.json b/package.json index 661177c..7fb2f02 100644 --- a/package.json +++ b/package.json @@ -4,18 +4,27 @@ "description": "Obfuscating text or phrases with random uncommon characters to avoid banning.", "type": "module", "main": "dist/index.js", - "types": "dist/index.d.ts", "files": [ "charsets/", "dist/" ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./core": { + "types": "./dist/core.d.ts", + "default": "./dist/core.js" + } + }, "scripts": { "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "vitest run", "test:watch": "vitest", "test:cov": "vitest run --coverage", - "build": "tsup src/index.ts --format esm -d dist --clean --dts src/index.ts --sourcemap --minify", + "build": "tsup src/index.ts src/core.ts --format esm -d dist --clean --dts --sourcemap --minify", "prepublish": "npm run build" }, "repository": { diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..ee57a4a --- /dev/null +++ b/src/core.ts @@ -0,0 +1,126 @@ +export type CharSet = Record; + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +/** + * Check if the given charset is valid. + * @param charSet The charset to check. + */ +export function isCharSetValid(charSet: object): boolean { + return typeof charSet === 'object' + && Object.keys(charSet).every((key) => ( + key.length === 1 + && /^[a-zA-Z]$/.test(key) + )) + && Object.values(charSet).every((replacements) => ( + Array.isArray(replacements) && replacements.every((char) => ( + typeof char === 'string' + && char.length === 1 + // eslint-disable-next-line no-control-regex + && /[^\u0000-\u001f\u007f-\u009f]/.test(char) + )) + )); +} + +/** + * Merge multiple charsets. + * @param charSets The charsets to merge. + * @returns The merged charset. + * @throws {ValidationError} If the given custom charset is invalid. + */ +export function mergeCharSets(...charSets: CharSet[]): CharSet { + const res: CharSet = {}; + + for (const charSet of charSets) { + // Validate the charSet + if (!isCharSetValid(charSet)) { + throw new ValidationError('Invalid charSet: each key and value must be a single character'); + } + + for (const [key, replacements] of Object.entries(charSet)) { + res[key] = Array.from(new Set([ + ...(res[key] ?? []), + ...(replacements ?? []), + ])).sort(); + } + } + + return res; +} + +/** + * Check if the given phrase is valid. + * @param phrase The phrase to check. + */ +export function isPhraseValid(phrase: string): boolean { + return typeof phrase === 'string' + && /^[a-zA-Z0-9 \-_'/]+$/.test(phrase) + && phrase.trim().length > 0 && phrase.length <= 30; +} + +/** + * Get a random replacement for the given character. + * @param char The character to replace. + * @param charSet The charset to use. + * @param caseSensitive Whether to use case sensitive replacements. + * @returns The replacement character. + */ +function getChar(char: string, charSet: CharSet, caseSensitive?: boolean) { + const replacements = caseSensitive ? charSet[char] ?? [] + : Array.from(new Set([ + ...(charSet[char.toUpperCase()] ?? []), + ...(charSet[char.toLowerCase()] ?? []), + ])); + + if (!replacements.length) { + return char; + } + + return replacements[Math.floor(Math.random() * replacements.length)]; +} + +export type Options = { + text: string; + phrases?: string[]; + caseSensitive?: boolean; + charSets?: CharSet[]; +}; + +/** + * @param options The options. + * @throws {ValidationError} If either the given custom charset or phrases are invalid. + */ +export default function wisely(options: Options): string { + const charSet = mergeCharSets(...(options.charSets ?? [])); + + const censor = (phrase: string): string => phrase.split('') + .map((char) => getChar(char, charSet, options.caseSensitive)) + .join(''); + + if (!options.phrases?.length) { + return censor(options.text); + } + + let res = options.text; + for (const phrase of options.phrases) { + if (!isPhraseValid(phrase)) { + throw new ValidationError(`Invalid phrase: ${phrase}`); + } + + const regex = new RegExp(`\\b${phrase.trim()}\\b`, options.caseSensitive ? 'g' : 'gi'); + + for (const m of options.text.matchAll(regex)) { + const [match] = m; + // Replace only for current match by the index + res = res.slice(0, m.index) + censor(match) + + (m.index === undefined ? '' : res.slice(m.index + match.length)); + } + } + + return res; +} diff --git a/src/index.ts b/src/index.ts index 8309475..9b2a29b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,14 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import coreWisely, { + CharSet, + Options as CoreOptions, + ValidationError, + mergeCharSets as coreMergeCharSets, + isCharSetValid, + isPhraseValid, +} from './core.js'; /** * The name of built-in charsets. @@ -11,24 +19,15 @@ export const CharSets = { } as const; export type CharSetNames = typeof CharSets[keyof typeof CharSets]; -export type CharSet = Record; const dirname = path.dirname(fileURLToPath(import.meta.url)); -export class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = 'ValidationError'; - } -} - /** * Get a built-in charset. * @param name The name of the charset. * @throws {ValidationError} If the given charset name is invalid. */ -function getCharSet(name: CharSetNames = 'latin'): CharSet { - // Validating the name +export function getCharSet(name: CharSetNames = 'latin'): CharSet { if (!Object.values(CharSets).includes(name)) { throw new ValidationError(`Invalid charSet name: ${name}`); } @@ -41,125 +40,40 @@ function getCharSet(name: CharSetNames = 'latin'): CharSet { return JSON.parse(strJson) as CharSet; } -/** - * Check if the given charset is valid. - * @param charSet The charset to check. - */ -export function isCharSetValid(charSet: object): boolean { - return typeof charSet === 'object' - && Object.keys(charSet).every((key) => ( - key.length === 1 - && /^[a-zA-Z]$/.test(key) - )) - && Object.values(charSet).every((replacements) => ( - Array.isArray(replacements) && replacements.every((char) => ( - typeof char === 'string' - && char.length === 1 - // eslint-disable-next-line no-control-regex - && /[^\u0000-\u001f\u007f-\u009f]/.test(char) - )) - )); -} - -/** - * Check if the given phrase is valid. - * @param phrase The phrase to check. - */ -export function isPhraseValid(phrase: string): boolean { - return typeof phrase === 'string' - && /^[a-zA-Z0-9 \-_'/]+$/.test(phrase) - && phrase.trim().length > 0 && phrase.length <= 30; -} - /** * Merge multiple charsets. * @param charSets The names of built-in charset or custom charsets to merge. * @returns The merged charset. - * @throws {ValidationError} If the given built-in charset name is invalid - * or if the given custom charset is invalid. + * @throws {ValidationError} If either the given built-in charset name or custom charset are invalid. */ export function mergeCharSets(...charSets: (CharSetNames | CharSet)[]): CharSet { - const res: CharSet = {}; - - for (const charSet of charSets) { - const charSetObj = typeof charSet === 'string' ? getCharSet(charSet) : charSet; - - // Validate the charSet - if (!isCharSetValid(charSetObj)) { - throw new ValidationError('Invalid charSet: each key and value must be a single character'); - } - - for (const [key, replacements] of Object.entries(charSetObj)) { - res[key] = Array.from(new Set([ - ...(res[key] ?? []), - ...(replacements ?? []), - ])).sort(); - } - } - - return res; + return coreMergeCharSets(...charSets.map((charSet) => ( + typeof charSet === 'string' ? getCharSet(charSet) : charSet + ))); } -/** - * Get a random replacement for the given character. - * @param char The character to replace. - * @param charSet The charset to use. - * @param caseSensitive Whether to use case sensitive replacements. - * @returns The replacement character. - */ -function getChar(char: string, charSet: CharSet, caseSensitive?: boolean) { - const replacements = caseSensitive ? charSet[char] ?? [] - : Array.from(new Set([ - ...(charSet[char.toUpperCase()] ?? []), - ...(charSet[char.toLowerCase()] ?? []), - ])); - - if (!replacements.length) { - return char; - } - - return replacements[Math.floor(Math.random() * replacements.length)]; -} - -export type Options = { - text: string; - phrases?: string[]; - caseSensitive?: boolean; +export type Options = Omit & { + /** + * The names of built-in charset or custom charsets to use. + * @default ['latin'] + */ charSets?: (CharSetNames | CharSet)[]; }; /** * @param options The options. - * @throws {ValidationError} If the given built-in charset name, - * the given custom charset, or if the given phrases are invalid. + * @throws {ValidationError} If one of the given built-in charset names, custom charsets, or phrases are invalid. */ export default function wisely(options: Options): string { const charSet = mergeCharSets(...(options.charSets ?? ['latin'])); - const censor = (phrase: string): string => phrase.split('') - .map((char) => getChar(char, charSet, options.caseSensitive)) - .join(''); - - if (!options.phrases?.length) { - return censor(options.text); - } - - let res = options.text; - for (const phrase of options.phrases) { - // Validating the phrase - if (!isPhraseValid(phrase)) { - throw new ValidationError(`Invalid phrase: ${phrase}`); - } - - const regex = new RegExp(`\\b${phrase.trim()}\\b`, options.caseSensitive ? 'g' : 'gi'); - - for (const m of options.text.matchAll(regex)) { - const [match] = m; - // Replace only for current match by the index - res = res.slice(0, m.index) + censor(match) - + (m.index === undefined ? '' : res.slice(m.index + match.length)); - } - } - - return res; + return coreWisely({ + ...options, + charSets: [charSet], + }); } + +// Export from core +export { + CharSet, ValidationError, isCharSetValid, isPhraseValid, +};