Skip to content

Commit

Permalink
feat: add core module (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
fityannugroho authored Nov 18, 2023
1 parent 474de8f commit 6ef4a27
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 116 deletions.
13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
126 changes: 126 additions & 0 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
export type CharSet = Record<string, string[] | undefined>;

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;
}
142 changes: 28 additions & 114 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -11,24 +19,15 @@ export const CharSets = {
} as const;

export type CharSetNames = typeof CharSets[keyof typeof CharSets];
export type CharSet = Record<string, string[] | undefined>;

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}`);
}
Expand All @@ -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<CoreOptions, 'charSets'> & {
/**
* 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,
};

0 comments on commit 6ef4a27

Please sign in to comment.