diff --git a/.all-contributorsrc b/.all-contributorsrc deleted file mode 100644 index 877830a..0000000 --- a/.all-contributorsrc +++ /dev/null @@ -1,87 +0,0 @@ -{ - "projectName": "react-ideal-image", - "projectOwner": "stereobooster", - "files": [ - "README.md" - ], - "imageSize": 100, - "commit": false, - "contributors": [ - { - "login": "stereobooster", - "name": "stereobooster", - "avatar_url": "https://avatars3.githubusercontent.com/u/179534?s=460&v=4", - "profile": "https://github.com/stereobooster", - "contributions": [ - "code", - "doc", - "infra", - "test" - ] - }, - { - "login": "sompylasar", - "name": "Ivan Babak", - "avatar_url": "https://avatars1.githubusercontent.com/u/498274?s=460&v=4", - "profile": "https://github.com/sompylasar", - "contributions": [ - "doc" - ] - }, - { - "login": "palerdot", - "name": "Arun Kumar", - "avatar_url": "https://avatars1.githubusercontent.com/u/4299398?s=460&v=4", - "profile": "https://github.com/palerdot", - "contributions": [ - "doc" - ] - }, - { - "login": "hipstersmoothie", - "name": "Andrew Lisowski", - "avatar_url": "https://avatars3.githubusercontent.com/u/1192452?v=4", - "profile": "http://hipstersmoothie.com", - "contributions": [ - "code" - ] - }, - { - "login": "tvthatsme", - "name": "Timothy Vernon", - "avatar_url": "https://avatars1.githubusercontent.com/u/3386714?v=4", - "profile": "https://github.com/tvthatsme", - "contributions": [ - "test" - ] - }, - { - "login": "vs1682", - "name": "vishalShinde", - "avatar_url": "https://avatars0.githubusercontent.com/u/5151881?v=4", - "profile": "http://vs1682.github.io", - "contributions": [ - "doc" - ] - }, - { - "login": "EvgeniyKumachev", - "name": "Evgeniy Kumachev", - "avatar_url": "https://avatars3.githubusercontent.com/u/5207796?v=4", - "profile": "https://github.com/EvgeniyKumachev", - "contributions": [ - "doc" - ] - }, - { - "login": "Tawe", - "name": "John Munn", - "avatar_url": "https://avatars0.githubusercontent.com/u/2087056?v=4", - "profile": "https://github.com/Tawe", - "contributions": [ - "code" - ] - } - ], - "repoType": "github" -} diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 0805b0b..0000000 --- a/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "presets": ["@babel/env", "@babel/react"], - "plugins": ["@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-object-rest-spread"] -} diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..2a3ad6a --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,42 @@ +module.exports = { + root: true, + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: "latest", + jsxPragma: "null", + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + extraFileExtensions: [], + }, + ignorePatterns: ["**/lib", "**/dist", "**/*.json"], + extends: [ + "react-app", // provides @typescript-eslint DO NOT added it twice + "react-app/jest", + "plugin:jsx-a11y/recommended", + "prettier", + ], + // @see https://eslint.org/docs/latest/user-guide/configuring/rules + // @see https://typescript-eslint.io/rules/some-rule-name-here + // 0 off + // 1 warn + // 2 error + rules: { + // use what makes sense + "@typescript-eslint/prefer-nullish-coalescing": 0, + // force error + "@typescript-eslint/restrict-template-expressions": 2, + // too many false positives/TSC does it better + "@typescript-eslint/await-thenable": 0, + "@typescript-eslint/ban-ts-comment": 0, + "@typescript-eslint/no-empty-interface": 0, + "@typescript-eslint/no-explicit-any": 0, + "@typescript-eslint/no-non-null-assertion": 0, + "@typescript-eslint/no-unnecessary-condition": 0, + "@typescript-eslint/no-unnecessary-type-assertion": 0, + "@typescript-eslint/no-unsafe-argument": 1, + "@typescript-eslint/no-unsafe-assignment": 0, + "@typescript-eslint/no-unsafe-call": 0, + "@typescript-eslint/no-unsafe-member-access": 0, + "@typescript-eslint/no-unsafe-return": 0, + }, +}; diff --git a/.gitattributes b/.gitattributes index 391f0a4..a4b59ae 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,4 @@ * text=auto *.js text eol=lf +bun.lockb binary +bun.lockb linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 2867310..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,38 +0,0 @@ - - -* `react-ideal-image` version: -* `node` version: -* `npm` (or `yarn`) version: - -Relevant code or config - -```javascript -``` - -What you did: - -What happened: - - - -Reproduction repository: - - - -Problem description: - -Suggested solution: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index aa0dc2b..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,42 +0,0 @@ - - - - -**What**: - - - -**Why**: - - - -**How**: - - - -**Checklist**: - - - - - -* [ ] Documentation -* [ ] Tests -* [ ] Ready to be merged -* [ ] Added myself to contributors table - - diff --git a/.gitignore b/.gitignore index 13496a9..97b2265 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules coverage -dist styleguide .opt-in .opt-out @@ -12,3 +11,7 @@ styleguide package-lock.json yarn.lock yarn-error.log + +# cosmos +cosmos.imports.ts +build/ diff --git a/.npmrc b/.npmrc deleted file mode 100644 index d272289..0000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -registry=http://registry.npmjs.org/ -package-lock=false diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0d8d02c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,20 +0,0 @@ -sudo: false -language: node_js -cache: - directories: - - "~/.npm" - - "node_modules" -notifications: - email: false -node_js: - - '10' -install: - - npm install -g npm - - npm install -script: npm run validate -after_success: kcd-scripts travis-after-success -branches: - only: master -env: - global: - secure: hG6olL1YgWWkvDW+oVQ4BNIape5GsI9uOqPG6WAP2t1LjG5McqFCP6N0HJLfGI/iiTjbhDhBi69lFXyBzWGEM8Smvmnh1iekRQ+Vrt1L9Q+aXEoRm97LtAbIlKNat4QP9ZW1gaNAPgWrzE0mlPxtOYUNjpR8+T6Ivt2Q04iT2cP7kqgC6CmGVVEk3hOAQqWXkkzUUNifcRs9IdeJrBQzWBE2s1Pw+e2GSvb75fW0hXT9AzfFbl5Q1Nw4R2Mai8uDcqaTEROYxdipz1NJTxvEzix71a2vNEXDGMocnLJldd6SCIJ4SzTwq2qcTnlH0PizvK7ym9yRqDbUudxq09/hqf/q8UzjbWMxImB6Aa7E6KH0zfguJUb0jlOd5mkGnJBE67G+yYrgfCutmGIho4UzWgwpu5PJsENzy1xUN/syKtgCc7Nu1l4tag+3U1vo45GcN+CcY05gKWj7rK9RkXTwBvloXJXhocXfVP3hoQq+WDqFyfEdg74U97Rn6bJ7kluC14m7IAoKRwLjJpLx8BRGCVhMwvPlCdW2ZkBkLgkQVHuNXXZ41sSVxq4PEI+04CncEvmrrWbyBJSApq998hKVkVLwtl1RtVxBN5jJJk59JuDjpq4WTN5GIDNs7x6/oyuGFAQzPJ4/tKxXPA6Jls+TzruY837oG5hfhk2vUY1NzHg= diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 06d221a..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,4 +0,0 @@ -# CHANGELOG - -The changelog is automatically updated using [semantic-release](https://github.com/semantic-release/semantic-release). -You can see it on the [releases page](../../releases). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index c73c0e3..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,72 +0,0 @@ -# Contributing - -Thanks for being willing to contribute! - -**Working on your first Pull Request?** You can learn how from this _free_ series -[How to Contribute to an Open Source Project on GitHub][egghead] - -## Project setup - -1. Fork and clone the repo -2. Run `npm run setup -s` to install dependencies and run validation -3. Create a branch for your PR with `git checkout -b pr/your-branch-name` - -> Tip: Keep your `master` branch pointing at the original repository and make -> pull requests from branches on your fork. To do this, run: -> -> ``` -> git remote add upstream https://github.com/stereobooster/react-ideal-image.git -> git fetch upstream -> git branch --set-upstream-to=upstream/master master -> ``` -> -> This will add the original repository as a "remote" called "upstream," -> Then fetch the git information from that remote, then set your local `master` -> branch to use the upstream master branch whenever you run `git pull`. -> Then you can make all of your pull request branches based on this `master` -> branch. Whenever you want to update your version of `master`, do a regular -> `git pull`. - -## Add yourself as a contributor - -This project follows the [all contributors][all-contributors] specification. -To add yourself to the table of contributors on the `README.md`, please use the -automated script as part of your PR: - -```console -npm run add-contributor -``` - -Follow the prompt and commit `.all-contributorsrc` and `README.md` in the PR. -If you've already added yourself to the list and are making -a new type of contribution, you can run it again and select the added -contribution type. - -## Committing and Pushing changes - -Please make sure to run the tests before you commit your changes. You can run -`npm run test:update` which will update any snapshots that need updating. -Make sure to include those changes (if they exist) in your commit. - -### opt into git hooks - -There are git hooks set up with this project that are automatically installed -when you install dependencies. They're really handy, but are turned off by -default (so as to not hinder new contributors). You can opt into these by -creating a file called `.opt-in` at the root of the project and putting this -inside: - -``` -pre-commit -``` - -## Help needed - -Please checkout the [the open issues][issues] - -Also, please watch the repo and respond to questions/bug reports/feature -requests! Thanks! - -[egghead]: https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github -[all-contributors]: https://github.com/stereobooster/all-contributors -[issues]: https://github.com/stereobooster/react-ideal-image/issues diff --git a/README.md b/README.md index 9d762a7..b17e0eb 100644 --- a/README.md +++ b/README.md @@ -1,283 +1,47 @@ -[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://vshymanskyy.github.io/StandWithUkraine) +# react-idealer-image -
-

react-ideal-image

+- shoutouts to the [original authors and contributors](https://github.com/stereobooster/react-ideal-image) +- please see the original repository for extended readme and documentation +- the goal of this fork should be to merge into the original repo -

Adaptive image component

-
+## TLDR -
- -[![Build Status][build-badge]][build] -[![Code Coverage][coverage-badge]][coverage] -[![version][version-badge]][package] -[![downloads][downloads-badge]][npmtrends] -[![MIT License][license-badge]][license] - -[![All Contributors](https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square)](#contributors) -[![PRs Welcome][prs-badge]][prs] -[![Code of Conduct][coc-badge]][coc] - -[![Watch on GitHub][github-watch-badge]][github-watch] -[![Star on GitHub][github-star-badge]][github-star] -[![Tweet][twitter-badge]][twitter] - -## The problem - -I need React component to asynchronously load images, which will adapt based on network, which will allow a user to control, which image to load. - -## This solution - -Read the [introduction](introduction.md). - -## Table of Contents - - - - -- [Installation](#installation) -- [Usage](#usage) -- [Props](#props) - - [getIcon](#geticon) - - [getMessage](#getmessage) - - [getUrl](#geturl) - - [height](#height) - - [icons](#icons) - - [loader](#loader) - - [placeholder](#placeholder) - - [shouldAutoDownload](#shouldautodownload) - - [srcSet](#srcset) - - [theme](#theme) - - [threshold](#threshold) - - [width](#width) -- [Other Solutions](#other-solutions) -- [Contributors](#contributors) -- [LICENSE](#license) - - - -## Installation - -This module is distributed via [npm][npm] which is bundled with [node][node] and -should be installed as one of your project's `dependencies`: +- uses the bun runtime and is a complete refactor +- typescript first, esm only, no support for legacy browsers +- framer-motion replaces react-waypoint +```sh +bun add \ + github:noahehall/react-idealer-image \ + framer-motion \ + react \ + react-dom ``` -npm install react-ideal-image --save -``` - -> This package also depends on `react`, `prop-types`, and `react-waypoint`. -> Please make sure you have those installed as well. -## Usage - -Example for create-react-app (you need v2 for macros) based project - -```js -import React from 'react' -import lqip from 'lqip.macro' -import IdealImage from 'react-ideal-image' - -import image from './images/doggo.jpg' -const lqip = lqip('./images/doggo.jpg') +```ts +import { IdealImage } from "react-idealer-image"; const App = () => ( + /** check the component source for the full api */ -) +); ``` -## Props - -This is the list of props that you need to pass to the component. +## Contributing -### getIcon +```sh -> `function(state: object)` | optional, default icon is provided based on state object - -This function decides what icon to show based on the current state of the component. - -### getMessage - -> `function(icon: string, state: object)` | optional, default message is provided based on the icon and state object. - -This function decides what message to show based on the icon (returned from getIcon prop) and the current state of the component. - -### getUrl - -> `function({})` | optional, no useful default - -This function is called as soon as the component enters the viewport and is used to generate urls based on width and format if `props.srcSet` doesn't provide src field. - -### height - -> `number` | required - -The Height of the image in px. - -### icons - -> `object` | required - -This provides a map of the icons. By default, the component uses icons from material design, implemented as React components with the SVG element. You can customize icons - -```js -const icons = { - load: DownloadIcon, - //... -} +# clone repo +bun install +bun run cosmos +# open localhost:5000 +# set network throttling to something really slow ``` - -### loader - -> `string` | optional, defaults to 'xhr' - -This prop takes one of the 2 options, `xhr` and `image`. Read more about it [here](https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#cancel-download). - -### placeholder - -> `object` | required - -This takes one of the 2 objects - -```js -// To add a solid color placeholder -{ - color: '' -} -``` - -or - -```js -/** - * To add a low quality image - * [Low Quality Image Placeholder](https://github.com/zouhir/lqip) - * [SVG-Based Image Placeholder](https://github.com/technopagan/sqip) - * base64 encoded image of low quality - */ -{ - lqip: '' -} -``` - -Read more about it [here](https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#lqip). - -### shouldAutoDownload - -> `function({})` | optional, default function is provided which decides based on the device network. - -This function decides if image should be downloaded automatically. The default function returns `false` for a `2g` network, -for a `3g` network it decides based on `props.threshold` and for a `4g` network it returns `true` by default. - -### srcSet - -> `array[srcType: object]` | required - -This provides an array of sources of different format and size of the image. Read more about it [here](https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#srcset). -The `srcType` has below structure - -```js -srcType = { - width: number, // required - src: string, - size: number, - format: string, // one of the 'jpeg' or 'webp' -} -``` - -### theme - -> `object` | required - -This provides a theme to the component. By default, the component uses inline styles, but it is also possible to use CSS modules and override all styles. - -```js -const theme = { - placeholder: { - backgroundSize: 'cover', - backgroundRepeat: 'no-repeat', - position: 'relative', - }, - // ... -} -``` - -### threshold - -> `number` | optional - -Tells how much to wait in milliseconds until consider the download to be slow. - -### width - -> `number` | required - -Width of the image in px. - -## Other Solutions - -- [react-progressive-image](https://github.com/FormidableLabs/react-progressive-image) -- [react-lazyload](https://github.com/jasonslyvia/react-lazyload) -- [react-lazy-image](https://github.com/sergiodxa/react-lazy-image) -- [react-image](https://github.com/mbrevda/react-image) -- [react-lazy-load](https://github.com/loktar00/react-lazy-load) -- [react-graceful-image](https://github.com/linasmnew/react-graceful-image) -- [react-worker-image](https://github.com/nitish24p/react-worker-image) -- [lazy-image](https://github.com/notwaldorf/lazy-image) -- [react-simple-image](https://github.com/bitjourney/react-simple-image) -- [react-power-picture](https://github.com/tvthatsme/react-power-picture) -- [react-shimmer](https://github.com/gokcan/react-shimmer) -- [gatsby-image](https://www.gatsbyjs.org/packages/gatsby-image/) -- [react-async-elements ``](https://github.com/palmerhq/react-async-elements#img) - -## Contributors - -Thanks goes to these people ([emoji key][emojis]): - - - -| [
stereobooster](https://github.com/stereobooster)
[💻](https://github.com/stereobooster/react-ideal-image/commits?author=stereobooster "Code") [📖](https://github.com/stereobooster/react-ideal-image/commits?author=stereobooster "Documentation") [🚇](#infra-stereobooster "Infrastructure (Hosting, Build-Tools, etc)") [⚠️](https://github.com/stereobooster/react-ideal-image/commits?author=stereobooster "Tests") | [
Ivan Babak](https://github.com/sompylasar)
[📖](https://github.com/stereobooster/react-ideal-image/commits?author=sompylasar "Documentation") | [
Arun Kumar](https://github.com/palerdot)
[📖](https://github.com/stereobooster/react-ideal-image/commits?author=palerdot "Documentation") | [
Andrew Lisowski](http://hipstersmoothie.com)
[💻](https://github.com/stereobooster/react-ideal-image/commits?author=hipstersmoothie "Code") | [
Timothy Vernon](https://github.com/tvthatsme)
[⚠️](https://github.com/stereobooster/react-ideal-image/commits?author=tvthatsme "Tests") | [
vishalShinde](http://vs1682.github.io)
[📖](https://github.com/stereobooster/react-ideal-image/commits?author=vs1682 "Documentation") | [
Evgeniy Kumachev](https://github.com/EvgeniyKumachev)
[📖](https://github.com/stereobooster/react-ideal-image/commits?author=EvgeniyKumachev "Documentation") | -| :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| [
John Munn](https://github.com/Tawe)
[💻](https://github.com/stereobooster/react-ideal-image/commits?author=Tawe "Code") | - - - -This project follows the [all-contributors][all-contributors] specification. -Contributions of any kind welcome! - -## LICENSE - -Code - MIT - -Icons - [Apache License 2.0](https://github.com/google/material-design-icons/blob/master/LICENSE) - -[npm]: https://www.npmjs.com/ -[node]: https://nodejs.org -[build-badge]: https://img.shields.io/travis/stereobooster/react-ideal-image.svg?style=flat-square -[build]: https://travis-ci.org/stereobooster/react-ideal-image -[coverage-badge]: https://img.shields.io/codecov/c/github/stereobooster/react-ideal-image.svg?style=flat-square -[coverage]: https://codecov.io/github/stereobooster/react-ideal-image -[version-badge]: https://img.shields.io/npm/v/react-ideal-image.svg?style=flat-square -[package]: https://www.npmjs.com/package/react-ideal-image -[downloads-badge]: https://img.shields.io/npm/dm/react-ideal-image.svg?style=flat-square -[npmtrends]: http://www.npmtrends.com/react-ideal-image -[license-badge]: https://img.shields.io/npm/l/react-ideal-image.svg?style=flat-square -[license]: https://github.com/stereobooster/react-ideal-image/blob/master/LICENSE -[prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square -[prs]: http://makeapullrequest.com -[donate-badge]: https://img.shields.io/badge/$-support-green.svg?style=flat-square -[coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square -[coc]: https://github.com/stereobooster/react-ideal-image/blob/master/other/CODE_OF_CONDUCT.md -[github-watch-badge]: https://img.shields.io/github/watchers/stereobooster/react-ideal-image.svg?style=social -[github-watch]: https://github.com/stereobooster/react-ideal-image/watchers -[github-star-badge]: https://img.shields.io/github/stars/stereobooster/react-ideal-image.svg?style=social -[github-star]: https://github.com/stereobooster/react-ideal-image/stargazers -[twitter]: https://twitter.com/intent/tweet?text=Check%20out%20react-ideal-image%20by%20%40stereobooster%20https%3A%2F%2Fgithub.com%2Fstereobooster%2Freact-ideal-image%20%F0%9F%91%8D -[twitter-badge]: https://img.shields.io/twitter/url/https/github.com/stereobooster/react-ideal-image.svg?style=social -[emojis]: https://github.com/kentcdodds/all-contributors#emoji-key -[all-contributors]: https://github.com/kentcdodds/all-contributors diff --git a/barrels.json b/barrels.json new file mode 100644 index 0000000..9fb19d9 --- /dev/null +++ b/barrels.json @@ -0,0 +1,8 @@ +{ + "delete": true, + "directory": ["src"], + "exclude": ".*.d.ts|__tests__", + "local": false, + "location": "all", + "structure": "flat" +} diff --git a/bun.d.ts b/bun.d.ts new file mode 100644 index 0000000..ffea261 --- /dev/null +++ b/bun.d.ts @@ -0,0 +1,54 @@ +// atleast bun-types is required +/// +/// +/// + +// fear the copypasta: https://github.com/lacolaco/network-information-types/blob/master/index.d.ts +// W3C Spec Draft http://wicg.github.io/netinfo/ +// Edition: Draft Community Group Report 20 February 2019 +// http://wicg.github.io/netinfo/#navigatornetworkinformation-interface +declare interface Navigator extends NavigatorNetworkInformation {} +declare interface WorkerNavigator extends NavigatorNetworkInformation {} + +// http://wicg.github.io/netinfo/#navigatornetworkinformation-interface +declare interface NavigatorNetworkInformation { + readonly connection: NetworkInformation; +} + +// http://wicg.github.io/netinfo/#connection-types +type ConnectionType = + | "bluetooth" + | "cellular" + | "ethernet" + | "mixed" + | "none" + | "other" + | "unknown" + | "wifi" + | "wimax"; + +// http://wicg.github.io/netinfo/#effectiveconnectiontype-enum +type EffectiveConnectionType = "2g" | "3g" | "4g" | "5g" | "6g" | "slow-2g"; // lol added 5 and 6g + +// http://wicg.github.io/netinfo/#dom-megabit +type Megabit = number; +// http://wicg.github.io/netinfo/#dom-millisecond +type Millisecond = number; + +// http://wicg.github.io/netinfo/#networkinformation-interface +interface NetworkInformation extends EventTarget { + // http://wicg.github.io/netinfo/#type-attribute + readonly type?: ConnectionType; + // http://wicg.github.io/netinfo/#effectivetype-attribute + readonly effectiveType?: EffectiveConnectionType; + // http://wicg.github.io/netinfo/#downlinkmax-attribute + readonly downlinkMax?: Megabit; + // http://wicg.github.io/netinfo/#downlink-attribute + readonly downlink?: Megabit; + // http://wicg.github.io/netinfo/#rtt-attribute + readonly rtt?: Millisecond; + // http://wicg.github.io/netinfo/#savedata-attribute + readonly saveData?: boolean; + // http://wicg.github.io/netinfo/#handling-changes-to-the-underlying-connection + onchange?: EventListener; +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..6d3abeb Binary files /dev/null and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..f6ad7b5 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install] +registry = "https://registry.npmjs.org" diff --git a/cosmos.bunserver.ts b/cosmos.bunserver.ts new file mode 100644 index 0000000..ce413d0 --- /dev/null +++ b/cosmos.bunserver.ts @@ -0,0 +1,118 @@ +import { rm, stat } from "node:fs/promises"; +import type { ServeOptions } from "bun"; +import * as path from "path"; + +import cosmosConfig from "./cosmos.config.json"; + +const PROJECT_ROOT = import.meta.dir; +const BUILD_DIR = path.resolve(PROJECT_ROOT, "build"); + +const waitForCosmosImports = async () => { + const fpath = `${PROJECT_ROOT}/cosmos.imports.ts`; + try { + const cosmosImports = await stat(fpath); + if (!cosmosImports.isFile()) { + throw new Error(` + file doesnt exist yet + `); + } + } catch { + return new Promise((resolve) => { + setTimeout(() => resolve(waitForCosmosImports()), 1000); + }); + } +}; + +const buildApp = async () => + rm(BUILD_DIR, { force: true, recursive: true }).then(() => + Bun.build({ + entrypoints: ["./cosmos.entrypoint.tsx"], + target: "browser", + outdir: "build", + }) + .then((output) => output) + .catch((e) => { + console.info("\n\n error in build", e); + }) + ); + +await waitForCosmosImports(); +await buildApp().then((output) => { + if (output.success) + console.info( + `app built: ${output.success}; ${output.outputs.length} files ` + ); + else { + for (const message of output.logs) { + // Bun will pretty print the message object + console.error(message); + } + throw new Error(`build failed`); + } +}); + +const returnIndex = () => { + const index = ` + + + + + + + `; + + return new Response(index, { + headers: { + "Content-Type": "text/html", + "Access-Control-Allow-Origin": "*", + }, + }); +}; + +async function serveFromDir(config: { + directory?: string; + path: string; +}): Promise { + const filepath = path.join(config.directory || "", config.path); + + try { + const fd = await stat(filepath); + if (fd && fd.isFile()) { + return new Response(Bun.file(filepath), { + headers: { "Access-Control-Allow-Origin": "*" }, + }); + } + } catch (err) {} + + return null; +} +export default { + port: cosmosConfig.rendererUrl.split(":").pop(), + hostname: "0.0.0.0", + async fetch(req) { + const reqPath = new URL(req.url).pathname; + console.log(req.method, reqPath); + + if (reqPath === "/") return returnIndex(); + else { + const filepath = req.url.replace(cosmosConfig.rendererUrl, ""); + + const exactResponse = await serveFromDir({ path: filepath }); + if (exactResponse) return exactResponse; + + const buildResponse = await serveFromDir({ + directory: BUILD_DIR, + path: filepath, + }); + if (buildResponse) return buildResponse; + + return new Response("File not found", { + status: 404, + }); + } + }, +} satisfies ServeOptions; + +// watch imports +await import("./cosmos.imports.ts").catch((e) => e); diff --git a/cosmos.config.json b/cosmos.config.json new file mode 100644 index 0000000..4a362c1 --- /dev/null +++ b/cosmos.config.json @@ -0,0 +1,4 @@ +{ + "port": 5000, + "rendererUrl": "http://0.0.0.0:5050" +} diff --git a/cosmos.entrypoint.tsx b/cosmos.entrypoint.tsx new file mode 100644 index 0000000..349dde1 --- /dev/null +++ b/cosmos.entrypoint.tsx @@ -0,0 +1,4 @@ +import { mountDomRenderer } from "react-cosmos-dom"; +import * as mountArgs from "./cosmos.imports"; + +mountDomRenderer(mountArgs); diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index 71bbf6f..0000000 --- a/index.d.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {Component, ComponentType, ComponentClass, CSSProperties} from 'react' - -export type LoadingState = 'initial' | 'loading' | 'loaded' | 'error' - -export type IconKey = - | 'load' - | 'loading' - | 'loaded' - | 'error' - | 'noicon' - | 'offline' - -export interface SrcType { - width: number - src?: string - size?: number - format?: 'webp' | 'jpeg' -} - -type ThemeKey = 'placeholder' | 'img' | 'icon' | 'noscript' - -export interface ImageProps { - /** - * This function decides what icon to show based on the current state of the component. - */ - getIcon?: (state: LoadingState) => IconKey - /** - * This function decides what message to show based on the icon (returned from getIcon prop) and - * the current state of the component. - */ - getMessage?: (icon: IconKey, state: LoadingState) => string - /** - * This function is called as soon as the component enters the viewport and is used to generate urls - * based on width and format if props.srcSet doesn't provide src field. - */ - getUrl?: (srcType: SrcType) => string - /** - * The Height of the image in px. - */ - height: number - /** - * This provides a map of the icons. By default, the component uses icons from material design, - * implemented as React components with the SVG element. You can customize icons - */ - icons: Partial> - /** - * This prop takes one of the 2 options, xhr and image. - * Read more about it: - * https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#cancel-download - */ - loader?: 'xhr' | 'image' - /** - * https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#lqip - */ - placeholder: {color: string} | {lqip: string} - /** - * This function decides if image should be downloaded automatically. The default function - * returns false for a 2g network, for a 3g network it decides based on props.threshold - * and for a 4g network it returns true by default. - */ - shouldAutoDownload?: ( - options: { - connection?: 'slow-2g' | '2g' | '3g' | '4g' - size?: number - threshold?: number - possiblySlowNetwork?: boolean - }, - ) => boolean - /** - * This provides an array of sources of different format and size of the image. - * Read more about it: - * https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#srcset - */ - srcSet: SrcType[] - /** - * This provides a theme to the component. By default, the component uses inline styles, - * but it is also possible to use CSS modules and override all styles. - */ - theme?: Partial> - /** - * Tells how much to wait in milliseconds until consider the download to be slow. - */ - threshold?: number - /** - * Width of the image in px. - */ - width: number -} - -type IdealImageComponent = ComponentClass - -declare const IdealImage: IdealImageComponent -export default IdealImage diff --git a/introduction.md b/introduction.md deleted file mode 100644 index 80e4055..0000000 --- a/introduction.md +++ /dev/null @@ -1,366 +0,0 @@ -# An Almost Ideal React Image Component - -TL;DR. This started as an exercise - how to build ideal React image component. The focus was more on UX and browser capabilities, rather than React code. I created react component and published it to npm, but it has no tests and not battle tested in the wild, use it at your own risk. - -[Online example](https://stereobooster.github.io/react-ideal-image-experiments/) | [HN discussion](https://news.ycombinator.com/item?id=17210378) | [Guide To Async Components](https://github.com/stereobooster/guide-to-async-components) | [IdealImage vs img](https://github.com/stereobooster/react-ideal-image/blob/master/other/idealimage-vs-img.md) - -## Lazy loading - -This is a straightforward feature - do not load images which are outside of the screen. Do not need to reinvent a wheel, there is [react-waypoint](https://github.com/brigade/react-waypoint), to trigger actions in the component as soon as it appears on the screen (pseudo code): - -```js - this.setState({src})}> - - -``` - - - - - - - - - -
- Pic 1. Browser's `img` loads all 5 images on the page, but only 3 are visible - - - Pic 3. Screenshot of the page - -
- Pic 2. "Lazy-load" loads only 3 visible images - -
- -## Placeholder - -As soon as you start to do lazy loading you will notice unpleasant content jumps as soon as images get loaded. This is bad for two reasons: UX - content jumps make user loose visual track, performance - content jumps are [browser redraws](https://developers.google.com/speed/docs/insights/browser-reflow). This is why we need a placeholder - a thing which will fill place until the image gets loaded. To do this we need to know image size upfront. AMP has same requirements for all blocks. Simplest placeholder(pseudo code): - -```js -load () { - const img = new Image() - img.onload = () => this.setState({loaded:true}) - img.src = this.props.src -} -render() { - if (!this.state.loaded) { - return () - } else { - return () -  } -} -``` - -**Pic 4.** Load progress of images without dimension - -![](other/introduction/filmstrip-img.png) - -### LQIP - -Better, but not ideal. A user will see blank space until image load, this can be perceived as broken functionality - what if the image fails to load, what if it takes too long. Low-Quality Image Placeholder to the rescue. This technique is known since times of progressive JPEGs, later forgotten and reinvented by Facebook, Medium, and others. Also, we can use solid color placeholder or SQIP. Read more about placeholders [here](https://medium.freecodecamp.org/using-svg-as-placeholders-more-image-loading-techniques-bed1b810ab2c). To get LQIP you can use [sharp](https://github.com/lovell/sharp) - -```js -const getLqip = file => - new Promise((resolve, reject) => { - sharp(file) - .resize(20) - .toBuffer((err, data, info) => { - if (err) return reject(err) - const {format} = info - return resolve(`data:image/${format};base64,${data.toString('base64')}`) - }) - }) - -const lqip = await getLqip('cute-dog.jpg') -``` - -Also check: [lqip](https://github.com/zouhir/lqip) or [lqip.macro](https://github.com/stereobooster/lqip.macro); [sqip](https://github.com/technopagan/sqip) or [sqip.macro](https://github.com/stereobooster/sqip.macro); - -Use LQIP like this (pseudo code): - -```js -
- -
-``` - -Or in the component: - -```js - -``` - -**Pic 5.** Load progress of images with LQIP, but without JS - -![](other/introduction/filmstrip-lqip.png) - -**Pic 6.** Load progress of images with LQIP and with JS - -![](other/introduction/filmstrip-lqip-react.png) - -## Responsive - -### Styles - -We are specifying exact width and height of the image and the placeholder. To make it responsive we need to add some CSS (pseudo code): - -```js -const img = { - width: '100%', - height: 'auto', - maxWidth: '100%', -} - -render() { - if (this.state.loaded) { - return () - } else { - return () -  } -} -``` - -**Pic 7.1** Load progress of `img` with "responsive style" in Android (0.1s interval) - -![](other/introduction/filmstrip-img-android-0.1.png) - -**Pic 7.2.** Load progress of `img` with "responsive style" in iOS (0.5s interval) - -![](other/introduction/filmstrip-img-ios-0.1.png) - -### `srcSet` - -This feature is about reimplementing `srcSet` property of [responsive image](https://css-tricks.com/responsive-images-youre-just-changing-resolutions-use-srcSet/). It would be nice to use image based on the size of the screen, to minimize traffic for the images on small devices. - -To do this we will need: - -- Set of images resized for different devices. You can use [sharp](https://github.com/lovell/sharp) to resize images. -- Data about how much space image takes on the screen. This is easy because we mount placeholder before the image, so the reference to the placeholder can be used to get dimensions -- Some heuristic based on `screen.width`, `screen.height`, `window.devicePixelRatio`, `body.clientHeight` to guess maximum image size for given device -- Would be nice to take into account `orientationchange` events, but will not do this for now. - -See exact implementation in the code (`guessMaxImageWidth`). Our component will look like this: - -```js - -``` - -Also possible to reimplement `sizes` param with [css-mediaquery](https://github.com/ericf/css-mediaquery), but this potentially can give more bugs than the actual value. - -## Adaptive - -Most likely you haven't heard this term applied to the images, because I made it up. Adaptive image - an image which adapts to the environment, for example, if the browser supports WebP use it if the network is too slow stop auto download images if the browser is offline communicate to the user that download of the image is not possible at the moment. - -### WebP - -To detect WebP support we can use this snippet copy-pasted from StackOverflow: - -```js -const detectWebpSupport = () => { - if (ssr) return false - const elem = document.createElement('canvas') - if (elem.getContext && elem.getContext('2d')) { - // was able or not to get WebP representation - return elem.toDataURL('image/webp').indexOf('data:image/webp') === 0 - } else { - // very old browser like IE 8, canvas not supported - return false - } -} -``` - -Use component like this: - -```js - -``` - -### Slow network - -If the network is slow it makes no sense to auto-download image (as soon as it appears on the screen), because it will take a long time to load even more time if the browser tries to download more than one image simultaneously. - -Instead, we can let the user decide if they want to download image or not. There should be an icon over placeholder, so the user can click it to start the download, and click again to cancel the download. As soon as the download starts there should be no icon, but if it takes too long some indicator of loading state should appear to inform the user that it is still working. - -| load | no icon | loading | -| -------------------------------- | ---------------------------------- | ----------------------------------- | -| ![](other/introduction/load.png) | ![](other/introduction/noicon.png) | ![](other/introduction/loading.png) | - -In Chrome it is pretty easy to detect the slow network with `navigator.connection.effectiveType`. If it is 'slow-2g', '2g', '3g' then the component will not auto-download images. - -| Component detected slow network and didn't try to load images ![](other/introduction/waterfall-slow3g-chrome.png) | Component switched to manual mode ![](other/introduction/screen-slow3g-chrome.jpg) | -| :---------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- | - - -For other browsers, we can try to guess if the download of the image takes too much time. How much time should be considered as too much is up-to developer, via `threshold` property (optional): - -```js - -``` - -If image takes too long to download and the load was initiated by "Lazy loading" feature then: - -- load process will be canceled -- the component will show control, so the user can initiate the download of the image manually -- the component will broadcast event `possibly slow network`, so other components would not even try load images and will be switched to "Manual mode" - -| Component tried to download images, but canceled load after 1 second ![](other/introduction/waterfall-slow3g-safari-1sec.png) | Component switched to manual mode ![](other/introduction/screen-slow3g-safari-1sec.jpg) | -| :---------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------- | - - -### Cancel download - -In Chrome (and probably other browsers) you can asign empty string to `src` to cancel download, but this doesn't work in Mobile Safari: - -```js -const img = new Image() -//... -img.src = '' -``` - -Other way to do it is to use good old `XMLHttpRequest` which supports cancel: - -```js -const request = new XMLHttpRequest() -//... -request.abort() -``` - -Buuut: - -- if images are uncacheable this will not work - the browser will trigger another request for the image as soon as we insert an image in the DOM -- if images are hosted on the different domain we will need to configure CORS properly - -This is why I chose to let developer decide which approach to use (default is `xhr`): - -```js - -``` - -It is also possible to use `fetch` with [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), but it is supported only in Chrome 66+ at the moment. - -## More UX - -### Network error - -If image network request errored we need to show user message that browser failed to download the image. The user should be able to recover from the error (in case of temporal issue), by clicking on the image user can trigger repetitive load. - -### 404 error - -404 error is the special one. We use LQIP placeholder, which creates "impression" of content, but our component can outlive real image. We need clearly explain to the user that image doesn't exist anymore. - -### Offline - -Because we are lazy loading images, it can happen that we have some unloaded images at the moment when the browser goes offline. We should not confuse users in this case with an error message, instead we should clearly identify that browser is offline and this is why browser cannot load images. - -| Network error | 404 error | Offline | -| --------------------------------- | ------------------------------------- | ----------------------------------- | -| ![](other/introduction/error.png) | ![](other/introduction/error-404.png) | ![](other/introduction/offline.png) | - -## SSR or prerendering - -On the server, the component will be rendered with a placeholder (lqip) and without an icon. As soon as React application will boot, the component will decide if it needs to start download image or show download icon. - -### Users with disabled JavaScript - -For users with disabled JavaScript or for search bots component will render the good old image in ` - ) : null - } - - render() { - const props = this.props - const {placeholder, theme} = props - let background - if (props.icon === loaded) { - background = {} - } else if (placeholder.lqip) { - background = { - backgroundImage: `url("${placeholder.lqip}")`, - } - } else { - background = { - backgroundColor: placeholder.color, - } - } - return ( -
- {this.renderImage(props)} - {this.renderNoscript(props)} - {this.renderIcon(props)} -
- ) - } -} diff --git a/src/components/MediaWithDefaults/README.md b/src/components/MediaWithDefaults/README.md deleted file mode 100644 index 3e99ed1..0000000 --- a/src/components/MediaWithDefaults/README.md +++ /dev/null @@ -1,89 +0,0 @@ -All possible states of the component - -```js -const lqip = - 'data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAA4DASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAUG/8QAIRAAAQQDAAEFAAAAAAAAAAAAAQIDBREABAYhEjEyQVH/xAAUAQEAAAAAAAAAAAAAAAAAAAAE/8QAGBEBAAMBAAAAAAAAAAAAAAAAAQACIRH/2gAMAwEAAhEDEQA/AMJ2DG+7Dw0nz8gsx+uyhlxnWdLakOlfzpIF3aRf1WT5t96P5+N1ug9Tu7ZWS8q1gG6B8H2FDz+YxhjUrEOdZ//Z' - -const sqip = - "data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4774 3024'%3e%3cfilter id='b'%3e%3cfeGaussianBlur stdDeviation='12' /%3e%3c/filter%3e%3cpath fill='%23515a57' d='M0 0h4774v3021H0z'/%3e%3cg filter='url(%23b)' transform='translate(9.3 9.3) scale(18.64844)' fill-opacity='.5'%3e%3cellipse fill='whitefef' rx='1' ry='1' transform='matrix(74.55002 60.89891 -21.7939 26.67923 151.8 104.4)'/%3e%3cellipse fill='black80c' cx='216' cy='49' rx='59' ry='59'/%3e%3cellipse fill='black60e' cx='22' cy='60' rx='46' ry='89'/%3e%3cellipse fill='%23ffebd5' cx='110' cy='66' rx='42' ry='28'/%3e%3cellipse fill='whiteff9' rx='1' ry='1' transform='rotate(33.3 -113.2 392.6) scale(42.337 17.49703)'/%3e%3cellipse fill='%23031f1e' rx='1' ry='1' transform='matrix(163.4651 -64.93326 6.77862 17.06471 111 16.4)'/%3e%3cpath fill='whitefea' d='M66 74l9 39 16-44z'/%3e%3cellipse fill='%23a28364' rx='1' ry='1' transform='rotate(-32.4 253.2 -179) scale(30.79511 43.65381)'/%3e%3cpath fill='%231a232c' d='M40 139l61-57 33 95z'/%3e%3cpath fill='%230a222b' d='M249.8 153.3l-48.1-48 32.5-32.6 48.1 48z'/%3e%3c/g%3e%3c/svg%3e" -; - - - - - - - - - - - - - - - - - - - - -
- load - - - - noicon - - -
loading - - offline - -
loaded - - error - -
-``` diff --git a/src/components/MediaWithDefaults/index.js b/src/components/MediaWithDefaults/index.js deleted file mode 100644 index b123afc..0000000 --- a/src/components/MediaWithDefaults/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react' -import Media from '../Media' -import icons from '../icons' -import theme from '../theme' - -const MediaWithDefaults = props => - -MediaWithDefaults.defaultProps = { - ...Media.defaultProps, - icons, - theme, -} - -// eslint-disable-next-line react/forbid-foreign-prop-types -MediaWithDefaults.propTypes = Media.propTypes - -export default MediaWithDefaults diff --git a/src/components/composeStyle.js b/src/components/composeStyle.js deleted file mode 100644 index ad2ce59..0000000 --- a/src/components/composeStyle.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Composes styles and/or classes - * - * For classes it will concat them in in one string - * and return as `className` property. - * Alternative is https://github.com/JedWatson/classnames - * - * For objects it will merge them in one object - * and return as `style` property. - * - * Usage: - * Asume you have `theme` object, which can be css-module - * or object or other css-in-js compatible with css-module - * - * link - * - * @returns {{className: string, style: object}} - params for React component - */ -export default (...stylesOrClasses) => { - const classes = [] - let style - for (const obj of stylesOrClasses) { - if (obj instanceof Object) { - Object.assign(style || (style = {}), obj) - } else if (obj === undefined || obj === false) { - // ignore - } else if (typeof obj === 'string') { - classes.push(obj) - } else { - throw new Error(`Unexpected value ${obj}`) - } - } - return { - className: classes.length > 1 ? classes.join(' ') : classes[0], - style, - } -} diff --git a/src/components/constants.js b/src/components/constants.js deleted file mode 100644 index 4cc8105..0000000 --- a/src/components/constants.js +++ /dev/null @@ -1,24 +0,0 @@ -const load = 'load' -const loading = 'loading' -const loaded = 'loaded' -const error = 'error' -const noicon = 'noicon' -const offline = 'offline' - -export const icons = { - load, - loading, - loaded, - error, - noicon, - offline, -} - -const initial = 'initial' - -export const loadStates = { - initial, - loading, - loaded, - error, -} diff --git a/src/components/helpers.js b/src/components/helpers.js deleted file mode 100644 index b197419..0000000 --- a/src/components/helpers.js +++ /dev/null @@ -1,136 +0,0 @@ -export const ssr = - typeof window === 'undefined' || window.navigator.userAgent === 'ReactSnap' - -export const nativeConnection = !ssr && !!window.navigator.connection - -// export const getScreenWidth = () => { -// if (ssr) return 0 -// const devicePixelRatio = window.devicePixelRatio || 1 -// const {screen} = window -// const {width} = screen -// // const angle = (screen.orientation && screen.orientation.angle) || 0 -// // return Math.max(width, height) -// // const rotated = Math.floor(angle / 90) % 2 !== 0 -// // return (rotated ? height : width) * devicePixelRatio -// return width * devicePixelRatio -// } -// export const screenWidth = getScreenWidth() - -export const guessMaxImageWidth = (dimensions, w) => { - if (ssr) return 0 - - // Default to window object but don't use window as a default - // parameter so that this can be used on the server as well - if (!w) { - w = window - } - - const imgWidth = dimensions.width - - const {screen} = w - const sWidth = screen.width - const sHeight = screen.height - - const {documentElement} = document - const windowWidth = w.innerWidth || documentElement.clientWidth - const windowHeight = w.innerHeight || documentElement.clientHeight - const devicePixelRatio = w.devicePixelRatio || 1 - - const windowResized = sWidth > windowWidth - - let result - if (windowResized) { - const body = document.getElementsByTagName('body')[0] - const scrollWidth = windowWidth - imgWidth - const isScroll = - body.clientHeight > windowHeight || body.clientHeight > sHeight - if (isScroll && scrollWidth <= 15) { - result = sWidth - scrollWidth - } else { - result = (imgWidth / windowWidth) * sWidth - } - } else { - result = imgWidth - } - - return result * devicePixelRatio -} - -export const bytesToSize = bytes => { - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] - if (bytes === 0) return 'n/a' - const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)), 10) - if (i === 0) return `${bytes} ${sizes[i]}` - return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}` -} - -// async function supportsWebp() { -// if (typeof createImageBitmap === 'undefined' || typeof fetch === 'undefined') -// return false -// return fetch( -// 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=', -// ) -// .then(response => response.blob()) -// .then(blob => createImageBitmap(blob).then(() => true, () => false)) -// } -// let webp = undefined -// const webpPromise = supportsWebp() -// webpPromise.then(x => (webp = x)) -// export default () => { -// if (webp === undefined) return webpPromise -// return { -// then: callback => callback(webp), -// } -// } - -const detectWebpSupport = () => { - if (ssr) return false - const elem = document.createElement('canvas') - if (elem.getContext && elem.getContext('2d')) { - // was able or not to get WebP representation - return elem.toDataURL('image/webp').indexOf('data:image/webp') === 0 - } else { - // very old browser like IE 8, canvas not supported - return false - } -} - -export const supportsWebp = detectWebpSupport() - -const isWebp = x => - x.format === 'webp' || (x.src && x.src.match(/\.webp($|\?.*)/i)) - -// eslint-disable-next-line no-shadow -export const selectSrc = ({srcSet, maxImageWidth, supportsWebp}) => { - if (srcSet.length === 0) throw new Error('Need at least one item in srcSet') - let supportedFormat, width - if (supportsWebp) { - supportedFormat = srcSet.filter(isWebp) - if (supportedFormat.length === 0) supportedFormat = srcSet - } else { - supportedFormat = srcSet.filter(x => !isWebp(x)) - if (supportedFormat.length === 0) - throw new Error('Need at least one supported format item in srcSet') - } - let widths = supportedFormat.filter(x => x.width >= maxImageWidth) - if (widths.length === 0) { - widths = supportedFormat - width = Math.max.apply(null, widths.map(x => x.width)) - } else { - width = Math.min.apply(null, widths.map(x => x.width)) - } - return supportedFormat.filter(x => x.width === width)[0] -} - -export const fallbackParams = ({srcSet, getUrl}) => { - if (!ssr) return {} - const notWebp = srcSet.filter(x => !isWebp(x)) - const first = notWebp[0] - return { - nsSrcSet: notWebp - .map(x => `${getUrl ? getUrl(x) : x.src} ${x.width}w`) - .join(','), - nsSrc: getUrl ? getUrl(first) : first.src, - ssr, - } -} diff --git a/src/components/icons.js b/src/components/icons.js deleted file mode 100644 index 9de5b3e..0000000 --- a/src/components/icons.js +++ /dev/null @@ -1,16 +0,0 @@ -import DownloadIcon from './Icon/Download' -import OfflineIcon from './Icon/Offline' -import WarningIcon from './Icon/Warning' -import LoadingIcon from './Icon/Loading' -import {icons} from './constants' - -const {load, loading, loaded, error, noicon, offline} = icons - -export default { - [load]: DownloadIcon, - [loading]: LoadingIcon, - [loaded]: null, - [error]: WarningIcon, - [noicon]: null, - [offline]: OfflineIcon, -} diff --git a/src/components/loaders.js b/src/components/loaders.js deleted file mode 100644 index b27bd4b..0000000 --- a/src/components/loaders.js +++ /dev/null @@ -1,109 +0,0 @@ -// There is an issue with cancelable interface -// It is not obvious that -// `image(src)` has `cancel` function -// but `image(src).then()` doesn't - -import {unfetch, UnfetchAbortController} from './unfetch' - -/** - * returns new "promise" with cancel function combined - * - * @param {Promise} p1 - first "promise" with cancel - * @param {Promise} p2 - second "promise" with cancel - * @returns {Promise} - new "promise" with cancel - */ -export const combineCancel = (p1, p2) => { - if (!p2) return p1 - const result = p1.then(x => x, x => x) - result.cancel = () => { - p1.cancel() - p2.cancel() - } - return result -} - -export const timeout = threshold => { - let timerId - const result = new Promise(resolve => { - timerId = setTimeout(resolve, threshold) - }) - result.cancel = () => { - // there is a bug with cancel somewhere in the code - // if (!timerId) throw new Error('Already canceled') - clearTimeout(timerId) - timerId = undefined - } - return result -} - -// Caveat: image loader can not cancel download in some browsers -export const imageLoader = src => { - let img = new Image() - const result = new Promise((resolve, reject) => { - img.onload = resolve - // eslint-disable-next-line no-multi-assign - img.onabort = img.onerror = () => reject({}) - img.src = src - }) - result.cancel = () => { - if (!img) throw new Error('Already canceled') - // eslint-disable-next-line no-multi-assign - img.onload = img.onabort = img.onerror = undefined - img.src = '' - img = undefined - } - return result -} - -// Caveat: XHR loader can cause errors because of 'Access-Control-Allow-Origin' -// Caveat: we still need imageLoader to do actual decoding, -// but if images are uncachable this will lead to two requests -export const xhrLoader = (url, options) => { - let controller = new UnfetchAbortController() - const signal = controller.signal - const result = new Promise((resolve, reject) => - unfetch(url, {...options, signal}).then(response => { - if (response.ok) { - response - .blob() - .then(() => imageLoader(url)) - .then(resolve) - } else { - reject({status: response.status}) - } - }, reject), - ) - result.cancel = () => { - if (!controller) throw new Error('Already canceled') - controller.abort() - controller = undefined - } - return result -} - -// Caveat: AbortController only supported in Chrome 66+ -// Caveat: we still need imageLoader to do actual decoding, -// but if images are uncachable this will lead to two requests -// export const fetchLoader = (url, options) => { -// let controller = new AbortController() -// const signal = controller.signal -// const result = new Promise((resolve, reject) => -// fetch(url, {...options, signal}).then(response => { -// if (response.ok) { -// options && options.onMeta && options.onMeta(response.headers) -// response -// .blob() -// .then(() => imageLoader(url)) -// .then(resolve) -// } else { -// reject({status: response.status}) -// } -// }, reject), -// ) -// result.cancel = () => { -// if (!controller) throw new Error('Already canceled') -// controller.abort() -// controller = undefined -// } -// return result -// } diff --git a/src/components/theme.js b/src/components/theme.js deleted file mode 100644 index 9891301..0000000 --- a/src/components/theme.js +++ /dev/null @@ -1,26 +0,0 @@ -export default { - placeholder: { - backgroundSize: 'cover', - backgroundRepeat: 'no-repeat', - position: 'relative', - }, - img: { - width: '100%', - height: 'auto', - maxWidth: '100%', - /* TODO: fix bug in styles */ - marginBottom: '-4px', - }, - icon: { - position: 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - textAlign: 'center', - }, - noscript: { - position: 'absolute', - top: 0, - left: 0, - }, -} diff --git a/src/components/theme.module.css b/src/components/theme.module.css deleted file mode 100644 index 5d1c8de..0000000 --- a/src/components/theme.module.css +++ /dev/null @@ -1,28 +0,0 @@ -.placeholder { - background-size: cover; - background-repeat: no-repeat; - position: relative; -} - -.img { - width: 100%; - height: auto; - max-width: 100%; - /* TODO: fix bug in styles */ - margin-bottom: -4px; - -webkit-transform: translate3d(0, 0, 0); -} - -.icon { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - text-align: center; -} - -.noscript { - position: absolute; - top: 0; - left: 0; -} diff --git a/src/components/unfetch.js b/src/components/unfetch.js deleted file mode 100644 index 223a65c..0000000 --- a/src/components/unfetch.js +++ /dev/null @@ -1,74 +0,0 @@ -export class UnfetchAbortController { - constructor() { - this.signal = {onabort: () => {}} - this.abort = () => this.signal.onabort() - } -} - -// modified version of https://github.com/developit/unfetch -// - ponyfill intead of polyfill -// - add support for AbortController -export const unfetch = (url, options) => { - options = options || {} - return new Promise((resolve, reject) => { - const request = new XMLHttpRequest() - - request.open(options.method || 'get', url, true) - - // eslint-disable-next-line guard-for-in - for (const i in options.headers) { - request.setRequestHeader(i, options.headers[i]) - } - - request.withCredentials = options.credentials === 'include' - - request.onload = () => { - resolve(response()) - } - - request.onerror = reject - - if (options.signal) - options.signal.onabort = () => { - // eslint-disable-next-line no-multi-assign - request.onerror = request.onload = undefined - request.abort() - } - - request.send(options.body) - - function response() { - const keys = [] - const all = [] - const headers = {} - let header - - request - .getAllResponseHeaders() - .replace(/^(.*?):\s*?([\s\S]*?)$/gm, (m, key, value) => { - keys.push((key = key.toLowerCase())) - all.push([key, value]) - header = headers[key] - headers[key] = header ? `${header},${value}` : value - }) - - return { - // eslint-disable-next-line no-bitwise - ok: ((request.status / 100) | 0) === 2, // 200-299 - status: request.status, - statusText: request.statusText, - url: request.responseURL, - clone: response, - text: () => Promise.resolve(request.responseText), - json: () => Promise.resolve(request.responseText).then(JSON.parse), - blob: () => Promise.resolve(new Blob([request.response])), - headers: { - keys: () => keys, - entries: () => all, - get: n => headers[n.toLowerCase()], - has: n => n.toLowerCase() in headers, - }, - } - } - }) -} diff --git a/src/fixtures/bun.fixture.tsx b/src/fixtures/bun.fixture.tsx new file mode 100644 index 0000000..ee49dee --- /dev/null +++ b/src/fixtures/bun.fixture.tsx @@ -0,0 +1,6 @@ +import { IdealImage } from "IdealImage"; +import bunImg from "./img/bun.svg"; + +export default ( + +); diff --git a/src/fixtures/cosmos.fixture.tsx b/src/fixtures/cosmos.fixture.tsx new file mode 100644 index 0000000..7367542 --- /dev/null +++ b/src/fixtures/cosmos.fixture.tsx @@ -0,0 +1,6 @@ +import { IdealImage } from "IdealImage"; +import cosmosImg from "./img/cosmos.png"; + +export default ( + +); diff --git a/src/fixtures/img/bun.svg b/src/fixtures/img/bun.svg new file mode 100644 index 0000000..7ef1500 --- /dev/null +++ b/src/fixtures/img/bun.svg @@ -0,0 +1 @@ +Bun Logo \ No newline at end of file diff --git a/src/fixtures/img/cosmos.png b/src/fixtures/img/cosmos.png new file mode 100644 index 0000000..f54e3c9 Binary files /dev/null and b/src/fixtures/img/cosmos.png differ diff --git a/src/idealImageUtils.tsx b/src/idealImageUtils.tsx new file mode 100644 index 0000000..ad5679b --- /dev/null +++ b/src/idealImageUtils.tsx @@ -0,0 +1,183 @@ +import { iconKeys, iconMap, type IconKeys, type IconMap } from "Icons"; +import { loadStates } from "loaders"; +import type { SrcType } from "types"; + +export const ssr = (): boolean => + typeof window === "undefined" || window.navigator.userAgent === "ReactSnap"; + +export const nativeConnection = () => + !ssr() && "connection" in window.navigator; + +export const guessMaxImageWidth = (dimensions, w?) => { + if (ssr()) return 0; + + // Default to window object but don't use window as a default + // parameter so that this can be used on the server as well + if (!w) { + w = window; + } + + const imgWidth = dimensions.width; + + const { screen } = w; + const sWidth = screen.width; + const sHeight = screen.height; + + const { documentElement } = document; + const windowWidth = w.innerWidth || documentElement.clientWidth; + const windowHeight = w.innerHeight || documentElement.clientHeight; + const devicePixelRatio = w.devicePixelRatio || 1; + + const windowResized = sWidth > windowWidth; + + let result; + if (windowResized) { + const body = document.getElementsByTagName("body")[0]; + const scrollWidth = windowWidth - imgWidth; + const isScroll = + body.clientHeight > windowHeight || body.clientHeight > sHeight; + if (isScroll && scrollWidth <= 15) { + result = sWidth - scrollWidth; + } else { + result = (imgWidth / windowWidth) * sWidth; + } + } else { + result = imgWidth; + } + + return result * devicePixelRatio; +}; + +export const bytesToSize = (bytes: number) => { + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + if (bytes === 0) return "n/a"; + const i = Math.floor(Math.log(bytes) / Math.log(1024)) || 0; + return i === 0 + ? `${bytes} ${sizes[i]}` + : `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}`; +}; + +export const selectSrc = ({ srcSet, maxImageWidth }): SrcType => { + switch (srcSet.length) { + case 0: + throw new Error("Need at least one item in srcSet"); + case 1: + return srcSet[0]; + default: { + const sorted = srcSet.toSorted((a, b) => a.width - b.width); + const filteredSources = sorted.filter((x) => x.width >= maxImageWidth); + + return filteredSources.length + ? filteredSources[0] // return smallest of the largest + : sorted.at(-1); // return the largest + } + } +}; + +export interface AutodownloadInterface { + connection?: Partial; + inViewport?: boolean; + possiblySlowNetwork?: boolean; + size?: number | string; + threshold?: number; + [x: string]: any; +} +export const defaultShouldAutoDownload = ({ + connection, + inViewport = false, + possiblySlowNetwork, + size, + threshold = 0, // TODO(noah): find a reasonable threshold number +}: AutodownloadInterface) => { + if (!connection || inViewport) + return true; // fallback to always auto download + else if (possiblySlowNetwork || typeof size !== "number") return false; + + const { downlink, rtt = 0, effectiveType } = connection; + switch (effectiveType) { + case "slow-2g": + case "2g": + return false; + case "3g": + if (downlink && size && threshold) { + return (size * 8) / (downlink * 1000) + rtt < threshold; + } + return false; + default: // fast connections always autodownload + return true; + } +}; + +export const defaultGetMessage = ( + iconKey: IconKeys | null | string, + imgState: Record +) => { + switch (iconKey) { + case iconKeys.NoIcon: + case iconKeys.Loaded: + return null; + case iconKeys.Initial: + case iconKeys.Loading: + return "Loading..."; + case iconKeys.Load: + // we can show `alt` here + if (imgState.size) { + return ( + + Click to load ( + + {bytesToSize(imgState.size as number)} + + , ), + + ); + } else { + return "Click to load"; + } + case iconKeys.Offline: + return "Your browser is offline. Image not loaded"; + case iconKeys.Error: + default: + return imgState.loadInfo === 404 + ? "404. Image not found" + : `Error: ${ + (imgState.loadInfo as string) || "unknown" + }. Click to reload`; + } +}; + +export const defaultGetIcon = ({ + imgState, + networkState, + icons = iconMap, +}: { + imgState: Record; + networkState: Record; + icons?: IconMap; +}) => { + if (ssr()) return icons[iconKeys.NoIcon]; + switch (imgState.state) { + case loadStates.Loaded: + return icons[iconKeys.Loaded]; + case loadStates.Loading: + return networkState.overThreshold + ? icons[iconKeys.Loading] + : icons[iconKeys.NoIcon]; + case loadStates.Initial: + if (networkState.onLine) { + if (imgState.shouldAutoDownload === undefined) + return icons[iconKeys.NoIcon]; + return imgState.userTriggered || !imgState.shouldAutoDownload + ? icons[iconKeys.Load] + : icons[iconKeys.NoIcon]; + } else { + return icons[iconKeys.Offline]; + } + case loadStates.Error: + return networkState.onLine + ? icons[iconKeys.Error] + : icons[iconKeys.Offline]; + default: + return () => null; + } +}; diff --git a/src/images.d.ts b/src/images.d.ts new file mode 100644 index 0000000..1cc5cb0 --- /dev/null +++ b/src/images.d.ts @@ -0,0 +1,14 @@ +declare module "*.svg" { + const content: any; + export default content; +} + +declare module "*.jpg" { + const content: any; + export default content; +} + +declare module "*.png" { + const content: any; + export default content; +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index dadcb75..0000000 --- a/src/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import IdealImage from './components/IdealImageWithDefaults' - -export default IdealImage diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4f3199a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,14 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./IdealImage"; +export * from "./Image"; +export * from "./Media"; +export * from "./Noscript"; +export * from "./idealImageUtils"; +export * from "./loaders"; +export * from "./theme"; +export * from "./types"; +export * from "./unfetch"; +export * from "./Icons/index"; diff --git a/src/loaders.ts b/src/loaders.ts new file mode 100644 index 0000000..22e1095 --- /dev/null +++ b/src/loaders.ts @@ -0,0 +1,108 @@ +// There is an issue with cancelable interface +// It is not obvious that +// `image(src)` has `cancel` function +// but `image(src).then()` doesn't + +import { + unfetch, + UnfetchAbortController, + type UnfetchOptionsInterface, +} from "./unfetch"; +import { iconKeys } from "Icons"; + +// map iconKeys to load states, currently not adding any different ones +export const loadStates = { + ...iconKeys, +}; +export type LoadStates = keyof typeof loadStates; + +export interface Cancelable extends Promise { + cancel: () => Cancelable; +} + +export const combineCancel = (p1: T, p2?: T): T => { + if (!p2) return p1; + const p3: Awaited = p1.then( + (x) => x, + (x) => x + ); + p3.cancel = () => { + p1.cancel(); + p2.cancel(); + }; + return p3; +}; + +export const timeout = (threshold): Cancelable => { + let timerId; + const result: Awaited = new Promise((resolve) => { + timerId = setTimeout(resolve, threshold); + }); + result.cancel = () => { + clearTimeout(timerId); + timerId = undefined; + }; + return result; +}; + +// Caveat: image loader can not cancel download in some browsers +export const imageLoader = (src: string): Cancelable => { + let img = new Image(); + const result: Awaited = new Promise((resolve, reject) => { + img.onload = resolve; + // eslint-disable-next-line no-multi-assign + img.onabort = img.onerror = () => reject({}); + img.src = src; + }); + result.cancel = () => { + if (!img) { + throw new Error("Already canceled"); + } + // eslint-disable-next-line no-multi-assign + img.onload = img.onabort = img.onerror = undefined; + img.src = ""; + img = undefined; + }; + return result; +}; + +// Caveat: XHR loader can cause errors because of 'Access-Control-Allow-Origin' +// Caveat: we still need imageLoader to do actual decoding, +// but if images are uncachable this will lead to two requests +export const xhrLoader = ( + url: string, + options: UnfetchOptionsInterface = {} +): Cancelable => { + let controller = new UnfetchAbortController(); + const signal = controller.signal; + const result: Awaited = new Promise((resolve, reject) => + unfetch(url, { ...options, signal }).then((response) => { + if (response.ok) { + response + .blob() + .then(() => imageLoader(url)) + .then(resolve); + } else { + reject({ status: response.status }); + } + }, reject) + ); + result.cancel = () => { + if (!controller) { + throw new Error("Already canceled"); + } + controller.abort(); + controller = undefined; + }; + return result; +}; + +export type LoaderTypes = "xhr" | "image"; +export const idealLoader = (type: LoaderTypes) => { + switch (type) { + case "xhr": + return xhrLoader; + default: + return imageLoader; + } +}; diff --git a/src/theme.tsx b/src/theme.tsx new file mode 100644 index 0000000..a1abeee --- /dev/null +++ b/src/theme.tsx @@ -0,0 +1,31 @@ +import { type CSSProperties } from "react"; + +export type Theme = Record; + +export const theme: Theme = { + wrapper: {}, + placeholder: { + backgroundSize: "cover", + backgroundRepeat: "no-repeat", + position: "relative", + }, + img: { + width: "100%", + height: "auto", + maxWidth: "100%", + }, + icon: { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + textAlign: "center", + }, + noscript: { + position: "absolute", + top: 0, + left: 0, + }, +}; + +export type ThemeKeys = keyof typeof theme; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7a0ffbb --- /dev/null +++ b/src/types.ts @@ -0,0 +1,34 @@ +// TODO(noah): move all the shared types in here +// ^ check idealimage, media, and image that shiz is getting out of hand + +import type { RefObject } from "react"; +import type { MotionProps } from "framer-motion"; + +import type { Theme } from "./theme"; + +export type SvgRef = RefObject; + +type BaseProps = { + width: string | number; + // + height?: string | number; + theme?: Theme; +}; + +export interface SrcType extends BaseProps { + src: string; + // + size?: number; + format?: string; // we dont care: but @see https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types +} + +export type SrcSet = SrcType[]; + +export type GetUrl = (x: SrcType) => string; + +export type IdealImageProps = BaseProps & { + alt: string; + motionProps?: MotionProps; + placeholder?: string; + srcSet: SrcSet; +}; diff --git a/src/unfetch.ts b/src/unfetch.ts new file mode 100644 index 0000000..62791bb --- /dev/null +++ b/src/unfetch.ts @@ -0,0 +1,85 @@ +export class UnfetchAbortController { + constructor() { + this.signal = { onabort: () => {} }; + this.abort = () => this.signal.onabort(); + } +} + +export interface UnfetchOptionsInterface { + method?: string; + headers?: Record; + credentials?: string; + body?: any; +} +export const unfetchOptions: UnfetchOptionsInterface = { + method: "get", + headers: {}, +}; +// modified version of https://github.com/developit/unfetch +// - ponyfill intead of polyfill +// - add support for AbortController +export const unfetch = (url: string, opts: UnfetchOptionsInterface = {}) => { + return new Promise((resolve, reject) => { + const options = { ...unfetchOptions, ...opts }; + const request = new XMLHttpRequest(); + + request.open(options.method || "get", url, true); + + // eslint-disable-next-line guard-for-in + for (const i in options.headers) { + request.setRequestHeader(i, options.headers[i]); + } + + request.withCredentials = options.credentials === "include"; + + request.onload = () => { + resolve(response()); + }; + + request.onerror = reject; + + if (options.signal) { + options.signal.onabort = () => { + // eslint-disable-next-line no-multi-assign + request.onerror = request.onload = undefined; + request.abort(); + }; + } + + request.send(options.body); + + function response() { + const keys = []; + const all = []; + const headers = {}; + let header; + + request + .getAllResponseHeaders() + .replace(/^(.*?):\s*?([\s\S]*?)$/gm, (m, key, value) => { + keys.push((key = key.toLowerCase())); + all.push([key, value]); + header = headers[key]; + headers[key] = header ? `${header},${value}` : value; + }); + + return { + // eslint-disable-next-line no-bitwise + ok: ((request.status / 100) | 0) === 2, // 200-299 + status: request.status, + statusText: request.statusText, + url: request.responseURL, + clone: response, + text: () => Promise.resolve(request.responseText), + json: () => Promise.resolve(request.responseText).then(JSON.parse), + blob: () => Promise.resolve(new Blob([request.response])), + headers: { + keys: () => keys, + entries: () => all, + get: (n) => headers[n.toLowerCase()], + has: (n) => n.toLowerCase() in headers, + }, + }; + } + }); +}; diff --git a/styleguide.config.js b/styleguide.config.js deleted file mode 100644 index affefb8..0000000 --- a/styleguide.config.js +++ /dev/null @@ -1,23 +0,0 @@ -module.exports = { - title: 'react-ideal-image', - components: 'src/components/**/index.js', - skipComponentsWithoutExample: true, - assetsDir: 'other/images', - webpackConfig: { - module: { - rules: [ - { - test: /\.jsx?$/, - exclude: /node_modules/, - loader: 'babel-loader', - }, - ], - }, - externals: { - react: 'react', - 'react-dom': 'react-dom', - 'prop-types': 'prop-types', - 'react-waypoint': 'react-waypoint', - }, - }, -} diff --git a/tsconfig.json b/tsconfig.json index 2555102..c970b7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,43 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "jsx": "react", - "noUnusedLocals": true, - "strict": true, - "noImplicitReturns": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noEmitOnError": true, + "allowImportingTsExtensions": true, + "allowJs": true, + "allowSyntheticDefaultImports": true, + "assumeChangesOnlyAffectDirectDependencies": true, + "baseUrl": "src", + "checkJs": false, + "declaration": true, + "declarationMap": true, + "downlevelIteration": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "experimentalDecorators": true, "forceConsistentCasingInFileNames": true, - "lib": ["es2016", "dom"] - }, - "include": ["src/__tests__/**.tsx"] + "incremental": true, + "isolatedModules": false, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "esnext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "noEmit": true, + "noErrorTruncation": false, + "noFallthroughCasesInSwitch": false, + "noImplicitAny": false, + "noImplicitThis": true, + "preserveConstEnums": true, + "preserveSymlinks": false, + "pretty": false, + "removeComments": false, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "target": "esnext", + "types": ["bun-types"], + "useUnknownInCatchVariables": true + } }