├── .husky ├── pre-commit └── pre-push ├── .npmrc ├── .npmignore ├── .github ├── FUNDING.yml ├── pr-badge.yml ├── workflows │ └── main.yml └── settings.yml ├── .gitignore ├── source ├── index.ts ├── timer.ts ├── HTTP.ts ├── assert.ts ├── URL.ts ├── crypto.ts ├── math.ts ├── date.ts ├── event.ts ├── animation.ts ├── parser.ts ├── DOM-type.ts ├── DOM.ts └── data.ts ├── test ├── polyfill.ts ├── crypto.spec.ts ├── HTTP.spec.ts ├── animation.spec.ts ├── timer.spec.ts ├── event.spec.ts ├── math.spec.ts ├── URL.spec.ts ├── date.spec.ts ├── DOM.spec.ts ├── parser.spec.ts └── data.spec.ts ├── .vscode ├── extensions.json └── launch.json ├── tsconfig.json ├── .gitpod.yml ├── package.json └── ReadMe.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm test 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run build 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = false -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | docs/ 3 | .parcel-cache/ 4 | .husky/ 5 | .vscode/ 6 | .github/ 7 | .gitpod.yml -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: 2 | - https://paypal.me/TechQuery 3 | - https://tech-query.me/image/TechQuery-Alipay.jpg 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules/ 3 | docs/ 4 | dist/ 5 | .parcel-cache/ 6 | .vscode/settings.json 7 | .idea -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | export * from './data'; 2 | export * from './math'; 3 | export * from './date'; 4 | export * from './parser'; 5 | export * from './URL'; 6 | export * from './HTTP'; 7 | export * from './crypto'; 8 | export * from './DOM-type'; 9 | export * from './DOM'; 10 | export * from './timer'; 11 | export * from './event'; 12 | export * from './animation'; 13 | export * from './assert'; 14 | -------------------------------------------------------------------------------- /test/polyfill.ts: -------------------------------------------------------------------------------- 1 | import '@webcomponents/webcomponentsjs/custom-elements-es5-adapter'; 2 | import { TextEncoder, TextDecoder } from 'util'; 3 | import { Crypto } from '@peculiar/webcrypto'; 4 | import 'intersection-observer'; 5 | 6 | const polyfill = { TextEncoder, TextDecoder, crypto: new Crypto() }; 7 | 8 | for (const [key, value] of Object.entries(polyfill)) 9 | Object.defineProperty(globalThis, key, { value }); 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "yzhang.markdown-all-in-one", 4 | "redhat.vscode-yaml", 5 | "akamud.vscode-caniuse", 6 | "visualstudioexptteam.intellicode-api-usage-examples", 7 | "pflannery.vscode-versionlens", 8 | "christian-kohler.npm-intellisense", 9 | "esbenp.prettier-vscode", 10 | "eamodio.gitlens", 11 | "github.vscode-pull-request-github", 12 | "github.vscode-github-actions", 13 | "github.copilot" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /source/timer.ts: -------------------------------------------------------------------------------- 1 | export function sleep(seconds = 1) { 2 | return new Promise(resolve => setTimeout(resolve, seconds * 1000)); 3 | } 4 | 5 | export function asyncLoop(executor: (...data: any[]) => any, seconds = 1) { 6 | var stop = false; 7 | 8 | (async () => { 9 | while (!stop) { 10 | const result = executor(); 11 | 12 | if (result instanceof Promise) await result; 13 | 14 | await sleep(seconds); 15 | } 16 | })(); 17 | 18 | return () => (stop = true); 19 | } 20 | -------------------------------------------------------------------------------- /.github/pr-badge.yml: -------------------------------------------------------------------------------- 1 | - icon: visualstudio 2 | label: 'GitHub.dev' 3 | message: 'PR-$prNumber' 4 | color: 'blue' 5 | url: 'https://github.dev/$owner/$repo/pull/$prNumber' 6 | 7 | - icon: github 8 | label: 'GitHub codespaces' 9 | message: 'PR-$prNumber' 10 | color: 'black' 11 | url: 'https://codespaces.new/$owner/$repo/pull/$prNumber' 12 | 13 | - icon: git 14 | label: 'GitPod.io' 15 | message: 'PR-$prNumber' 16 | color: 'orange' 17 | url: 'https://gitpod.io/?autostart=true#https://github.com/$owner/$repo/pull/$prNumber' 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest", 6 | "type": "node", 7 | "request": "launch", 8 | "port": 9229, 9 | "runtimeArgs": [ 10 | "--inspect-brk", 11 | "${workspaceRoot}/node_modules/jest/bin/jest.js", 12 | "--runInBand" 13 | ], 14 | "console": "integratedTerminal", 15 | "internalConsoleOptions": "neverOpen" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /test/crypto.spec.ts: -------------------------------------------------------------------------------- 1 | import './polyfill'; 2 | import { makeCRC32, makeSHA } from '../source/crypto'; 3 | 4 | describe('Crypto function', () => { 5 | it('should create a CRC-32 Hex string', async () => { 6 | expect(makeCRC32('Web Utility')).toBe('0xa72f2c8e'); 7 | }); 8 | 9 | it('should create a SHA Hash string with various algorithms', async () => { 10 | expect(await makeSHA('Web Utility')).toBe( 11 | '3d6f5afa692ed347c21444bccf8dcc22ba637d3d' 12 | ); 13 | expect(await makeSHA('Web Utility', 'SHA-256')).toBe( 14 | '98e049c68fb717fae9aebb6800863bb4d0093e752872e74e614c291328f33331' 15 | ); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /test/HTTP.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseCookie, setCookie } from '../source/HTTP'; 2 | 3 | describe('HTTP', () => { 4 | it('should set a Cookie value in Web browser', () => { 5 | setCookie('test', 'value'); 6 | 7 | expect(document.cookie.includes('test=value')); 8 | }); 9 | 10 | it('should parse Cookie values in Web browser', () => { 11 | const { test } = parseCookie(document.cookie); 12 | 13 | expect(test).toBe('value'); 14 | }); 15 | 16 | it('should delete a Cookie value in Web browser', () => { 17 | setCookie('test', '', { expires: new Date(0) }); 18 | 19 | expect(document.cookie.includes('test=')).toBe(false); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/animation.spec.ts: -------------------------------------------------------------------------------- 1 | import './polyfill'; 2 | import { PageVector, durationOf } from '../source/animation'; 3 | 4 | describe('Animation', () => { 5 | it('should calculate the Length & Direction of a Page Vector', () => { 6 | const { length, direction } = new PageVector( 7 | { x: 0, y: 0 }, 8 | { x: 3, y: 4 } 9 | ); 10 | expect(length).toBe(5); 11 | expect(direction).toBe('forward'); 12 | }); 13 | 14 | it('should return Millisecond Duration of Transition Style', () => { 15 | document.body.style.transitionDuration = '0.25s'; 16 | 17 | expect(durationOf('transition', document.body)).toBe(250); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "ES6", 5 | "moduleResolution": "Node", 6 | "esModuleInterop": true, 7 | "downlevelIteration": true, 8 | "declaration": true, 9 | "skipLibCheck": true, 10 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 11 | "types": ["node", "jest"], 12 | "outDir": "dist/" 13 | }, 14 | "include": ["source/*.ts", "test/*.ts"], 15 | "typedocOptions": { 16 | "name": "Web utility", 17 | "excludeExternals": true, 18 | "excludePrivate": true, 19 | "readme": "./ReadMe.md", 20 | "plugin": ["typedoc-plugin-mdn-links"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/timer.spec.ts: -------------------------------------------------------------------------------- 1 | import { asyncLoop, sleep } from '../source/timer'; 2 | 3 | describe('Timer', () => { 4 | it('should wait for seconds', async () => { 5 | const start = Date.now(); 6 | 7 | await sleep(); 8 | 9 | expect(Date.now() - start).toBeGreaterThanOrEqual(1000); 10 | }); 11 | 12 | it('should call a function for each unit seconds', async () => { 13 | const start = Date.now(), 14 | list = []; 15 | 16 | await new Promise(resolve => { 17 | var stop = asyncLoop(() => { 18 | list.push(list.length); 19 | 20 | if (list.length < 3) return; 21 | 22 | stop(); 23 | resolve(); 24 | }, 0.25); 25 | }); 26 | 27 | expect(Date.now() - start).toBeGreaterThanOrEqual(500); 28 | expect(list).toEqual([0, 1, 2]); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | # Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart 6 | 7 | tasks: 8 | - init: pnpm i && pnpm build 9 | command: pnpm start 10 | vscode: 11 | extensions: 12 | - yzhang.markdown-all-in-one 13 | - redhat.vscode-yaml 14 | - akamud.vscode-caniuse 15 | - visualstudioexptteam.intellicode-api-usage-examples 16 | - pflannery.vscode-versionlens 17 | - christian-kohler.npm-intellisense 18 | - esbenp.prettier-vscode 19 | - eamodio.gitlens 20 | - github.vscode-pull-request-github 21 | - github.vscode-github-actions 22 | -------------------------------------------------------------------------------- /source/HTTP.ts: -------------------------------------------------------------------------------- 1 | export const parseCookie = >( 2 | value = globalThis.document?.cookie 3 | ) => 4 | (value 5 | ? Object.fromEntries(value.split(/;\s*/).map(item => item.split('='))) 6 | : {}) as T; 7 | 8 | export interface CookieAttribute { 9 | domain?: string; 10 | path?: string; 11 | expires?: Date; 12 | 'max-age'?: number; 13 | samesite?: 'lax' | 'strict' | 'none'; 14 | secure?: boolean; 15 | partitioned?: boolean; 16 | } 17 | 18 | export function setCookie( 19 | key: string, 20 | value: string, 21 | attributes: CookieAttribute = {} 22 | ) { 23 | const data = `${key}=${value}`, 24 | option = Object.entries(attributes) 25 | .map(([key, value]) => 26 | typeof value === 'boolean' 27 | ? value 28 | ? key 29 | : '' 30 | : `${key}=${value}` 31 | ) 32 | .filter(Boolean) 33 | .join('; '); 34 | 35 | document.cookie = `${data}; expires=${new Date(0)}`; 36 | 37 | return (document.cookie = `${data}; ${option}`); 38 | } 39 | -------------------------------------------------------------------------------- /source/assert.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from './timer'; 2 | 3 | export async function describe(title: string, cases: () => any) { 4 | console.log(title); 5 | console.time(title); 6 | 7 | await cases(); 8 | 9 | console.timeEnd(title); 10 | } 11 | 12 | export type Expector = (status: boolean | (() => boolean)) => void; 13 | 14 | export async function it( 15 | title: string, 16 | userCase: (expect: Expector) => T | Promise, 17 | secondsOut = 3 18 | ): Promise { 19 | title = ' ' + title; 20 | 21 | console.time(title); 22 | 23 | const expect: Expector = status => { 24 | const assert = typeof status === 'function' ? status : undefined; 25 | 26 | status = assert?.() ?? status; 27 | 28 | if (!status) 29 | throw new Error(`Assertion failed: ${title}\n\n${assert || ''}`); 30 | }; 31 | const timeOut = (): Promise => 32 | sleep(secondsOut).then(() => 33 | Promise.reject(new RangeError('Timed out')) 34 | ); 35 | try { 36 | return await Promise.race([userCase(expect), timeOut()]); 37 | } finally { 38 | console.timeEnd(title); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI & CD 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | jobs: 7 | Build-and-Publish: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | id-token: write 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: pnpm/action-setup@v4 16 | with: 17 | version: 10 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 22 21 | registry-url: https://registry.npmjs.org 22 | cache: pnpm 23 | - name: Install Dependencies 24 | run: pnpm i --frozen-lockfile 25 | 26 | - name: Build & Publish 27 | run: npm publish --access public --provenance 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | 31 | - name: Update document 32 | uses: peaceiris/actions-gh-pages@v4 33 | with: 34 | publish_dir: ./docs 35 | personal_token: ${{ secrets.GITHUB_TOKEN }} 36 | force_orphan: true 37 | -------------------------------------------------------------------------------- /test/event.spec.ts: -------------------------------------------------------------------------------- 1 | import './polyfill'; 2 | import { delegate, promisify } from '../source/event'; 3 | 4 | describe('Event', () => { 5 | it('should call an Event handler when the target matches a CSS selector', async () => { 6 | document.body.innerHTML = '
'; 7 | 8 | const result = new Promise<[Element, Event, Element, string]>(resolve => 9 | document.body.addEventListener( 10 | 'test', 11 | delegate('a', function (event, target, detail: string) { 12 | resolve([this, event, target, detail]); 13 | }) 14 | ) 15 | ); 16 | const customEvent = new CustomEvent('test', { 17 | bubbles: true, 18 | detail: 'test' 19 | }), 20 | link = document.querySelector('a'); 21 | 22 | link.dispatchEvent(customEvent); 23 | 24 | const [thisNode, event, target, detail] = await result; 25 | 26 | expect(thisNode).toBe(document.body); 27 | expect(event).toBe(customEvent); 28 | expect(target).toBe(link); 29 | expect(detail).toBe('test'); 30 | }); 31 | 32 | it('should convert an End Event to a Promise resolve', async () => { 33 | var end = promisify('transition', document.body), 34 | event = new CustomEvent('transitionend'); 35 | 36 | document.body.dispatchEvent(event); 37 | 38 | expect(await end).toBe(event); 39 | }); 40 | 41 | it('should convert an Cancel Event to a Promise reject', async () => { 42 | const end = promisify('animation', document.body), 43 | event = new CustomEvent('animationcancel'); 44 | 45 | document.body.dispatchEvent(event); 46 | try { 47 | await end; 48 | } catch (error) { 49 | expect(error).toBe(event); 50 | } 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /source/URL.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty, likeArray, makeArray } from './data'; 2 | import { parseJSON } from './parser'; 3 | 4 | export const isXDomain = (URI: string) => 5 | new URL(URI, document.baseURI).origin !== location.origin; 6 | 7 | export type JSONValue = number | boolean | string | null; 8 | export interface URLData { 9 | [key: string]: JSONValue | JSONValue[] | URLData | URLData[] | E; 10 | } 11 | 12 | export function parseURLData( 13 | raw = globalThis.location?.search || '', 14 | toBuiltIn = true 15 | ): URLData { 16 | const rawData = raw 17 | .split('#') 18 | .map(URI => { 19 | const [before, after] = URI.split('?'); 20 | 21 | return new URLSearchParams( 22 | after || (before.includes('=') ? before : '') 23 | ); 24 | }) 25 | .join('&'); 26 | const data = new URLSearchParams(rawData); 27 | 28 | return Object.fromEntries( 29 | [...data.keys()].map(key => { 30 | const list = toBuiltIn 31 | ? data.getAll(key).map(parseJSON) 32 | : data.getAll(key); 33 | 34 | return [key, list.length < 2 ? list[0] : list]; 35 | }) 36 | ); 37 | } 38 | 39 | const stringify = (value: any) => 40 | typeof value === 'string' 41 | ? value 42 | : likeArray(value) 43 | ? makeArray(value) + '' 44 | : JSON.stringify(value); 45 | 46 | export function buildURLData(map: string[][] | Record) { 47 | if (!(map instanceof Array)) map = Object.entries(map); 48 | 49 | const list = (map as any[][]) 50 | .map(([key, value]) => !isEmpty(value) && [key, stringify(value)]) 51 | .filter(Boolean); 52 | 53 | return new URLSearchParams(list); 54 | } 55 | 56 | export const blobOf = async (URI: string | URL) => 57 | (await fetch(URI + '')).blob(); 58 | 59 | const DataURI = /^data:(.+?\/(.+?))?(;base64)?,([\s\S]+)/; 60 | /** 61 | * Blob logic forked from axes's 62 | * 63 | * @see {@link http://www.cnblogs.com/axes/p/4603984.html} 64 | */ 65 | export function blobFrom(URI: string) { 66 | var [_, type, __, base64, data] = DataURI.exec(URI) || []; 67 | 68 | data = base64 ? atob(data) : data; 69 | 70 | const aBuffer = Uint8Array.from(data, char => char.charCodeAt(0)); 71 | 72 | return new Blob([aBuffer], { type }); 73 | } 74 | -------------------------------------------------------------------------------- /source/crypto.ts: -------------------------------------------------------------------------------- 1 | const CRC_32_Table = Array.from(new Array(256), (_, cell) => { 2 | for (var j = 0; j < 8; j++) 3 | if (cell & 1) cell = ((cell >> 1) & 0x7fffffff) ^ 0xedb88320; 4 | else cell = (cell >> 1) & 0x7fffffff; 5 | 6 | return cell; 7 | }); 8 | 9 | /** 10 | * CRC-32 algorithm forked from Bakasen's 11 | * 12 | * @see http://blog.csdn.net/bakasen/article/details/6043797 13 | */ 14 | export function makeCRC32(raw: string) { 15 | var value = 0xffffffff; 16 | 17 | for (const char of raw) 18 | value = 19 | ((value >> 8) & 0x00ffffff) ^ 20 | CRC_32_Table[(value & 0xff) ^ char.charCodeAt(0)]; 21 | 22 | return '0x' + ((value ^ 0xffffffff) >>> 0).toString(16); 23 | } 24 | 25 | if (typeof self === 'object') { 26 | if ('msCrypto' in globalThis) { 27 | // @ts-ignore 28 | const { subtle } = (globalThis.crypto = globalThis.msCrypto as Crypto); 29 | 30 | for (const key in subtle) { 31 | const origin = subtle[key]; 32 | 33 | if (origin instanceof Function) 34 | subtle[key] = function () { 35 | const observer = origin.apply(this, arguments); 36 | 37 | return new Promise((resolve, reject) => { 38 | observer.oncomplete = ({ 39 | target 40 | }: Parameters[0]) => 41 | resolve(target.result); 42 | 43 | observer.onabort = observer.onerror = reject; 44 | }); 45 | }; 46 | } 47 | } 48 | const { crypto } = globalThis; 49 | 50 | if (!crypto?.subtle && crypto?.['webkitSubtle']) 51 | // @ts-ignore 52 | crypto.subtle = crypto['webkitSubtle']; 53 | } 54 | 55 | export type SHAAlgorithm = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'; 56 | 57 | /** 58 | * @see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#Converting_a_digest_to_a_hex_string 59 | */ 60 | export async function makeSHA(raw: string, algorithm: SHAAlgorithm = 'SHA-1') { 61 | const buffer = await crypto.subtle.digest( 62 | algorithm, 63 | new TextEncoder().encode(raw) 64 | ); 65 | return Array.from(new Uint8Array(buffer), byte => 66 | byte.toString(16).padStart(2, '0') 67 | ).join(''); 68 | } 69 | -------------------------------------------------------------------------------- /test/math.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | sum, 3 | averageOf, 4 | varianceOf, 5 | standardDeviationOf, 6 | hypotenuseOf, 7 | carryFloat, 8 | fixFloat 9 | } from '../source/math'; 10 | 11 | describe('Math functions', () => { 12 | it('should calculate the sum of all numbers', () => { 13 | expect(sum(1990, 0, -1)).toBe(1989); 14 | expect(sum(1, 2, 3)).toBe(6); 15 | expect(sum(6, -2)).toBe(4); 16 | }); 17 | 18 | it('should calculate the average of all numbers', () => { 19 | expect(averageOf(2, 4, 8, 16, 32, 64, 128, 256, 512, 1024)).toBe(204.6); 20 | }); 21 | 22 | it('should calculate the variance of all numbers', () => { 23 | expect(varianceOf([5, 6, 8, 9])).toBe(2.5); 24 | expect(varianceOf([5, 6, 8, 9], true)).toBe(10 / 3); 25 | }); 26 | 27 | it('should calculate the standard deviation of all numbers', () => { 28 | expect(standardDeviationOf([5, 6, 8, 9]).toFixed(5)).toBe('1.58114'); 29 | expect(standardDeviationOf([5, 6, 8, 9], true).toFixed(5)).toBe( 30 | '1.82574' 31 | ); 32 | }); 33 | 34 | it('should calculate the square root of sum of squares', () => { 35 | expect(hypotenuseOf(1, 2, 3).toFixed(3)).toBe('3.742'); 36 | }); 37 | 38 | it('should carry rest bits of a Float Number based on length', () => { 39 | expect(carryFloat(0.01, 1)).toBe('0.1'); 40 | expect(carryFloat(0.01, 2)).toBe('0.01'); 41 | expect(carryFloat(1.01, 0)).toBe('2'); 42 | expect(carryFloat(0.03001, 3)).toBe('0.031'); 43 | expect(carryFloat(0.049999999999999996, 3)).toBe('0.050'); 44 | expect(carryFloat(1573.1666666666667, 1)).toBe('1573.2'); 45 | expect(carryFloat(7.527726527090811e-7, 7)).toBe('0.0000008'); 46 | }); 47 | 48 | it('should fix a Float Number with Banker Rounding Algorithm', () => { 49 | expect(fixFloat(89, 0)).toBe('89'); 50 | expect(fixFloat(89, 1)).toBe('89.0'); 51 | expect(fixFloat(89.5, 0)).toBe('89'); 52 | expect(fixFloat(89.64, 0)).toBe('90'); 53 | expect(fixFloat(89.64, 1)).toBe('89.6'); 54 | 55 | expect(fixFloat(0.8964, 5)).toBe('0.89640'); 56 | expect(fixFloat(0.8964, 3)).toBe('0.896'); 57 | expect(fixFloat(0.8966, 3)).toBe('0.897'); 58 | expect(fixFloat(0.8965, 3)).toBe('0.896'); 59 | expect(fixFloat(0.8955, 3)).toBe('0.896'); 60 | expect(fixFloat(0.89651, 3)).toBe('0.897'); 61 | expect(fixFloat(0.89551, 3)).toBe('0.896'); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /source/math.ts: -------------------------------------------------------------------------------- 1 | export function sum(...data: number[]) { 2 | return data.reduce((sum, item) => sum + item, 0); 3 | } 4 | 5 | export function averageOf(...data: number[]) { 6 | return sum(...data) / data.length; 7 | } 8 | 9 | export function varianceOf(data: number[], sample = false) { 10 | const average = averageOf(...data); 11 | const summary = sum(...data.map(item => (item - average) ** 2)); 12 | 13 | return summary / (data.length - (sample ? 1 : 0)); 14 | } 15 | 16 | export function standardDeviationOf(data: number[], sample = false) { 17 | return Math.sqrt(varianceOf(data, sample)); 18 | } 19 | 20 | export function hypotenuseOf(...data: number[]) { 21 | return Math.sqrt(sum(...data.map(item => item ** 2))); 22 | } 23 | 24 | export function carryFloat(raw: number, length: number) { 25 | const text = raw.toFixed(length + 2); 26 | const offset = text.indexOf('.') + length + 1; 27 | 28 | const cut = (text: string) => text.slice(0, offset - (length ? 0 : 1)); 29 | 30 | if (!+text.slice(offset)) return cut(text); 31 | 32 | const result = cut((+cut(text) + 10 ** -length).toFixed(length)); 33 | 34 | return result.includes('.') ? result.padEnd(offset, '0') : result; 35 | } 36 | 37 | export function fixFloat(raw: number, length = 2) { 38 | const text = raw.toFixed(length + 2); 39 | const floatOffset = text.indexOf('.'); 40 | 41 | if (floatOffset < 0) return length ? `${text}.${'0'.repeat(length)}` : text; 42 | 43 | const offset = floatOffset + length + 1; 44 | 45 | const before = +text[offset - 1], 46 | anchor = +text[offset], 47 | after = +text[offset + 1]; 48 | 49 | const carry = anchor > 5 || (anchor === 5 && (!!after || !!(before % 2))); 50 | 51 | if (carry) return carryFloat(raw, length); 52 | 53 | const result = text.slice(0, offset - (length ? 0 : 1)); 54 | 55 | return result.includes('.') ? result.padEnd(offset, '0') : result; 56 | } 57 | 58 | export abstract class Scalar { 59 | abstract units: { base: number; name: string }[]; 60 | 61 | constructor(public value: number) {} 62 | 63 | valueOf() { 64 | return this.value; 65 | } 66 | 67 | toShortString(fractionDigits = 2) { 68 | const { units, value } = this; 69 | const { base, name } = 70 | [...units].reverse().find(({ base }) => Math.abs(value) >= base) || 71 | units[0]; 72 | 73 | return `${(value / base).toFixed(fractionDigits)} ${name}`; 74 | } 75 | 76 | static distanceOf(a: number, b: number) { 77 | return Reflect.construct(this, [a - b]) as T; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-utility", 3 | "version": "4.6.4", 4 | "license": "LGPL-3.0", 5 | "author": "shiy2008@gmail.com", 6 | "description": "Web front-end toolkit based on TypeScript", 7 | "keywords": [ 8 | "web", 9 | "front-end", 10 | "utility", 11 | "toolkit", 12 | "typescript" 13 | ], 14 | "homepage": "https://web-cell.dev/web-utility/", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/EasyWebApp/web-utility.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/EasyWebApp/web-utility/issues" 21 | }, 22 | "source": "source/index.ts", 23 | "types": "dist/index.d.ts", 24 | "main": "dist/index.js", 25 | "module": "dist/index.esm.js", 26 | "dependencies": { 27 | "@swc/helpers": "^0.5.17", 28 | "regenerator-runtime": "^0.14.1" 29 | }, 30 | "peerDependencies": { 31 | "element-internals-polyfill": ">=1", 32 | "typescript": ">=4.1" 33 | }, 34 | "devDependencies": { 35 | "@parcel/packager-ts": "~2.16.0", 36 | "@parcel/transformer-typescript-types": "~2.16.0", 37 | "@peculiar/webcrypto": "^1.5.0", 38 | "@types/jest": "^29.5.14", 39 | "@types/node": "^22.18.13", 40 | "@webcomponents/webcomponentsjs": "^2.8.0", 41 | "core-js": "^3.46.0", 42 | "husky": "^9.1.7", 43 | "intersection-observer": "^0.12.2", 44 | "jest": "^29.7.0", 45 | "jest-environment-jsdom": "^29.7.0", 46 | "lint-staged": "^16.2.6", 47 | "open-cli": "^8.0.0", 48 | "parcel": "~2.16.0", 49 | "prettier": "^3.6.2", 50 | "ts-jest": "^29.4.5", 51 | "typedoc": "^0.28.14", 52 | "typedoc-plugin-mdn-links": "^5.0.10", 53 | "typescript": "~5.9.3" 54 | }, 55 | "pnpm": { 56 | "onlyBuiltDependencies": [ 57 | "@parcel/watcher", 58 | "@swc/core", 59 | "core-js", 60 | "lmdb", 61 | "msgpackr-extract" 62 | ] 63 | }, 64 | "prettier": { 65 | "singleQuote": true, 66 | "trailingComma": "none", 67 | "arrowParens": "avoid", 68 | "tabWidth": 4 69 | }, 70 | "lint-staged": { 71 | "*.{md,json,yml,ts}": "prettier --write" 72 | }, 73 | "jest": { 74 | "preset": "ts-jest", 75 | "testEnvironment": "jsdom" 76 | }, 77 | "browserslist": "> 0.5%, last 2 versions, not dead, IE 11", 78 | "targets": { 79 | "main": { 80 | "optimize": true 81 | } 82 | }, 83 | "scripts": { 84 | "prepare": "husky", 85 | "test": "lint-staged && jest --no-cache", 86 | "build": "rm -rf dist/ docs/ && typedoc source/ && parcel build", 87 | "start": "typedoc source/ && open-cli docs/index.html", 88 | "prepublishOnly": "npm test && npm run build" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /test/URL.spec.ts: -------------------------------------------------------------------------------- 1 | import './polyfill'; 2 | import { isXDomain, parseURLData, buildURLData, blobFrom } from '../source/URL'; 3 | 4 | describe('URL', () => { 5 | it('should return "true" while an URL is cross domain in current page', () => { 6 | expect(isXDomain('https://www.google.com/')).toBe(true); 7 | }); 8 | 9 | describe('Parse URL data', () => { 10 | it('should accept History & Hash routes', () => { 11 | expect(parseURLData('a/b?c=1')).toEqual({ c: 1 }); 12 | expect(parseURLData('a/b?c=1#d/e')).toEqual({ c: 1 }); 13 | expect(parseURLData('a/b?c=1#d/e?f=2')).toEqual({ c: 1, f: 2 }); 14 | }); 15 | 16 | it('should parse Primitive values by default', () => 17 | expect( 18 | parseURLData( 19 | 'a=A&b=2&c=false&d=9007199254740993&e=1031495205251190784&f=2022-1' 20 | ) 21 | ).toEqual( 22 | expect.objectContaining({ 23 | a: 'A', 24 | b: 2, 25 | c: false, 26 | d: '9007199254740993', 27 | e: '1031495205251190784', 28 | f: new Date('2022-1') 29 | }) 30 | )); 31 | it('should parse strings when toBuiltIn parameter is false', () => 32 | expect( 33 | parseURLData( 34 | 'a=A&b=2&c=false&d=9007199254740993&e=1031495205251190784&f=2022-1', 35 | false 36 | ) 37 | ).toEqual( 38 | expect.objectContaining({ 39 | a: 'A', 40 | b: '2', 41 | c: 'false', 42 | d: '9007199254740993', 43 | e: '1031495205251190784', 44 | f: '2022-1' 45 | }) 46 | )); 47 | 48 | it('should parse Multiple key to Array', () => 49 | expect(parseURLData('/test/example?a=1&b=2&b=3')).toEqual( 50 | expect.objectContaining({ a: 1, b: [2, 3] }) 51 | )); 52 | }); 53 | 54 | describe('Build URL Data', () => { 55 | it('should build from an Object or Array', () => { 56 | expect(buildURLData({ a: 1, b: 2 }) + '').toBe('a=1&b=2'); 57 | 58 | expect( 59 | buildURLData([ 60 | ['a', 1], 61 | ['a', 2], 62 | ['b', 3] 63 | ]) + '' 64 | ).toBe('a=1&a=2&b=3'); 65 | }); 66 | 67 | it('should filter Null Values and handle JSON stringify', () => { 68 | expect( 69 | buildURLData({ 70 | a: 1, 71 | b: null, 72 | c: '', 73 | d: { toJSON: () => 4 }, 74 | e: [1, 2] 75 | }) + '' 76 | ).toBe('a=1&d=4&e=1%2C2'); 77 | }); 78 | }); 79 | 80 | describe('Blob', () => { 81 | it('should create a Blob from a Base64 URI', () => { 82 | const URI = 83 | 'data:text/plain;base64,' + 84 | Buffer.from('123').toString('base64'); 85 | 86 | const { type, size } = blobFrom(URI); 87 | 88 | expect(type).toBe('text/plain'); 89 | expect(size).toBe(3); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | # These settings are synced to GitHub by https://probot.github.io/apps/settings/ 2 | 3 | repository: 4 | allow_merge_commit: false 5 | 6 | delete_branch_on_merge: true 7 | 8 | enable_vulnerability_alerts: true 9 | 10 | labels: 11 | - name: bug 12 | color: '#d73a4a' 13 | description: Something isn't working 14 | 15 | - name: documentation 16 | color: '#0075ca' 17 | description: Improvements or additions to documentation 18 | 19 | - name: duplicate 20 | color: '#cfd3d7' 21 | description: This issue or pull request already exists 22 | 23 | - name: enhancement 24 | color: '#a2eeef' 25 | description: Some improvements 26 | 27 | - name: feature 28 | color: '#16b33f' 29 | description: New feature or request 30 | 31 | - name: good first issue 32 | color: '#7057ff' 33 | description: Good for newcomers 34 | 35 | - name: help wanted 36 | color: '#008672' 37 | description: Extra attention is needed 38 | 39 | - name: invalid 40 | color: '#e4e669' 41 | description: This doesn't seem right 42 | 43 | - name: question 44 | color: '#d876e3' 45 | description: Further information is requested 46 | 47 | - name: wontfix 48 | color: '#ffffff' 49 | description: This will not be worked on 50 | 51 | branches: 52 | - name: master 53 | # https://docs.github.com/en/rest/reference/repos#update-branch-protection 54 | protection: 55 | # Required. Require at least one approving review on a pull request, before merging. Set to null to disable. 56 | required_pull_request_reviews: 57 | # The number of approvals required. (1-6) 58 | required_approving_review_count: 1 59 | # Dismiss approved reviews automatically when a new commit is pushed. 60 | dismiss_stale_reviews: true 61 | # Blocks merge until code owners have reviewed. 62 | require_code_owner_reviews: true 63 | # Specify which users and teams can dismiss pull request reviews. 64 | # Pass an empty dismissal_restrictions object to disable. 65 | # User and team dismissal_restrictions are only available for organization-owned repositories. 66 | # Omit this parameter for personal repositories. 67 | dismissal_restrictions: 68 | # users: [] 69 | # teams: [] 70 | # Required. Require status checks to pass before merging. Set to null to disable 71 | required_status_checks: 72 | # Required. Require branches to be up to date before merging. 73 | strict: true 74 | # Required. The list of status checks to require in order to merge into this branch 75 | contexts: [] 76 | # Required. Enforce all configured restrictions for administrators. 77 | # Set to true to enforce required status checks for repository administrators. 78 | # Set to null to disable. 79 | enforce_admins: true 80 | # Prevent merge commits from being pushed to matching branches 81 | required_linear_history: true 82 | # Required. Restrict who can push to this branch. 83 | # Team and user restrictions are only available for organization-owned repositories. 84 | # Set to null to disable. 85 | restrictions: null 86 | -------------------------------------------------------------------------------- /source/date.ts: -------------------------------------------------------------------------------- 1 | import { Scalar } from './math'; 2 | 3 | export const Second = 1000; 4 | export const Minute = Second * 60; 5 | export const Quarter = Minute * 15; 6 | export const Hour = Quarter * 4; 7 | export const Day = Hour * 24; 8 | export const Week = Day * 7; 9 | export const Year = Day * 365; 10 | export const Month = Year / 12; 11 | export const Season = Month * 3; 12 | 13 | export class Timestamp extends Scalar { 14 | units = [ 15 | { base: Second, name: 's' }, 16 | { base: Minute, name: 'm' }, 17 | { base: Hour, name: 'H' }, 18 | { base: Day, name: 'D' }, 19 | { base: Week, name: 'W' }, 20 | { base: Month, name: 'M' }, 21 | { base: Year, name: 'Y' } 22 | ]; 23 | 24 | toShortString(fractionDigits = 0) { 25 | return super.toShortString(fractionDigits); 26 | } 27 | } 28 | 29 | export type TimeData = number | string | Date; 30 | 31 | /** 32 | * @deprecated since v4.4, use {@link Timestamp.distanceOf} instead. 33 | */ 34 | export function diffTime(end: TimeData, start: TimeData = new Date()) { 35 | const timeDistance = Timestamp.distanceOf( 36 | +new Date(end), 37 | +new Date(start) 38 | ) as Timestamp; 39 | 40 | const [value, unit] = timeDistance.toShortString().split(/\s+/); 41 | 42 | return { distance: +value, unit }; 43 | } 44 | 45 | function fitUnit(value: string) { 46 | value = +value + ''; 47 | 48 | return (template: string) => 49 | (value.length < template.length 50 | ? value.padStart(template.length, '0') 51 | : value 52 | ).slice(-Math.max(template.length, 2)); 53 | } 54 | 55 | export function formatDate( 56 | time: TimeData = new Date(), 57 | template = 'YYYY-MM-DD HH:mm:ss' 58 | ) { 59 | const [year, month, day, hour, minute, second, millisecond] = new Date(time) 60 | .toJSON() 61 | .split(/\D/); 62 | 63 | return template 64 | .replace(/ms/g, millisecond) 65 | .replace(/Y+/g, fitUnit(year)) 66 | .replace(/M+/g, fitUnit(month)) 67 | .replace(/D+/g, fitUnit(day)) 68 | .replace(/H+/g, fitUnit(hour)) 69 | .replace(/m+/g, fitUnit(minute)) 70 | .replace(/s+/g, fitUnit(second)); 71 | } 72 | 73 | export function changeYear(date: TimeData, delta: number) { 74 | date = new Date(date); 75 | 76 | date.setFullYear(date.getFullYear() + delta); 77 | 78 | return date; 79 | } 80 | 81 | export function changeMonth(date: TimeData, delta: number) { 82 | date = new Date(date); 83 | 84 | const month = date.getMonth() + delta; 85 | 86 | date = changeYear(date, Math.floor(month / 12)); 87 | 88 | delta = month % 12; 89 | 90 | date.setMonth(delta < 0 ? 12 + delta : delta); 91 | 92 | return date; 93 | } 94 | 95 | export const changeDate = (date: TimeData, unit: number, delta: number) => 96 | unit === Year 97 | ? changeYear(date, delta) 98 | : unit === Month 99 | ? changeMonth(date, delta) 100 | : new Date(+new Date(date) + delta * unit); 101 | 102 | const DateLength2Unit = { 103 | 4: Year, 104 | 7: Month, 105 | 10: Day, 106 | 13: Hour, 107 | 16: Minute, 108 | 19: Second 109 | }; 110 | 111 | export function makeDateRange(value: string) { 112 | const defaultValue = `2025-01-01T00:00:00.000Z`; 113 | 114 | const startedAt = new Date(value + defaultValue.slice(value.length)); 115 | const endedAt = changeDate(startedAt, DateLength2Unit[value.length], 1); 116 | 117 | return [startedAt, endedAt]; 118 | } 119 | -------------------------------------------------------------------------------- /source/event.ts: -------------------------------------------------------------------------------- 1 | import { uniqueID } from './data'; 2 | 3 | export type DelegateEventHandler = ( 4 | event: Event, 5 | currentTarget: Element, 6 | detail?: T 7 | ) => any; 8 | 9 | export function delegate( 10 | selector: string, 11 | handler: DelegateEventHandler 12 | ) { 13 | return function (this: Node, event: Event) { 14 | var node: EventTarget, 15 | path = event.composedPath(); 16 | 17 | while ((node = path.shift()) && node !== event.currentTarget) 18 | if (node instanceof HTMLElement && node.matches(selector)) 19 | return handler.call( 20 | this, 21 | event, 22 | node, 23 | (event as CustomEvent).detail 24 | ); 25 | }; 26 | } 27 | 28 | export const documentReady = 29 | typeof window !== 'undefined' 30 | ? new Promise(resolve => { 31 | function done() { 32 | document?.removeEventListener('DOMContentLoaded', done); 33 | window.removeEventListener('load', done); 34 | resolve(); 35 | } 36 | document?.addEventListener('DOMContentLoaded', done); 37 | window.addEventListener('load', done); 38 | 39 | setTimeout(function check() { 40 | document?.readyState === 'complete' 41 | ? resolve() 42 | : setTimeout(check); 43 | }); 44 | }) 45 | : Promise.resolve(); 46 | 47 | export function promisify(scope: string, element: Element) { 48 | return new Promise((resolve, reject) => { 49 | function end(event: T) { 50 | resolve(event), clean(); 51 | } 52 | function cancel(event: T) { 53 | reject(event), clean(); 54 | } 55 | 56 | function clean() { 57 | element.removeEventListener(scope + 'end', end); 58 | element.removeEventListener(scope + 'cancel', cancel); 59 | } 60 | 61 | element.addEventListener(scope + 'end', end); 62 | element.addEventListener(scope + 'cancel', cancel); 63 | }); 64 | } 65 | 66 | export type MessageGlobal = Window | Worker; 67 | 68 | export function createMessageServer( 69 | handlers: Record any | Promise> 70 | ) { 71 | async function server({ 72 | data: { type, id, ...data }, 73 | source, 74 | origin 75 | }: MessageEvent) { 76 | var result = handlers[type]?.(data); 77 | 78 | if (result instanceof Promise) result = await result; 79 | // @ts-ignore 80 | (source as MessageGlobal).postMessage({ ...result, id }, origin); 81 | } 82 | 83 | globalThis.addEventListener('message', server); 84 | 85 | return () => globalThis.removeEventListener('message', server); 86 | } 87 | 88 | export function createMessageClient(target: Window | Worker, origin = '*') { 89 | return (type: string, data?: any) => 90 | new Promise(resolve => { 91 | const UID = uniqueID(); 92 | 93 | function handler({ data: { id, ...data } }: MessageEvent) { 94 | if (id !== UID) return; 95 | 96 | resolve(data); 97 | 98 | globalThis.removeEventListener('message', handler); 99 | } 100 | 101 | globalThis.addEventListener('message', handler); 102 | // @ts-ignore 103 | target.postMessage({ id: UID, type, ...data }, origin); 104 | }); 105 | } 106 | 107 | export function serviceWorkerUpdate(registration: ServiceWorkerRegistration) { 108 | return new Promise(resolve => { 109 | if (registration.waiting) return resolve(registration.waiting); 110 | 111 | registration.onupdatefound = () => 112 | registration.installing?.addEventListener( 113 | 'statechange', 114 | function () { 115 | if ( 116 | this.state === 'installed' && 117 | navigator.serviceWorker.controller 118 | ) 119 | resolve(this); 120 | } 121 | ); 122 | }); 123 | } 124 | -------------------------------------------------------------------------------- /source/animation.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from './event'; 2 | 3 | export interface CartesianCoordinate { 4 | x: number; 5 | y: number; 6 | z?: number; 7 | } 8 | 9 | export class PageVector { 10 | from: CartesianCoordinate; 11 | to: CartesianCoordinate; 12 | 13 | constructor(from: CartesianCoordinate, to: CartesianCoordinate) { 14 | this.from = from; 15 | this.to = to; 16 | } 17 | 18 | get length() { 19 | const { from, to } = this; 20 | 21 | return Math.sqrt( 22 | Math.pow(to.x - from.x, 2) + 23 | Math.pow(to.y - from.y, 2) + 24 | (to.z != null ? Math.pow(to.z - from.z, 2) : 0) 25 | ); 26 | } 27 | 28 | get direction() { 29 | const { from, to } = this; 30 | const XD = to.x - from.x, 31 | YD = to.y - from.y, 32 | ZD = to.z - from.z; 33 | const XL = Math.abs(XD), 34 | YL = Math.abs(YD), 35 | ZL = Math.abs(ZD); 36 | 37 | switch (isNaN(ZL) ? Math.max(XL, YL) : Math.max(XL, YL, ZL)) { 38 | case XL: 39 | return XD > 0 ? 'right' : 'left'; 40 | case YL: 41 | return YD > 0 ? 'forward' : 'backward'; 42 | case ZL: 43 | return ZD > 0 ? 'up' : 'down'; 44 | } 45 | } 46 | } 47 | 48 | export function getSwipeVector( 49 | from: CartesianCoordinate, 50 | to: CartesianCoordinate, 51 | threshold = parseInt(getComputedStyle(document.body).fontSize) * 6 52 | ) { 53 | const vector = new PageVector(from, to); 54 | 55 | if (vector.length >= threshold && !getSelection()?.toString().trim()) 56 | return vector; 57 | } 58 | 59 | export interface AnimationEvents { 60 | transition: TransitionEvent; 61 | animation: AnimationEvent; 62 | } 63 | 64 | export type AnimationType = keyof AnimationEvents; 65 | 66 | export function durationOf(type: AnimationType, element: HTMLElement) { 67 | const { transitionDuration, animationDuration } = getComputedStyle(element); 68 | 69 | const duration = 70 | type === 'animation' ? animationDuration : transitionDuration; 71 | 72 | return parseFloat(duration) * (duration.slice(-2) === 'ms' ? 1 : 1000); 73 | } 74 | 75 | export function watchMotion( 76 | type: T, 77 | element: HTMLElement 78 | ) { 79 | return Promise.race([ 80 | promisify(type, element).catch(event => 81 | Promise.resolve(event) 82 | ), 83 | new Promise(resolve => 84 | setTimeout(resolve, durationOf(type, element)) 85 | ) 86 | ]); 87 | } 88 | 89 | function fadeIn( 90 | type: T, 91 | element: HTMLElement, 92 | className: string, 93 | display: string 94 | ) { 95 | element.style.display = display; 96 | 97 | const end = watchMotion(type, element); 98 | 99 | return new Promise(resolve => 100 | requestAnimationFrame(() => { 101 | element.classList.add(className); 102 | 103 | end.then(resolve); 104 | }) 105 | ); 106 | } 107 | 108 | async function fadeOut( 109 | type: T, 110 | element: HTMLElement, 111 | className: string, 112 | remove?: boolean 113 | ) { 114 | const end = watchMotion(type, element); 115 | 116 | element.classList.remove(className); 117 | 118 | await end; 119 | 120 | if (remove) element.remove(); 121 | else element.style.display = 'none'; 122 | } 123 | 124 | export function transitIn( 125 | element: HTMLElement, 126 | className: string, 127 | display = 'block' 128 | ) { 129 | return fadeIn('transition', element, className, display); 130 | } 131 | 132 | export function animateIn( 133 | element: HTMLElement, 134 | className: string, 135 | display = 'block' 136 | ) { 137 | return fadeIn('animation', element, className, display); 138 | } 139 | 140 | export function transitOut( 141 | element: HTMLElement, 142 | className: string, 143 | remove?: boolean 144 | ) { 145 | return fadeOut('transition', element, className, remove); 146 | } 147 | 148 | export function animateOut( 149 | element: HTMLElement, 150 | className: string, 151 | remove?: boolean 152 | ) { 153 | return fadeOut('animation', element, className, remove); 154 | } 155 | -------------------------------------------------------------------------------- /test/date.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | diffTime, 3 | Second, 4 | Minute, 5 | Hour, 6 | Day, 7 | Month, 8 | Quarter, 9 | Year, 10 | formatDate, 11 | changeDate, 12 | changeMonth, 13 | makeDateRange, 14 | Timestamp, 15 | changeYear 16 | } from '../source/date'; 17 | 18 | describe('Date', () => { 19 | const date = new Date('1989-06-04T00:00:00.000Z'); 20 | 21 | describe('Relative time', () => { 22 | it('should calculate Time based on Gregorian calendar defaultly', () => { 23 | expect(diffTime(date, new Date(1989, 3, 26))).toEqual( 24 | expect.objectContaining({ distance: 1, unit: 'M' }) 25 | ); 26 | }); 27 | 28 | it('should calculate Time based on Chinese calendar', () => { 29 | class ChineseTimestamp extends Timestamp { 30 | units = [ 31 | { base: Second, name: '秒' }, 32 | { base: Minute, name: '分' }, 33 | { base: Quarter, name: '刻' }, 34 | { base: Quarter * 8, name: '时辰' }, 35 | { base: Day, name: '日' }, 36 | { base: Month, name: '月' }, 37 | { base: Year, name: '岁' } 38 | ]; 39 | } 40 | expect( 41 | ChineseTimestamp.distanceOf( 42 | +date, 43 | +new Date(1989, 3, 15) 44 | ).toShortString() 45 | ).toBe('2 月'); 46 | }); 47 | }); 48 | 49 | it('should format Date according to a Template', () => { 50 | expect(formatDate(date, 'YY/MM/DD')).toBe('89/06/04'); 51 | expect( 52 | formatDate(new Date('2020-01-23T00:00:00.000Z'), 'YYYY/M/D') 53 | ).toBe('2020/1/23'); 54 | }); 55 | 56 | describe('change Date', () => { 57 | it('should handle the Year Delta', () => { 58 | expect(changeYear(date, 1).toJSON()).toBe( 59 | '1990-06-04T00:00:00.000Z' 60 | ); 61 | expect(changeYear(date, -1).toJSON()).toBe( 62 | '1988-06-04T00:00:00.000Z' 63 | ); 64 | }); 65 | 66 | it('should handle the Month Delta less than a year', () => { 67 | expect(changeMonth(date, 1).toJSON()).toBe( 68 | '1989-07-04T00:00:00.000Z' 69 | ); 70 | expect(changeMonth(date, -1).toJSON()).toBe( 71 | '1989-05-04T00:00:00.000Z' 72 | ); 73 | }); 74 | 75 | it('should handle the Month Delta greater than a year', () => { 76 | expect(changeMonth(date, 12).toJSON()).toBe( 77 | '1990-06-04T00:00:00.000Z' 78 | ); 79 | expect(changeMonth(date, -12).toJSON()).toBe( 80 | '1988-06-04T00:00:00.000Z' 81 | ); 82 | }); 83 | 84 | it('should change the date by the specified Unit and Delta', () => { 85 | const date = new Date('2022-11-27T00:00:00.000Z'); 86 | 87 | expect(changeDate(date, Second, 30).toJSON()).toBe( 88 | '2022-11-27T00:00:30.000Z' 89 | ); 90 | expect(changeDate(date, Minute, 30).toJSON()).toBe( 91 | '2022-11-27T00:30:00.000Z' 92 | ); 93 | expect(changeDate(date, Hour, 1).toJSON()).toBe( 94 | '2022-11-27T01:00:00.000Z' 95 | ); 96 | expect(changeDate(date, Day, 1).toJSON()).toBe( 97 | '2022-11-28T00:00:00.000Z' 98 | ); 99 | expect(changeDate(date, Month, -1).toJSON()).toBe( 100 | '2022-10-27T00:00:00.000Z' 101 | ); 102 | expect(changeDate(date, Year, -1).toJSON()).toBe( 103 | '2021-11-27T00:00:00.000Z' 104 | ); 105 | }); 106 | }); 107 | 108 | it('should make a Date Range based on a Short Value', () => { 109 | expect(makeDateRange('2022')).toEqual([ 110 | new Date('2022-01-01T00:00:00.000Z'), 111 | new Date('2023-01-01T00:00:00.000Z') 112 | ]); 113 | expect(makeDateRange('2022-11')).toEqual([ 114 | new Date('2022-11-01T00:00:00.000Z'), 115 | new Date('2022-12-01T00:00:00.000Z') 116 | ]); 117 | expect(makeDateRange('2022-11-27')).toEqual([ 118 | new Date('2022-11-27T00:00:00.000Z'), 119 | new Date('2022-11-28T00:00:00.000Z') 120 | ]); 121 | expect(makeDateRange('2022-11-27T12:30')).toEqual([ 122 | new Date('2022-11-27T12:30:00.000Z'), 123 | new Date('2022-11-27T12:31:00.000Z') 124 | ]); 125 | expect(makeDateRange('2022-11-27T12:30:15')).toEqual([ 126 | new Date('2022-11-27T12:30:15.000Z'), 127 | new Date('2022-11-27T12:30:16.000Z') 128 | ]); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /source/parser.ts: -------------------------------------------------------------------------------- 1 | import { isUnsafeNumeric, objectFrom } from './data'; 2 | 3 | export function parseJSON(raw: string) { 4 | function parseItem(value: any) { 5 | if (typeof value === 'string' && /^\d+(-\d{1,2}){1,2}/.test(value)) { 6 | const date = new Date(value); 7 | 8 | if (!Number.isNaN(+date)) return date; 9 | } 10 | return value; 11 | } 12 | 13 | const value = parseItem(raw); 14 | 15 | if (typeof value !== 'string' || isUnsafeNumeric(value)) return value; 16 | 17 | try { 18 | return JSON.parse(raw, (key, value) => parseItem(value)); 19 | } catch { 20 | return raw; 21 | } 22 | } 23 | 24 | export function toJSValue(raw: string) { 25 | const parsed = parseJSON(raw); 26 | 27 | if (typeof parsed !== 'string') return parsed; 28 | 29 | const number = +parsed; 30 | 31 | return Number.isNaN(number) || number + '' !== parsed ? parsed : number; 32 | } 33 | 34 | export const stringifyTextTable = (data: object[], separator = ',') => 35 | [ 36 | Object.keys(data[0]).join(separator), 37 | ...data.map(item => 38 | Object.values(item) 39 | .map(value => JSON.stringify(value)) 40 | .join(separator) 41 | ) 42 | ].join('\n'); 43 | 44 | function readQuoteValue(raw: string) { 45 | const quote = raw[0]; 46 | const index = raw.indexOf(quote, 1); 47 | 48 | if (index < 0) throw SyntaxError(`A ${quote} is missing`); 49 | 50 | return raw.slice(1, index); 51 | } 52 | 53 | function parseRow(row: string, separator = ',') { 54 | const list = []; 55 | 56 | do { 57 | let value: string; 58 | 59 | if (row[0] === '"' || row[0] === "'") { 60 | value = readQuoteValue(row); 61 | 62 | row = row.slice(value.length + 3); 63 | } else { 64 | const index = row.indexOf(separator); 65 | 66 | if (index > -1) { 67 | value = row.slice(0, index); 68 | row = row.slice(index + 1); 69 | } else { 70 | value = row; 71 | row = ''; 72 | } 73 | } 74 | list.push(toJSValue(value.trim())); 75 | } while (row); 76 | 77 | return list; 78 | } 79 | 80 | /** 81 | * @deprecated Since 4.6.0, please use {@link parseTextTableAsync} or {@link readTextTable} 82 | * for better performance with large tables to avoid high memory usage 83 | */ 84 | export function parseTextTable = {}>( 85 | raw: string, 86 | header?: boolean, 87 | separator = ',' 88 | ) { 89 | const data = raw 90 | .trim() 91 | .split(/[\r\n]+/) 92 | .map(row => parseRow(row, separator)); 93 | 94 | return !header 95 | ? data 96 | : data.slice(1).map(row => objectFrom(row, data[0]) as T); 97 | } 98 | 99 | export const parseTextTableAsync = async = {}>( 100 | raw: string 101 | ) => Array.fromAsync(readTextTable(raw[Symbol.iterator]())); 102 | 103 | async function* characterStream( 104 | chunks: Iterable | AsyncIterable 105 | ) { 106 | for await (const chunk of chunks) yield* chunk; 107 | } 108 | 109 | async function* parseCharacterStream( 110 | chars: AsyncGenerator, 111 | separator = ',' 112 | ) { 113 | let inQuote = false; 114 | let quoteChar = ''; 115 | let prevChar = ''; 116 | let cellBuffer = ''; 117 | let currentRow: any[] = []; 118 | 119 | const completeCell = () => { 120 | currentRow.push(toJSValue(cellBuffer.trim())); 121 | cellBuffer = ''; 122 | }; 123 | 124 | for await (const char of chars) { 125 | if (char === '\n' || char === '\r') { 126 | if (char === '\n' && prevChar === '\r') { 127 | prevChar = char; 128 | continue; 129 | } 130 | completeCell(); 131 | 132 | if (currentRow.length > 1 || currentRow[0]) yield currentRow; 133 | 134 | currentRow = []; 135 | } else if ( 136 | (char === '"' || char === "'") && 137 | !inQuote && 138 | cellBuffer.trim() === '' 139 | ) { 140 | inQuote = true; 141 | quoteChar = char; 142 | } else if (char === quoteChar && inQuote) { 143 | inQuote = false; 144 | quoteChar = ''; 145 | } else if (inQuote) { 146 | cellBuffer += char; 147 | } else if (char === separator) { 148 | completeCell(); 149 | } else { 150 | cellBuffer += char; 151 | } 152 | prevChar = char; 153 | } 154 | 155 | if (cellBuffer || currentRow.length > 0) { 156 | completeCell(); 157 | 158 | if (currentRow.length > 1 || currentRow[0]) yield currentRow; 159 | } 160 | } 161 | 162 | export async function* readTextTable = {}>( 163 | chunks: Iterable | AsyncIterable, 164 | header?: boolean, 165 | separator = ',' 166 | ) { 167 | let headerRow: string[] | undefined; 168 | let isFirstRow = true; 169 | 170 | const chars = characterStream(chunks); 171 | 172 | for await (const row of parseCharacterStream(chars, separator)) 173 | if (header && isFirstRow) { 174 | headerRow = row; 175 | isFirstRow = false; 176 | } else 177 | yield header && headerRow ? (objectFrom(row, headerRow) as T) : row; 178 | } 179 | -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # Web utility 2 | 3 | Web & [JavaScript runtimes][1] toolkit based on [TypeScript][2] 4 | 5 | [![NPM Dependency](https://img.shields.io/librariesio/github/EasyWebApp/web-utility.svg)][3] 6 | [![CI & CD](https://github.com/EasyWebApp/web-utility/actions/workflows/main.yml/badge.svg)][4] 7 | 8 | [![Open in Visual Studio Code](https://open.vscode.dev/badges/open-in-vscode.svg)][5] 9 | 10 | [![NPM](https://nodei.co/npm/web-utility.png?downloads=true&downloadRank=true&stars=true)][6] 11 | 12 | ## Installation 13 | 14 | ```shell 15 | npm install web-utility 16 | ``` 17 | 18 | ### `index.html` 19 | 20 | ```html 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ``` 30 | 31 | ### `tsconfig.json` 32 | 33 | ```json 34 | { 35 | "compilerOptions": { 36 | "module": "ES2021", 37 | "moduleResolution": "Node", 38 | "downlevelIteration": true, 39 | "lib": ["ES2021", "DOM", "DOM.Iterable"] 40 | } 41 | } 42 | ``` 43 | 44 | ## Usage 45 | 46 | [API document](https://web-cell.dev/web-utility/) 47 | 48 | ### CSS Animation 49 | 50 | 1. [Watch Swipe](https://github.com/EasyWebApp/BootCell/blob/11c5d6f/source%2FMedia%2FCarousel.tsx#L200-L218) 51 | 52 | 2. [Simple Hover](https://github.com/EasyWebApp/BootCell/blob/a41bbc1/source/Prompt/Tooltip.tsx#L38-L43) 53 | 54 | 3. [Switch with `await`](https://github.com/EasyWebApp/BootCell/blob/a41bbc1/source/Content/TabList.tsx#L77-85) 55 | 56 | 4. [Toggle with Inline Styles](https://github.com/EasyWebApp/BootCell/blob/a41bbc1/source/Content/Collapse.tsx#L19-L38) 57 | 58 | 5. [Work with Existed Classes](https://github.com/EasyWebApp/BootCell/blob/a41bbc1/source/Content/Carousel.tsx#L82-L99) 59 | 60 | ### Function Cache 61 | 62 | ```typescript 63 | import { cache } from 'web-utility'; 64 | 65 | const getToken = cache(async (cleaner, code) => { 66 | const { access_token, expires_in } = await ( 67 | await fetch(`https://example.com/access_token?code=${code}`) 68 | ).json(); 69 | 70 | setTimeout(cleaner, expires_in * 1000); 71 | 72 | return access_token; 73 | }, 'Get Token'); 74 | 75 | Promise.all([getToken('xxx'), getToken('yyy')]).then(([first, second]) => 76 | console.assert( 77 | first === second, 78 | 'Getting token for many times should return the same before deadline' 79 | ) 80 | ); 81 | ``` 82 | 83 | ### DOM operation 84 | 85 | ```javascript 86 | import { parseDOM, walkDOM, stringifyDOM } from 'web-utility'; 87 | 88 | const [root] = parseDOM('Hello, Web!'); 89 | 90 | var count = 0; 91 | 92 | for (const { nodeName, nodeType, dataset } of walkDOM(root)) { 93 | console.log(nodeName); 94 | 95 | if (nodeType === Node.ELEMENT_NODE) dataset.id = ++count; 96 | } 97 | 98 | console.log(stringifyDOM(root)); // 'Hello, Web!' 99 | ``` 100 | 101 | ### jQuery-like DOM event delegation 102 | 103 | ```javascript 104 | import { delegate } from 'web-utility'; 105 | 106 | document.addEventListener( 107 | 'click', 108 | delegate('a[href]', (event, link) => { 109 | event.preventDefault(); 110 | 111 | console.log(link.href); 112 | }) 113 | ); 114 | ``` 115 | 116 | ### Message Channel 117 | 118 | #### `index.ts` 119 | 120 | ```typescript 121 | import { createMessageServer } from 'web-utility'; 122 | 123 | createMessageServer({ 124 | preset: () => ({ test: 1 }) 125 | }); 126 | ``` 127 | 128 | #### `iframe.ts` 129 | 130 | ```typescript 131 | import { createMessageClient } from 'web-utility'; 132 | 133 | const request = createMessageClient(globalThis.parent); 134 | 135 | request('preset').then(console.log); // { test: 1 } 136 | ``` 137 | 138 | ### Service Worker updating 139 | 140 | ```javascript 141 | import { serviceWorkerUpdate } from 'web-utility'; 142 | 143 | const { serviceWorker } = window.navigator; 144 | 145 | serviceWorker 146 | ?.register('/sw.js') 147 | .then(serviceWorkerUpdate) 148 | .then(worker => { 149 | if (window.confirm('New version of this Web App detected, update now?')) 150 | // Trigger the message callback listened in the Service Worker 151 | // generated by Workbox CLI 152 | worker.postMessage({ type: 'SKIP_WAITING' }); 153 | }); 154 | 155 | serviceWorker?.addEventListener('controllerchange', () => 156 | window.location.reload() 157 | ); 158 | ``` 159 | 160 | ### Internationalization 161 | 162 | Migrate to [MobX i18n][7] since v4. 163 | 164 | ### Test scripts 165 | 166 | If you are looking for a simple alternative of [Mocha][8] or [Jest][9], just use these **Test Utility** methods with [`tsx`][10]: 167 | 168 | ```shell 169 | npx tsx index.spec.ts 170 | ``` 171 | 172 | #### `index.spec.ts` 173 | 174 | ```typescript 175 | import { describe, it } from 'web-utility'; 176 | 177 | class App { 178 | name = 'test'; 179 | 180 | static create() { 181 | return new App(); 182 | } 183 | } 184 | 185 | describe('My module', async () => { 186 | const app = await it('should create an App object', async expect => { 187 | const app = App.create(); 188 | 189 | expect(app instanceof App); 190 | 191 | return app; 192 | }); 193 | 194 | await it('should init an App name', expect => { 195 | expect(app.name === 'test'); 196 | }); 197 | }); 198 | ``` 199 | 200 | ### CSV/TSV Parser 201 | 202 | ```typescript 203 | import { FileHandle, open } from 'fs/promises'; 204 | import { readTextTable } from 'web-utility'; 205 | 206 | interface Article { 207 | id: number; 208 | title: string; 209 | } 210 | let fileHandle: FileHandle | undefined; 211 | 212 | try { 213 | fileHandle = await open('path/to/your-article.csv'); 214 | 215 | for await (const row of readTextTable
( 216 | fileHandle.createReadStream() 217 | )) 218 | console.table(row); 219 | } finally { 220 | await fileHandle?.close(); 221 | } 222 | ``` 223 | 224 | [1]: https://min-common-api.proposal.wintertc.org/ 225 | [2]: https://www.typescriptlang.org/ 226 | [3]: https://libraries.io/npm/web-utility 227 | [4]: https://github.com/EasyWebApp/web-utility/actions/workflows/main.yml 228 | [5]: https://open.vscode.dev/EasyWebApp/web-utility 229 | [6]: https://www.npmjs.com/package/web-utility 230 | [7]: https://github.com/idea2app/MobX-i18n 231 | [8]: https://mochajs.org/ 232 | [9]: https://jestjs.io/ 233 | [10]: https://tsx.is 234 | -------------------------------------------------------------------------------- /test/DOM.spec.ts: -------------------------------------------------------------------------------- 1 | import './polyfill'; 2 | import { CSSStyles } from '../source/DOM-type'; 3 | import { 4 | templateOf, 5 | elementTypeOf, 6 | isHTMLElementClass, 7 | tagNameOf, 8 | parseDOM, 9 | stringifyDOM, 10 | walkDOM, 11 | getVisibleText, 12 | stringifyCSS, 13 | watchScroll, 14 | formToJSON, 15 | isDOMReadOnly 16 | } from '../source/DOM'; 17 | 18 | describe('DOM', () => { 19 | it('should detect the Element Type of a Tag Name', () => { 20 | expect(templateOf('div')).toBeInstanceOf(HTMLDivElement); 21 | 22 | expect(elementTypeOf('div')).toBe('html'); 23 | expect(elementTypeOf('x-tag')).toBe('html'); 24 | expect(elementTypeOf('svg')).toBe('xml'); 25 | expect(elementTypeOf('svg:a')).toBe('xml'); 26 | expect(elementTypeOf('math')).toBe('xml'); 27 | }); 28 | 29 | it('should get the Tag Name of a Custom Element', () => { 30 | class TestCell extends HTMLElement {} 31 | 32 | customElements.define('test-cell', TestCell); 33 | 34 | expect(isHTMLElementClass(TestCell)).toBeTruthy(); 35 | expect(tagNameOf(TestCell)).toBe('test-cell'); 36 | }); 37 | 38 | it('should detect Read-only properties of DOM elements', () => { 39 | expect(isDOMReadOnly('input', 'list')).toBeTruthy(); 40 | expect(isDOMReadOnly('a', 'href')).toBeFalsy(); 41 | }); 42 | 43 | it('should parse HTML to DOM & stringify DOM to HTML', () => { 44 | const markup = 'testdemo'; 45 | const nodes = parseDOM(markup); 46 | 47 | expect(nodes[0]).toBeInstanceOf(HTMLElement); 48 | expect(nodes[1]).toBeInstanceOf(Text); 49 | 50 | const fragment = document.createDocumentFragment(); 51 | fragment.append(...nodes); 52 | 53 | expect(stringifyDOM(fragment)).toBe(markup); 54 | }); 55 | 56 | it('should walk through a DOM tree with(out) a Type filter', () => { 57 | document.body.innerHTML = 'test'; 58 | 59 | expect( 60 | Array.from(walkDOM(document.body), ({ nodeName }) => nodeName) 61 | ).toStrictEqual(['BODY', 'A', 'B', '#text']); 62 | 63 | expect( 64 | Array.from( 65 | walkDOM(document.body, Node.TEXT_NODE), 66 | ({ nodeValue }) => nodeValue 67 | ) + '' 68 | ).toBe('test'); 69 | }); 70 | 71 | it('should get all the text of Visible elements', () => { 72 | document.body.innerHTML = ` 73 | 74 | 75 | test example 76 | current 77 | `; 78 | 79 | const { firstElementChild: link } = document.body; 80 | 81 | link!.getBoundingClientRect = () => 82 | ({ width: 48, height: 16 }) as DOMRect; 83 | 84 | expect(getVisibleText(link!)).toBe('test example'); 85 | }); 86 | 87 | describe('Stringify CSS', () => { 88 | const rule: CSSStyles = { 89 | position: 'absolute', 90 | top: '0', 91 | height: '100%', 92 | objectFit: 'contain' 93 | }; 94 | 95 | it('should stringify Simple CSS Object to a Rule', () => { 96 | const CSS = stringifyCSS(rule); 97 | 98 | expect(CSS).toBe(`position: absolute; 99 | top: 0; 100 | height: 100%; 101 | object-fit: contain;`); 102 | }); 103 | 104 | it('should stringify Nested CSS Object to Rules', () => { 105 | const CSS = stringifyCSS({ 106 | '.test': rule, 107 | '#demo': rule 108 | }); 109 | 110 | expect(CSS).toBe(`.test { 111 | position: absolute; 112 | top: 0; 113 | height: 100%; 114 | object-fit: contain; 115 | } 116 | #demo { 117 | position: absolute; 118 | top: 0; 119 | height: 100%; 120 | object-fit: contain; 121 | }`); 122 | }); 123 | 124 | it('should stringify Nested CSS Object to Nested Rules', () => { 125 | const simple_rule = { '.test': rule }; 126 | 127 | const CSS = stringifyCSS({ 128 | ...simple_rule, 129 | '@media (min-width: 600px)': simple_rule 130 | }); 131 | 132 | expect(CSS).toBe(`.test { 133 | position: absolute; 134 | top: 0; 135 | height: 100%; 136 | object-fit: contain; 137 | } 138 | @media (min-width: 600px) { 139 | .test { 140 | position: absolute; 141 | top: 0; 142 | height: 100%; 143 | object-fit: contain; 144 | } 145 | }`); 146 | }); 147 | }); 148 | 149 | it('should find all depth-matched Heading Elements in a container', () => { 150 | document.body.innerHTML = ` 151 |

Level 1

152 |
153 |

Level 2

154 |

test

155 |

Level 3

156 |

example

157 |
158 |
159 |

Level 2

160 |

test

161 |

Level 3

162 |

example

163 |
164 | `; 165 | 166 | expect(watchScroll(document.body, () => {})).toEqual([ 167 | { level: 1, id: 'h1', text: 'Level 1' }, 168 | { level: 2, id: 'h2.1', text: 'Level 2' }, 169 | { level: 3, id: 'h3.1', text: 'Level 3' }, 170 | { level: 2, id: 'h2.2', text: 'Level 2' }, 171 | { level: 3, id: 'h3.2', text: 'Level 3' } 172 | ]); 173 | 174 | expect(watchScroll(document.body, () => {}, 2)).toEqual([ 175 | { level: 1, id: 'h1', text: 'Level 1' }, 176 | { level: 2, id: 'h2.1', text: 'Level 2' }, 177 | { level: 2, id: 'h2.2', text: 'Level 2' } 178 | ]); 179 | }); 180 | 181 | it('should convert a Form to JSON', () => { 182 | document.body.innerHTML = ` 183 |
184 | 185 | 186 | 187 | 188 | 189 | 190 | 195 | 196 |
197 | 198 | 199 | 204 |
205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 |
`; 213 | 214 | const data = formToJSON(document.forms[0]); 215 | 216 | expect(data).toEqual( 217 | expect.objectContaining({ 218 | switch: false, 219 | list: ['01', '03'], 220 | array: [2, 3], 221 | test: { example: '123', other: 2 }, 222 | date: new Date('1989-06-04T00:00') 223 | }) 224 | ); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /test/parser.spec.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/actual/array/from-async'; 2 | import { ReadableStream } from 'stream/web'; 3 | 4 | import { 5 | parseJSON, 6 | toJSValue, 7 | parseTextTable, 8 | readTextTable, 9 | stringifyTextTable 10 | } from '../source/parser'; 11 | 12 | describe('Data String Parser', () => { 13 | describe('JSON Parser', () => { 14 | it('should parse JSON strings within Primitive values', () => { 15 | expect(parseJSON('1')).toBe(1); 16 | expect(parseJSON('9007199254740993')).toBe('9007199254740993'); 17 | expect(parseJSON('1031495205251190784')).toBe( 18 | '1031495205251190784' 19 | ); 20 | expect(parseJSON('1x')).toBe('1x'); 21 | expect(parseJSON('0xFF')).toBe('0xFF'); 22 | expect(parseJSON('1989-')).toBe('1989-'); 23 | }); 24 | 25 | it('should parse JSON strings within ISO Date values', () => { 26 | const { time } = parseJSON('{"time": "2020-01-23T00:00:00.000Z"}'); 27 | 28 | expect(time).toBeInstanceOf(Date); 29 | expect((time as Date).toJSON()).toBe('2020-01-23T00:00:00.000Z'); 30 | }); 31 | }); 32 | 33 | it('should parse a String to a Primitive value or JS object', () => { 34 | expect(toJSValue('1')).toBe(1); 35 | expect(toJSValue('true')).toBe(true); 36 | expect(toJSValue('1989-06-04')).toStrictEqual(new Date('1989-06-04')); 37 | }); 38 | 39 | describe('Text Table parser', () => { 40 | it('should stringify Text Table', () => { 41 | const data = [ 42 | { a: 1, b: 'text', c: true }, 43 | { a: 2, b: 'more text', c: false } 44 | ]; 45 | expect(stringifyTextTable(data)).toBe( 46 | 'a,b,c\n1,"text",true\n2,"more text",false' 47 | ); 48 | }); 49 | 50 | it('should parse Simple CSV', () => { 51 | expect(parseTextTable('1,2,3\n4,5,6')).toEqual([ 52 | [1, 2, 3], 53 | [4, 5, 6] 54 | ]); 55 | }); 56 | 57 | it('should parse Quoted CSV', () => { 58 | expect(parseTextTable('"a,1","b,2","c,3"')).toEqual([ 59 | ['a,1', 'b,2', 'c,3'] 60 | ]); 61 | }); 62 | 63 | it('should parse Mixed CSV', () => { 64 | expect(parseTextTable('"a,1",2,\'c,3\'')).toEqual([ 65 | ['a,1', 2, 'c,3'] 66 | ]); 67 | }); 68 | 69 | it('should parse Table Headers', () => { 70 | expect( 71 | parseTextTable>( 72 | 'a,b,c\n1,2,3', 73 | true 74 | ) 75 | ).toEqual([{ a: 1, b: 2, c: 3 }]); 76 | }); 77 | }); 78 | 79 | describe('Text Table Stream parser', () => { 80 | it('should parse Simple CSV stream', async () => { 81 | const chunks = ReadableStream.from(['1,2,3\n', '4,5,6']); 82 | 83 | const results = await Array.fromAsync(readTextTable(chunks)); 84 | 85 | expect(results).toEqual([ 86 | [1, 2, 3], 87 | [4, 5, 6] 88 | ]); 89 | }); 90 | 91 | it('should parse Quoted CSV stream', async () => { 92 | const chunks = ReadableStream.from(['"a,1","b,2",', '"c,3"']); 93 | 94 | const results = await Array.fromAsync(readTextTable(chunks)); 95 | 96 | expect(results).toEqual([['a,1', 'b,2', 'c,3']]); 97 | }); 98 | 99 | it('should parse Mixed CSV stream', async () => { 100 | const chunks = ReadableStream.from(['"a,1",2,', "'c,3'"]); 101 | 102 | const results = await Array.fromAsync(readTextTable(chunks)); 103 | 104 | expect(results).toEqual([['a,1', 2, 'c,3']]); 105 | }); 106 | 107 | it('should parse Table Headers in stream', async () => { 108 | const chunks = ReadableStream.from(['a,b,c\n', '1,2,3']); 109 | const results = await Array.fromAsync( 110 | readTextTable>(chunks, true) 111 | ); 112 | expect(results).toEqual([{ a: 1, b: 2, c: 3 }]); 113 | }); 114 | 115 | it('should handle chunk boundaries that split rows', async () => { 116 | const chunks = ReadableStream.from(['1,2', ',3\n4,5', ',6\n7,8,9']); 117 | 118 | const results = await Array.fromAsync(readTextTable(chunks)); 119 | 120 | expect(results).toEqual([ 121 | [1, 2, 3], 122 | [4, 5, 6], 123 | [7, 8, 9] 124 | ]); 125 | }); 126 | 127 | it('should handle multiple rows in single chunk', async () => { 128 | const chunks = ReadableStream.from(['1,2,3\n4,5,6\n7,8,9']); 129 | 130 | const results = await Array.fromAsync(readTextTable(chunks)); 131 | 132 | expect(results).toEqual([ 133 | [1, 2, 3], 134 | [4, 5, 6], 135 | [7, 8, 9] 136 | ]); 137 | }); 138 | 139 | it('should handle empty chunks and lines', async () => { 140 | const chunks = ReadableStream.from([ 141 | '1,2,3\n', 142 | '', 143 | '\n4,5,6\n', 144 | '\n' 145 | ]); 146 | const results = await Array.fromAsync(readTextTable(chunks)); 147 | 148 | expect(results).toEqual([ 149 | [1, 2, 3], 150 | [4, 5, 6] 151 | ]); 152 | }); 153 | 154 | it('should handle different separators', async () => { 155 | const chunks = ReadableStream.from(['1;2;3\n', '4;5;6']); 156 | const results = await Array.fromAsync( 157 | readTextTable(chunks, false, ';') 158 | ); 159 | expect(results).toEqual([ 160 | [1, 2, 3], 161 | [4, 5, 6] 162 | ]); 163 | }); 164 | 165 | it('should handle quoted values with complex separators', async () => { 166 | const chunks = ReadableStream.from(['"a,bc",2,', '3\n"d,ef",4,5']); 167 | 168 | const results = await Array.fromAsync(readTextTable(chunks)); 169 | 170 | expect(results).toEqual([ 171 | ['a,bc', 2, 3], 172 | ['d,ef', 4, 5] 173 | ]); 174 | }); 175 | 176 | it('should handle different newline formats (Windows CRLF)', async () => { 177 | const chunks = ReadableStream.from(['1,2,3\r\n', '4,5,6\r\n']); 178 | 179 | const results = await Array.fromAsync(readTextTable(chunks)); 180 | 181 | expect(results).toEqual([ 182 | [1, 2, 3], 183 | [4, 5, 6] 184 | ]); 185 | }); 186 | 187 | it('should handle mixed newline formats', async () => { 188 | const chunks = ReadableStream.from([ 189 | '1,2,3\r\n4,5,6\n', 190 | '7,8,9\r\n' 191 | ]); 192 | const results = await Array.fromAsync(readTextTable(chunks)); 193 | 194 | expect(results).toEqual([ 195 | [1, 2, 3], 196 | [4, 5, 6], 197 | [7, 8, 9] 198 | ]); 199 | }); 200 | 201 | it('should handle old Mac newline formats (\\r only)', async () => { 202 | const chunks = ReadableStream.from(['1,2,3\r', '4,5,6\r']); 203 | 204 | const results = await Array.fromAsync(readTextTable(chunks)); 205 | 206 | expect(results).toEqual([ 207 | [1, 2, 3], 208 | [4, 5, 6] 209 | ]); 210 | }); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /source/DOM-type.ts: -------------------------------------------------------------------------------- 1 | import type { DataKeys, PickData } from './data'; 2 | 3 | export type SelfCloseTags = 4 | | 'area' 5 | | 'base' 6 | | 'br' 7 | | 'col' 8 | | 'embed' 9 | | 'hr' 10 | | 'img' 11 | | 'input' 12 | | 'link' 13 | | 'meta' 14 | | 'param' 15 | | 'source' 16 | | 'track' 17 | | 'wbr'; 18 | 19 | export type ShadowableTags = 20 | | 'article' 21 | | 'aside' 22 | | 'blockquote' 23 | | 'body' 24 | | 'div' 25 | | 'footer' 26 | | 'h1' 27 | | 'h2' 28 | | 'h3' 29 | | 'h4' 30 | | 'h5' 31 | | 'h6' 32 | | 'header' 33 | | 'main' 34 | | 'nav' 35 | | 'p' 36 | | 'section' 37 | | 'span' 38 | | `${string}-${string}`; 39 | 40 | /* -------------------- Event Handlers -------------------- */ 41 | 42 | export type EventTypes = { 43 | [K in keyof typeof globalThis]: K extends `${infer N}Event` 44 | ? N extends '' 45 | ? never 46 | : N 47 | : never; 48 | }[keyof typeof globalThis]; 49 | 50 | export type UniqueEventNames = { 51 | [K in keyof HTMLElementEventMap]: K extends `${Lowercase}${string}` 52 | ? never 53 | : K extends `${string}${Lowercase}` 54 | ? never 55 | : K; 56 | }[keyof HTMLElementEventMap]; 57 | 58 | export type ComplexUniqueEventNames = { 59 | [K in UniqueEventNames]: K extends `${infer L}${UniqueEventNames}` 60 | ? L extends '' 61 | ? never 62 | : K 63 | : never; 64 | }[UniqueEventNames]; 65 | 66 | export type SimpleEventNames = Exclude< 67 | UniqueEventNames, 68 | ComplexUniqueEventNames 69 | >; 70 | 71 | export type EventHandlerNames = { 72 | [K in keyof T]: K extends `on${infer N}` 73 | ? T[K] extends (event: Event) => any 74 | ? N 75 | : never 76 | : never; 77 | }[keyof T]; 78 | 79 | export type CamelEventName = T extends SimpleEventNames 80 | ? Capitalize 81 | : T extends `${infer L}${SimpleEventNames}` 82 | ? T extends `${L}${infer R}` 83 | ? `${Capitalize}${Capitalize}` 84 | : T 85 | : T extends `${Lowercase}${infer R}` 86 | ? T extends `${infer L}${R}` 87 | ? `${Capitalize}${Capitalize}` 88 | : T 89 | : T extends `${infer L}${Lowercase}` 90 | ? T extends `${L}${infer R}` 91 | ? `${Capitalize}${Capitalize}` 92 | : T 93 | : T; 94 | 95 | export type EventHandlers< 96 | T extends Element, 97 | M extends HTMLElementEventMap = HTMLElementEventMap 98 | > = { 99 | [K in EventHandlerNames as `on${CamelEventName}`]: ( 100 | event: M[K] 101 | ) => any; 102 | }; 103 | 104 | export type ContainerEvents = 'focusin' | 'focusout'; 105 | 106 | export type ContainerEventHandlers = 107 | T extends SelfCloseTags 108 | ? {} 109 | : { 110 | [K in ContainerEvents as `on${CamelEventName}`]: ( 111 | event: HTMLElementEventMap[K] 112 | ) => any; 113 | }; 114 | 115 | /* -------------------- DOM Props -------------------- */ 116 | 117 | export type XMLOwnKeys< 118 | T extends HTMLElement | SVGElement | MathMLElement = HTMLElement 119 | > = Exclude; 120 | /** 121 | * @deprecated since v4.4.2, use {@link XMLOwnKeys} instead 122 | */ 123 | export type HTMLOwnKeys = XMLOwnKeys; 124 | /** 125 | * @deprecated since v4.4.2, use {@link XMLOwnKeys} instead 126 | */ 127 | export type SVGOwnKeys = XMLOwnKeys; 128 | 129 | export type CSSStyles = Partial< 130 | Omit, 'length' | 'parentRule'> & 131 | Record 132 | >; 133 | export type CSSRule = Record; 134 | export type CSSObject = CSSRule | Record; 135 | 136 | export type DOMProps_Read2Write> = { 137 | [K in keyof T]: T[K] extends HTMLElement 138 | ? string 139 | : T[K] extends DOMTokenList 140 | ? string 141 | : T[K] extends Element 142 | ? string 143 | : T[K] extends CSSStyleDeclaration 144 | ? CSSStyles 145 | : T[K]; 146 | }; 147 | export type HTMLProps = Partial< 148 | ARIAMixin & 149 | EventHandlers & 150 | DOMProps_Read2Write, XMLOwnKeys>>> 151 | >; 152 | 153 | export type SVGProps_Read2Write> = { 154 | [K in keyof T]: T[K] extends 155 | | SVGAnimatedString 156 | | SVGAnimatedBoolean 157 | | SVGAnimatedEnumeration 158 | | SVGAnimatedNumber 159 | | SVGAnimatedNumberList 160 | | SVGAnimatedInteger 161 | | SVGAnimatedLength 162 | | SVGAnimatedLengthList 163 | | SVGAnimatedPoints 164 | | SVGAnimatedAngle 165 | | SVGAnimatedRect 166 | | SVGAnimatedPreserveAspectRatio 167 | | SVGAnimatedTransformList 168 | ? string 169 | : T[K]; 170 | }; 171 | export type SVGProps = Partial< 172 | EventHandlers & 173 | SVGProps_Read2Write< 174 | DOMProps_Read2Write, XMLOwnKeys>>> 175 | > 176 | >; 177 | 178 | export type MathMLProps = Partial< 179 | EventHandlers & 180 | DOMProps_Read2Write, XMLOwnKeys>>> 181 | >; 182 | 183 | export interface HTMLHyperLinkProps 184 | extends HTMLProps { 185 | href?: string; 186 | target?: '_self' | '_parent' | '_top' | '_blank'; 187 | } 188 | 189 | export type HTMLTableCellProps = HTMLProps; 190 | 191 | export type BaseFieldProps = Partial< 192 | Pick< 193 | HTMLInputElement, 194 | 'name' | 'defaultValue' | 'value' | 'required' | 'disabled' 195 | > 196 | >; 197 | export interface BaseInputProps 198 | extends Partial> { 199 | list?: string; 200 | } 201 | export type TextFieldProps = BaseInputProps & 202 | Partial< 203 | Pick< 204 | HTMLInputElement, 205 | | 'size' 206 | | 'minLength' 207 | | 'maxLength' 208 | | 'pattern' 209 | | 'autocomplete' 210 | | 'spellcheck' 211 | > 212 | >; 213 | export type NumberFieldProps = BaseInputProps & 214 | Partial>; 215 | 216 | export type HTMLFieldInternals = Pick< 217 | HTMLInputElement, 218 | | 'form' 219 | | 'validity' 220 | | 'validationMessage' 221 | | 'willValidate' 222 | | 'checkValidity' 223 | | 'reportValidity' 224 | >; 225 | 226 | export type HTMLFieldProps = 227 | HTMLProps & BaseFieldProps; 228 | 229 | export interface HTMLButtonProps extends HTMLFieldProps {} 230 | 231 | export interface HTMLInputProps 232 | extends HTMLFieldProps, 233 | Omit { 234 | type?: 235 | | 'checkbox' 236 | | 'color' 237 | | 'date' 238 | | 'datetime-local' 239 | | 'email' 240 | | 'file' 241 | | 'hidden' 242 | | 'month' 243 | | 'number' 244 | | 'password' 245 | | 'radio' 246 | | 'range' 247 | | 'search' 248 | | 'tel' 249 | | 'text' 250 | | 'time' 251 | | 'url' 252 | | 'week' 253 | | HTMLButtonProps['type']; 254 | } 255 | 256 | export type HTMLField = HTMLInputElement & 257 | HTMLTextAreaElement & 258 | HTMLSelectElement & 259 | HTMLFieldSetElement; 260 | 261 | /** 262 | * @see https://developers.google.com/web/fundamentals/web-components/customelements#reactions 263 | */ 264 | export interface CustomElement extends HTMLElement { 265 | /** 266 | * Called every time the element is inserted into the DOM 267 | */ 268 | connectedCallback?(): void; 269 | /** 270 | * Called every time the element is removed from the DOM. 271 | */ 272 | disconnectedCallback?(): void; 273 | /** 274 | * Called when an observed attribute has been added, removed, updated, or replaced. 275 | * Also called for initial values when an element is created by the parser, or upgraded. 276 | * 277 | * Note: only attributes listed in static `observedAttributes` property will receive this callback. 278 | */ 279 | attributeChangedCallback?( 280 | name: string, 281 | oldValue: string, 282 | newValue: string 283 | ): void; 284 | /** 285 | * The custom element has been moved into a new document 286 | * (e.g. someone called `document.adoptNode(el)`). 287 | */ 288 | adoptedCallback?(): void; 289 | } 290 | 291 | /** 292 | * @see https://developers.google.com/web/fundamentals/web-components/customelements#attrchanges 293 | */ 294 | export interface CustomElementClass { 295 | new (...data: any[]): T; 296 | 297 | observedAttributes?: string[]; 298 | } 299 | 300 | /** 301 | * @see https://web.dev/more-capable-form-controls/#lifecycle-callbacks 302 | */ 303 | export interface CustomFormElement 304 | extends CustomElement, 305 | BaseFieldProps, 306 | HTMLFieldInternals { 307 | /** 308 | * Called when the browser associates the element with a form element, 309 | * or disassociates the element from a form element. 310 | */ 311 | formAssociatedCallback?(form: HTMLFormElement): void; 312 | /** 313 | * Called after the disabled state of the element changes, 314 | * either because the disabled attribute of this element was added or removed; 315 | * or because the disabled state changed on a `
` that's an ancestor of this element. 316 | * 317 | * @param disabled This parameter represents the new disabled state of the element. 318 | */ 319 | formDisabledCallback?(disabled: boolean): void; 320 | /** 321 | * Called after the form is reset. 322 | * The element should reset itself to some kind of default state. 323 | */ 324 | formResetCallback?(): void; 325 | /** 326 | * Called in one of two circumstances: 327 | * - When the browser restores the state of the element (for example, after a navigation, or when the browser restarts). The `mode` argument is `"restore"` in this case. 328 | * - When the browser's input-assist features such as form autofilling sets a value. The `mode` argument is `"autocomplete"` in this case. 329 | * 330 | * @param state The type of this argument depends on how the `this.internals.setFormValue()` method was called. 331 | * @param mode 332 | */ 333 | formStateRestoreCallback?( 334 | state: string | File | FormData, 335 | mode: 'restore' | 'autocomplete' 336 | ): void; 337 | } 338 | 339 | /** 340 | * @see https://web.dev/more-capable-form-controls/#defining-a-form-associated-custom-element 341 | */ 342 | export interface CustomFormElementClass 343 | extends CustomElementClass { 344 | formAssociated?: boolean; 345 | } 346 | -------------------------------------------------------------------------------- /source/DOM.ts: -------------------------------------------------------------------------------- 1 | import { URLData } from './URL'; 2 | import { HTMLProps, HTMLField, CSSStyles, CSSObject } from './DOM-type'; 3 | import { 4 | Constructor, 5 | isEmpty, 6 | assertInheritance, 7 | toHyphenCase, 8 | likeArray 9 | } from './data'; 10 | import { toJSValue } from './parser'; 11 | 12 | export const XMLNamespace = { 13 | html: 'http://www.w3.org/1999/xhtml', 14 | svg: 'http://www.w3.org/2000/svg', 15 | math: 'http://www.w3.org/1998/Math/MathML' 16 | }; 17 | 18 | const templateMap: Record = {}; 19 | 20 | export function templateOf(tagName: string) { 21 | if (templateMap[tagName]) return templateMap[tagName]; 22 | 23 | const spawn = document.createElement('template'); 24 | 25 | spawn.innerHTML = `<${tagName} />`; 26 | 27 | return (templateMap[tagName] = spawn.content.firstElementChild!); 28 | } 29 | 30 | export function elementTypeOf(tagName: string) { 31 | if (tagName.includes('-')) return 'html'; 32 | 33 | const [prefix, localName] = tagName.split(':'); 34 | 35 | if (localName) return prefix === 'html' ? 'html' : 'xml'; 36 | 37 | const node = templateOf(tagName); 38 | 39 | return node instanceof HTMLElement && !(node instanceof HTMLUnknownElement) 40 | ? 'html' 41 | : 'xml'; 42 | } 43 | 44 | export const isHTMLElementClass = >( 45 | Class: any 46 | ): Class is T => assertInheritance(Class, HTMLElement); 47 | 48 | const nameMap = new WeakMap, string>(); 49 | 50 | export function tagNameOf(Class: CustomElementConstructor) { 51 | const name = nameMap.get(Class); 52 | 53 | if (name) return name; 54 | 55 | var { tagName } = new Class(); 56 | 57 | nameMap.set(Class, (tagName = tagName.toLowerCase())); 58 | 59 | return tagName; 60 | } 61 | 62 | export function isDOMReadOnly( 63 | tagName: T, 64 | propertyName: keyof HTMLProps 65 | ) { 66 | /** 67 | * fetch from https://html.spec.whatwg.org/ 68 | */ 69 | const ReadOnly_Properties: [Constructor, string[]][] = [ 70 | [HTMLLinkElement, ['sizes']], 71 | [HTMLIFrameElement, ['sandbox']], 72 | [HTMLObjectElement, ['form']], 73 | [HTMLInputElement, ['form', 'list']], 74 | [HTMLButtonElement, ['form']], 75 | [HTMLSelectElement, ['form']], 76 | [HTMLTextAreaElement, ['form']], 77 | [HTMLOutputElement, ['form']], 78 | [HTMLFieldSetElement, ['form']] 79 | ]; 80 | const template = templateOf(tagName); 81 | 82 | for (const [Class, keys] of ReadOnly_Properties) 83 | if (template instanceof Class && keys.includes(propertyName as string)) 84 | return true; 85 | return false; 86 | } 87 | 88 | export function parseDOM(HTML: string) { 89 | const spawn = document.createElement('template'); 90 | 91 | spawn.innerHTML = HTML; 92 | 93 | return [...spawn.content.childNodes].map(node => { 94 | node.remove(); 95 | return node; 96 | }); 97 | } 98 | 99 | export const stringifyDOM = (node: Node) => 100 | new XMLSerializer() 101 | .serializeToString(node) 102 | .replace(/ xmlns="http:\/\/www.w3.org\/1999\/xhtml"/g, ''); 103 | 104 | export function* walkDOM( 105 | root: Node, 106 | type?: Node['nodeType'] 107 | ): Generator { 108 | const children = [...root.childNodes]; 109 | 110 | if (isEmpty(type) || type === root.nodeType) yield root as T; 111 | 112 | for (const node of children) yield* walkDOM(node, type); 113 | } 114 | 115 | export function inViewport(element: Element) { 116 | const { left, top } = element.getBoundingClientRect(); 117 | 118 | return document.elementFromPoint(left, top) === element; 119 | } 120 | 121 | export function getVisibleText(root: Element) { 122 | var text = ''; 123 | 124 | for (const { nodeType, parentElement, nodeValue } of walkDOM(root)) 125 | if ( 126 | nodeType === Node.TEXT_NODE && 127 | parentElement.getAttribute('aria-hidden') !== 'true' 128 | ) { 129 | const { width, height } = parentElement.getBoundingClientRect(); 130 | 131 | if (width && height) text += nodeValue.trim().replace(/\s+/g, ' '); 132 | } 133 | 134 | return text; 135 | } 136 | 137 | /** 138 | * Split a DOM tree into Pages like PDF files 139 | * 140 | * @param pageHeight the default value is A4 paper's height 141 | * @param pageWidth the default value is A4 paper's width 142 | */ 143 | export function splitPages( 144 | { offsetWidth, children }: HTMLElement, 145 | pageHeight = 841.89, 146 | pageWidth = 595.28 147 | ) { 148 | const scrollHeight = (pageHeight / pageWidth) * offsetWidth; 149 | var offset = 0; 150 | 151 | return [...children].reduce((pages, node) => { 152 | var { offsetTop: top, offsetHeight: height } = node as HTMLElement; 153 | top += offset; 154 | var bottom = top + height; 155 | 156 | const bottomOffset = bottom / scrollHeight; 157 | const topIndex = ~~(top / scrollHeight), 158 | bottomIndex = ~~bottomOffset; 159 | 160 | if (topIndex !== bottomIndex) offset += height - bottomOffset; 161 | 162 | (pages[bottomIndex] ||= []).push(node); 163 | 164 | return pages; 165 | }, [] as Element[][]); 166 | } 167 | 168 | export interface CSSOptions 169 | extends Pick< 170 | HTMLLinkElement, 171 | 'title' | 'media' | 'crossOrigin' | 'integrity' 172 | > { 173 | alternate?: boolean; 174 | } 175 | 176 | export function importCSS( 177 | URI: string, 178 | { alternate, ...options }: CSSOptions = {} as CSSOptions 179 | ) { 180 | const style = [...document.styleSheets].find(({ href }) => href === URI); 181 | 182 | if (style) return Promise.resolve(style); 183 | 184 | const link = document.createElement('link'); 185 | 186 | return new Promise((resolve, reject) => { 187 | link.onload = () => resolve(link.sheet); 188 | link.onerror = (_1, _2, _3, _4, error) => reject(error); 189 | 190 | Object.assign(link, options); 191 | 192 | link.rel = (alternate ? 'alternate ' : '') + 'stylesheet'; 193 | link.href = URI; 194 | 195 | document.head.append(link); 196 | }); 197 | } 198 | 199 | export function stringifyCSS( 200 | data: CSSStyles | CSSObject, 201 | depth = 0, 202 | indent = ' ' 203 | ): string { 204 | const padding = indent.repeat(depth); 205 | 206 | return Object.entries(data) 207 | .map(([key, value]) => 208 | typeof value !== 'object' 209 | ? `${padding}${toHyphenCase(key)}: ${value};` 210 | : `${padding}${key} { 211 | ${stringifyCSS(value as CSSObject, depth + 1, indent)} 212 | ${padding}}` 213 | ) 214 | .join('\n'); 215 | } 216 | 217 | export function insertToCursor(...nodes: Node[]) { 218 | const fragment = document.createDocumentFragment(); 219 | 220 | fragment.append(...nodes); 221 | 222 | for (const node of walkDOM(fragment)) 223 | if ( 224 | ![1, 3, 11].includes(node.nodeType) || 225 | ['meta', 'title', 'link', 'script'].includes( 226 | node.nodeName.toLowerCase() 227 | ) 228 | ) 229 | (node as ChildNode).replaceWith(...node.childNodes); 230 | 231 | const selection = globalThis.getSelection(); 232 | 233 | if (!selection) return; 234 | 235 | const range = selection.getRangeAt(0); 236 | 237 | range.deleteContents(); 238 | range.insertNode(fragment); 239 | } 240 | 241 | export function scrollTo( 242 | selector: string, 243 | root?: Element, 244 | align?: ScrollLogicalPosition, 245 | justify?: ScrollLogicalPosition 246 | ) { 247 | const [_, ID] = /^#(.+)/.exec(selector) || []; 248 | 249 | if (ID === 'top') window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }); 250 | else 251 | (root || document) 252 | .querySelector(ID ? `[id="${ID}"]` : selector) 253 | ?.scrollIntoView({ 254 | behavior: 'smooth', 255 | block: align, 256 | inline: justify 257 | }); 258 | } 259 | 260 | export interface ScrollEvent { 261 | target: HTMLHeadingElement; 262 | links: (HTMLAnchorElement | HTMLAreaElement)[]; 263 | } 264 | 265 | export const watchScroll = ( 266 | box: HTMLElement, 267 | handler: (event: ScrollEvent) => any, 268 | depth = 6 269 | ) => 270 | Array.from( 271 | box.querySelectorAll( 272 | Array.from(new Array(depth), (_, index) => `h${++index}`) + '' 273 | ), 274 | header => { 275 | new IntersectionObserver(([item]) => { 276 | if (!item.isIntersecting) return; 277 | 278 | const target = item.target as HTMLHeadingElement; 279 | 280 | handler({ 281 | target, 282 | links: [ 283 | ...target.ownerDocument.querySelectorAll< 284 | HTMLAnchorElement | HTMLAreaElement 285 | >(`[href="#${target.id}"]`) 286 | ] 287 | }); 288 | }).observe(header); 289 | 290 | if (!header.id.trim()) 291 | header.id = header.textContent.trim().replace(/\W+/g, '-'); 292 | 293 | return { 294 | level: +header.tagName[1], 295 | id: header.id, 296 | text: header.textContent.trim() 297 | }; 298 | } 299 | ); 300 | export function watchVisible( 301 | root: Element, 302 | handler: (visible: boolean) => any 303 | ) { 304 | var last = document.visibilityState === 'visible' ? 1 : 0; 305 | 306 | function change(state: number) { 307 | if (state === 3 || last === 3) handler(state === 3); 308 | 309 | last = state; 310 | } 311 | 312 | new IntersectionObserver(([{ isIntersecting }]) => 313 | change(isIntersecting ? last | 2 : last & 1) 314 | ).observe(root); 315 | 316 | document.addEventListener('visibilitychange', () => 317 | change(document.visibilityState === 'visible' ? last | 1 : last & 2) 318 | ); 319 | } 320 | 321 | export function formToJSON>( 322 | form: HTMLFormElement | HTMLFieldSetElement 323 | ) { 324 | const { elements } = form, 325 | data = {} as T; 326 | 327 | for (const field of elements) { 328 | let { name, value, checked, defaultValue, selectedOptions, files } = 329 | field as HTMLField; 330 | const type = (field as HTMLField).type as string; 331 | 332 | if (!name || value === '') continue; 333 | 334 | const box = type !== 'fieldset' && field.closest('fieldset'); 335 | 336 | if (box && box !== form) continue; 337 | 338 | let parsedValue: any = value; 339 | 340 | switch (type) { 341 | case 'radio': 342 | case 'checkbox': 343 | if (checked) 344 | parsedValue = !defaultValue || toJSValue(defaultValue); 345 | else if (likeArray(elements.namedItem(name))) continue; 346 | else parsedValue = false; 347 | break; 348 | case 'select-multiple': 349 | parsedValue = Array.from(selectedOptions, ({ value }) => 350 | toJSValue(value) 351 | ); 352 | break; 353 | case 'fieldset': 354 | parsedValue = formToJSON(field as HTMLFieldSetElement); 355 | break; 356 | case 'file': 357 | parsedValue = files && Array.from(files); 358 | break; 359 | case 'date': 360 | case 'datetime-local': 361 | case 'month': 362 | case 'hidden': 363 | case 'number': 364 | case 'range': 365 | case 'select-one': 366 | parsedValue = toJSValue(value); 367 | } 368 | 369 | if (name in data) data[name] = [].concat(data[name], parsedValue); 370 | else 371 | data[name] = 372 | !(parsedValue instanceof Array) || !isEmpty(parsedValue[1]) 373 | ? parsedValue 374 | : parsedValue[0]; 375 | } 376 | 377 | return data; 378 | } 379 | -------------------------------------------------------------------------------- /test/data.spec.ts: -------------------------------------------------------------------------------- 1 | import './polyfill'; 2 | import 'core-js/proposals/promise-with-resolvers'; 3 | import { 4 | likeNull, 5 | isEmpty, 6 | classNameOf, 7 | assertInheritance, 8 | proxyPrototype, 9 | byteLength, 10 | toHyphenCase, 11 | toCamelCase, 12 | encodeBase64, 13 | decodeBase64, 14 | objectFrom, 15 | DiffStatus, 16 | diffKeys, 17 | likeArray, 18 | isTypedArray, 19 | makeArray, 20 | splitArray, 21 | groupBy, 22 | countBy, 23 | findDeep, 24 | treeFrom, 25 | cache, 26 | mergeStream, 27 | createAsyncIterator, 28 | ByteSize 29 | } from '../source/data'; 30 | import { sleep } from '../source/timer'; 31 | 32 | describe('Data', () => { 33 | it('should detect Meaningless Null-like Values', () => { 34 | expect( 35 | [0, false, '', null, undefined, NaN, [], {}].map(likeNull) 36 | ).toEqual([false, false, false, true, true, true, false, false]); 37 | }); 38 | 39 | it('should detect Meaningless Empty Values', () => { 40 | expect( 41 | [0, false, '', null, undefined, NaN, [], {}].map(isEmpty) 42 | ).toEqual([false, false, true, true, true, true, true, true]); 43 | }); 44 | 45 | it('should return the Class Name of an object', () => { 46 | class NewObject {} 47 | 48 | expect(classNameOf(new NewObject())).toBe('Object'); 49 | 50 | expect(classNameOf(new URLSearchParams(''))).toBe('URLSearchParams'); 51 | }); 52 | 53 | it('should detect the inheritance of Sub & Super classes', () => { 54 | class A {} 55 | class B extends A {} 56 | class C extends B {} 57 | 58 | expect(assertInheritance(C, A)).toBeTruthy(); 59 | }); 60 | 61 | it('should proxy the Prototype object of a Class', () => { 62 | const setter = jest.fn(); 63 | 64 | class ProxyElement extends HTMLElement { 65 | #props = {}; 66 | 67 | toJSON() { 68 | return this.#props; 69 | } 70 | 71 | constructor() { 72 | super(); 73 | proxyPrototype(this, this.#props, setter); 74 | } 75 | } 76 | customElements.define('proxy-tag', ProxyElement); 77 | 78 | const proxyElement = new ProxyElement(); 79 | 80 | proxyElement['test'] = 'example'; 81 | 82 | expect(proxyElement.toJSON()).toEqual({ test: 'example' }); 83 | 84 | expect(setter).toHaveBeenCalledTimes(1); 85 | expect(setter).toHaveBeenCalledWith('test', 'example'); 86 | }); 87 | 88 | it('should calculate the Byte Length of a String', () => { 89 | expect(byteLength('123')).toBe(3); 90 | 91 | expect(byteLength('xX中')).toBe(5); 92 | }); 93 | 94 | it('should convert a Camel-case String to Hyphen-case', () => { 95 | expect(toHyphenCase('smallCamel')).toBe('small-camel'); 96 | expect(toHyphenCase('LargeCamel')).toBe('large-camel'); 97 | expect(toHyphenCase('With space')).toBe('with-space'); 98 | expect(toHyphenCase('With Space')).toBe('with-space'); 99 | expect(toHyphenCase('with space')).toBe('with-space'); 100 | expect(toHyphenCase('with Space')).toBe('with-space'); 101 | }); 102 | 103 | it('should convert a Hyphen-case String to Camel-case', () => { 104 | expect(toCamelCase('small-camel')).toBe('smallCamel'); 105 | expect(toCamelCase('large-camel', true)).toBe('LargeCamel'); 106 | expect(toCamelCase('Small Camel')).toBe('smallCamel'); 107 | }); 108 | 109 | it('should encode and decode Base64 with Unicode support', () => { 110 | // Test basic ASCII 111 | const ascii = 'Hello World'; 112 | expect(decodeBase64(encodeBase64(ascii))).toBe(ascii); 113 | 114 | // Test Unicode characters 115 | const unicode = 'Hello 世界 🌍 😀'; 116 | expect(decodeBase64(encodeBase64(unicode))).toBe(unicode); 117 | 118 | // Test various Unicode ranges 119 | const emoji = '🚀🎉🌟💖'; 120 | expect(decodeBase64(encodeBase64(emoji))).toBe(emoji); 121 | 122 | // Test mathematical symbols 123 | const math = '∑∏∫∆∇∂'; 124 | expect(decodeBase64(encodeBase64(math))).toBe(math); 125 | 126 | // Test empty string 127 | expect(decodeBase64(encodeBase64(''))).toBe(''); 128 | 129 | // Test known Base64 encoding 130 | expect(encodeBase64('Hello')).toBe('SGVsbG8='); 131 | expect(decodeBase64('SGVsbG8=')).toBe('Hello'); 132 | 133 | // Test known Unicode encoding 134 | expect(encodeBase64('世界')).toBe('5LiW55WM'); 135 | expect(decodeBase64('5LiW55WM')).toBe('世界'); 136 | }); 137 | 138 | it('should build an Object with Key & Value arrays', () => { 139 | expect(objectFrom([1, '2'], ['x', 'y'])).toStrictEqual({ 140 | x: 1, 141 | y: '2' 142 | }); 143 | }); 144 | 145 | it('should find out Old, Same & New keys from 2 keys arrays', () => { 146 | expect(diffKeys(['a', 'b'], ['b', 'c'])).toEqual({ 147 | map: { a: DiffStatus.Old, b: DiffStatus.Same, c: DiffStatus.New }, 148 | group: { 149 | [DiffStatus.Old]: [['a', DiffStatus.Old]], 150 | [DiffStatus.Same]: [['b', DiffStatus.Same]], 151 | [DiffStatus.New]: [['c', DiffStatus.New]] 152 | } 153 | }); 154 | }); 155 | 156 | it('should detect an Object whether is Array-like or not', () => { 157 | expect(likeArray(NaN)).toBe(false); 158 | expect(likeArray('a')).toBe(true); 159 | expect(likeArray({ 0: 'a' })).toBe(false); 160 | expect(likeArray({ 0: 'a', length: 1 })).toBe(true); 161 | }); 162 | 163 | it('should detect an Object whether is TypedArray or not', () => { 164 | expect(isTypedArray([])).toBe(false); 165 | expect(isTypedArray(new Uint32Array())).toBe(true); 166 | }); 167 | 168 | it('should make sure the result is an Array', () => { 169 | expect(makeArray()).toStrictEqual([]); 170 | expect(makeArray('a')).toStrictEqual(['a']); 171 | expect(makeArray({ 0: 'a' })).toStrictEqual([{ 0: 'a' }]); 172 | expect(makeArray({ 0: 'a', length: 1 })).toStrictEqual(['a']); 173 | 174 | const list = [0]; 175 | expect(makeArray(list)).toBe(list); 176 | }); 177 | 178 | it('should split an Array into several arrays with Unit Size', () => { 179 | expect(splitArray([1, 2, 3, 4, 5, 6], 3)).toEqual([ 180 | [1, 2, 3], 181 | [4, 5, 6] 182 | ]); 183 | expect(splitArray([1, 2, 3, 4, 5, 6, 7], 3)).toEqual([ 184 | [1, 2, 3], 185 | [4, 5, 6], 186 | [7] 187 | ]); 188 | }); 189 | 190 | describe('Group by', () => { 191 | it('should handle single Group Key', () => { 192 | expect(groupBy([{ a: 1 }, { a: 2 }], 'a')).toEqual( 193 | expect.objectContaining({ 194 | '1': [{ a: 1 }], 195 | '2': [{ a: 2 }] 196 | }) 197 | ); 198 | }); 199 | 200 | it('should handle multiple Group Keys', () => { 201 | expect( 202 | groupBy( 203 | [{ a: [1, 2] }, { a: [2, 3] }, { b: 4 }], 204 | ({ a = [] }) => a 205 | ) 206 | ).toEqual( 207 | expect.objectContaining({ 208 | '1': [{ a: [1, 2] }], 209 | '2': [{ a: [1, 2] }, { a: [2, 3] }], 210 | '3': [{ a: [2, 3] }] 211 | }) 212 | ); 213 | }); 214 | }); 215 | 216 | describe('Count by', () => { 217 | it('should handle a simple Group Key', () => { 218 | expect(countBy([{ a: 1 }, { a: 1 }], 'a')).toEqual( 219 | expect.objectContaining({ '1': 2 }) 220 | ); 221 | }); 222 | 223 | it('should handle a custom Group Key', () => { 224 | expect( 225 | countBy( 226 | [{ date: '1989-04-26' }, { date: '1989-06-04' }], 227 | ({ date }) => new Date(date).getFullYear() 228 | ) 229 | ).toEqual(expect.objectContaining({ '1989': 2 })); 230 | }); 231 | }); 232 | 233 | it('should find data in Nested Object Array', () => { 234 | const data = [ 235 | { name: 'a' }, 236 | { name: 'b', list: [{ name: 'c' }] }, 237 | { name: 'd' } 238 | ]; 239 | 240 | expect( 241 | findDeep(data, 'list', ({ name }) => name === 'c') 242 | ).toStrictEqual([ 243 | { 244 | name: 'b', 245 | list: [{ name: 'c' }] 246 | }, 247 | { name: 'c' } 248 | ]); 249 | }); 250 | 251 | it('should build a Tree from a flat Array', () => { 252 | interface Item { 253 | id: number; 254 | name: string; 255 | parentId?: number; 256 | children?: Item[]; 257 | } 258 | const data: Item[] = [ 259 | { id: 1, name: 'a' }, 260 | { id: 2, name: 'b', parentId: 1 }, 261 | { id: 3, name: 'c', parentId: 1 }, 262 | { id: 4, name: 'd', parentId: 2 } 263 | ]; 264 | const tree = treeFrom(data, 'id', 'parentId', 'children'); 265 | 266 | expect(tree).toEqual([ 267 | { 268 | id: 1, 269 | name: 'a', 270 | children: [ 271 | { 272 | id: 2, 273 | name: 'b', 274 | parentId: 1, 275 | children: [{ id: 4, name: 'd', parentId: 2 }] 276 | }, 277 | { id: 3, name: 'c', parentId: 1 } 278 | ] 279 | } 280 | ]); 281 | }); 282 | 283 | describe('Function Cache', () => { 284 | it('should cache result of a Sync Function', () => { 285 | const add = cache((_, x: number, y: number) => x + y, 'add'); 286 | 287 | expect(add(1, 1)).toBe(2); 288 | expect(add(1, 2)).toBe(2); 289 | }); 290 | 291 | it('should cache result of an Async Function', async () => { 292 | const origin = jest.fn(() => Promise.resolve(1)); 293 | const asyncFunc = cache(origin, 'async'); 294 | 295 | expect(asyncFunc()).toBeInstanceOf(Promise); 296 | expect(asyncFunc()).toBe(asyncFunc()); 297 | expect(await asyncFunc()).toBe(1); 298 | expect(origin).toBeCalledTimes(1); 299 | }); 300 | 301 | it('should renew Cache data manually', async () => { 302 | const asyncFunc = cache(async (clean, data: any) => { 303 | setTimeout(clean, 1000); 304 | 305 | return data; 306 | }, 'cleanable'); 307 | 308 | expect(await asyncFunc(1)).toBe(1); 309 | expect(await asyncFunc(2)).toBe(1); 310 | 311 | await sleep(); 312 | expect(await asyncFunc(3)).toBe(3); 313 | }); 314 | }); 315 | 316 | it('should wrap some Async Data into an Async Generator', async () => { 317 | const disposer = jest.fn(); 318 | 319 | const stream = createAsyncIterator(({ next, complete }) => { 320 | setTimeout(() => next(1)); 321 | setTimeout(() => next(2)); 322 | setTimeout(() => (next(3), complete())); 323 | 324 | return disposer; 325 | }); 326 | 327 | const list: number[] = []; 328 | 329 | for await (const item of stream) list.push(item); 330 | 331 | expect(list).toEqual([1, 2, 3]); 332 | expect(disposer).toHaveBeenCalledTimes(1); 333 | }); 334 | 335 | it('should merge some Async Generators into one', async () => { 336 | const list: number[] = [], 337 | stream = mergeStream( 338 | async function* () { 339 | yield* [1, 3]; 340 | }, 341 | async function* () { 342 | yield* [2, 4, 5]; 343 | } 344 | ); 345 | for await (const item of stream) list.push(item); 346 | 347 | expect(list).toEqual([1, 2, 3, 4, 5]); 348 | }); 349 | 350 | it('should convert Byte Size number to Human Readable string', () => { 351 | expect(new ByteSize(65535).toShortString()).toBe('64.00 KB'); 352 | }); 353 | }); 354 | -------------------------------------------------------------------------------- /source/data.ts: -------------------------------------------------------------------------------- 1 | import { Scalar } from './math'; 2 | 3 | export type Constructor = new (...args: any[]) => T; 4 | 5 | export type AbstractClass = abstract new (...args: any[]) => T; 6 | 7 | export type Values = Required[keyof T]; 8 | 9 | export type TypeKeys = { 10 | [K in keyof T]: Required[K] extends D ? K : never; 11 | }[keyof T]; 12 | 13 | export type PickSingle = T extends infer S | (infer S)[] ? S : T; 14 | 15 | export type PickData = Omit>; 16 | 17 | export type DataKeys = Exclude>; 18 | 19 | export const likeNull = (value?: any) => 20 | !(value != null) || Number.isNaN(value); 21 | 22 | export const isEmpty = (value?: any) => 23 | likeNull(value) || 24 | (typeof value === 'object' ? !Object.keys(value).length : value === ''); 25 | 26 | /** 27 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toStringTag} 28 | */ 29 | export const classNameOf = (data: any): string => 30 | Object.prototype.toString.call(data).slice(8, -1); 31 | 32 | export const assertInheritance = (Sub: Function, Super: Function) => 33 | Sub.prototype instanceof Super; 34 | 35 | export function proxyPrototype( 36 | target: T, 37 | dataStore: Record, 38 | setter?: (key: IndexKey, value: any) => any 39 | ) { 40 | const prototype = Object.getPrototypeOf(target); 41 | 42 | const prototypeProxy = new Proxy(prototype, { 43 | set: (_, key, value, receiver) => { 44 | if (key in receiver) Reflect.set(prototype, key, value, receiver); 45 | else dataStore[key] = value; 46 | 47 | setter?.(key, value); 48 | 49 | return true; 50 | }, 51 | get: (prototype, key, receiver) => 52 | key in dataStore 53 | ? dataStore[key] 54 | : Reflect.get(prototype, key, receiver) 55 | }); 56 | 57 | Object.setPrototypeOf(target, prototypeProxy); 58 | } 59 | 60 | export const isUnsafeNumeric = (raw: string) => 61 | /^[\d.]+$/.test(raw) && 62 | raw.localeCompare(Number.MAX_SAFE_INTEGER + '', undefined, { 63 | numeric: true 64 | }) > 0; 65 | 66 | export const byteLength = (raw: string) => 67 | raw.replace(/[^\u0021-\u007e\uff61-\uffef]/g, 'xx').length; 68 | 69 | export type HyphenCase = T extends `${infer L}${infer R}` 70 | ? `${L extends Uppercase ? `-${Lowercase}` : L}${HyphenCase}` 71 | : T; 72 | export const toHyphenCase = (raw: string) => 73 | raw.replace( 74 | /[A-Z]+|[^A-Za-z][A-Za-z]/g, 75 | (match, offset) => 76 | `${offset ? '-' : ''}${(match[1] || match[0]).toLowerCase()}` 77 | ); 78 | 79 | export type CamelCase< 80 | Raw extends string, 81 | Delimiter extends string = '-' 82 | > = Uncapitalize< 83 | Raw extends `${infer L}${Delimiter}${infer R}` 84 | ? `${Capitalize}${Capitalize>}` 85 | : `${Capitalize}` 86 | >; 87 | export const toCamelCase = (raw: string, large = false) => 88 | raw.replace(/^[A-Za-z]|[^A-Za-z][A-Za-z]/g, (match, offset) => 89 | offset || large 90 | ? (match[1] || match[0]).toUpperCase() 91 | : match.toLowerCase() 92 | ); 93 | /** 94 | * generate a Number with 36 radix 95 | */ 96 | export const uniqueID = () => 97 | (Date.now() + parseInt((Math.random() + '').slice(2))).toString(36); 98 | 99 | /** 100 | * Encode string to Base64 with Unicode support 101 | * 102 | * @param input - String to encode 103 | * @returns Base64 encoded string 104 | */ 105 | export const encodeBase64 = (input: string) => 106 | btoa(String.fromCharCode(...new TextEncoder().encode(input))); 107 | 108 | /** 109 | * Decode Base64 string with Unicode support 110 | * 111 | * @param input - Base64 encoded string to decode 112 | * @returns Decoded Unicode string 113 | */ 114 | export const decodeBase64 = (input: string) => 115 | new TextDecoder().decode( 116 | Uint8Array.from(atob(input), char => char.charCodeAt(0)) 117 | ); 118 | 119 | export const objectFrom = (values: V[], keys: K[]) => 120 | Object.fromEntries( 121 | values.map((value, index) => [keys[index], value]) 122 | ) as Record; 123 | 124 | export enum DiffStatus { 125 | Old = -1, 126 | Same = 0, 127 | New = 1 128 | } 129 | 130 | export function diffKeys(oldList: T[], newList: T[]) { 131 | const map = {} as Record; 132 | 133 | for (const item of oldList) map[item] = DiffStatus.Old; 134 | 135 | for (const item of newList) { 136 | map[item] ||= 0; 137 | map[item] += DiffStatus.New; 138 | } 139 | 140 | return { 141 | map, 142 | group: groupBy( 143 | Object.entries(map), 144 | ([key, status]) => status 145 | ) 146 | }; 147 | } 148 | 149 | export type ResultArray = T extends ArrayLike ? D[] : T[]; 150 | 151 | export function likeArray(data?: any): data is ArrayLike { 152 | if (likeNull(data)) return false; 153 | 154 | const { length } = data; 155 | 156 | return typeof length === 'number' && length >= 0 && ~~length === length; 157 | } 158 | 159 | export type TypedArray = 160 | | Int8Array 161 | | Uint8Array 162 | | Uint8ClampedArray 163 | | Int16Array 164 | | Uint16Array 165 | | Int32Array 166 | | Uint32Array 167 | | Float32Array 168 | | Float64Array 169 | | BigInt64Array 170 | | BigUint64Array; 171 | 172 | /** 173 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray} 174 | */ 175 | export const isTypedArray = (data: any): data is TypedArray => 176 | data instanceof Object.getPrototypeOf(Int8Array); 177 | 178 | export function makeArray(data?: T) { 179 | if (data instanceof Array) return data as unknown as ResultArray; 180 | 181 | if (likeNull(data)) return [] as ResultArray; 182 | 183 | if (likeArray(data)) return Array.from(data) as ResultArray; 184 | 185 | return [data] as ResultArray; 186 | } 187 | 188 | export const splitArray = (array: T[], unitLength: number) => 189 | array.reduce((grid, item, index) => { 190 | (grid[~~(index / unitLength)] ||= [])[index % unitLength] = item; 191 | 192 | return grid; 193 | }, [] as T[][]); 194 | 195 | export type IndexKey = number | string | symbol; 196 | export type GroupKey> = keyof T | IndexKey; 197 | export type Iteratee> = 198 | | keyof T 199 | | ((item: T) => GroupKey | GroupKey[]); 200 | 201 | export function groupBy>( 202 | list: T[], 203 | iteratee: Iteratee 204 | ) { 205 | const data = {} as Record, T[]>; 206 | 207 | for (const item of list) { 208 | let keys = 209 | iteratee instanceof Function ? iteratee(item) : item[iteratee]; 210 | 211 | if (!(keys instanceof Array)) keys = [keys]; 212 | 213 | for (const key of new Set( 214 | (keys as GroupKey[]).filter(key => key != null) 215 | )) 216 | (data[key] = data[key] || []).push(item); 217 | } 218 | 219 | return data; 220 | } 221 | 222 | export function countBy>( 223 | list: T[], 224 | iteratee: Iteratee 225 | ) { 226 | const group = groupBy(list, iteratee); 227 | 228 | const sortedList = Object.entries(group).map( 229 | ([key, { length }]) => [key, length] as const 230 | ); 231 | return Object.fromEntries(sortedList); 232 | } 233 | 234 | export function findDeep( 235 | list: T[], 236 | subKey: TypeKeys, any[]>, 237 | handler: (item: T) => boolean 238 | ): T[] { 239 | for (const item of list) { 240 | if (handler(item)) return [item]; 241 | 242 | if (item[subKey] instanceof Array) { 243 | const result = findDeep( 244 | item[subKey] as unknown as T[], 245 | subKey, 246 | handler 247 | ); 248 | if (result.length) return [item, ...result]; 249 | } 250 | } 251 | return []; 252 | } 253 | 254 | export function clone(data: T): T { 255 | try { 256 | return globalThis.structuredClone(data); 257 | } catch { 258 | return JSON.parse(JSON.stringify(data)); 259 | } 260 | } 261 | 262 | export type TreeNode< 263 | IK extends string, 264 | PK extends string, 265 | CK extends string 266 | > = { 267 | [key in IK]: number | string; 268 | } & { 269 | [key in PK]?: number | string; 270 | } & { 271 | [key in CK]?: TreeNode[]; 272 | }; 273 | 274 | export function treeFrom< 275 | IK extends string, 276 | PK extends string, 277 | CK extends string, 278 | N extends TreeNode 279 | >( 280 | list: N[], 281 | idKey = 'id' as IK, 282 | parentIdKey = 'parentId' as PK, 283 | childrenKey = 'children' as CK 284 | ) { 285 | list = clone(list); 286 | 287 | const map: Record = {}; 288 | const roots: N[] = []; 289 | 290 | for (const item of list) map[item[idKey] as string] = item; 291 | 292 | for (const item of list) { 293 | const parent = map[item[parentIdKey] as string]; 294 | 295 | if (!parent) roots.push(item); 296 | else { 297 | parent[childrenKey] ||= [] as TreeNode[] as N[CK]; 298 | parent[childrenKey].push(item); 299 | } 300 | } 301 | if (!roots[0]) throw new ReferenceError('No root node is found'); 302 | 303 | return roots; 304 | } 305 | 306 | export function cache( 307 | executor: (cleaner: () => void, ...data: I[]) => O, 308 | title: string 309 | ) { 310 | var cacheData: O; 311 | 312 | return function (...data: I[]) { 313 | if (cacheData != null) return cacheData; 314 | 315 | console.trace(`[Cache] execute: ${title}`); 316 | 317 | cacheData = executor.call( 318 | this, 319 | (): void => (cacheData = undefined), 320 | ...data 321 | ); 322 | Promise.resolve(cacheData).then( 323 | data => console.log(`[Cache] refreshed: ${title} => ${data}`), 324 | error => console.error(`[Cache] failed: ${error?.message || error}`) 325 | ); 326 | return cacheData; 327 | }; 328 | } 329 | 330 | export interface IteratorController { 331 | next: (value: V) => any; 332 | error: (error: E) => any; 333 | complete: () => any; 334 | } 335 | 336 | export async function* createAsyncIterator( 337 | executor: (controller: IteratorController) => (() => any) | void 338 | ) { 339 | let { promise, resolve, reject } = Promise.withResolvers(); 340 | 341 | const doneSymbol = Symbol('done'), 342 | done = Promise.withResolvers(); 343 | 344 | const disposer = executor({ 345 | next: value => resolve(value), 346 | error: error => { 347 | reject(error); 348 | // @ts-ignore 349 | disposer?.(); 350 | }, 351 | complete: () => { 352 | done.resolve(doneSymbol); 353 | // @ts-ignore 354 | disposer?.(); 355 | } 356 | }); 357 | 358 | while (true) { 359 | const value = await Promise.race([promise, done.promise]); 360 | 361 | if (value === doneSymbol) return; 362 | 363 | yield value as V; 364 | 365 | ({ promise, resolve, reject } = Promise.withResolvers()); 366 | } 367 | } 368 | 369 | export async function* mergeStream( 370 | ...sources: (() => AsyncIterator)[] 371 | ) { 372 | var iterators = sources.map(item => item()); 373 | 374 | while (iterators[0]) { 375 | const dones: number[] = []; 376 | 377 | for ( 378 | let i = 0, iterator: AsyncIterator; 379 | (iterator = iterators[i]); 380 | i++ 381 | ) { 382 | const { done, value } = await iterator.next(); 383 | 384 | if (!done) yield value; 385 | else dones.push(i); 386 | } 387 | iterators = iterators.filter((_, i) => !dones.includes(i)); 388 | } 389 | } 390 | 391 | export class ByteSize extends Scalar { 392 | units = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'].map((name, i) => ({ 393 | base: 1024 ** i, 394 | name: name + 'B' 395 | })); 396 | } 397 | --------------------------------------------------------------------------------