Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e32e5d6f71 | |||
| 41bc5e7eb5 | |||
| fda92e46d5 | |||
| 1be2c1118f | |||
| ead8fcffc0 | |||
| 367b026cea | |||
| 1b5e16ae5f | |||
| 1b0061b714 | |||
| 3048b74b2f | |||
| 49959f3060 | |||
| cabfc93773 | |||
| 38207eb618 | |||
| 1352e69895 | |||
| 1b05af09fb | |||
| 32c61fff42 | |||
| df517b6078 | |||
| 1e54ee57a1 | |||
| b6f8da512c | |||
| 9cf86f4be1 | |||
| 2240c93db5 | |||
| 3e9052c4a7 | |||
| 326f6fe851 | |||
| 5b57b67542 | |||
| bcc76c7561 | |||
| 67c2e8c434 | |||
| c88d96bcfa |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@ node_modules
|
||||
uploads
|
||||
public/momentum*js
|
||||
junit.xml
|
||||
/docs/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
5
.gitmodules
vendored
5
.gitmodules
vendored
@@ -1,5 +0,0 @@
|
||||
[submodule "docs"]
|
||||
path = docs
|
||||
url = ../utils.wiki.git
|
||||
branch = master
|
||||
ignore = all
|
||||
1
docs
1
docs
Submodule docs deleted from dbf5d8cd07
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ztimson/utils",
|
||||
"version": "0.27.9",
|
||||
"version": "0.28.11",
|
||||
"description": "Utility library",
|
||||
"author": "Zak Timson",
|
||||
"license": "MIT",
|
||||
@@ -30,15 +30,15 @@
|
||||
"var-persist": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"fake-indexeddb": "^6.0.1",
|
||||
"jest": "^29.7.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jest": "^30.2.0",
|
||||
"jest-junit": "^16.0.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typedoc": "^0.26.7",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12",
|
||||
"vite-plugin-dts": "^4.5.3"
|
||||
"ts-jest": "^29.4.5",
|
||||
"typedoc": "^0.28.15",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vite-plugin-dts": "^4.5.4"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -122,6 +122,8 @@ export function sortByProp(prop: string, reverse = false) {
|
||||
return function (a: any, b: any) {
|
||||
const aVal = dotNotation<any>(a, prop);
|
||||
const bVal = dotNotation<any>(b, prop);
|
||||
if(aVal === undefined) return 1;
|
||||
if(bVal === undefined) return -1;
|
||||
if(typeof aVal == 'number' && typeof bVal == 'number')
|
||||
return (reverse ? -1 : 1) * (aVal - bVal);
|
||||
if(aVal > bVal) return reverse ? -1 : 1;
|
||||
|
||||
79
src/cache.ts
79
src/cache.ts
@@ -85,6 +85,7 @@ export class Cache<K extends string | number | symbol, T> {
|
||||
return <K>(value as any)[this.key];
|
||||
}
|
||||
|
||||
/** Save item to storage */
|
||||
private save(key?: K) {
|
||||
const persists: {storage: any, key: string} = <any>this.options.persistentStorage;
|
||||
if(!!persists?.storage) {
|
||||
@@ -131,16 +132,13 @@ export class Cache<K extends string | number | symbol, T> {
|
||||
const out: CachedValue<T>[] = [];
|
||||
for(const v of this.store.values()) {
|
||||
const val: any = v;
|
||||
if(expired || !val?._expired) out.push(deepCopy<any>(val));
|
||||
if(expired || !val?._expired) out.push(deepCopy(val));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new item to the cache. Like set, but finds key automatically
|
||||
* @param {T} value Item to add to cache
|
||||
* @param {number | undefined} ttl Override default expiry
|
||||
* @return {this}
|
||||
*/
|
||||
add(value: T, ttl = this.ttl): this {
|
||||
const key = this.getKey(value);
|
||||
@@ -150,9 +148,6 @@ export class Cache<K extends string | number | symbol, T> {
|
||||
|
||||
/**
|
||||
* Add several rows to the cache
|
||||
* @param {T[]} rows Several items that will be cached using the default key
|
||||
* @param complete Mark cache as complete & reliable, defaults to true
|
||||
* @return {this}
|
||||
*/
|
||||
addAll(rows: T[], complete = true): this {
|
||||
this.clear();
|
||||
@@ -161,9 +156,7 @@ export class Cache<K extends string | number | symbol, T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all keys from cache
|
||||
*/
|
||||
/** Remove all keys */
|
||||
clear(): this {
|
||||
this.complete = false;
|
||||
for (const [k, t] of this.timers) clearTimeout(t);
|
||||
@@ -174,10 +167,7 @@ export class Cache<K extends string | number | symbol, T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an item from the cache
|
||||
* @param {K} key Item's primary key
|
||||
*/
|
||||
/** Delete a cached item */
|
||||
delete(key: K): this {
|
||||
this.clearTimer(key);
|
||||
const idx = this.lruOrder.indexOf(key);
|
||||
@@ -187,23 +177,17 @@ export class Cache<K extends string | number | symbol, T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return cache as an array of key-value pairs
|
||||
* @return {[K, T][]} Key-value pairs array
|
||||
*/
|
||||
/** Return entries as array */
|
||||
entries(expired?: boolean): [K, CachedValue<T>][] {
|
||||
const out: [K, CachedValue<T>][] = [];
|
||||
for(const [k, v] of this.store.entries()) {
|
||||
const val: any = v;
|
||||
if(expired || !val?._expired) out.push([k, deepCopy<any>(val)]);
|
||||
if(expired || !val?._expired) out.push([k, deepCopy(val)]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually expire a cached item
|
||||
* @param {K} key Key to expire
|
||||
*/
|
||||
/** Manually expire a cached item */
|
||||
expire(key: K): this {
|
||||
this.complete = false;
|
||||
if(this.options.expiryPolicy == 'keep') {
|
||||
@@ -217,39 +201,26 @@ export class Cache<K extends string | number | symbol, T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first cached item to match a filter
|
||||
* @param {Partial<T>} filter Partial item to match
|
||||
* @param {Boolean} expired Include expired items, defaults to false
|
||||
* @returns {T | undefined} Cached item or undefined if nothing matched
|
||||
*/
|
||||
/** Find first matching item */
|
||||
find(filter: Partial<T>, expired?: boolean): T | undefined {
|
||||
for(const v of this.store.values()) {
|
||||
const row: any = v;
|
||||
if((expired || !row._expired) && includes(row, filter)) return deepCopy<any>(row);
|
||||
if((expired || !row._expired) && includes(row, filter)) return deepCopy(row);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item from the cache
|
||||
* @param {K} key Key to lookup
|
||||
* @param expired Include expired items
|
||||
* @return {T} Cached item
|
||||
*/
|
||||
/** Get cached item by key */
|
||||
get(key: K, expired?: boolean): CachedValue<T> | null {
|
||||
const raw = this.store.get(key);
|
||||
if(raw == null) return null;
|
||||
const cached: any = deepCopy<any>(raw);
|
||||
this.touchLRU(key);
|
||||
if(expired || !cached?._expired) return cached;
|
||||
const isExpired = (raw as any)?._expired;
|
||||
if(expired || !isExpired) return deepCopy(raw);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of cached keys
|
||||
* @return {K[]} Array of keys
|
||||
*/
|
||||
/** Return list of keys */
|
||||
keys(expired?: boolean): K[] {
|
||||
const out: K[] = [];
|
||||
for(const [k, v] of this.store.entries()) {
|
||||
@@ -259,26 +230,17 @@ export class Cache<K extends string | number | symbol, T> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get map of cached items
|
||||
* @return {Record<K, T>}
|
||||
*/
|
||||
/** Return map of key → item */
|
||||
map(expired?: boolean): Record<K, CachedValue<T>> {
|
||||
const copy: any = {};
|
||||
for(const [k, v] of this.store.entries()) {
|
||||
const val: any = v;
|
||||
if(expired || !val?._expired) copy[k as any] = deepCopy<any>(val);
|
||||
if(expired || !val?._expired) copy[k as any] = deepCopy(val);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to the cache manually specifying the key
|
||||
* @param {K} key Key item will be cached under
|
||||
* @param {T} value Item to cache
|
||||
* @param {number | undefined} ttl Override default expiry in seconds
|
||||
* @return {this}
|
||||
*/
|
||||
/** Add item manually specifying the key */
|
||||
set(key: K, value: T, ttl = this.options.ttl): this {
|
||||
if(this.options.expiryPolicy == 'keep') delete (<any>value)._expired;
|
||||
this.clearTimer(key);
|
||||
@@ -289,15 +251,12 @@ export class Cache<K extends string | number | symbol, T> {
|
||||
const t = setTimeout(() => {
|
||||
this.expire(key);
|
||||
this.save(key);
|
||||
}, (ttl || 0) * 1000);
|
||||
}, ttl * 1000);
|
||||
this.timers.set(key, t);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached items
|
||||
* @return {T[]} Array of items
|
||||
*/
|
||||
values = this.all
|
||||
/** Get all cached items */
|
||||
values = this.all;
|
||||
}
|
||||
|
||||
@@ -113,6 +113,18 @@ export class NotAcceptableError extends CustomError {
|
||||
}
|
||||
}
|
||||
|
||||
export class TooManyRequestsError extends CustomError {
|
||||
static code = 429;
|
||||
|
||||
constructor(message: string = 'Rate Limit Reached') {
|
||||
super(message);
|
||||
}
|
||||
|
||||
static instanceof(err: Error) {
|
||||
return (<any>err).constructor.code == this.code;
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerError extends CustomError {
|
||||
static code = 500;
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ export * from './path-events';
|
||||
export * from './promise-progress';
|
||||
export * from './search';
|
||||
export * from './string';
|
||||
export * from './template';
|
||||
export * from './time';
|
||||
export * from './tts';
|
||||
export * from './types';
|
||||
export * from 'var-persist';
|
||||
|
||||
17
src/misc.ts
17
src/misc.ts
@@ -76,6 +76,23 @@ export function gravatar(email: string, def='mp') {
|
||||
return `https://www.gravatar.com/avatar/${md5(email)}?d=${def}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IP address falls within CIDR range
|
||||
* @param {string} ip IPV4 to check (192.168.0.12)
|
||||
* @param {string} cidr IP range to check against (example: 192.168.0.0/24)
|
||||
* @returns {boolean} Whether IP address is within range
|
||||
*/
|
||||
export function matchesCidr(ip: string, cidr: string): boolean {
|
||||
if(!cidr) return true;
|
||||
if(!ip) return false;
|
||||
if(!cidr?.includes('/')) return ip === cidr; // Single IP
|
||||
const [range, bits] = cidr.split('/');
|
||||
const mask = ~(2 ** (32 - parseInt(bits)) - 1);
|
||||
const ipToInt = (str: string) => str.split('.')
|
||||
.reduce((int, octet) => (int << 8) + parseInt(octet), 0) >>> 0;
|
||||
return (ipToInt(ip) & mask) === (ipToInt(range) & mask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert IPv6 to v4 because who uses that, NAT4Life
|
||||
* @param {string} ip IPv6 address, e.g. 2001:0db8:85a3:0000:0000:8a2e:0370:7334
|
||||
|
||||
@@ -82,6 +82,9 @@ export function clean<T>(obj: T, undefinedOnly = false): Partial<T> {
|
||||
* @returns {T} Type
|
||||
*/
|
||||
export function deepCopy<T>(value: T): T {
|
||||
if(value == null) return value;
|
||||
const t = typeof value;
|
||||
if(t === 'string' || t === 'number' || t === 'boolean' || t === 'function') return value;
|
||||
try {return structuredClone(value); }
|
||||
catch { return JSON.parse(JSONSanitize(value)); }
|
||||
}
|
||||
|
||||
@@ -55,24 +55,33 @@ export function PES(str: TemplateStringsArray, ...args: any[]) {
|
||||
export class PathError extends Error { }
|
||||
|
||||
/**
|
||||
* A event broken down into its core components for easy processing
|
||||
* Event Structure: `module/path/name:property:method`
|
||||
* A event broken down into its core components for easy processing
|
||||
* Event Structure: `module/path/name:method`
|
||||
* Example: `users/system:crud` or `storage/some/path/file.txt:r`
|
||||
* Supports glob patterns: `users/*:r` or `storage/**:rw`
|
||||
*/
|
||||
export class PathEvent {
|
||||
/** First directory in path */
|
||||
module!: string;
|
||||
/** Entire path, including the module & name */
|
||||
fullPath!: string;
|
||||
/** Parent directory, excludes module & name */
|
||||
dir!: string;
|
||||
/** Path including the name, excluding the module */
|
||||
path!: string;
|
||||
/** Last sagment of path */
|
||||
/** Last segment of path */
|
||||
name!: string;
|
||||
/** List of methods */
|
||||
methods!: ASet<Method>;
|
||||
/** Whether this path contains glob patterns */
|
||||
hasGlob!: boolean;
|
||||
|
||||
/** Internal cache for PathEvent instances to avoid redundant parsing */
|
||||
private static pathEventCache: Map<string, PathEvent> = new Map();
|
||||
/** Cache for compiled permissions (path + required permissions → result) */
|
||||
private static permissionCache: Map<string, PathEvent> = new Map();
|
||||
/** Max size for permission cache before LRU eviction */
|
||||
private static readonly MAX_PERMISSION_CACHE_SIZE = 1000;
|
||||
|
||||
/** All/Wildcard specified */
|
||||
get all(): boolean { return this.methods.has('*') }
|
||||
@@ -83,6 +92,9 @@ export class PathEvent {
|
||||
/** Create method specified */
|
||||
get create(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('c')) }
|
||||
set create(v: boolean) { v ? this.methods.delete('n').delete('*').add('c') : this.methods.delete('c'); }
|
||||
/** Execute method specified */
|
||||
get execute(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('x')) }
|
||||
set execute(v: boolean) { v ? this.methods.delete('n').delete('*').add('x') : this.methods.delete('x'); }
|
||||
/** Read method specified */
|
||||
get read(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('r')) }
|
||||
set read(v: boolean) { v ? this.methods.delete('n').delete('*').add('r') : this.methods.delete('r'); }
|
||||
@@ -99,34 +111,111 @@ export class PathEvent {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (PathEvent.pathEventCache.has(e)) {
|
||||
if(PathEvent.pathEventCache.has(e)) {
|
||||
Object.assign(this, PathEvent.pathEventCache.get(e)!);
|
||||
return;
|
||||
}
|
||||
|
||||
let [p, scope, method] = e.replaceAll(/\/{2,}/g, '/').split(':');
|
||||
if(!method) method = scope || '*';
|
||||
if(p == '*' || (!p && method == '*')) {
|
||||
p = '';
|
||||
method = '*';
|
||||
let [p, method] = e.replaceAll(/(^|\/)\*+\/?$/g, '').split(':');
|
||||
if(!method) method = '*';
|
||||
|
||||
// Handle special cases
|
||||
if(p === '' || p === undefined || p === '*') {
|
||||
this.module = '';
|
||||
this.path = '';
|
||||
this.dir = '';
|
||||
this.fullPath = '**';
|
||||
this.name = '';
|
||||
this.methods = new ASet<Method>(p === '*' ? ['*'] : <any>method.split(''));
|
||||
this.hasGlob = true;
|
||||
PathEvent.pathEventCache.set(e, this);
|
||||
return;
|
||||
}
|
||||
|
||||
let temp = p.split('/').filter(p => !!p);
|
||||
this.module = temp.splice(0, 1)[0] || '';
|
||||
this.path = temp.join('/');
|
||||
this.dir = temp.length > 2 ? temp.slice(0, -1).join('/') : '';
|
||||
this.fullPath = `${this.module}${this.module && this.path ? '/' : ''}${this.path}`;
|
||||
this.name = temp.pop() || '';
|
||||
this.hasGlob = this.fullPath.includes('*');
|
||||
this.methods = new ASet(<any>method.split(''));
|
||||
|
||||
// Store in cache
|
||||
PathEvent.pathEventCache.set(e, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filter pattern matches a target path
|
||||
* @private
|
||||
*/
|
||||
private static matches(pattern: PathEvent, target: PathEvent): boolean {
|
||||
const methodsMatch = pattern.all || target.all || pattern.methods.intersection(target.methods).length > 0;
|
||||
if(!methodsMatch) return false;
|
||||
if(!pattern.hasGlob && !target.hasGlob) {
|
||||
const last = pattern.fullPath[target.fullPath.length];
|
||||
return pattern.fullPath.startsWith(target.fullPath) && (last == null || last == '/');
|
||||
}
|
||||
if(pattern.hasGlob) return this.pathMatchesGlob(target.fullPath, pattern.fullPath);
|
||||
return this.pathMatchesGlob(pattern.fullPath, target.fullPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path matches a glob pattern
|
||||
* @private
|
||||
*/
|
||||
private static pathMatchesGlob(path: string, pattern: string): boolean {
|
||||
if(pattern === path) return true;
|
||||
const pathParts = path.split('/').filter(p => !!p);
|
||||
const patternParts = pattern.split('/').filter(p => !!p);
|
||||
|
||||
let pathIdx = 0;
|
||||
let patternIdx = 0;
|
||||
while (patternIdx < patternParts.length && pathIdx < pathParts.length) {
|
||||
const patternPart = patternParts[patternIdx];
|
||||
if(patternPart === '**') {
|
||||
if(patternIdx === patternParts.length - 1) return true;
|
||||
while (pathIdx < pathParts.length) {
|
||||
if(PathEvent.pathMatchesGlob(pathParts.slice(pathIdx).join('/'), patternParts.slice(patternIdx + 1).join('/'))) return true;
|
||||
pathIdx++;
|
||||
}
|
||||
return false;
|
||||
} else if(patternPart === '*') {
|
||||
pathIdx++;
|
||||
patternIdx++;
|
||||
} else {
|
||||
if(patternPart !== pathParts[pathIdx]) return false;
|
||||
pathIdx++;
|
||||
patternIdx++;
|
||||
}
|
||||
}
|
||||
if(patternIdx < patternParts.length) return patternParts.slice(patternIdx).every(p => p === '**');
|
||||
return pathIdx === pathParts.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a path for specificity ranking (lower = more specific = higher priority)
|
||||
* @private
|
||||
*/
|
||||
private static scoreSpecificity(path: string): number {
|
||||
if(path === '**' || path === '') return Number.MAX_SAFE_INTEGER;
|
||||
const segments = path.split('/').filter(p => !!p);
|
||||
let score = -segments.length;
|
||||
segments.forEach(seg => {
|
||||
if(seg === '**') score += 0.5;
|
||||
else if(seg === '*') score += 0.25;
|
||||
});
|
||||
return score;
|
||||
}
|
||||
|
||||
/** Clear the cache of all PathEvents */
|
||||
static clearCache(): void {
|
||||
PathEvent.pathEventCache.clear();
|
||||
}
|
||||
|
||||
/** Clear the permission cache */
|
||||
static clearPermissionCache(): void {
|
||||
PathEvent.permissionCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine multiple events into one parsed object. Longest path takes precedent, but all subsequent methods are
|
||||
* combined until a "none" is reached
|
||||
@@ -135,40 +224,37 @@ export class PathEvent {
|
||||
* @return {PathEvent} Final combined permission
|
||||
*/
|
||||
static combine(...paths: (string | PathEvent)[]): PathEvent {
|
||||
let hitNone = false;
|
||||
const combined = paths.map(p => p instanceof PathEvent ? p : new PathEvent(p))
|
||||
.toSorted((p1, p2) => {
|
||||
const l1 = p1.fullPath.length, l2 = p2.fullPath.length;
|
||||
return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0);
|
||||
}).reduce((acc, p) => {
|
||||
if(acc && !acc.fullPath.startsWith(p.fullPath)) return acc;
|
||||
if(p.none) hitNone = true;
|
||||
if(!acc) return p;
|
||||
if(hitNone) return acc;
|
||||
acc.methods = new ASet([...acc.methods, ...p.methods]);
|
||||
return acc;
|
||||
}, <any>null);
|
||||
return combined;
|
||||
const parsed = paths.map(p => p instanceof PathEvent ? p : new PathEvent(p));
|
||||
const sorted = parsed.toSorted((p1, p2) => {
|
||||
const score1 = PathEvent.scoreSpecificity(p1.fullPath);
|
||||
const score2 = PathEvent.scoreSpecificity(p2.fullPath);
|
||||
return score1 - score2;
|
||||
});
|
||||
let result: PathEvent | null = null;
|
||||
for (const p of sorted) {
|
||||
if(!result) {
|
||||
result = p;
|
||||
} else {
|
||||
if(result.fullPath.startsWith(p.fullPath)) {
|
||||
if(p.none) break;
|
||||
result.methods = new ASet([...result.methods, ...p.methods]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result || new PathEvent('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a set of paths based on the target
|
||||
*
|
||||
* @param {string | PathEvent | (string | PathEvent)[]} target Array of events that will filtered
|
||||
* @param filter {...PathEvent} Must container one of
|
||||
* @return {boolean} Whether there is any overlap
|
||||
* @param filter {...PathEvent} Must contain one of
|
||||
* @return {PathEvent[]} Filtered results
|
||||
*/
|
||||
static filter(target: string | PathEvent | (string | PathEvent)[], ...filter: (string | PathEvent)[]): PathEvent[] {
|
||||
const parsedTarget = makeArray(target).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe));
|
||||
const parsedFilter = makeArray(filter).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe));
|
||||
return parsedTarget.filter(t => !!parsedFilter.find(r => {
|
||||
const wildcard = r.fullPath == '*' || t.fullPath == '*';
|
||||
const p1 = r.fullPath.includes('*') ? r.fullPath.slice(0, r.fullPath.indexOf('*')) : r.fullPath;
|
||||
const p2 = t.fullPath.includes('*') ? t.fullPath.slice(0, t.fullPath.indexOf('*')) : t.fullPath;
|
||||
const scope = p1.startsWith(p2) || p2.startsWith(p1);
|
||||
const methods = r.all || t.all || r.methods.intersection(t.methods).length;
|
||||
return (wildcard || scope) && methods;
|
||||
}));
|
||||
return parsedTarget.filter(t => !!parsedFilter.find(r => PathEvent.matches(r, t)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,14 +267,7 @@ export class PathEvent {
|
||||
static has(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): boolean {
|
||||
const parsedTarget = makeArray(target).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe));
|
||||
const parsedRequired = makeArray(has).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe));
|
||||
return !!parsedRequired.find(r => !!parsedTarget.find(t => {
|
||||
const wildcard = r.fullPath == '*' || t.fullPath == '*';
|
||||
const p1 = r.fullPath.includes('*') ? r.fullPath.slice(0, r.fullPath.indexOf('*')) : r.fullPath;
|
||||
const p2 = t.fullPath.includes('*') ? t.fullPath.slice(0, t.fullPath.indexOf('*')) : t.fullPath;
|
||||
const scope = p1.startsWith(p2); // Note: Original had || p2.startsWith(p1) here, but has implies target has required.
|
||||
const methods = r.all || t.all || r.methods.intersection(t.methods).length;
|
||||
return (wildcard || scope) && methods;
|
||||
}));
|
||||
return !!parsedRequired.find(r => !!parsedTarget.find(t => PathEvent.matches(r, t)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,7 +275,7 @@ export class PathEvent {
|
||||
*
|
||||
* @param {string | PathEvent | (string | PathEvent)[]} target Array of Events as strings or pre-parsed
|
||||
* @param has Target must have all these paths
|
||||
* @return {boolean} Whether there is any overlap
|
||||
* @return {boolean} Whether all are present
|
||||
*/
|
||||
static hasAll(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): boolean {
|
||||
return has.filter(h => PathEvent.has(target, h)).length == has.length;
|
||||
@@ -205,7 +284,7 @@ export class PathEvent {
|
||||
/**
|
||||
* Same as `has` but raises an error if there is no overlap
|
||||
*
|
||||
* @param {string | string[]} target Array of Events as strings or pre-parsed
|
||||
* @param {string | PathEvent | (string | PathEvent)[]} target Array of Events as strings or pre-parsed
|
||||
* @param has Target must have at least one of these path
|
||||
*/
|
||||
static hasFatal(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): void {
|
||||
@@ -215,7 +294,7 @@ export class PathEvent {
|
||||
/**
|
||||
* Same as `hasAll` but raises an error if the target is missing any paths
|
||||
*
|
||||
* @param {string | string[]} target Array of Events as strings or pre-parsed
|
||||
* @param {string | PathEvent | (string | PathEvent)[]} target Array of Events as strings or pre-parsed
|
||||
* @param has Target must have all these paths
|
||||
*/
|
||||
static hasAllFatal(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): void {
|
||||
@@ -250,7 +329,7 @@ export class PathEvent {
|
||||
* Squash 2 sets of paths & return true if the target has all paths
|
||||
*
|
||||
* @param has Target must have all these paths
|
||||
* @return {boolean} Whether there is any overlap
|
||||
* @return {boolean} Whether all are present
|
||||
*/
|
||||
hasAll(...has: (string | PathEvent)[]): boolean {
|
||||
return PathEvent.hasAll(this, ...has);
|
||||
@@ -278,7 +357,7 @@ export class PathEvent {
|
||||
* Filter a set of paths based on this event
|
||||
*
|
||||
* @param {string | PathEvent | (string | PathEvent)[]} target Array of events that will filtered
|
||||
* @return {boolean} Whether there is any overlap
|
||||
* @return {PathEvent[]} Filtered results
|
||||
*/
|
||||
filter(target: string | PathEvent | (string | PathEvent)[]): PathEvent[] {
|
||||
return PathEvent.filter(target, this);
|
||||
@@ -317,7 +396,7 @@ export class PathEventEmitter implements IPathEventEmitter{
|
||||
emit(event: Event, ...args: any[]) {
|
||||
const parsed = event instanceof PathEvent ? event : new PathEvent(`${this.prefix}/${event}`);
|
||||
this.listeners.filter(l => PathEvent.has(l[0], parsed))
|
||||
.forEach(async l => l[1](parsed, ...args));
|
||||
.forEach(l => l[1](parsed, ...args));
|
||||
};
|
||||
|
||||
off(listener: PathListener) {
|
||||
@@ -326,9 +405,21 @@ export class PathEventEmitter implements IPathEventEmitter{
|
||||
|
||||
on(event: Event | Event[], listener: PathListener): PathUnsubscribe {
|
||||
makeArray(event).forEach(e => {
|
||||
if(typeof e == 'string' && e[0] == '*' && this.prefix) e = e.slice(1);
|
||||
let fullEvent: string;
|
||||
if(typeof e === 'string') {
|
||||
// If event starts with ':', it's a scope specifier - prepend prefix
|
||||
if(e[0] === ':' && this.prefix) {
|
||||
fullEvent = `${this.prefix}${e}`;
|
||||
} else if(this.prefix) {
|
||||
fullEvent = `${this.prefix}/${e}`;
|
||||
} else {
|
||||
fullEvent = e;
|
||||
}
|
||||
} else {
|
||||
fullEvent = e instanceof PathEvent ? PathEvent.toString(e.fullPath, e.methods) : (e as string);
|
||||
}
|
||||
this.listeners.push([
|
||||
e instanceof PathEvent ? e : new PathEvent(`${this.prefix}/${e}`),
|
||||
new PathEvent(fullEvent),
|
||||
listener
|
||||
])
|
||||
});
|
||||
@@ -346,6 +437,6 @@ export class PathEventEmitter implements IPathEventEmitter{
|
||||
}
|
||||
|
||||
relayEvents(emitter: IPathEventEmitter) {
|
||||
emitter.on('*', (event, ...args) => this.emit(event, ...args));
|
||||
emitter.on('**', (event, ...args) => this.emit(event, ...args));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,15 @@ export function pascalCase(str?: string): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove all emojis from a string
|
||||
* @param {string} str Input string with emojis
|
||||
* @returns {string} Sanitized string without emojis
|
||||
*/
|
||||
export function removeEmojis(str: string): string {
|
||||
const emojiRegex = /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud83c[\udde6-\uddff]|[\ud83d[\ude00-\ude4f]|[\ud83d[\ude80-\udeff]|[\ud83c[\udd00-\uddff]|[\ud83d[\ude50-\ude7f]|[\u2600-\u26ff]|[\u2700-\u27bf]|[\ud83e[\udd00-\uddff]|[\ud83c[\udf00-\uffff]|[\ud83d[\ude00-\udeff]|[\ud83c[\udde6-\uddff])/g;
|
||||
return str.replace(emojiRegex, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random hexadecimal value
|
||||
|
||||
241
src/template.ts
Normal file
241
src/template.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import {BadRequestError} from './errors.ts';
|
||||
import {dotNotation} from './objects.ts';
|
||||
import {matchAll} from './string.ts';
|
||||
import {formatDate} from './time.ts';
|
||||
|
||||
export class TemplateError extends BadRequestError { }
|
||||
|
||||
export function findTemplateVars(html: string): Record<string, any> {
|
||||
const variables = new Set<string>();
|
||||
const arrays = new Set<string>();
|
||||
const excluded = new Set<string>(['true', 'false', 'null', 'undefined']);
|
||||
|
||||
// Extract & exclude loop variables, mark arrays
|
||||
for (const loop of matchAll(html, /\{\{\s*?\*\s*?(.+?)\s+in\s+(.+?)\s*?}}/g)) {
|
||||
const [element, index = 'index'] = loop[1].replaceAll(/[()\s]/g, '').split(',');
|
||||
excluded.add(element);
|
||||
excluded.add(index);
|
||||
const arrayVar = loop[2].trim();
|
||||
const root = arrayVar.split('.')[0];
|
||||
if(!excluded.has(root)) {
|
||||
variables.add(arrayVar);
|
||||
arrays.add(arrayVar);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract variables from if/else-if conditions
|
||||
for (const ifStmt of matchAll(html, /\{\{\s*?[!]?\?\s*?([^}]+?)\s*?}}/g)) {
|
||||
const code = ifStmt[1].replace(/["'`][^"'`]*["'`]/g, '');
|
||||
const cleaned = code.replace(/([a-zA-Z_$][a-zA-Z0-9_$.]*)\s*\(/g, (_, v) => {
|
||||
const parts = v.split('.');
|
||||
return parts.length > 1 ? parts.slice(0, -1).join('.') + ' ' : '';
|
||||
});
|
||||
const vars = cleaned.match(/[a-zA-Z_$][a-zA-Z0-9_$.]+/g) || [];
|
||||
for (const v of vars) {
|
||||
const root = v.split('.')[0];
|
||||
if(!excluded.has(root)) variables.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from if block content & regular interpolations
|
||||
const regex = /\{\{\s*([^<>\*\?!/}\s][^}]*?)\s*}}/g;
|
||||
let match;
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
const code = match[1].trim().replace(/["'`][^"'`]*["'`]/g, '');
|
||||
const cleaned = code.replace(/([a-zA-Z_$][a-zA-Z0-9_$.]*)\s*\(/g, (_, v) => {
|
||||
const parts = v.split('.');
|
||||
return parts.length > 1 ? parts.slice(0, -1).join('.') + ' ' : '';
|
||||
});
|
||||
const vars = cleaned.match(/[a-zA-Z_$][a-zA-Z0-9_$.]+/g) || [];
|
||||
for (const v of vars) {
|
||||
const root = v.split('.')[0];
|
||||
if(!excluded.has(root)) variables.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
for (const path of variables) {
|
||||
const parts = path.split('.');
|
||||
let current = result;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if(i === parts.length - 1) {
|
||||
const fullPath = parts.slice(0, i + 1).join('.');
|
||||
current[part] = arrays.has(fullPath) ? [] : '';
|
||||
} else {
|
||||
current[part] = current[part] || {};
|
||||
current = current[part];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export async function renderTemplate(template: string, data: any, fetch?: (file: string) => Promise<string>) {
|
||||
if(!fetch) fetch = (file) => { throw new TemplateError(`Unable to fetch template: ${file}`); }
|
||||
|
||||
const now = new Date();
|
||||
const d = {
|
||||
date: {
|
||||
day: now.getDate(),
|
||||
month: now.toLocaleString('default', { month: 'long' }),
|
||||
year: now.getFullYear(),
|
||||
time: now.toLocaleTimeString(),
|
||||
format: formatDate
|
||||
},
|
||||
...(data || {}),
|
||||
};
|
||||
|
||||
const evaluate = (code: string, data: object, fatal = true) => {
|
||||
try {
|
||||
return Function('data', `with(data) { return ${code}; }`)(data);
|
||||
} catch(err: any) {
|
||||
if(fatal) throw new TemplateError(`Failed to evaluate: ${code}\n${err.message || err.toString()}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function process(content: string, ctx: object = d): Promise<string> {
|
||||
let result = content;
|
||||
|
||||
// Process extends first (they wrap everything)
|
||||
const extendsMatch = result.match(/\{\{\s*>\s*(.+?):(.+?)\s*}}([\s\S]*?)\{\{\s*\/>\s*}}/);
|
||||
if(extendsMatch) {
|
||||
const parentTemplate = await (<Function>fetch)(extendsMatch[1].trim());
|
||||
if(!parentTemplate) throw new TemplateError(`Unknown extended template: ${extendsMatch[1].trim()}`);
|
||||
const slotName = extendsMatch[2].trim();
|
||||
const slotContent = await process(extendsMatch[3], ctx);
|
||||
return process(parentTemplate, {...ctx, [slotName]: slotContent});
|
||||
}
|
||||
|
||||
let changed = true;
|
||||
while(changed) {
|
||||
changed = false;
|
||||
const before = result;
|
||||
|
||||
// Process imports
|
||||
const importMatch = result.match(/\{\{\s*<\s*(.+?)\s*}}/);
|
||||
if(importMatch) {
|
||||
const t = await (<Function>fetch)(importMatch[1].trim());
|
||||
if(!t) throw new TemplateError(`Unknown imported template: ${importMatch[1].trim()}`);
|
||||
const rendered = await process(t, ctx);
|
||||
result = result.replace(importMatch[0], rendered);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process for-loops (innermost first)
|
||||
const forMatch = findInnermostFor(result);
|
||||
if(forMatch) {
|
||||
const { full, vars, array, body, start } = forMatch;
|
||||
const [element, index = 'index'] = vars.split(',').map(v => v.trim());
|
||||
const arr: any[] = <any>dotNotation(ctx, array);
|
||||
if(!arr || typeof arr != 'object') throw new TemplateError(`Cannot iterate: ${array}`);
|
||||
let output = [];
|
||||
for(let i = 0; i < arr.length; i++)
|
||||
output.push(await process(body, {...ctx, [element]: arr[i], [index]: i}));
|
||||
result = result.slice(0, start) + output.join('\n') + result.slice(start + full.length);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process if-statements (innermost first)
|
||||
const ifMatch = findInnermostIf(result);
|
||||
if(ifMatch) {
|
||||
const { full, condition, body, start } = ifMatch;
|
||||
const branches = parseIfBranches(body);
|
||||
let output = '';
|
||||
if(evaluate(condition, ctx, false)) {
|
||||
output = branches.if;
|
||||
} else {
|
||||
for(const branch of branches.elseIf) {
|
||||
if(evaluate(branch.condition, ctx, false)) {
|
||||
output = branch.body;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!output && branches.else) output = branches.else;
|
||||
}
|
||||
result = result.slice(0, start) + output + result.slice(start + full.length);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if(before === result) changed = false;
|
||||
}
|
||||
return processVariables(result, ctx);
|
||||
}
|
||||
|
||||
function processVariables(content: string, data: object): string {
|
||||
return content.replace(/\{\{\s*([^<>\*\?!/}\s][^{}]*?)\s*}}/g, (match, code) => {
|
||||
return evaluate(code.trim(), data) ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
function findInnermostIf(content: string) {
|
||||
const regex = /\{\{\s*\?\s*(.+?)\s*}}/g;
|
||||
let match, lastMatch = null;
|
||||
while((match = regex.exec(content)) !== null) {
|
||||
const start = match.index;
|
||||
const condition = match[1];
|
||||
const bodyStart = match.index + match[0].length;
|
||||
const end = findMatchingClose(content, bodyStart, /\{\{\s*\?\s*/, /\{\{\s*\/\?\s*}}/);
|
||||
if(end === -1) throw new TemplateError(`Unmatched if-statement at position ${start}`);
|
||||
const closeTag = content.slice(end).match(/\{\{\s*\/\?\s*}}/);
|
||||
const full = content.slice(start, end + (<any>closeTag)[0].length);
|
||||
const body = content.slice(bodyStart, end);
|
||||
lastMatch = { full, condition, body, start };
|
||||
}
|
||||
return lastMatch;
|
||||
}
|
||||
|
||||
function findInnermostFor(content: string) {
|
||||
const regex = /\{\{\s*\*\s*(.+?)\s+in\s+(.+?)\s*}}/g;
|
||||
let match, lastMatch = null;
|
||||
while((match = regex.exec(content)) !== null) {
|
||||
const start = match.index;
|
||||
const vars = match[1].replaceAll(/[()\s]/g, '');
|
||||
const array = match[2];
|
||||
const bodyStart = match.index + match[0].length;
|
||||
const end = findMatchingClose(content, bodyStart, /\{\{\s*\*\s*/, /\{\{\s*\/\*\s*}}/);
|
||||
if(end === -1) throw new TemplateError(`Unmatched for-loop at position ${start}`);
|
||||
const closeTag = content.slice(end).match(/\{\{\s*\/\*\s*}}/);
|
||||
const full = content.slice(start, end + (<any>closeTag)[0].length);
|
||||
const body = content.slice(bodyStart, end);
|
||||
lastMatch = { full, vars, array, body, start };
|
||||
}
|
||||
return lastMatch;
|
||||
}
|
||||
|
||||
function findMatchingClose(content: string, startIndex: number, openTag: RegExp, closeTag: RegExp): number {
|
||||
let depth = 1, pos = startIndex;
|
||||
while (depth > 0 && pos < content.length) {
|
||||
const remaining = content.slice(pos);
|
||||
const nextOpen = remaining.search(openTag);
|
||||
const nextClose = remaining.search(closeTag);
|
||||
if(nextClose === -1) return -1;
|
||||
if(nextOpen !== -1 && nextOpen < nextClose) {
|
||||
depth++;
|
||||
pos += nextOpen + 1;
|
||||
} else {
|
||||
depth--;
|
||||
if(depth === 0) return pos + nextClose;
|
||||
pos += nextClose + 1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseIfBranches(body: string) {
|
||||
const parts = body.split(/\{\{\s*!\?\s*/);
|
||||
const result = { if: parts[0], elseIf: [] as any[], else: '' };
|
||||
for(let i = 1; i < parts.length; i++) {
|
||||
const closeBrace = parts[i].indexOf('}}');
|
||||
const condition = parts[i].slice(0, closeBrace).trim();
|
||||
const branchBody = parts[i].slice(closeBrace + 2);
|
||||
if(!condition) result.else = branchBody;
|
||||
else result.elseIf.push({ condition, body: branchBody });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return process(template);
|
||||
}
|
||||
161
src/tts.ts
Normal file
161
src/tts.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {removeEmojis} from './string.ts';
|
||||
|
||||
export class TTS {
|
||||
private static readonly QUALITY_PATTERNS = ['Google', 'Microsoft', 'Samantha', 'Premium', 'Natural', 'Neural'];
|
||||
|
||||
private _currentUtterance: SpeechSynthesisUtterance | null = null;
|
||||
private _voicesLoaded: Promise<void>;
|
||||
private _stoppedUtterances = new WeakSet<SpeechSynthesisUtterance>();
|
||||
|
||||
private _rate: number = 1.0;
|
||||
get rate(): number { return this._rate; }
|
||||
set rate(value: number) {
|
||||
this._rate = value;
|
||||
if(this._currentUtterance) this._currentUtterance.rate = value;
|
||||
}
|
||||
|
||||
private _pitch: number = 1.0;
|
||||
get pitch(): number { return this._pitch; }
|
||||
set pitch(value: number) {
|
||||
this._pitch = value;
|
||||
if(this._currentUtterance) this._currentUtterance.pitch = value;
|
||||
}
|
||||
|
||||
private _volume: number = 1.0;
|
||||
get volume(): number { return this._volume; }
|
||||
set volume(value: number) {
|
||||
this._volume = value;
|
||||
if(this._currentUtterance) this._currentUtterance.volume = value;
|
||||
}
|
||||
|
||||
private _voice: SpeechSynthesisVoice | undefined;
|
||||
get voice(): SpeechSynthesisVoice | undefined { return this._voice; }
|
||||
set voice(value: SpeechSynthesisVoice | undefined) {
|
||||
this._voice = value;
|
||||
if(this._currentUtterance && value) this._currentUtterance.voice = value;
|
||||
}
|
||||
|
||||
/** Create a TTS instance with optional configuration */
|
||||
constructor(config?: {rate?: number; pitch?: number; volume?: number; voice?: SpeechSynthesisVoice | null}) {
|
||||
this._voicesLoaded = this.initializeVoices();
|
||||
if(config) {
|
||||
if(config.rate !== undefined) this._rate = config.rate;
|
||||
if(config.pitch !== undefined) this._pitch = config.pitch;
|
||||
if(config.volume !== undefined) this._volume = config.volume;
|
||||
this._voice = config.voice === null ? undefined : (config.voice || undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Initializes voice loading and sets default voice if needed */
|
||||
private initializeVoices(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
if(voices.length > 0) {
|
||||
if(!this._voice) this._voice = TTS.bestVoice();
|
||||
resolve();
|
||||
} else {
|
||||
const handler = () => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', handler);
|
||||
if(!this._voice) this._voice = TTS.bestVoice();
|
||||
resolve();
|
||||
};
|
||||
window.speechSynthesis.addEventListener('voiceschanged', handler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the best available TTS voice, prioritizing high-quality options
|
||||
* @param lang Speaking language
|
||||
* @returns Highest quality voice
|
||||
*/
|
||||
private static bestVoice(lang = 'en'): SpeechSynthesisVoice | undefined {
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
for (const pattern of this.QUALITY_PATTERNS) {
|
||||
const voice = voices.find(v => v.name.includes(pattern) && v.lang.startsWith(lang));
|
||||
if(voice) return voice;
|
||||
}
|
||||
return voices.find(v => v.lang.startsWith(lang));
|
||||
}
|
||||
|
||||
/** Cleans text for TTS by removing emojis, markdown and code block */
|
||||
private static cleanText(text: string): string {
|
||||
return removeEmojis(text)
|
||||
.replace(/```[\s\S]*?```/g, ' code block ')
|
||||
.replace(/[#*_~`]/g, '');
|
||||
}
|
||||
|
||||
/** Creates a speech utterance with current options */
|
||||
private createUtterance(text: string): SpeechSynthesisUtterance {
|
||||
const cleanedText = TTS.cleanText(text);
|
||||
const utterance = new SpeechSynthesisUtterance(cleanedText);
|
||||
const voice = this._voice || TTS.bestVoice();
|
||||
if(voice) utterance.voice = voice;
|
||||
utterance.rate = this._rate;
|
||||
utterance.pitch = this._pitch;
|
||||
utterance.volume = this._volume;
|
||||
return utterance;
|
||||
}
|
||||
|
||||
/** Speaks text and returns a Promise which resolves once complete */
|
||||
async speak(text: string): Promise<void> {
|
||||
if(!text.trim()) return Promise.resolve();
|
||||
await this._voicesLoaded;
|
||||
return new Promise((resolve, reject) => {
|
||||
this._currentUtterance = this.createUtterance(text);
|
||||
const utterance = this._currentUtterance;
|
||||
utterance.onend = () => {
|
||||
this._currentUtterance = null;
|
||||
resolve();
|
||||
};
|
||||
utterance.onerror = (error) => {
|
||||
this._currentUtterance = null;
|
||||
if(this._stoppedUtterances.has(utterance) && error.error === 'interrupted') resolve();
|
||||
else reject(error);
|
||||
};
|
||||
window.speechSynthesis.speak(utterance);
|
||||
});
|
||||
}
|
||||
|
||||
/** Stops all TTS */
|
||||
stop(): void {
|
||||
if(this._currentUtterance) this._stoppedUtterances.add(this._currentUtterance);
|
||||
window.speechSynthesis.cancel();
|
||||
this._currentUtterance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a stream that chunks text into sentences and speak them.
|
||||
*
|
||||
* @example
|
||||
* const stream = tts.speakStream();
|
||||
* stream.next("Hello ");
|
||||
* stream.next("World. How");
|
||||
* stream.next(" are you?");
|
||||
* await stream.done();
|
||||
*
|
||||
* @returns Object with next function for passing chunk of streamed text and done for completing the stream
|
||||
*/
|
||||
speakStream(): {next: (text: string) => void, done: () => Promise<void>} {
|
||||
let buffer = '';
|
||||
let streamPromise: Promise<void> = Promise.resolve();
|
||||
const sentenceRegex = /[^.!?\n]+[.!?\n]+/g;
|
||||
return {
|
||||
next: (text: string): void => {
|
||||
buffer += text;
|
||||
const sentences = buffer.match(sentenceRegex);
|
||||
if(sentences) {
|
||||
sentences.forEach(sentence => streamPromise = this.speak(sentence.trim()));
|
||||
buffer = buffer.replace(sentenceRegex, '');
|
||||
}
|
||||
},
|
||||
done: async (): Promise<void> => {
|
||||
if(buffer.trim()) {
|
||||
streamPromise = this.speak(buffer.trim());
|
||||
buffer = '';
|
||||
}
|
||||
await streamPromise;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,11 @@ describe('Object utilities', () => {
|
||||
dotNotation(obj, 'd.e.f', 5);
|
||||
expect(obj.d.e.f).toBe(5);
|
||||
});
|
||||
it('undefined', () => {
|
||||
const obj: any = {a: 1};
|
||||
const resp = dotNotation(obj, 'a.b');
|
||||
expect(resp).toBe(undefined);
|
||||
})
|
||||
});
|
||||
|
||||
describe('encodeQuery', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {PathError, PathEvent, PathEventEmitter, PE, PES} from '../src';
|
||||
describe('Path Events', () => {
|
||||
beforeEach(() => {
|
||||
PathEvent.clearCache();
|
||||
PathEvent.clearPermissionCache();
|
||||
});
|
||||
|
||||
describe('PE', () => {
|
||||
@@ -42,8 +43,14 @@ describe('Path Events', () => {
|
||||
it('parses wildcard', () => {
|
||||
const pe = new PathEvent('*');
|
||||
expect(pe.all).toBe(true);
|
||||
expect(pe.fullPath).toBe('');
|
||||
expect(pe.fullPath).toBe('**');
|
||||
expect(pe.methods.has('*')).toBe(true);
|
||||
expect(pe.hasAll('module:c', 'some/path:ud')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('parses empty string as wildcard', () => {
|
||||
const pe = new PathEvent('');
|
||||
expect(pe.has('any/path:c')).toBe(true);
|
||||
});
|
||||
|
||||
it('parses none method', () => {
|
||||
@@ -53,6 +60,10 @@ describe('Path Events', () => {
|
||||
expect(pe.none).toBe(false);
|
||||
});
|
||||
|
||||
it('Test several wild card perms', () => {
|
||||
expect(PathEvent.has(['actions:*', 'groups:*', 'users:*'], 'groups/AUTH:r')).toBe(true);
|
||||
});
|
||||
|
||||
it('setters for methods', () => {
|
||||
const pe = new PathEvent('users/system:r');
|
||||
pe.create = true;
|
||||
@@ -73,6 +84,7 @@ describe('Path Events', () => {
|
||||
expect(c.methods.has('c')).toBe(true);
|
||||
expect(c.methods.has('r')).toBe(true);
|
||||
expect(c.methods.has('u')).toBe(true);
|
||||
expect(c.delete).toBe(false);
|
||||
});
|
||||
|
||||
it('combine stops at none', () => {
|
||||
@@ -87,6 +99,17 @@ describe('Path Events', () => {
|
||||
expect(d.none).toBe(false);
|
||||
});
|
||||
|
||||
it('combine works with wildcards', () => {
|
||||
expect(PathEvent.has([
|
||||
'payments/anonymous:d',
|
||||
'payments/anonymous:c',
|
||||
'payments/system:cr',
|
||||
'logs/Momentum:c',
|
||||
'products:r',
|
||||
'*'
|
||||
], 'actions/692a92d18afa11e6722e9f2e:r')).toBe(true);
|
||||
});
|
||||
|
||||
it('filter finds overlap by path and methods', () => {
|
||||
const events = [
|
||||
new PathEvent('users/sys:cr'),
|
||||
@@ -143,6 +166,82 @@ describe('Path Events', () => {
|
||||
const filtered = pe.filter(arr);
|
||||
expect(filtered[0].fullPath).toBe('users/sys');
|
||||
});
|
||||
|
||||
it('caches PathEvent instances', () => {
|
||||
const pe1 = new PathEvent('users/sys:cr');
|
||||
const pe2 = new PathEvent('users/sys:cr');
|
||||
expect(pe1.fullPath).toBe(pe2.fullPath);
|
||||
});
|
||||
|
||||
it('clearCache removes all cached instances', () => {
|
||||
new PathEvent('users/sys:cr');
|
||||
PathEvent.clearCache();
|
||||
const pe = new PathEvent('users/sys:cr');
|
||||
expect(pe.fullPath).toBe('users/sys');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Glob Patterns', () => {
|
||||
it('** matches zero or more segments', () => {
|
||||
const pattern = new PathEvent('storage/**:r');
|
||||
expect(PathEvent.has(pattern, 'storage:r')).toBe(true);
|
||||
expect(PathEvent.has(pattern, 'storage/file:r')).toBe(true);
|
||||
expect(PathEvent.has(pattern, 'storage/dir/file:r')).toBe(true);
|
||||
});
|
||||
|
||||
it('** in middle matches multiple segments', () => {
|
||||
const pattern = new PathEvent('data/**/archive:r');
|
||||
expect(PathEvent.has(pattern, 'data/archive:r')).toBe(true);
|
||||
expect(PathEvent.has(pattern, 'data/old/archive:r')).toBe(true);
|
||||
});
|
||||
|
||||
it('filters by glob and methods', () => {
|
||||
const events = [
|
||||
new PathEvent('users/123/profile:cr'),
|
||||
new PathEvent('users/456/profile:r'),
|
||||
new PathEvent('users/789/settings:r')
|
||||
];
|
||||
const filtered = PathEvent.filter(events, 'users/*/profile:r');
|
||||
expect(filtered.length).toBe(2);
|
||||
});
|
||||
|
||||
it('wildcard method matches all methods', () => {
|
||||
const pattern = new PathEvent('users/**:*');
|
||||
expect(PathEvent.has(pattern, 'users/123:c')).toBe(true);
|
||||
expect(PathEvent.has(pattern, 'users/456:r')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Specificity and None Permissions', () => {
|
||||
it('more specific path wins when sorted by specificity', () => {
|
||||
const perms = [new PathEvent('users/**:*'), new PathEvent('users/admin:r')];
|
||||
const combined = PathEvent.combine(...perms);
|
||||
expect(combined.fullPath).toBe('users/admin');
|
||||
});
|
||||
|
||||
it('none at parent stops combining', () => {
|
||||
const a = new PathEvent('data/public:cr');
|
||||
const b = new PathEvent('data:n');
|
||||
const combined = PathEvent.combine(a, b);
|
||||
expect(combined.fullPath).toBe('data/public');
|
||||
expect(combined.read).toBe(true);
|
||||
});
|
||||
|
||||
it('instance methods work correctly', () => {
|
||||
const pe = new PathEvent('users/sys:r');
|
||||
expect(pe.has('users/sys:r')).toBe(true);
|
||||
expect(pe.hasAll('users/sys:r')).toBe(true);
|
||||
expect(pe.filter(['users/sys:r', 'users/other:r']).length).toBe(1);
|
||||
});
|
||||
|
||||
it('combines methods from hierarchy', () => {
|
||||
const a = new PathEvent('users/admin:c');
|
||||
const b = new PathEvent('users/admin:r');
|
||||
const combined = PathEvent.combine(a, b);
|
||||
expect(combined.fullPath).toBe('users/admin');
|
||||
expect(combined.create).toBe(true);
|
||||
expect(combined.read).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PathEventEmitter', () => {
|
||||
@@ -157,7 +256,7 @@ describe('Path Events', () => {
|
||||
|
||||
it('scoped', done => {
|
||||
const emitter = new PathEventEmitter('users');
|
||||
emitter.on(':cud', (event) => {
|
||||
emitter.on('*:cud', (event) => {
|
||||
expect(event.fullPath).toBe('users/system');
|
||||
done();
|
||||
});
|
||||
@@ -198,5 +297,35 @@ describe('Path Events', () => {
|
||||
expect(event.fullPath).toBe('foo/bar'));
|
||||
emitter.emit('bar:r');
|
||||
});
|
||||
|
||||
it('registers multiple listeners', () => {
|
||||
const emitter = new PathEventEmitter();
|
||||
const fn1 = jest.fn();
|
||||
const fn2 = jest.fn();
|
||||
emitter.on('users/sys:r', fn1);
|
||||
emitter.on('users/sys:r', fn2);
|
||||
emitter.emit('users/sys:r');
|
||||
expect(fn1).toHaveBeenCalled();
|
||||
expect(fn2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches multiple listeners with overlapping globs', () => {
|
||||
const emitter = new PathEventEmitter();
|
||||
const fn1 = jest.fn();
|
||||
const fn2 = jest.fn();
|
||||
emitter.on('users/**:r', fn1);
|
||||
emitter.on('users/*/profile:r', fn2);
|
||||
emitter.emit('users/123/profile:r');
|
||||
expect(fn1).toHaveBeenCalled();
|
||||
expect(fn2).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('glob patterns in listener registration', () => {
|
||||
const emitter = new PathEventEmitter();
|
||||
const fn = jest.fn();
|
||||
emitter.on('users/sys:r', fn);
|
||||
emitter.emit('users/sys:r');
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
304
tests/templates.spec.ts
Normal file
304
tests/templates.spec.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import {findTemplateVars, renderTemplate, TemplateError} from '../src';
|
||||
|
||||
describe('findTemplateVars', () => {
|
||||
test('extracts simple variables', () => {
|
||||
const result = findTemplateVars('Hello {{ name }}!');
|
||||
expect(result).toEqual({ name: '' });
|
||||
});
|
||||
|
||||
test('extracts nested object paths', () => {
|
||||
const result = findTemplateVars('{{ user.name }} is {{ user.age }}');
|
||||
expect(result).toEqual({ user: { name: '', age: '' } });
|
||||
});
|
||||
|
||||
test('extracts variables from if statements', () => {
|
||||
const result = findTemplateVars('{{ ? active }}{{ message }}{{ /? }}');
|
||||
expect(result).toEqual({ active: '', message: '' });
|
||||
});
|
||||
|
||||
test('extracts variables from else-if conditions', () => {
|
||||
const result = findTemplateVars('{{ ? status == "paid" }}PAID{{ !? status == "pending" }}{{ value }}{{ /? }}');
|
||||
expect(result).toEqual({ status: '', value: '' });
|
||||
});
|
||||
|
||||
test('extracts array reference from loops', () => {
|
||||
const result = findTemplateVars('{{ * item in items }}{{ item }}{{ /* }}');
|
||||
expect(result).toEqual({ items: [] });
|
||||
});
|
||||
|
||||
test('excludes loop element variable', () => {
|
||||
const result = findTemplateVars('{{ * item in items }}{{ item.name }}{{ /* }}');
|
||||
expect(result).toEqual({ items: [] });
|
||||
expect(result).not.toHaveProperty('item');
|
||||
});
|
||||
|
||||
test('excludes loop index variable', () => {
|
||||
const result = findTemplateVars('{{ * (item, i) in items }}{{ i }}:{{ item }}{{ /* }}');
|
||||
expect(result).toEqual({ items: [] });
|
||||
expect(result).not.toHaveProperty('item');
|
||||
expect(result).not.toHaveProperty('i');
|
||||
});
|
||||
|
||||
test('extracts external vars used inside loops', () => {
|
||||
const result = findTemplateVars('{{ * item in items }}{{ item }}-{{ prefix }}{{ /* }}');
|
||||
expect(result).toEqual({ items: [], prefix: '' });
|
||||
});
|
||||
|
||||
test('handles nested loops', () => {
|
||||
const result = findTemplateVars('{{ * row in rows }}{{ * col in row.cols }}{{ col }}{{ /* }}{{ /* }}');
|
||||
expect(result).toEqual({ rows: [] });
|
||||
expect(result).not.toHaveProperty('row');
|
||||
expect(result).not.toHaveProperty('col');
|
||||
});
|
||||
|
||||
test('extracts from complex nested template', () => {
|
||||
const tpl = `
|
||||
{{ ? items.length > 0 }}
|
||||
{{ * (item, i) in items }}
|
||||
{{ i }}. {{ item.name }}: {{ currency }}{{ item.price }}
|
||||
{{ /* }}
|
||||
Total: {{ total }}
|
||||
{{ !? }}
|
||||
{{ emptyMessage }}
|
||||
{{ /? }}`;
|
||||
const result = findTemplateVars(tpl);
|
||||
expect(result).toEqual({
|
||||
items: [],
|
||||
currency: '',
|
||||
total: '',
|
||||
emptyMessage: ''
|
||||
});
|
||||
expect(result).not.toHaveProperty('item');
|
||||
expect(result).not.toHaveProperty('i');
|
||||
});
|
||||
|
||||
test('ignores template control syntax', () => {
|
||||
const result = findTemplateVars('{{ < header.html }}{{ > layout.html:content }}content{{ /> }}');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test('handles multiple variables in expressions', () => {
|
||||
const result = findTemplateVars('{{ firstName + " " + lastName }}');
|
||||
expect(result).toEqual({ firstName: '', lastName: '' });
|
||||
});
|
||||
|
||||
test('creates arrays for loop variables', () => {
|
||||
const result = findTemplateVars('{{ * item in items }}{{ item }}{{ /* }}');
|
||||
expect(result).toEqual({ items: [] });
|
||||
});
|
||||
|
||||
test('creates nested arrays', () => {
|
||||
const result = findTemplateVars('{{ * row in data.rows }}{{ row }}{{ /* }}');
|
||||
expect(result).toEqual({ data: { rows: [] } });
|
||||
});
|
||||
|
||||
test('creates multiple arrays', () => {
|
||||
const result = findTemplateVars('{{ * user in users }}{{ user }}{{ /* }}{{ * post in posts }}{{ post }}{{ /* }}');
|
||||
expect(result).toEqual({ users: [], posts: [] });
|
||||
});
|
||||
|
||||
test('excludes function calls', () => {
|
||||
const result = findTemplateVars('{{ value.toFixed(2) }}');
|
||||
expect(result).toEqual({ value: '' });
|
||||
expect(result.value).not.toBe('toFixed');
|
||||
});
|
||||
|
||||
test('excludes method chains', () => {
|
||||
const result = findTemplateVars('{{ text.replaceAll("\\n", "<br>") }}');
|
||||
expect(result).toEqual({ text: '' });
|
||||
});
|
||||
|
||||
test('handles mix of arrays and regular variables', () => {
|
||||
const result = findTemplateVars('{{ * item in cart }}{{ item.name }}{{ /* }}{{ total }}');
|
||||
expect(result).toEqual({ cart: [], total: '' });
|
||||
});
|
||||
|
||||
test('real world template', async () => {
|
||||
const wrapper = `<div> {{body}} </div>`;
|
||||
const tpl = `
|
||||
{{ > email:body }}
|
||||
<div style="text-align: center">
|
||||
{{ ? title }}<h1 style="margin: 0">{{ title }}</h1>{{ /? }}
|
||||
{{ ? subject }}<h2 style="margin: 0">{{ subject }}</h2>{{ /? }}
|
||||
{{ ? message }}<p style="margin-top: 1rem">{{ message }}</p>{{ /? }}
|
||||
{{ ? link }}
|
||||
<br>
|
||||
<div style="background: #dedede; padding: 8px 20px; border-radius: 10px; overflow-x: auto;">
|
||||
{{ ? link.startsWith('http') }}
|
||||
<a style="word-break: break-all;" target="_blank" href="{{ link }}">{{ link }}</a>
|
||||
{{ !? }}
|
||||
<p style="margin: 0; word-break: break-all;">{{ link }}</p>
|
||||
{{ /? }}
|
||||
</div>
|
||||
{{ /? }}
|
||||
{{ ? footer }}
|
||||
<br>
|
||||
<p>{{ footer }}</p>
|
||||
{{ /? }}
|
||||
</div>
|
||||
{{ /> }}`;
|
||||
console.log(await renderTemplate(tpl, {
|
||||
title: 'test',
|
||||
subject: 'test',
|
||||
message: 'test',
|
||||
link: 'test',
|
||||
footer: 'test',
|
||||
}, async () => wrapper))
|
||||
});
|
||||
|
||||
test('real world invoice template', () => {
|
||||
const tpl = `
|
||||
{{ settings.title }}
|
||||
{{ * (row, index) in transaction.cart }}
|
||||
{{ row.quantity }} x {{ row.name }} = \${{ row.cost.toFixed(2) }}
|
||||
{{ /* }}
|
||||
Total: \${{ transaction.total.toFixed(2) }}
|
||||
{{ ? transaction.discount }}
|
||||
Discount: {{ transaction.discount.value }}
|
||||
{{ /? }}
|
||||
`;
|
||||
const result = findTemplateVars(tpl);
|
||||
expect(result).toEqual({
|
||||
settings: { title: '' },
|
||||
transaction: {
|
||||
cart: [],
|
||||
total: '',
|
||||
discount: { value: '' }
|
||||
}
|
||||
});
|
||||
expect(result).not.toHaveProperty('row');
|
||||
expect(result).not.toHaveProperty('index');
|
||||
expect(result.transaction.cart).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTemplate', () => {
|
||||
test('basic variable interpolation', async () => {
|
||||
const result = await renderTemplate('Hello {{ name }}!', { name: 'World' });
|
||||
expect(result).toBe('Hello World!');
|
||||
});
|
||||
|
||||
test('nested object access', async () => {
|
||||
const result = await renderTemplate('{{ user.name }} is {{ user.age }}', {
|
||||
user: { name: 'Alice', age: 25 }
|
||||
});
|
||||
expect(result).toBe('Alice is 25');
|
||||
});
|
||||
|
||||
test('method calls', async () => {
|
||||
const result = await renderTemplate('{{ price.toFixed(2) }}', { price: 9.5 });
|
||||
expect(result).toBe('9.50');
|
||||
});
|
||||
|
||||
test('if statement true', async () => {
|
||||
const result = await renderTemplate('{{ ? active }}YES{{ /? }}', { active: true });
|
||||
expect(result).toBe('YES');
|
||||
});
|
||||
|
||||
test('if statement false', async () => {
|
||||
const result = await renderTemplate('{{ ? active }}YES{{ /? }}', { active: false });
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
test('if-else', async () => {
|
||||
const result = await renderTemplate('{{ ? active }}YES{{ !? }}NO{{ /? }}', { active: false });
|
||||
expect(result).toBe('NO');
|
||||
});
|
||||
|
||||
test('if-elseif-else', async () => {
|
||||
const tpl = '{{ ? status == "paid" }}PAID{{ !? status == "pending" }}PENDING{{ !? }}OTHER{{ /? }}';
|
||||
expect(await renderTemplate(tpl, { status: 'paid' })).toBe('PAID');
|
||||
expect(await renderTemplate(tpl, { status: 'pending' })).toBe('PENDING');
|
||||
expect(await renderTemplate(tpl, { status: 'failed' })).toBe('OTHER');
|
||||
});
|
||||
|
||||
test('nested if statements', async () => {
|
||||
const tpl = '{{ ? a }}A{{ ? b }}B{{ /? }}{{ /? }}';
|
||||
expect(await renderTemplate(tpl, { a: true, b: true })).toBe('AB');
|
||||
expect(await renderTemplate(tpl, { a: true, b: false })).toBe('A');
|
||||
expect(await renderTemplate(tpl, { a: false, b: true })).toBe('');
|
||||
});
|
||||
|
||||
test('for loop', async () => {
|
||||
const tpl = '{{ * item in items }}{{ item }}{{ /* }}';
|
||||
const result = await renderTemplate(tpl, { items: ['a', 'b', 'c'] });
|
||||
expect(result).toBe('a\nb\nc');
|
||||
});
|
||||
|
||||
test('for loop with index', async () => {
|
||||
const tpl = '{{ * (item, i) in items }}{{ i }}:{{ item }}{{ /* }}';
|
||||
const result = await renderTemplate(tpl, { items: ['a', 'b'] });
|
||||
expect(result).toBe('0:a\n1:b');
|
||||
});
|
||||
|
||||
test('for loop with objects', async () => {
|
||||
const tpl = '{{ * user in users }}{{ user.name }}{{ /* }}';
|
||||
const result = await renderTemplate(tpl, {
|
||||
users: [{ name: 'Alice' }, { name: 'Bob' }]
|
||||
});
|
||||
expect(result).toBe('Alice\nBob');
|
||||
});
|
||||
|
||||
test('for loop error on non-array', async () => {
|
||||
await expect(
|
||||
renderTemplate('{{ * x in notArray }}{{ x }}{{ /* }}', { notArray: 'string' })
|
||||
).rejects.toThrow(TemplateError);
|
||||
});
|
||||
|
||||
test('import template', async () => {
|
||||
const fetch = async (file: string) => {
|
||||
if (file === 'header.html') return 'HEADER: {{ title }}';
|
||||
throw new Error('Not found');
|
||||
};
|
||||
const result = await renderTemplate('{{ < header.html }}', { title: 'Test' }, fetch);
|
||||
expect(result).toBe('HEADER: Test');
|
||||
});
|
||||
|
||||
test('import template error', async () => {
|
||||
await expect(
|
||||
renderTemplate('{{ < missing.html }}', {})
|
||||
).rejects.toThrow(TemplateError);
|
||||
});
|
||||
|
||||
test('extend template', async () => {
|
||||
const fetch = async (file: string) => {
|
||||
if (file === 'layout.html') return '<div>{{ content }}</div>';
|
||||
throw new Error('Not found');
|
||||
};
|
||||
const result = await renderTemplate('{{ > layout.html:content }}Hello{{ /> }}', {}, fetch);
|
||||
expect(result).toBe('<div>Hello</div>');
|
||||
});
|
||||
|
||||
test('date object', async () => {
|
||||
const result = await renderTemplate('{{ date.year }}', {});
|
||||
expect(result).toBe(new Date().getFullYear().toString());
|
||||
});
|
||||
|
||||
test('evaluation error', async () => {
|
||||
await expect(
|
||||
renderTemplate('{{ undefined.property }}', {})
|
||||
).rejects.toThrow(TemplateError);
|
||||
});
|
||||
|
||||
test('complex nested example', async () => {
|
||||
const tpl = `
|
||||
{{ ? items.length > 0 }}
|
||||
{{ * (item, i) in items }}
|
||||
{{ i + 1 }}. {{ item.name }}: \${{ item.price.toFixed(2) }}
|
||||
{{ /* }}
|
||||
Total: \${{ total.toFixed(2) }}
|
||||
{{ !? }}
|
||||
No items
|
||||
{{ /? }}`;
|
||||
const result = await renderTemplate(tpl, {
|
||||
items: [
|
||||
{ name: 'A', price: 10 },
|
||||
{ name: 'B', price: 20 }
|
||||
],
|
||||
total: 30
|
||||
});
|
||||
expect(result.trim()).toContain('1. A: $10.00');
|
||||
expect(result.trim()).toContain('2. B: $20.00');
|
||||
expect(result.trim()).toContain('Total: $30.00');
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ describe('Time Utilities', () => {
|
||||
});
|
||||
|
||||
it('throws for unknown timezone', () => {
|
||||
expect(() => formatDate('YYYY', new Date(), '???')).toThrowError(/Invalid timezone/);
|
||||
expect(() => formatDate('YYYY', new Date(), '???')).toThrow(/Invalid timezone/);
|
||||
});
|
||||
|
||||
it('handles timezone by offset number', () => {
|
||||
|
||||
Reference in New Issue
Block a user