@@ -0,0 +1,11 @@ | |||||
root = true | |||||
[*] | |||||
charset = utf-8 | |||||
end_of_line = lf | |||||
indent_size = 2 | |||||
indent_style = space | |||||
insert_final_newline = true | |||||
max_line_length = 120 | |||||
tab_width = 8 | |||||
trim_trailing_whitespace = true |
@@ -0,0 +1,6 @@ | |||||
*.log | |||||
.DS_Store | |||||
node_modules | |||||
dist | |||||
coverage/ | |||||
.idea/ |
@@ -0,0 +1,6 @@ | |||||
{ | |||||
"jsxSingleQuote": false, | |||||
"singleQuote": true, | |||||
"semi": false, | |||||
"trailingComma": "es5" | |||||
} |
@@ -0,0 +1,21 @@ | |||||
MIT License | |||||
Copyright (c) 2020 Allan Crisostomo | |||||
Permission is hereby granted, free of charge, to any person obtaining a copy | |||||
of this software and associated documentation files (the "Software"), to deal | |||||
in the Software without restriction, including without limitation the rights | |||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||||
copies of the Software, and to permit persons to whom the Software is | |||||
furnished to do so, subject to the following conditions: | |||||
The above copyright notice and this permission notice shall be included in all | |||||
copies or substantial portions of the Software. | |||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||||
SOFTWARE. |
@@ -0,0 +1,99 @@ | |||||
# TSDX User Guide | |||||
Congrats! You just saved yourself hours of work by bootstrapping this project with TSDX. Let’s get you oriented with what’s here and how to use it. | |||||
> This TSDX setup is meant for developing libraries (not apps!) that can be published to NPM. If you’re looking to build a Node app, you could use `ts-node-dev`, plain `ts-node`, or simple `tsc`. | |||||
> If you’re new to TypeScript, checkout [this handy cheatsheet](https://devhints.io/typescript) | |||||
## Commands | |||||
TSDX scaffolds your new library inside `/src`. | |||||
To run TSDX, use: | |||||
```bash | |||||
npm start # or yarn start | |||||
``` | |||||
This builds to `/dist` and runs the project in watch mode so any edits you save inside `src` causes a rebuild to `/dist`. | |||||
To do a one-off build, use `npm run build` or `yarn build`. | |||||
To run tests, use `npm test` or `yarn test`. | |||||
## Configuration | |||||
Code quality is set up for you with `prettier`, `husky`, and `lint-staged`. Adjust the respective fields in `package.json` accordingly. | |||||
### Jest | |||||
Jest tests are set up to run with `npm test` or `yarn test`. | |||||
#### Setup Files | |||||
This is the folder structure we set up for you: | |||||
```txt | |||||
/src | |||||
index.tsx # EDIT THIS | |||||
/test | |||||
blah.test.tsx # EDIT THIS | |||||
.gitignore | |||||
package.json | |||||
README.md # EDIT THIS | |||||
tsconfig.json | |||||
``` | |||||
### Rollup | |||||
TSDX uses [Rollup](https://rollupjs.org) as a bundler and generates multiple rollup configs for various module formats and build settings. See [Optimizations](#optimizations) for details. | |||||
### TypeScript | |||||
`tsconfig.json` is set up to interpret `dom` and `esnext` types, as well as `react` for `jsx`. Adjust according to your needs. | |||||
## Continuous Integration | |||||
### GitHub Actions | |||||
A simple action is included that runs these steps on all pushes: | |||||
- Installs deps w/ cache | |||||
- Lints, tests, and builds | |||||
## Optimizations | |||||
Please see the main `tsdx` [optimizations docs](https://github.com/palmerhq/tsdx#optimizations). In particular, know that you can take advantage of development-only optimizations: | |||||
```js | |||||
// ./types/index.d.ts | |||||
declare var __DEV__: boolean; | |||||
// inside your code... | |||||
if (__DEV__) { | |||||
console.log('foo'); | |||||
} | |||||
``` | |||||
You can also choose to install and use [invariant](https://github.com/palmerhq/tsdx#invariant) and [warning](https://github.com/palmerhq/tsdx#warning) functions. | |||||
## Module Formats | |||||
CJS, ESModules, and UMD module formats are supported. | |||||
The appropriate paths are configured in `package.json` and `dist/index.js` accordingly. Please report if any issues are found. | |||||
## Named Exports | |||||
Per Palmer Group guidelines, [always use named exports.](https://github.com/palmerhq/typescript#exports) Code split inside your React app instead of your React library. | |||||
## Including Styles | |||||
There are many ways to ship styles, including with CSS-in-JS. TSDX has no opinion on this, configure how you like. | |||||
For vanilla CSS, you can include it at the root directory and add it to the `files` section in your `package.json`, so that it can be imported separately by your users and run through their bundler's loader. | |||||
## Publishing to NPM | |||||
We recommend using [np](https://github.com/sindresorhus/np). |
@@ -0,0 +1,56 @@ | |||||
{ | |||||
"version": "1.0.0", | |||||
"license": "MIT", | |||||
"main": "dist/index.js", | |||||
"typings": "dist/index.d.ts", | |||||
"files": [ | |||||
"dist", | |||||
"src" | |||||
], | |||||
"engines": { | |||||
"node": ">=10" | |||||
}, | |||||
"scripts": { | |||||
"start": "tsdx watch", | |||||
"build": "tsdx build", | |||||
"test": "tsdx test", | |||||
"lint": "tsdx lint", | |||||
"prepare": "tsdx build" | |||||
}, | |||||
"peerDependencies": { | |||||
"get-pixels": "^3.3.2", | |||||
"get-rgba-palette": "^2.0.1", | |||||
"image-size": "^0.9.1", | |||||
"numeral": "^2.0.6" | |||||
}, | |||||
"husky": { | |||||
"hooks": { | |||||
"pre-commit": "tsdx lint" | |||||
} | |||||
}, | |||||
"prettier": { | |||||
"printWidth": 80, | |||||
"semi": true, | |||||
"singleQuote": true, | |||||
"trailingComma": "es5" | |||||
}, | |||||
"name": "@theoryofnekomata/image-commons", | |||||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||||
"module": "dist/image-commons.esm.js", | |||||
"repository": { | |||||
"type": "git", | |||||
"url": "git@code.modal.sh:TheoryOfNekomata/image-commons.git" | |||||
}, | |||||
"devDependencies": { | |||||
"@types/numeral": "^0.0.28", | |||||
"fast-check": "^2.3.0", | |||||
"get-pixels": "^3.3.2", | |||||
"get-rgba-palette": "^2.0.1", | |||||
"husky": "^4.3.0", | |||||
"image-size": "^0.9.1", | |||||
"numeral": "^2.0.6", | |||||
"tsdx": "^0.13.3", | |||||
"tslib": "^2.0.1", | |||||
"typescript": "^4.0.3" | |||||
} | |||||
} |
@@ -0,0 +1,66 @@ | |||||
import * as fc from 'fast-check' | |||||
import formatPixelCount from './formatPixelCount' | |||||
it('should exist', () => { | |||||
expect(formatPixelCount).toBeDefined() | |||||
}) | |||||
it('should be a function', () => { | |||||
expect(typeof formatPixelCount).toBe('function') | |||||
}) | |||||
it('should take 1 argument', () => { | |||||
expect(formatPixelCount).toHaveLength(1) | |||||
}) | |||||
describe('on non-numeric arguments', () => { | |||||
it('should throw an error', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(v => typeof v !== 'number'), | |||||
v => { | |||||
expect(() => formatPixelCount(v as number)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) | |||||
describe('on numeric arguments', () => { | |||||
it('should throw an error on NaN', () => { | |||||
expect(() => formatPixelCount(NaN)).toThrow(RangeError) | |||||
}) | |||||
it('should format values of |x| < 1000', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.integer().filter(v => Math.abs(v) < 1000), | |||||
v => { | |||||
expect(formatPixelCount(v)).toMatch(/^-?\d+ P$/) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should format values of 1000 < |x| < 1000000', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.integer().filter(v => 1000 < Math.abs(v) && Math.abs(v) < 1000000), | |||||
v => { | |||||
expect(formatPixelCount(v)).toMatch(/^-?\d+\.\d\d kP$/) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should format values of |x| >= 1000000', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.integer().filter(v => Math.abs(v) >= 1000000), | |||||
v => { | |||||
expect(formatPixelCount(v)).toMatch(/^-?\d+\.\d\d .P$/) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) |
@@ -0,0 +1,28 @@ | |||||
import numeral from 'numeral' | |||||
type FormatPixelCount = (n: number) => string | |||||
const formatPixelCount: FormatPixelCount = n => { | |||||
if (typeof n as unknown !== 'number') { | |||||
throw TypeError('Argument should be a number.') | |||||
} | |||||
if (isNaN(n)) { | |||||
throw RangeError('Cannot format NaN.') | |||||
} | |||||
const absValue = Math.abs(n) | |||||
const theNumeral = numeral(n) | |||||
if (absValue < 1000) { | |||||
return `${absValue} P` | |||||
} | |||||
if (absValue < 1000000) { | |||||
return `${theNumeral.format('0.00 a')}P` | |||||
} | |||||
return `${theNumeral.format('0.00 a').toUpperCase()}P` | |||||
} | |||||
export default formatPixelCount |
@@ -0,0 +1,102 @@ | |||||
import * as fc from 'fast-check' | |||||
import { imageSize } from 'image-size' | |||||
import getDimensions, { Dimensions } from './getDimensions' | |||||
jest.mock('image-size') | |||||
it('should exist', () => { | |||||
expect(getDimensions).toBeDefined() | |||||
}) | |||||
it('should be a callable', () => { | |||||
expect(typeof getDimensions).toBe('function') | |||||
}) | |||||
it('should accept 1 argument', () => { | |||||
expect(getDimensions).toHaveLength(1) | |||||
}) | |||||
it('should throw an error given non-string arguments', async () => { | |||||
await fc.assert( | |||||
fc.asyncProperty( | |||||
fc.anything().filter(v => typeof v !== 'string'), | |||||
async notAString => { | |||||
try { | |||||
// expected to throw | |||||
await getDimensions(notAString as string) | |||||
} catch (err) { | |||||
expect(err instanceof TypeError).toBe(true) | |||||
} | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should throw an error given an empty string argument', async () => { | |||||
try { | |||||
await getDimensions('') | |||||
} catch (err) { | |||||
expect(err instanceof RangeError).toBe(true) | |||||
} | |||||
}) | |||||
describe('on string arguments', () => { | |||||
beforeAll(() => { | |||||
const imageSizeMock = imageSize as jest.Mock | |||||
imageSizeMock.mockImplementation((_: string, cb: Function) => { | |||||
cb(null, { images: [ { width: 0, height: 0, } ] }) | |||||
}) | |||||
}) | |||||
it('should return an array of objects containing `width` properties', async () => { | |||||
await fc.assert( | |||||
fc.asyncProperty( | |||||
fc.string(1, 20).filter(s => s.trim().length > 0), | |||||
async aString => { | |||||
const dimensionsArray: Dimensions[] = await getDimensions(aString) | |||||
expect( | |||||
dimensionsArray.every(d => 'width' in d) | |||||
).toBe(true) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should return an array of objects containing `height` properties', async () => { | |||||
await fc.assert( | |||||
fc.asyncProperty( | |||||
fc.string(1, 20).filter(s => s.trim().length > 0), | |||||
async aString => { | |||||
const dimensionsArray: Dimensions[] = await getDimensions(aString) | |||||
expect( | |||||
dimensionsArray.every(d => 'height' in d) | |||||
).toBe(true) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) | |||||
describe('on error handling', () => { | |||||
beforeAll(() => { | |||||
const imageSizeMock = imageSize as jest.Mock | |||||
imageSizeMock.mockImplementation((_: string, cb: Function) => { | |||||
cb(new Error('vro.')) | |||||
}) | |||||
}) | |||||
it('should throw caught error', async () => { | |||||
await fc.assert( | |||||
fc.asyncProperty( | |||||
fc.string(1, 20).filter(s => s.trim().length > 0), | |||||
async aString => { | |||||
try { | |||||
await getDimensions(aString) | |||||
} catch (err) { | |||||
expect(err).toBeTruthy() | |||||
} | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) |
@@ -0,0 +1,31 @@ | |||||
import { imageSize, } from 'image-size' | |||||
export type Dimensions = { | |||||
width: number | |||||
height: number | |||||
} | |||||
type GetDimensions = (src: string) => Promise<Dimensions[]> | |||||
const getDimensions: GetDimensions = async src => { | |||||
if (typeof src as unknown !== 'string') { | |||||
throw TypeError('Argument must be a string.') | |||||
} | |||||
if (src.trim().length < 1) { | |||||
throw RangeError('Invalid value for argument.') | |||||
} | |||||
return new Promise((resolve, reject) => { | |||||
imageSize(src, (err, dimensions) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
resolve(dimensions!.images as Dimensions[]) | |||||
}) | |||||
}) | |||||
} | |||||
export default getDimensions |
@@ -0,0 +1,132 @@ | |||||
import * as fc from 'fast-check' | |||||
import getProminentColors, { GetProminentColorsConfig } from './getProminentColors' | |||||
jest.mock('get-pixels', () => () => [[255, 255, 255]]) | |||||
jest.mock('get-rgba-palette', () => ({ | |||||
bins: () => [ | |||||
{ | |||||
color: [255, 255, 255], | |||||
size: 42069, | |||||
amount: 0.42069, | |||||
} | |||||
] | |||||
})) | |||||
it('should exist', () => { | |||||
expect(getProminentColors).toBeDefined() | |||||
}) | |||||
it('should be a callable', () => { | |||||
expect(typeof getProminentColors).toBe('function') | |||||
}) | |||||
it('should accept 1 argument', () => { | |||||
expect(getProminentColors).toHaveLength(1) | |||||
}) | |||||
it('should throw an error for invalid config', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(s => !(typeof s === 'object' && s !== null && 'count' in s)), | |||||
notAnObject => { | |||||
expect(() => getProminentColors(notAnObject as GetProminentColorsConfig)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should throw an error for non-number arguments as count', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(s => typeof s !== 'number'), | |||||
notANumber => { | |||||
expect(() => getProminentColors({ count: notANumber as number })).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should throw an error for NaN as count', () => { | |||||
expect(() => getProminentColors({ count: NaN })).toThrow(RangeError) | |||||
}) | |||||
it('should throw an error for 0 as count', () => { | |||||
expect(() => getProminentColors({ count: 0 })).toThrow(RangeError) | |||||
}) | |||||
it('should throw an error for negative values as count', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.nat().filter(n => n !== 0), | |||||
anAbsoluteNumber => { | |||||
expect(() => getProminentColors({ count: -anAbsoluteNumber })).toThrow(RangeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
describe('on valid `count` values', () => { | |||||
it('should throw an error for non-string sources', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.anything().filter(v => typeof v !== 'string'), | |||||
notAString => { | |||||
expect(() => getProminentColors({ count: 1 })(notAString as string)).toThrow(TypeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should throw an error for blank strings as sources', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.nat(20), | |||||
blankStringLength => { | |||||
let blankString = '' | |||||
for (let i = 0; i < blankStringLength; i += 1) { | |||||
blankString += ' ' | |||||
} | |||||
expect(() => getProminentColors({ count: 1 })(blankString)).toThrow(RangeError) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should return an array of objects with color attributes for valid sources', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.string(1, 20).filter(s => s.trim().length > 0), | |||||
aString => { | |||||
const result = getProminentColors({ count: 1, })(aString) | |||||
expect(result.every(s => 'color' in s)).toBe(true) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should return an array of objects with size attributes for valid sources', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.string(1, 20).filter(s => s.trim().length > 0), | |||||
aString => { | |||||
const result = getProminentColors({ count: 1, })(aString) | |||||
expect(result.every(s => 'size' in s)).toBe(true) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
it('should return an array of objects with amount attributes for valid sources', () => { | |||||
fc.assert( | |||||
fc.property( | |||||
fc.string(1, 20).filter(s => s.trim().length > 0), | |||||
aString => { | |||||
const result = getProminentColors({ count: 1, })(aString) | |||||
expect(result.every(s => 'amount' in s)).toBe(true) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) |
@@ -0,0 +1,71 @@ | |||||
import getPixels from 'get-pixels' | |||||
import getRgbaPalette from 'get-rgba-palette' | |||||
type PaletteColor = [number, number, number] | |||||
type Color = { | |||||
red: number | |||||
green: number | |||||
blue: number | |||||
} | |||||
interface PaletteBin<ColorFormat> { | |||||
color: ColorFormat | |||||
size: number, | |||||
amount: number | |||||
} | |||||
export type GetProminentColorsConfig = { | |||||
count: number | |||||
} | |||||
type GetProminentColors = (params: GetProminentColorsConfig) => (src: string) => PaletteBin<Color>[] | |||||
const getProminentColors: GetProminentColors = params => { | |||||
if (typeof params as unknown !== 'object') { | |||||
throw TypeError('Config must be an object.') | |||||
} | |||||
if ( | |||||
params === null | |||||
|| typeof params.count as unknown !== 'number' | |||||
) { | |||||
throw TypeError('Count must be a number.') | |||||
} | |||||
if (isNaN(params.count)) { | |||||
throw RangeError('Config cannot be NaN.') | |||||
} | |||||
if (params.count <= 0) { | |||||
throw RangeError('Only positive values allowed for count.') | |||||
} | |||||
return src => { | |||||
if (typeof src as unknown !== 'string') { | |||||
throw TypeError('Source must be a string.') | |||||
} | |||||
if (src.trim().length < 1) { | |||||
throw RangeError('Source must be non-empty.') | |||||
} | |||||
const pixels: PaletteColor[] = getPixels(src) | |||||
const colors: PaletteBin<PaletteColor>[] = getRgbaPalette.bins(pixels, params.count) | |||||
return colors.map(bin => { | |||||
const { color, ...etcBin } = bin | |||||
const [red, green, blue] = color | |||||
return { | |||||
color: { | |||||
red, | |||||
green, | |||||
blue | |||||
}, | |||||
...etcBin | |||||
} | |||||
}) | |||||
} | |||||
} | |||||
export default getProminentColors |
@@ -0,0 +1,2 @@ | |||||
declare module 'get-rgba-palette' | |||||
declare module 'get-pixels' |
@@ -0,0 +1,19 @@ | |||||
import * as index from './index' | |||||
describe('formatPixelCount', () => { | |||||
it('should exist', () => { | |||||
expect(index.formatPixelCount).toBeDefined() | |||||
}) | |||||
}) | |||||
describe('getDimensions', () => { | |||||
it('should exist', () => { | |||||
expect(index.getDimensions).toBeDefined() | |||||
}) | |||||
}) | |||||
describe('getProminentColors', () => { | |||||
it('should exist', () => { | |||||
expect(index.getProminentColors).toBeDefined() | |||||
}) | |||||
}) |
@@ -0,0 +1,9 @@ | |||||
import formatPixelCount from './formatPixelCount' | |||||
import getDimensions from './getDimensions' | |||||
import getProminentColors from './getProminentColors' | |||||
export { | |||||
formatPixelCount, | |||||
getDimensions, | |||||
getProminentColors, | |||||
} |
@@ -0,0 +1,19 @@ | |||||
{ | |||||
"include": ["src", "types"], | |||||
"compilerOptions": { | |||||
"module": "esnext", | |||||
"lib": ["dom", "esnext"], | |||||
"importHelpers": true, | |||||
"declaration": true, | |||||
"sourceMap": true, | |||||
"rootDir": "./src", | |||||
"strict": true, | |||||
"noUnusedLocals": true, | |||||
"noUnusedParameters": true, | |||||
"noImplicitReturns": true, | |||||
"noFallthroughCasesInSwitch": true, | |||||
"moduleResolution": "node", | |||||
"jsx": "react", | |||||
"esModuleInterop": true | |||||
} | |||||
} |