snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
This commit is contained in:
111
packages/tools/CHANGELOG.md
Normal file
111
packages/tools/CHANGELOG.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# @rocket.chat/tools
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#41763](https://github.com/RocketChat/Rocket.Chat/pull/41763)) Drops `@rocket.chat/string-helpers` in favor of `@rocket.chat/tools`
|
||||
|
||||
## 0.4.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#41763](https://github.com/RocketChat/Rocket.Chat/pull/41763)) Drops `@rocket.chat/string-helpers` in favor of `@rocket.chat/tools`
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40096](https://github.com/RocketChat/Rocket.Chat/pull/40096)) Adds new function for censoring URL components in logs
|
||||
|
||||
## 0.3.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40096](https://github.com/RocketChat/Rocket.Chat/pull/40096)) Adds new function for censoring URL components in logs
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
## 0.2.5-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37852](https://github.com/RocketChat/Rocket.Chat/pull/37852) by [@lucas-a-pelegrino](https://github.com/lucas-a-pelegrino)) Adds improvements to the push notifications logic; the logic now truncates messages and titles larger than 240, and 65 characters respectively.
|
||||
|
||||
## 0.2.4-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37852](https://github.com/RocketChat/Rocket.Chat/pull/37852)) Adds improvements to the push notifications logic; the logic now truncates messages and titles larger than 240, and 65 characters respectively.
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#35006](https://github.com/RocketChat/Rocket.Chat/pull/35006)) Fixes the parsing of FreeSwitch events to properly generate a history of calls on Rocket.Chat
|
||||
|
||||
## 0.2.3-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#35006](https://github.com/RocketChat/Rocket.Chat/pull/35006)) Fixes the parsing of FreeSwitch events to properly generate a history of calls on Rocket.Chat
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#32527](https://github.com/RocketChat/Rocket.Chat/pull/32527)) Fixed an inconsistent evaluation of the `Accounts_LoginExpiration` setting over the codebase. In some places, it was being used as milliseconds while in others as days. Invalid values produced different results. A helper function was created to centralize the setting validation and the proper value being returned to avoid edge cases.
|
||||
Negative values may be saved on the settings UI panel but the code will interpret any negative, NaN or 0 value to the default expiration which is 90 days.
|
||||
|
||||
## 0.2.2-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#32527](https://github.com/RocketChat/Rocket.Chat/pull/32527)) Fixed an inconsistent evaluation of the `Accounts_LoginExpiration` setting over the codebase. In some places, it was being used as milliseconds while in others as days. Invalid values produced different results. A helper function was created to centralize the setting validation and the proper value being returned to avoid edge cases.
|
||||
Negative values may be saved on the settings UI panel but the code will interpret any negative, NaN or 0 value to the default expiration which is 90 days.
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#31138](https://github.com/RocketChat/Rocket.Chat/pull/31138)) feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo
|
||||
|
||||
## 0.2.1-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b223cbde14: feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 92613680b7: Added option to select between two script engine options for the integrations
|
||||
|
||||
## 0.2.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 92613680b7: Added option to select between two script engine options for the integrations
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1041d4d361: Added option to select between two script engine options for the integrations
|
||||
|
||||
## 0.1.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1041d4d361: Added option to select between two script engine options for the integrations
|
||||
7
packages/tools/jest.config.ts
Normal file
7
packages/tools/jest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import server from '@rocket.chat/jest-presets/server';
|
||||
import type { Config } from 'jest';
|
||||
|
||||
export default {
|
||||
preset: server.preset,
|
||||
testMatch: ['<rootDir>/src/**/*.spec.(ts|js|mjs)'],
|
||||
} satisfies Config;
|
||||
31
packages/tools/package.json
Normal file
31
packages/tools/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@rocket.chat/tools",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"test": "jest",
|
||||
"test:cov": "jest --coverage",
|
||||
"testunit": "jest",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/jest-presets": "workspace:~",
|
||||
"@rocket.chat/tsconfig": "workspace:*",
|
||||
"@types/jest": "~30.0.0",
|
||||
"eslint": "~9.39.5",
|
||||
"jest": "~30.2.0",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
}
|
||||
}
|
||||
39
packages/tools/src/capitalize.spec.ts
Normal file
39
packages/tools/src/capitalize.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { capitalize } from './capitalize';
|
||||
|
||||
describe('capitalize', () => {
|
||||
it('should convert "xyz" to "Xyz"', () => {
|
||||
expect(capitalize('xyz')).toBe('Xyz');
|
||||
});
|
||||
|
||||
it('should convert "xyz xyz" to "Xyz xyz"', () => {
|
||||
expect(capitalize('xyz xyz')).toBe('Xyz xyz');
|
||||
});
|
||||
|
||||
it('should convert " xyz" to " xyz"', () => {
|
||||
expect(capitalize(' xyz')).toBe(' xyz');
|
||||
});
|
||||
|
||||
it('should convert undefined to ""', () => {
|
||||
expect(capitalize(undefined as unknown as string)).toBe('');
|
||||
});
|
||||
|
||||
it('should convert null to ""', () => {
|
||||
expect(capitalize(null as unknown as string)).toBe('');
|
||||
});
|
||||
|
||||
it('should convert false to ""', () => {
|
||||
expect(capitalize(false as unknown as string)).toBe('');
|
||||
});
|
||||
|
||||
it('should convert true to ""', () => {
|
||||
expect(capitalize(true as unknown as string)).toBe('');
|
||||
});
|
||||
|
||||
it('should convert 0 to ""', () => {
|
||||
expect(capitalize(0 as unknown as string)).toBe('');
|
||||
});
|
||||
|
||||
it('should convert 1 to ""', () => {
|
||||
expect(capitalize(1 as unknown as string)).toBe('');
|
||||
});
|
||||
});
|
||||
7
packages/tools/src/capitalize.ts
Normal file
7
packages/tools/src/capitalize.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const capitalize = <T extends string>(s: T): Capitalize<T> => {
|
||||
if (typeof s !== 'string') {
|
||||
return '' as Capitalize<T>;
|
||||
}
|
||||
|
||||
return (s.charAt(0).toUpperCase() + s.slice(1)) as Capitalize<T>;
|
||||
};
|
||||
53
packages/tools/src/censorUrl.spec.ts
Normal file
53
packages/tools/src/censorUrl.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { censorUrl } from './censorUrl';
|
||||
|
||||
describe('censorUrl', () => {
|
||||
it('returns the original value when URL parsing fails', () => {
|
||||
const input = 'not-a-url';
|
||||
|
||||
expect(censorUrl(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('returns relative URLs unchanged when no base is provided', () => {
|
||||
const input = '/path/to/resource?query=secret&access_token=token';
|
||||
|
||||
expect(censorUrl(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('does not change URLs without sensitive parts', () => {
|
||||
expect(censorUrl('https://example.com/path?foo=bar')).toBe('https://example.com/path?foo=bar');
|
||||
});
|
||||
|
||||
it('redacts username and password from auth section', () => {
|
||||
expect(censorUrl('https://user:password@example.com/path')).toBe('https://*Redacted*:*Redacted*@example.com/path');
|
||||
});
|
||||
|
||||
it('redacts only username when password is not present', () => {
|
||||
expect(censorUrl('https://user@example.com/path')).toBe('https://*Redacted*@example.com/path');
|
||||
});
|
||||
|
||||
it('redacts query and access_token search params', () => {
|
||||
expect(censorUrl('https://example.com/path?query=secret&access_token=token&foo=bar')).toBe(
|
||||
'https://example.com/path?query=*Redacted*&access_token=*Redacted*&foo=bar',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts access_token even when query is absent', () => {
|
||||
expect(censorUrl('https://example.com/path?access_token=token&foo=bar')).toBe(
|
||||
'https://example.com/path?access_token=*Redacted*&foo=bar',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts URL objects as input', () => {
|
||||
expect(censorUrl(new URL('https://user:password@example.com/path?query=secret'))).toBe(
|
||||
'https://*Redacted*:*Redacted*@example.com/path?query=*Redacted*',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not modify the original URL object', () => {
|
||||
const input = new URL('https://user:password@example.com/path?query=secret&access_token=token');
|
||||
const originalValue = input.toString();
|
||||
|
||||
expect(censorUrl(input)).toBe('https://*Redacted*:*Redacted*@example.com/path?query=*Redacted*&access_token=*Redacted*');
|
||||
expect(input.toString()).toBe(originalValue);
|
||||
});
|
||||
});
|
||||
45
packages/tools/src/censorUrl.ts
Normal file
45
packages/tools/src/censorUrl.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Redacts sensitive information from a URL string.
|
||||
*
|
||||
* This function parses a URL and replaces potentially sensitive data with placeholder text.
|
||||
* It redacts the following URL components:
|
||||
* - Username in the authentication section
|
||||
* - Password in the authentication section
|
||||
* - Query parameters named: 'query', 'access_token'
|
||||
*
|
||||
* Note: We use `*Redacted*` instead of `[Redacted]` for legibility, as `[` and `]` would be encoded by toString()
|
||||
*
|
||||
* @param url - The URL string to be censored
|
||||
* @returns The URL string with sensitive information redacted, or the original URL if parsing fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* censorUrl('https://user:password@example.com/path?query=secret&access_token=token');
|
||||
* // Returns: 'https://*Redacted*:*Redacted*@example.com/path?query=*Redacted*&access_token=*Redacted*'
|
||||
* ```
|
||||
*/
|
||||
export function censorUrl(url: string | URL): string {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (parsedUrl.username) {
|
||||
parsedUrl.username = '*Redacted*';
|
||||
}
|
||||
|
||||
if (parsedUrl.password) {
|
||||
parsedUrl.password = '*Redacted*';
|
||||
}
|
||||
|
||||
if (parsedUrl.searchParams.has('query')) {
|
||||
parsedUrl.searchParams.set('query', '*Redacted*');
|
||||
}
|
||||
|
||||
if (parsedUrl.searchParams.has('access_token')) {
|
||||
parsedUrl.searchParams.set('access_token', '*Redacted*');
|
||||
}
|
||||
|
||||
return parsedUrl.toString();
|
||||
} catch {
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
36
packages/tools/src/convertPathsIntoSubObjects.ts
Normal file
36
packages/tools/src/convertPathsIntoSubObjects.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export function convertPathsIntoSubObjects(object: Record<string, any>): Record<string, any> {
|
||||
const newObject: Record<string, any> = {};
|
||||
|
||||
for (const key of Object.keys(object)) {
|
||||
const value = object[key];
|
||||
if (!value && typeof value !== 'boolean') {
|
||||
continue;
|
||||
}
|
||||
const keyProperties = key.split('.');
|
||||
if (!keyProperties.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let current = newObject;
|
||||
|
||||
const finalProperty = keyProperties.pop() as string;
|
||||
for (const property of keyProperties) {
|
||||
if (!(property in current) || typeof current[property] !== 'object') {
|
||||
current[property] = {};
|
||||
}
|
||||
|
||||
current = current[property];
|
||||
}
|
||||
|
||||
if (current[finalProperty]) {
|
||||
current[finalProperty] = {
|
||||
...(typeof value === 'object' && value),
|
||||
...current[finalProperty],
|
||||
};
|
||||
} else {
|
||||
current[finalProperty] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return newObject;
|
||||
}
|
||||
133
packages/tools/src/convertSubObjectsIntoPaths.spec.ts
Normal file
133
packages/tools/src/convertSubObjectsIntoPaths.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { expect } from 'chai';
|
||||
|
||||
import { convertSubObjectsIntoPaths } from './convertSubObjectsIntoPaths';
|
||||
|
||||
describe('convertSubObjectsIntoPaths', () => {
|
||||
it('should flatten a simple object with no nested structure', () => {
|
||||
const input = { a: 1, b: 2, c: 3 };
|
||||
const expected = { a: 1, b: 2, c: 3 };
|
||||
|
||||
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
|
||||
});
|
||||
|
||||
it('should flatten a nested object into paths', () => {
|
||||
const input = {
|
||||
a: 1,
|
||||
b: {
|
||||
c: 2,
|
||||
d: {
|
||||
e: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
'a': 1,
|
||||
'b.c': 2,
|
||||
'b.d.e': 3,
|
||||
};
|
||||
|
||||
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
|
||||
});
|
||||
|
||||
it('should handle objects with array values', () => {
|
||||
const input = {
|
||||
a: [1, 2, 3],
|
||||
b: {
|
||||
c: [4, 5],
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
'a': [1, 2, 3],
|
||||
'b.c': [4, 5],
|
||||
};
|
||||
|
||||
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
|
||||
});
|
||||
|
||||
it('should handle deeply nested objects', () => {
|
||||
const input = {
|
||||
a: {
|
||||
b: {
|
||||
c: {
|
||||
d: {
|
||||
e: {
|
||||
f: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
'a.b.c.d.e.f': 6,
|
||||
};
|
||||
|
||||
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
|
||||
});
|
||||
|
||||
it('should handle an empty object', () => {
|
||||
const input = {};
|
||||
const expected = {};
|
||||
|
||||
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
|
||||
});
|
||||
|
||||
it('should handle objects with mixed types of values', () => {
|
||||
const input = {
|
||||
a: 1,
|
||||
b: 'string',
|
||||
c: true,
|
||||
d: {
|
||||
e: null,
|
||||
f: undefined,
|
||||
g: {
|
||||
h: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
'a': 1,
|
||||
|
||||
'b': 'string',
|
||||
|
||||
'c': true,
|
||||
'd.e': null,
|
||||
'd.f': undefined,
|
||||
'd.g.h': 2,
|
||||
};
|
||||
|
||||
expect(convertSubObjectsIntoPaths(input)).to.deep.equal(expected);
|
||||
});
|
||||
|
||||
it('should respect the parentPath parameter', () => {
|
||||
const input = {
|
||||
a: 1,
|
||||
b: {
|
||||
c: 2,
|
||||
},
|
||||
};
|
||||
const parentPath = 'root';
|
||||
const expected = {
|
||||
'root.a': 1,
|
||||
'root.b.c': 2,
|
||||
};
|
||||
|
||||
expect(convertSubObjectsIntoPaths(input, parentPath)).to.deep.equal(expected);
|
||||
});
|
||||
|
||||
it('should not try to convert class instances', () => {
|
||||
const input = {
|
||||
a: new Date(),
|
||||
b: {
|
||||
c: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
const converted = convertSubObjectsIntoPaths(input);
|
||||
expect(converted).not.to.be.undefined;
|
||||
expect(converted).to.have.keys(['a', 'b.c']);
|
||||
|
||||
expect(converted.a).to.be.a('Date');
|
||||
expect(converted['b.c']).to.be.a('Date');
|
||||
});
|
||||
});
|
||||
17
packages/tools/src/convertSubObjectsIntoPaths.ts
Normal file
17
packages/tools/src/convertSubObjectsIntoPaths.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { isRecord } from './isRecord';
|
||||
|
||||
export function convertSubObjectsIntoPaths(object: Record<string, any>, parentPath?: string): Record<string, any> {
|
||||
return Object.fromEntries(
|
||||
Object.keys(object).flatMap((key) => {
|
||||
const value = object[key];
|
||||
const fullKey = parentPath ? `${parentPath}.${key}` : key;
|
||||
|
||||
if (isRecord(value)) {
|
||||
const flattened = convertSubObjectsIntoPaths(value, fullKey);
|
||||
|
||||
return Object.keys(flattened).map((newKey) => [newKey, flattened[newKey]]);
|
||||
}
|
||||
return [[fullKey, value]];
|
||||
}) as [string, any][],
|
||||
);
|
||||
}
|
||||
15
packages/tools/src/converter.spec.ts
Normal file
15
packages/tools/src/converter.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { convertFromDaysToMilliseconds } from './converter';
|
||||
|
||||
describe('convertFromDaysToMilliseconds', () => {
|
||||
it('should throw an error when a non number is passed', () => {
|
||||
// @ts-expect-error - Testing
|
||||
expect(() => convertFromDaysToMilliseconds('90')).toThrow();
|
||||
});
|
||||
it('should return the value passed when its valid', () => {
|
||||
expect(convertFromDaysToMilliseconds(85)).toBe(85 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
it('should fail if anything but an integer is passed', () => {
|
||||
expect(() => convertFromDaysToMilliseconds(85.5)).toThrow();
|
||||
expect(() => convertFromDaysToMilliseconds(-2.3)).toThrow();
|
||||
});
|
||||
});
|
||||
7
packages/tools/src/converter.ts
Normal file
7
packages/tools/src/converter.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const convertFromDaysToMilliseconds = (days: number) => {
|
||||
if (typeof days !== 'number' || !Number.isInteger(days)) {
|
||||
throw new Error('days must be a number');
|
||||
}
|
||||
|
||||
return days * 24 * 60 * 60 * 1000;
|
||||
};
|
||||
17
packages/tools/src/escapeHTML.spec.ts
Normal file
17
packages/tools/src/escapeHTML.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { escapeHTML } from './escapeHTML';
|
||||
|
||||
describe('escapeHTML', () => {
|
||||
it('works', () => {
|
||||
expect(escapeHTML('<div>Blah & "blah" & \'blah\'</div>')).toBe(
|
||||
'<div>Blah & "blah" & 'blah'</div>',
|
||||
);
|
||||
expect(escapeHTML('<')).toBe('&lt;');
|
||||
expect(escapeHTML(' ')).toBe(' ');
|
||||
expect(escapeHTML('¢')).toBe('¢');
|
||||
expect(escapeHTML('¢ £ ¥ € © ® ™')).toBe('¢ £ ¥ € © ® ™');
|
||||
expect(escapeHTML(5 as unknown as string)).toBe('5');
|
||||
expect(escapeHTML('')).toBe('');
|
||||
expect(escapeHTML(null as unknown as string)).toBe('');
|
||||
expect(escapeHTML(undefined as unknown as string)).toBe('');
|
||||
});
|
||||
});
|
||||
24
packages/tools/src/escapeHTML.ts
Normal file
24
packages/tools/src/escapeHTML.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { toString } from './toString';
|
||||
|
||||
const characterToHtmlEntityCode = {
|
||||
'¢': 'cent',
|
||||
'£': 'pound',
|
||||
'¥': 'yen',
|
||||
'€': 'euro',
|
||||
'©': 'copy',
|
||||
'®': 'reg',
|
||||
'™': 'trade',
|
||||
'<': 'lt',
|
||||
'>': 'gt',
|
||||
'"': 'quot',
|
||||
'&': 'amp',
|
||||
"'": '#39',
|
||||
} as const;
|
||||
|
||||
const regex = new RegExp(`[${Object.keys(characterToHtmlEntityCode).join('')}]`, 'g');
|
||||
|
||||
const isEscapable = (char: string): char is keyof typeof characterToHtmlEntityCode => char in characterToHtmlEntityCode;
|
||||
|
||||
const escapeChar = (char: string): string => (isEscapable(char) ? `&${characterToHtmlEntityCode[char]};` : '');
|
||||
|
||||
export const escapeHTML = (str: string): string => toString(str).replace(regex, escapeChar);
|
||||
56
packages/tools/src/escapeRegExp.spec.ts
Normal file
56
packages/tools/src/escapeRegExp.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { escapeRegExp } from './escapeRegExp';
|
||||
|
||||
describe('escapeRegExp', () => {
|
||||
it('should keep strings with letters only unchanged', () => {
|
||||
expect(escapeRegExp('word')).toBe('word');
|
||||
});
|
||||
|
||||
it('should escape slashes', () => {
|
||||
expect(escapeRegExp('/slashes/')).toBe('\\/slashes\\/');
|
||||
expect(escapeRegExp('\\backslashes\\')).toBe('\\\\backslashes\\\\');
|
||||
expect(escapeRegExp('\\border of word')).toBe('\\\\border of word');
|
||||
});
|
||||
|
||||
it('should escape special group', () => {
|
||||
expect(escapeRegExp('(?:non-capturing)')).toBe('\\(\\?\\:non\\-capturing\\)');
|
||||
expect(new RegExp(`${escapeRegExp('(?:')}([^)]+)`).exec('(?:non-capturing)')?.[1]).toBe('non-capturing');
|
||||
|
||||
expect(escapeRegExp('(?=positive-lookahead)')).toBe('\\(\\?\\=positive\\-lookahead\\)');
|
||||
expect(new RegExp(`${escapeRegExp('(?=')}([^)]+)`).exec('(?=positive-lookahead)')?.[1]).toBe('positive-lookahead');
|
||||
|
||||
expect(escapeRegExp('(?<=positive-lookbehind)')).toBe('\\(\\?<\\=positive\\-lookbehind\\)');
|
||||
expect(new RegExp(`${escapeRegExp('(?<=')}([^)]+)`).exec('(?<=positive-lookbehind)')?.[1]).toBe('positive-lookbehind');
|
||||
|
||||
expect(escapeRegExp('(?!negative-lookahead)')).toBe('\\(\\?\\!negative\\-lookahead\\)');
|
||||
expect(new RegExp(`${escapeRegExp('(?!')}([^)]+)`).exec('(?!negative-lookahead)')?.[1]).toBe('negative-lookahead');
|
||||
|
||||
expect(escapeRegExp('(?<!negative-lookbehind)')).toBe('\\(\\?<\\!negative\\-lookbehind\\)');
|
||||
expect(new RegExp(`${escapeRegExp('(?<!')}([^)]+)`).exec('(?<!negative-lookbehind)')?.[1]).toBe('negative-lookbehind');
|
||||
|
||||
expect(escapeRegExp('[\\w]+')).toBe('\\[\\\\w\\]\\+');
|
||||
expect(new RegExp(`${escapeRegExp('[')}([^\\]]+)`).exec('[character class]')?.[1]).toBe('character class');
|
||||
|
||||
expect(new RegExp(escapeRegExp('<div>')).exec('<td><div></td>')?.[0]).toBe('<div>');
|
||||
|
||||
expect(escapeRegExp('{5,2}')).toBe('\\{5,2\\}');
|
||||
|
||||
expect(escapeRegExp('/([.*+?^=!:${}()|[\\]\\/\\\\])/g')).toBe(
|
||||
'\\/\\(\\[\\.\\*\\+\\?\\^\\=\\!\\:\\$\\{\\}\\(\\)\\|\\[\\\\\\]\\\\\\/\\\\\\\\\\]\\)\\/g',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not escape whitespace', () => {
|
||||
expect(escapeRegExp('\\n\\r\\t')).toBe('\\\\n\\\\r\\\\t');
|
||||
expect(escapeRegExp('\n\r\t')).toBe('\n\r\t');
|
||||
});
|
||||
|
||||
it('ignores errors from non-string argument', () => {
|
||||
expect(() => escapeRegExp(false as any)).not.toThrow();
|
||||
|
||||
expect(() => (escapeRegExp as any)()).not.toThrow();
|
||||
|
||||
expect(() => escapeRegExp(null as any)).not.toThrow();
|
||||
|
||||
expect(() => escapeRegExp(42 as any)).not.toThrow();
|
||||
});
|
||||
});
|
||||
3
packages/tools/src/escapeRegExp.ts
Normal file
3
packages/tools/src/escapeRegExp.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { toString } from './toString';
|
||||
|
||||
export const escapeRegExp = (input: string): string => toString(input).replace(/[-.*+?^=!:${}()|[\]/\\]/g, '\\$&');
|
||||
65
packages/tools/src/getHeader.spec.ts
Normal file
65
packages/tools/src/getHeader.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
|
||||
import { getHeader } from './getHeader';
|
||||
|
||||
describe('getHeader', () => {
|
||||
describe('default mode (string)', () => {
|
||||
it('returns empty string when headers is undefined', () => {
|
||||
expect(getHeader(undefined as unknown as IncomingHttpHeaders, 'origin')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when header does not exist', () => {
|
||||
expect(getHeader({ origin: 'localhost:3000' }, 'host')).toBe('');
|
||||
});
|
||||
|
||||
it('returns header value when it exists', () => {
|
||||
expect(getHeader({ origin: 'localhost:3000' }, 'origin')).toBe('localhost:3000');
|
||||
});
|
||||
|
||||
it('returns empty string when headers is empty object', () => {
|
||||
expect(getHeader({}, 'origin')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when value is undefined', () => {
|
||||
expect(getHeader({ origin: undefined }, 'origin')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic array mode (string[])', () => {
|
||||
it('returns the correct array when header is array', () => {
|
||||
expect(getHeader<string[]>({ 'x-forwarded-for': ['127.0.0.1', '10.0.0.1'] }, 'x-forwarded-for')).toStrictEqual([
|
||||
'127.0.0.1',
|
||||
'10.0.0.1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when value is empty array', () => {
|
||||
expect(getHeader<string[]>({ 'x-forwarded-for': [] }, 'x-forwarded-for')).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns string even when T is string[] (by design)', () => {
|
||||
expect(getHeader<string[]>({ origin: 'localhost:3000' }, 'origin')).toEqual('localhost:3000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IncomingHttpHeaders support', () => {
|
||||
it('works with IncomingHttpHeaders', () => {
|
||||
const headers: IncomingHttpHeaders = {
|
||||
connection: 'keep-alive',
|
||||
};
|
||||
|
||||
expect(getHeader(headers, 'connection')).toBe('keep-alive');
|
||||
expect(getHeader(headers, 'origin')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Headers support', () => {
|
||||
it('works with Headers', () => {
|
||||
const headers = new Headers();
|
||||
headers.set('host', 'localhost:3000');
|
||||
|
||||
expect(getHeader(headers, 'host')).toBe('localhost:3000');
|
||||
expect(getHeader(headers, 'origin')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
15
packages/tools/src/getHeader.ts
Normal file
15
packages/tools/src/getHeader.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
|
||||
type HeaderLike = IncomingHttpHeaders | Headers | Record<string, string | string[] | undefined>;
|
||||
|
||||
export const getHeader = <T extends string | string[] = string>(headers: HeaderLike, key: string): T => {
|
||||
if (!headers) {
|
||||
return '' as T;
|
||||
}
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
return (headers.get(key) ?? '') as T;
|
||||
}
|
||||
|
||||
return (headers[key] ?? '') as T;
|
||||
};
|
||||
35
packages/tools/src/getLoginExpiration.spec.ts
Normal file
35
packages/tools/src/getLoginExpiration.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getLoginExpirationInDays, getLoginExpirationInMs } from './getLoginExpiration';
|
||||
|
||||
describe('getLoginExpirationInDays', () => {
|
||||
it('should return 90 by default', () => {
|
||||
expect(getLoginExpirationInDays()).toBe(90);
|
||||
});
|
||||
it('should return 90 when value is 0', () => {
|
||||
expect(getLoginExpirationInDays(0)).toBe(90);
|
||||
});
|
||||
it('should return 90 when value is NaN', () => {
|
||||
expect(getLoginExpirationInDays(NaN)).toBe(90);
|
||||
});
|
||||
it('should return 90 when value is negative', () => {
|
||||
expect(getLoginExpirationInDays(-1)).toBe(90);
|
||||
});
|
||||
it('should return 90 when value is undefined', () => {
|
||||
expect(getLoginExpirationInDays(undefined)).toBe(90);
|
||||
});
|
||||
it('should return 90 when value is not a number', () => {
|
||||
// @ts-expect-error - Testing
|
||||
expect(getLoginExpirationInDays('90')).toBe(90);
|
||||
});
|
||||
it('should return the value passed when its valid', () => {
|
||||
expect(getLoginExpirationInDays(85)).toBe(85);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLoginExpirationInMs', () => {
|
||||
it('should return 90 days in milliseconds when no value is passed', () => {
|
||||
expect(getLoginExpirationInMs()).toBe(90 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
it('should return the value passed when its valid', () => {
|
||||
expect(getLoginExpirationInMs(85)).toBe(85 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
16
packages/tools/src/getLoginExpiration.ts
Normal file
16
packages/tools/src/getLoginExpiration.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { convertFromDaysToMilliseconds } from './converter';
|
||||
|
||||
const ACCOUNTS_DEFAULT_LOGIN_EXPIRATION_DAYS = 90;
|
||||
|
||||
// Given a value, validates if it mets the conditions to be a valid login expiration.
|
||||
// Else, returns the default login expiration (which for Meteor is 90 days)
|
||||
export const getLoginExpirationInDays = (expiry?: number) => {
|
||||
if (expiry && typeof expiry === 'number' && !Number.isNaN(expiry) && expiry > 0) {
|
||||
return expiry;
|
||||
}
|
||||
return ACCOUNTS_DEFAULT_LOGIN_EXPIRATION_DAYS;
|
||||
};
|
||||
|
||||
export const getLoginExpirationInMs = (expiry?: number) => {
|
||||
return convertFromDaysToMilliseconds(getLoginExpirationInDays(expiry));
|
||||
};
|
||||
1
packages/tools/src/getObjectKeys.ts
Normal file
1
packages/tools/src/getObjectKeys.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const getObjectKeys = <T extends object>(object: T) => Object.keys(object) as (keyof T)[];
|
||||
24
packages/tools/src/index.ts
Normal file
24
packages/tools/src/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export * from './capitalize';
|
||||
export * from './censorUrl';
|
||||
export * from './converter';
|
||||
export * from './convertPathsIntoSubObjects';
|
||||
export * from './convertSubObjectsIntoPaths';
|
||||
export * from './escapeHTML';
|
||||
export * from './escapeRegExp';
|
||||
export * from './getHeader';
|
||||
export * from './getLoginExpiration';
|
||||
export * from './getObjectKeys';
|
||||
export * from './isAbsoluteURL';
|
||||
export * from './isObject';
|
||||
export * from './isRecord';
|
||||
export * from './isTruthy';
|
||||
export * from './normalizeLanguage';
|
||||
export * from './objectMap';
|
||||
export * from './pick';
|
||||
export * from './removeEmpty';
|
||||
export * from './stream';
|
||||
export * from './timezone';
|
||||
export * from './truncateString';
|
||||
export * from './unescapeHTML';
|
||||
export * from './validateEmail';
|
||||
export * from './wrapExceptions';
|
||||
27
packages/tools/src/isAbsoluteURL.spec.ts
Normal file
27
packages/tools/src/isAbsoluteURL.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { isAbsoluteURL } from './isAbsoluteURL';
|
||||
|
||||
describe('isAbsoluteURL', () => {
|
||||
test.each([
|
||||
['/', false],
|
||||
['test', false],
|
||||
['test/test', false],
|
||||
['.', false],
|
||||
['./test', false],
|
||||
['/absolute/path', false],
|
||||
['relative/path?query=1', false],
|
||||
['ftp://example.com', false],
|
||||
])('should return false for non-absolute URL %# (%s)', (input, expected) => {
|
||||
expect(isAbsoluteURL(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['https://rocket.chat', true],
|
||||
['http://rocket.chat', true],
|
||||
['https://example.com/path?query=1#hash', true],
|
||||
['http://localhost:3000', true],
|
||||
['data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', true],
|
||||
['data:text/plain;charset=utf-8,Hello', true],
|
||||
])('should return true for absolute URL %# (%s)', (input, expected) => {
|
||||
expect(isAbsoluteURL(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
1
packages/tools/src/isAbsoluteURL.ts
Normal file
1
packages/tools/src/isAbsoluteURL.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const isAbsoluteURL = (str: string): boolean => /^(https?:\/\/|data:)/.test(str);
|
||||
4
packages/tools/src/isObject.ts
Normal file
4
packages/tools/src/isObject.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isObject(obj: unknown): obj is object {
|
||||
const type = typeof obj;
|
||||
return type === 'function' || (type === 'object' && !!obj);
|
||||
}
|
||||
48
packages/tools/src/isRecord.spec.ts
Normal file
48
packages/tools/src/isRecord.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { isRecord } from './isRecord';
|
||||
|
||||
describe('isRecord', () => {
|
||||
test.each([
|
||||
['case-1', undefined, false],
|
||||
['case-2', null, false],
|
||||
['case-3', 0, false],
|
||||
['case-4', false, false],
|
||||
['case-5', '', false],
|
||||
])('should return false for falsy values %# (%s)', (_caseId, input, expected) => {
|
||||
expect(isRecord(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['case-1', undefined, false],
|
||||
['case-2', null, false],
|
||||
['case-3', 20, false],
|
||||
['case-4', true, false],
|
||||
['case-5', 'string', false],
|
||||
])('should return false for non-objects %# (%s)', (_caseId, input, expected) => {
|
||||
expect(isRecord(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['case-1', {}, true],
|
||||
['case-2', { prop: 'value' }, true],
|
||||
['case-3', Object.fromEntries([]), true],
|
||||
['case-4', Object.create(null), true],
|
||||
['case-5', { 0: 'zero', 1: 'one' }, true],
|
||||
['case-6', { [Symbol('key')]: 'value' }, true],
|
||||
// we testing, we ignore eslint
|
||||
// eslint-disable-next-line no-new-wrappers
|
||||
['case-7', new String('string'), false],
|
||||
// eslint-disable-next-line no-new-wrappers
|
||||
['case-8', new Number(1), false],
|
||||
['case-9', [], false],
|
||||
])('should return true for records %# (%s)', (_caseId, input, expected) => {
|
||||
expect(isRecord(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['case-1', new Date(), false],
|
||||
['case-2', [], false],
|
||||
['case-2', [{}], false],
|
||||
])('should return false for objects that are not records %# (%s)', (_caseId, input, expected) => {
|
||||
expect(isRecord(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
17
packages/tools/src/isRecord.ts
Normal file
17
packages/tools/src/isRecord.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function isRecord(record: unknown): record is Record<string | number | symbol, unknown> {
|
||||
if (!record || typeof record !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(record)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(record);
|
||||
|
||||
if (prototype === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return prototype.constructor === Object;
|
||||
}
|
||||
1
packages/tools/src/isTruthy.ts
Normal file
1
packages/tools/src/isTruthy.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const isTruthy = <T>(x: T | null | undefined | 0 | false | ''): x is T => Boolean(x);
|
||||
10
packages/tools/src/normalizeLanguage.ts
Normal file
10
packages/tools/src/normalizeLanguage.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const normalizeLanguage = (language: string): string => {
|
||||
// Fix browsers having all-lowercase language settings eg. pt-br, en-us
|
||||
const regex = /([a-z]{2,3})-([a-z]{2,4})/;
|
||||
const matches = regex.exec(language);
|
||||
if (matches) {
|
||||
return `${matches[1]}-${matches[2].toUpperCase()}`;
|
||||
}
|
||||
|
||||
return language;
|
||||
};
|
||||
93
packages/tools/src/objectMap.spec.ts
Normal file
93
packages/tools/src/objectMap.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { expect } from 'chai';
|
||||
|
||||
import { objectMap } from './objectMap';
|
||||
|
||||
describe('objectMap', () => {
|
||||
it('should map a simple object non-recursively', () => {
|
||||
const input = { a: 1, b: 2, c: 3 };
|
||||
const callback = ({ key, value }) => ({ key: key.toUpperCase(), value: value * 2 });
|
||||
const expected = { A: 2, B: 4, C: 6 };
|
||||
expect(objectMap(input, callback)).to.deep.equal(expected);
|
||||
});
|
||||
it('should filter out undefined results from callback', () => {
|
||||
const input = { a: 1, b: 2, c: 3 };
|
||||
const callback = ({ key, value }) => (value > 1 ? { key, value } : undefined);
|
||||
const expected = { b: 2, c: 3 };
|
||||
expect(objectMap(input, callback)).to.deep.equal(expected);
|
||||
});
|
||||
it('should map a nested object recursively', () => {
|
||||
const input = {
|
||||
a: 1,
|
||||
b: {
|
||||
c: 2,
|
||||
d: {
|
||||
e: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
const callback = ({ key, value }) => ({ key: `mapped_${key}`, value: typeof value === 'number' ? value * 10 : value });
|
||||
const expected = {
|
||||
mapped_a: 10,
|
||||
mapped_b: {
|
||||
mapped_c: 20,
|
||||
mapped_d: {
|
||||
mapped_e: 30,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(objectMap(input, callback, true)).to.deep.equal(expected);
|
||||
});
|
||||
it('should handle an empty object', () => {
|
||||
const input = {};
|
||||
const callback = ({ key, value }) => ({ key: `mapped_${key}`, value });
|
||||
const expected = {};
|
||||
expect(objectMap(input, callback)).to.deep.equal(expected);
|
||||
});
|
||||
it('should handle mixed value types in non-recursive mode', () => {
|
||||
const input = {
|
||||
a: 1,
|
||||
b: 'string',
|
||||
c: true,
|
||||
d: null,
|
||||
};
|
||||
const callback = ({ key, value }) => ({ key: key.toUpperCase(), value: typeof value === 'number' ? value * 2 : value });
|
||||
const expected = {
|
||||
A: 2,
|
||||
B: 'string',
|
||||
C: true,
|
||||
D: null,
|
||||
};
|
||||
expect(objectMap(input, callback)).to.deep.equal(expected);
|
||||
});
|
||||
it('should handle nested objects with mixed types recursively', () => {
|
||||
const input = {
|
||||
a: 1,
|
||||
b: {
|
||||
c: 'string',
|
||||
d: {
|
||||
e: true,
|
||||
f: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
const callback = ({ key, value }) => ({ key: key.toUpperCase(), value });
|
||||
const expected = {
|
||||
A: 1,
|
||||
B: {
|
||||
C: 'string',
|
||||
D: {
|
||||
E: true,
|
||||
F: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(objectMap(input, callback, true)).to.deep.equal(expected);
|
||||
});
|
||||
it('should not modify the original object', () => {
|
||||
const input = { a: 1, b: 2 };
|
||||
const original = { ...input };
|
||||
const callback = ({ key, value }) => ({ key, value: value * 2 });
|
||||
objectMap(input, callback);
|
||||
expect(input).to.deep.equal(original);
|
||||
});
|
||||
});
|
||||
35
packages/tools/src/objectMap.ts
Normal file
35
packages/tools/src/objectMap.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export function objectMap<TObject extends Record<string, any> = Record<string, any>, K extends keyof TObject | string = keyof TObject>(
|
||||
object: TObject,
|
||||
cb: (value: { key: K; value: TObject[K] }) => { key: string | number | symbol; value: any } | undefined,
|
||||
recursive?: false,
|
||||
): Record<string, any>;
|
||||
export function objectMap<TObject extends Record<string, any> = Record<string, any>>(
|
||||
object: TObject,
|
||||
cb: (value: { key: string | number | symbol; value: any }) => { key: string | number | symbol; value: any } | undefined,
|
||||
recursive: true,
|
||||
): Record<string, any>;
|
||||
export function objectMap<TObject extends Record<string, any> = Record<string, any>, K extends keyof TObject | string = keyof TObject>(
|
||||
object: TObject,
|
||||
cb: (value: { key: K; value: any }) => { key: string | number | symbol; value: any } | undefined,
|
||||
recursive: false,
|
||||
): Record<string, any>;
|
||||
export function objectMap<TObject extends Record<string, any> = Record<string, any>, K extends keyof TObject | string = keyof TObject>(
|
||||
object: TObject,
|
||||
cb: (value: { key: K | string; value: any }) => { key: string | number | symbol; value: any } | undefined,
|
||||
recursive = false,
|
||||
): Record<string, any> {
|
||||
return Object.fromEntries(
|
||||
Object.keys(object)
|
||||
.map((key) => {
|
||||
const value = object[key as K];
|
||||
if (recursive && value && typeof value === 'object' && !Array.isArray(value) && !((value as any) instanceof Date)) {
|
||||
const newValue = objectMap(value, cb as any, true);
|
||||
return cb({ key, value: newValue });
|
||||
}
|
||||
|
||||
return cb({ key, value });
|
||||
})
|
||||
.filter((item) => !!item)
|
||||
.map((item) => [item.key, item.value]),
|
||||
);
|
||||
}
|
||||
14
packages/tools/src/pick.ts
Normal file
14
packages/tools/src/pick.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function pick<TObject extends Record<string, any>, TKey extends keyof TObject>(
|
||||
object: TObject,
|
||||
...attributes: TKey[]
|
||||
): Pick<TObject, TKey> {
|
||||
return {
|
||||
...attributes.reduce(
|
||||
(data, key) => ({
|
||||
...data,
|
||||
...(key in object ? { [key]: object[key] } : {}),
|
||||
}),
|
||||
{} as TObject,
|
||||
),
|
||||
};
|
||||
}
|
||||
87
packages/tools/src/removeEmpty.spec.ts
Normal file
87
packages/tools/src/removeEmpty.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { removeEmpty } from './removeEmpty';
|
||||
|
||||
describe('removeEmpty', () => {
|
||||
it('should remove null props', () => {
|
||||
const obj = { a: 1, b: null };
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('should remove undefined props', () => {
|
||||
const obj = { a: 1, b: undefined };
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('should not remove empty strings', () => {
|
||||
const obj = { a: 1, b: '' };
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({ a: 1, b: '' });
|
||||
});
|
||||
|
||||
it('should not remove empty arrays', () => {
|
||||
const obj = { a: 1, b: [] };
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({ a: 1, b: [] });
|
||||
});
|
||||
|
||||
it('should not remove empty objects', () => {
|
||||
const obj = { a: 1, b: {} };
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({ a: 1, b: {} });
|
||||
});
|
||||
|
||||
it('should not remove 0', () => {
|
||||
const obj = { a: 1, b: 0 };
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({ a: 1, b: 0 });
|
||||
});
|
||||
|
||||
it('should not remove false', () => {
|
||||
const obj = { a: 1, b: false };
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({ a: 1, b: false });
|
||||
});
|
||||
|
||||
it('should not remove NaN', () => {
|
||||
const obj = { a: 1, b: NaN };
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({ a: 1, b: NaN });
|
||||
});
|
||||
|
||||
it('should not remove Infinity', () => {
|
||||
const obj = { a: 1, b: Infinity };
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({ a: 1, b: Infinity });
|
||||
});
|
||||
|
||||
it('should not remove functions', () => {
|
||||
const fn = () => {
|
||||
// noop
|
||||
};
|
||||
|
||||
const obj = {
|
||||
a: 1,
|
||||
fn,
|
||||
};
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({
|
||||
a: 1,
|
||||
fn,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not remove symbols', () => {
|
||||
const b = Symbol('test');
|
||||
|
||||
const obj = { a: 1, b };
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({ a: 1, b });
|
||||
});
|
||||
|
||||
it('should not remove objects with non-empty props', () => {
|
||||
const obj = { a: 1, b: { c: 2 } };
|
||||
|
||||
expect(removeEmpty(obj)).toEqual({ a: 1, b: { c: 2 } });
|
||||
});
|
||||
});
|
||||
7
packages/tools/src/removeEmpty.ts
Normal file
7
packages/tools/src/removeEmpty.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
type NonEmpty<T> = {
|
||||
[K in keyof T]: Exclude<T[K], null | undefined>;
|
||||
};
|
||||
|
||||
export function removeEmpty<T extends Record<string, any>>(obj: T): NonEmpty<T> {
|
||||
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v != null)) as NonEmpty<T>;
|
||||
}
|
||||
13
packages/tools/src/stream.ts
Normal file
13
packages/tools/src/stream.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
export const streamToBuffer = (stream: Readable): Promise<Buffer> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const chunks: Array<Buffer> = [];
|
||||
|
||||
stream
|
||||
.on('data', (data) => chunks.push(data))
|
||||
.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
.on('error', (error) => reject(error))
|
||||
// force stream to resume data flow in case it was explicitly paused before
|
||||
.resume();
|
||||
});
|
||||
57
packages/tools/src/timezone.spec.ts
Normal file
57
packages/tools/src/timezone.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { canonicalizeTimezone, getTimezoneNames } from './timezone';
|
||||
|
||||
describe('canonicalizeTimezone', () => {
|
||||
it('returns the same value for a canonical IANA zone', () => {
|
||||
expect(canonicalizeTimezone('America/Sao_Paulo')).toBe('America/Sao_Paulo');
|
||||
});
|
||||
|
||||
it('resolves plain UTC to UTC', () => {
|
||||
expect(canonicalizeTimezone('UTC')).toBe('UTC');
|
||||
});
|
||||
|
||||
it('resolves Etc/UTC and Etc/GMT to UTC', () => {
|
||||
expect(canonicalizeTimezone('Etc/UTC')).toBe('UTC');
|
||||
expect(canonicalizeTimezone('Etc/GMT')).toBe('UTC');
|
||||
});
|
||||
|
||||
it('resolves legacy moment aliases to their canonical zone', () => {
|
||||
expect(canonicalizeTimezone('GMT')).toBe('UTC');
|
||||
expect(canonicalizeTimezone('Zulu')).toBe('UTC');
|
||||
expect(canonicalizeTimezone('Universal')).toBe('UTC');
|
||||
expect(canonicalizeTimezone('US/Pacific')).toBe('America/Los_Angeles');
|
||||
expect(canonicalizeTimezone('Japan')).toBe('Asia/Tokyo');
|
||||
});
|
||||
|
||||
it('resolves legacy IANA names to modern canonical names', () => {
|
||||
expect(canonicalizeTimezone('Asia/Calcutta')).toBe('Asia/Kolkata');
|
||||
expect(canonicalizeTimezone('Asia/Katmandu')).toBe('Asia/Kathmandu');
|
||||
expect(canonicalizeTimezone('Asia/Rangoon')).toBe('Asia/Yangon');
|
||||
expect(canonicalizeTimezone('Asia/Saigon')).toBe('Asia/Ho_Chi_Minh');
|
||||
expect(canonicalizeTimezone('Europe/Kiev')).toBe('Europe/Kyiv');
|
||||
expect(canonicalizeTimezone('America/Godthab')).toBe('America/Nuuk');
|
||||
expect(canonicalizeTimezone('Pacific/Enderbury')).toBe('Pacific/Kanton');
|
||||
});
|
||||
|
||||
it('preserves modern canonical names as-is', () => {
|
||||
expect(canonicalizeTimezone('Asia/Kolkata')).toBe('Asia/Kolkata');
|
||||
expect(canonicalizeTimezone('Europe/Kyiv')).toBe('Europe/Kyiv');
|
||||
expect(canonicalizeTimezone('Asia/Ho_Chi_Minh')).toBe('Asia/Ho_Chi_Minh');
|
||||
});
|
||||
|
||||
it('returns the input unchanged when it is not a recognized zone', () => {
|
||||
const input = 'Not/A_Zone';
|
||||
expect(canonicalizeTimezone(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimezoneNames', () => {
|
||||
it('returns modern canonical names instead of legacy ones', () => {
|
||||
const names = getTimezoneNames();
|
||||
expect(names).toContain('Asia/Kolkata');
|
||||
expect(names).not.toContain('Asia/Calcutta');
|
||||
expect(names).toContain('Europe/Kyiv');
|
||||
expect(names).not.toContain('Europe/Kiev');
|
||||
expect(names).toContain('Asia/Yangon');
|
||||
expect(names).not.toContain('Asia/Rangoon');
|
||||
});
|
||||
});
|
||||
76
packages/tools/src/timezone.ts
Normal file
76
packages/tools/src/timezone.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Zones where Node/browser Intl returns a legacy IANA name instead of the
|
||||
// current canonical one. Workaround until Temporal lands (~late 2026).
|
||||
// Source: https://data.iana.org/time-zones/tzdb/backward
|
||||
// Ref: https://github.com/tc39/proposal-temporal/issues/3249
|
||||
const LEGACY_TO_CANONICAL: Record<string, string> = {
|
||||
'America/Buenos_Aires': 'America/Argentina/Buenos_Aires',
|
||||
'America/Catamarca': 'America/Argentina/Catamarca',
|
||||
'America/Cordoba': 'America/Argentina/Cordoba',
|
||||
'America/Godthab': 'America/Nuuk',
|
||||
'America/Indianapolis': 'America/Indiana/Indianapolis',
|
||||
'America/Jujuy': 'America/Argentina/Jujuy',
|
||||
'America/Louisville': 'America/Kentucky/Louisville',
|
||||
'America/Mendoza': 'America/Argentina/Mendoza',
|
||||
'Asia/Calcutta': 'Asia/Kolkata',
|
||||
'Asia/Katmandu': 'Asia/Kathmandu',
|
||||
'Asia/Rangoon': 'Asia/Yangon',
|
||||
'Asia/Saigon': 'Asia/Ho_Chi_Minh',
|
||||
'Atlantic/Faeroe': 'Atlantic/Faroe',
|
||||
'Europe/Kiev': 'Europe/Kyiv',
|
||||
'Pacific/Enderbury': 'Pacific/Kanton',
|
||||
};
|
||||
|
||||
export const canonicalizeTimezone = (name: string): string => {
|
||||
try {
|
||||
const resolved = new Intl.DateTimeFormat(undefined, { timeZone: name }).resolvedOptions().timeZone;
|
||||
return LEGACY_TO_CANONICAL[resolved] ?? resolved;
|
||||
} catch {
|
||||
return name;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTimezoneNames = (): string[] => {
|
||||
const intl = Intl as typeof Intl & { supportedValuesOf?(key: 'timeZone'): string[] };
|
||||
const zones = typeof intl.supportedValuesOf === 'function' ? intl.supportedValuesOf('timeZone') : [];
|
||||
return zones.map((name) => LEGACY_TO_CANONICAL[name] ?? name).sort();
|
||||
};
|
||||
|
||||
export const guessTimezoneFromOffset = (offset: string | number): string => {
|
||||
const hours = Number(offset);
|
||||
const totalMinutes = Math.round(hours * 60);
|
||||
|
||||
const supportedZones = Intl.supportedValuesOf('timeZone');
|
||||
const now = new Date();
|
||||
|
||||
for (const tz of supportedZones) {
|
||||
// Skip synthetic Etc/ zones — prefer geographic names (DST-aware)
|
||||
if (tz.startsWith('Etc/')) {
|
||||
continue;
|
||||
}
|
||||
const formatter = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' });
|
||||
const parts = formatter.formatToParts(now);
|
||||
const tzPart = parts.find((p) => p.type === 'timeZoneName')?.value ?? '';
|
||||
// tzPart looks like "GMT", "GMT+5:30", "GMT-3"
|
||||
const match = tzPart.match(/^GMT([+-]\d{1,2}(?::(\d{2}))?)?$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const tzHours = match[1] ? parseInt(match[1], 10) : 0;
|
||||
const tzMinutes = match[2] ? parseInt(match[2], 10) * (tzHours < 0 ? -1 : 1) : 0;
|
||||
if (tzHours * 60 + tzMinutes === totalMinutes) {
|
||||
return LEGACY_TO_CANONICAL[tz] ?? tz;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Etc/GMT when no geographic zone matches
|
||||
const intHours = Math.trunc(hours);
|
||||
if (intHours === 0) {
|
||||
return 'Etc/GMT';
|
||||
}
|
||||
return `Etc/GMT${intHours > 0 ? '-' : '+'}${Math.abs(intHours)}`;
|
||||
};
|
||||
|
||||
export const guessTimezone = (): string => {
|
||||
const resolved = new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return LEGACY_TO_CANONICAL[resolved] ?? resolved;
|
||||
};
|
||||
2
packages/tools/src/toString.ts
Normal file
2
packages/tools/src/toString.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
|
||||
export const toString = (object: unknown): string => (object ? `${object}` : '');
|
||||
23
packages/tools/src/truncateString.ts
Normal file
23
packages/tools/src/truncateString.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Truncates a string to a specified maximum length, optionally adding ellipses.
|
||||
* @param str
|
||||
* @param maxLength
|
||||
* @param shouldAddEllipses
|
||||
* @return {string}
|
||||
*/
|
||||
export function truncateString(str: string, maxLength: number, shouldAddEllipses = true): string {
|
||||
const ellipsis = '...';
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
|
||||
if (shouldAddEllipses && str.length > maxLength) {
|
||||
if (maxLength <= ellipsis.length) {
|
||||
return str.slice(0, maxLength);
|
||||
}
|
||||
|
||||
return `${str.slice(0, maxLength - ellipsis.length)}${ellipsis}`;
|
||||
}
|
||||
|
||||
return str.slice(0, maxLength);
|
||||
}
|
||||
30
packages/tools/src/unescapeHTML.spec.ts
Normal file
30
packages/tools/src/unescapeHTML.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { unescapeHTML } from './unescapeHTML';
|
||||
|
||||
describe('unescapeHTML', () => {
|
||||
it('works', () => {
|
||||
expect(unescapeHTML('<div>Blah & "blah" & 'blah'</div>')).toBe(
|
||||
'<div>Blah & "blah" & \'blah\'</div>',
|
||||
);
|
||||
expect(unescapeHTML('&lt;')).toBe('<');
|
||||
expect(unescapeHTML(''')).toBe("'");
|
||||
expect(unescapeHTML(''')).toBe("'");
|
||||
expect(unescapeHTML(''')).toBe("'");
|
||||
expect(unescapeHTML('J')).toBe('J');
|
||||
expect(unescapeHTML('J')).toBe('J');
|
||||
expect(unescapeHTML('J')).toBe('J');
|
||||
expect(unescapeHTML('&_#39;')).toBe('&_#39;');
|
||||
expect(unescapeHTML(''_;')).toBe(''_;');
|
||||
expect(unescapeHTML('&#38;')).toBe('&');
|
||||
expect(unescapeHTML('&amp;')).toBe('&');
|
||||
expect(unescapeHTML('')).toBe('');
|
||||
expect(unescapeHTML(' ')).toBe(' ');
|
||||
expect(unescapeHTML('what is the ¥ to £ to € conversion process?')).toBe('what is the ¥ to £ to € conversion process?');
|
||||
expect(unescapeHTML('® trademark')).toBe('® trademark');
|
||||
expect(unescapeHTML('™ unregistered trademark')).toBe('™ unregistered trademark');
|
||||
expect(unescapeHTML('© 1992. License available for 50 ¢')).toBe('© 1992. License available for 50 ¢');
|
||||
|
||||
expect(unescapeHTML(null as unknown as string)).toBe('');
|
||||
expect(unescapeHTML(undefined as unknown as string)).toBe('');
|
||||
expect(unescapeHTML(5 as unknown as string)).toBe('5');
|
||||
});
|
||||
});
|
||||
41
packages/tools/src/unescapeHTML.ts
Normal file
41
packages/tools/src/unescapeHTML.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { toString } from './toString';
|
||||
|
||||
const htmlEntityCodeToCharacter = {
|
||||
nbsp: ' ',
|
||||
cent: '¢',
|
||||
pound: '£',
|
||||
yen: '¥',
|
||||
euro: '€',
|
||||
copy: '©',
|
||||
reg: '®',
|
||||
trade: '™',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
amp: '&',
|
||||
apos: "'",
|
||||
} as const;
|
||||
|
||||
const isHtmlEntityCode = (htmlEntityCode: string): htmlEntityCode is keyof typeof htmlEntityCodeToCharacter =>
|
||||
htmlEntityCode in htmlEntityCodeToCharacter;
|
||||
|
||||
export const unescapeHTML = (str: string): string =>
|
||||
toString(str).replace(/\&([^;]{1,10});/g, (entity, htmlEntityCode) => {
|
||||
let match;
|
||||
|
||||
if (isHtmlEntityCode(htmlEntityCode)) {
|
||||
return htmlEntityCodeToCharacter[htmlEntityCode];
|
||||
}
|
||||
|
||||
match = htmlEntityCode.match(/^#x([\da-fA-F]+)$/);
|
||||
if (match) {
|
||||
return String.fromCharCode(parseInt(match[1], 16));
|
||||
}
|
||||
|
||||
match = htmlEntityCode.match(/^#(\d+)$/);
|
||||
if (match) {
|
||||
return String.fromCharCode(~~match[1]);
|
||||
}
|
||||
|
||||
return entity;
|
||||
});
|
||||
13
packages/tools/src/validateEmail.ts
Normal file
13
packages/tools/src/validateEmail.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const validateEmail = (email: string, options: { style: string } = { style: 'basic' }): boolean => {
|
||||
const basicEmailRegex = /^[^@]+@[^@]+$/;
|
||||
const rfcEmailRegex =
|
||||
/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
|
||||
switch (options.style) {
|
||||
case 'rfc':
|
||||
return rfcEmailRegex.test(email);
|
||||
case 'basic':
|
||||
default:
|
||||
return basicEmailRegex.test(email);
|
||||
}
|
||||
};
|
||||
47
packages/tools/src/wrapExceptions.ts
Normal file
47
packages/tools/src/wrapExceptions.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
const isPromise = <T>(value: unknown): value is Promise<T> => !!value && value instanceof Promise;
|
||||
|
||||
export function wrapExceptions<T = any>(
|
||||
getter: () => T,
|
||||
): {
|
||||
catch: (errorWrapper: (error: any) => T) => T;
|
||||
suppress: (errorWrapper?: (error: any) => void) => T | undefined;
|
||||
};
|
||||
export function wrapExceptions<T = any>(
|
||||
getter: () => Promise<T>,
|
||||
): {
|
||||
catch: (errorWrapper: (error: any) => T | Awaited<T>) => Promise<T>;
|
||||
suppress: (errorWrapper?: (error: any) => void) => Promise<T | undefined>;
|
||||
};
|
||||
export function wrapExceptions<T>(getter: () => T) {
|
||||
const doCatch = (errorWrapper: (error: any) => T | Awaited<T>): T => {
|
||||
try {
|
||||
const value = getter();
|
||||
if (isPromise(value)) {
|
||||
return value.catch(errorWrapper) as T;
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
return errorWrapper(error);
|
||||
}
|
||||
};
|
||||
|
||||
const doSuppress = (errorWrapper?: (error: any) => void) => {
|
||||
try {
|
||||
const value = getter();
|
||||
if (isPromise(value)) {
|
||||
return value.catch((error) => errorWrapper?.(error));
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
errorWrapper?.(error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
catch: doCatch,
|
||||
suppress: doSuppress,
|
||||
};
|
||||
}
|
||||
4
packages/tools/tsconfig.build.json
Normal file
4
packages/tools/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["./**/*.spec.ts"]
|
||||
}
|
||||
10
packages/tools/tsconfig.json
Normal file
10
packages/tools/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["es2022"]
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user