├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __tests__ ├── fixtures │ ├── duplicated-sizes.ico │ ├── favicon.ico │ ├── favicon.png │ ├── favicon.svg │ ├── favicon.txt │ └── favicon.xml.svg ├── parse-apple-touch-icons.spec.ts ├── parse-fluid-icons.spec.ts ├── parse-icons.spec.ts ├── parse-ie-config.spec.ts ├── parse-ie11-tiles.spec.ts ├── parse-manifest.spec.ts ├── parse-mask-icons.spec.ts ├── parse-windows8-tiles.spec.ts └── utils │ └── parse-image.spec.ts ├── commitlint.config.cjs ├── jest.config.cjs ├── package.json ├── src ├── index.ts ├── parse-apple-touch-icons.ts ├── parse-fluid-icons.ts ├── parse-icons.ts ├── parse-ie-config.ts ├── parse-ie11-tiles.ts ├── parse-manifest.ts ├── parse-mask-icons.ts ├── parse-windows8-tiles.ts ├── types.ts └── utils │ ├── extract-attributes.ts │ ├── is-url-string.ts │ ├── link-element-utils.ts │ ├── merge-relative-urls.ts │ ├── parse-html.ts │ ├── parse-image.ts │ ├── parse-space-separated-sizes.ts │ └── parse-xml.ts ├── tsconfig.base.json ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | 4 | # Build 5 | dist 6 | lib 7 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true 3 | , parser: '@typescript-eslint/parser' 4 | , plugins: [ 5 | '@typescript-eslint' 6 | ] 7 | , extends: [ 8 | 'eslint:recommended' 9 | , 'plugin:@typescript-eslint/recommended' 10 | ] 11 | , rules: { 12 | 'no-constant-condition': 'off' 13 | , '@typescript-eslint/no-extra-semi': 'off' 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [18.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install -g yarn 21 | - run: yarn install 22 | - run: yarn lint 23 | - run: yarn build 24 | - run: yarn test 25 | env: 26 | CI: true 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Build 40 | lib 41 | dist 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [7.0.2](https://github.com/BlackGlory/parse-favicon/compare/v7.0.1...v7.0.2) (2024-12-23) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * regex ([2e7347c](https://github.com/BlackGlory/parse-favicon/commit/2e7347ca72eb92d6966d89459530a8de727e22dc)) 11 | 12 | ### [7.0.1](https://github.com/BlackGlory/parse-favicon/compare/v7.0.0...v7.0.1) (2024-03-26) 13 | 14 | ## [7.0.0](https://github.com/BlackGlory/parse-favicon/compare/v6.0.2...v7.0.0) (2023-12-20) 15 | 16 | 17 | ### ⚠ BREAKING CHANGES 18 | 19 | * Node.js v16 => Node.js v18.17.0 20 | 21 | * upgrade dependencies ([f65514c](https://github.com/BlackGlory/parse-favicon/commit/f65514c2ddf849a11b5a971f0c400a5500b999b2)) 22 | 23 | ### [6.0.2](https://github.com/BlackGlory/parse-favicon/compare/v6.0.1...v6.0.2) (2023-06-11) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * export src ([5346c1d](https://github.com/BlackGlory/parse-favicon/commit/5346c1d912ab364a89bc4db2b2024b2268d02ed8)) 29 | 30 | ### [6.0.1](https://github.com/BlackGlory/parse-favicon/compare/v6.0.0...v6.0.1) (2023-02-10) 31 | 32 | ## [6.0.0](https://github.com/BlackGlory/parse-favicon/compare/v5.0.4...v6.0.0) (2023-02-10) 33 | 34 | 35 | ### ⚠ BREAKING CHANGES 36 | 37 | * - Renamed `Icon` to `IIcon` 38 | - Renamed `Size` to `ISize` 39 | * - CommonJS => ESM 40 | - The minimal version of Node.js is 16 41 | 42 | ### Bug Fixes 43 | 44 | * [#24](https://github.com/BlackGlory/parse-favicon/issues/24) ([9c0271e](https://github.com/BlackGlory/parse-favicon/commit/9c0271e4f8fcc01a9e97e47ee03cab3317f15094)) 45 | 46 | 47 | * rename ([1db870f](https://github.com/BlackGlory/parse-favicon/commit/1db870f73ad32d37e35640d3ea6c022bd7f4d491)) 48 | * upgrade dependencies ([6c7abfe](https://github.com/BlackGlory/parse-favicon/commit/6c7abfeb8a7c080381bbd93f1bebf9161525f543)) 49 | 50 | ### [5.0.4](https://github.com/BlackGlory/parse-favicon/compare/v5.0.3...v5.0.4) (2022-10-18) 51 | 52 | ### [5.0.3](https://github.com/BlackGlory/parse-favicon/compare/v5.0.2...v5.0.3) (2022-03-23) 53 | 54 | ### [5.0.2](https://github.com/BlackGlory/parse-favicon/compare/v5.0.1...v5.0.2) (2022-01-06) 55 | 56 | ### [5.0.1](https://github.com/BlackGlory/parse-favicon/compare/v5.0.0...v5.0.1) (2021-12-17) 57 | 58 | ## [5.0.0](https://github.com/BlackGlory/parse-favicon/compare/v4.0.16...v5.0.0) (2021-12-16) 59 | 60 | 61 | ### ⚠ BREAKING CHANGES 62 | 63 | * - The minimum version is Node.js v16 64 | 65 | * upgrade dependencies ([af866ff](https://github.com/BlackGlory/parse-favicon/commit/af866ff553e5f9f647835b88ff51d3128c8117da)) 66 | 67 | ### [4.0.16](https://github.com/BlackGlory/parse-favicon/compare/v4.0.15...v4.0.16) (2021-12-12) 68 | 69 | ### [4.0.15](https://github.com/BlackGlory/parse-favicon/compare/v4.0.14...v4.0.15) (2021-10-14) 70 | 71 | ### [4.0.14](https://github.com/BlackGlory/parse-favicon/compare/v4.0.13...v4.0.14) (2021-09-15) 72 | 73 | ### [4.0.13](https://github.com/BlackGlory/parse-favicon/compare/v4.0.12...v4.0.13) (2021-07-13) 74 | 75 | ### [4.0.12](https://github.com/BlackGlory/parse-favicon/compare/v4.0.11...v4.0.12) (2021-07-11) 76 | 77 | ### [4.0.11](https://github.com/BlackGlory/parse-favicon/compare/v4.0.10...v4.0.11) (2021-07-03) 78 | 79 | ### [4.0.10](https://github.com/BlackGlory/parse-favicon/compare/v4.0.9...v4.0.10) (2021-05-17) 80 | 81 | ### [4.0.9](https://github.com/BlackGlory/parse-favicon/compare/v4.0.8...v4.0.9) (2021-05-17) 82 | 83 | ### [4.0.8](https://github.com/BlackGlory/parse-favicon/compare/v4.0.7...v4.0.8) (2021-05-16) 84 | 85 | ### [4.0.7](https://github.com/BlackGlory/parse-favicon/compare/v4.0.6...v4.0.7) (2021-05-07) 86 | 87 | ### [4.0.6](https://github.com/BlackGlory/parse-favicon/compare/v4.0.5...v4.0.6) (2021-04-02) 88 | 89 | ### [4.0.5](https://github.com/BlackGlory/parse-favicon/compare/v4.0.4...v4.0.5) (2021-03-27) 90 | 91 | ### [4.0.4](https://github.com/BlackGlory/parse-favicon/compare/v4.0.3...v4.0.4) (2021-03-17) 92 | 93 | ### [4.0.3](https://github.com/BlackGlory/parse-favicon/compare/v4.0.2...v4.0.3) (2021-03-08) 94 | 95 | ### [4.0.2](https://github.com/BlackGlory/parse-favicon/compare/v4.0.1...v4.0.2) (2021-02-25) 96 | 97 | ### [4.0.1](https://github.com/BlackGlory/parse-favicon/compare/v4.0.0...v4.0.1) (2021-02-03) 98 | 99 | ## [4.0.0](https://github.com/BlackGlory/parse-favicon/compare/v3.0.11...v4.0.0) (2021-02-01) 100 | 101 | 102 | ### ⚠ BREAKING CHANGES 103 | 104 | * props undefined => null 105 | 106 | ### Features 107 | 108 | * modify interfaces ([90e6388](https://github.com/BlackGlory/parse-favicon/commit/90e6388de50f790b5b485e72ab33a380c7838857)) 109 | 110 | ### [3.0.11](https://github.com/BlackGlory/parse-favicon/compare/v3.0.10...v3.0.11) (2021-02-01) 111 | 112 | ### [3.0.10](https://github.com/BlackGlory/parse-favicon/compare/v3.0.9...v3.0.10) (2021-02-01) 113 | 114 | 115 | ### Bug Fixes 116 | 117 | * double file path for absolute path ([#8](https://github.com/BlackGlory/parse-favicon/issues/8)) ([f002702](https://github.com/BlackGlory/parse-favicon/commit/f002702ba09aa25c3cb17f627015f068133d084a)) 118 | 119 | ### [3.0.9](https://github.com/BlackGlory/parse-favicon/compare/v3.0.8...v3.0.9) (2021-01-31) 120 | 121 | 122 | ### Bug Fixes 123 | 124 | * run in Node.js ([#7](https://github.com/BlackGlory/parse-favicon/issues/7)) ([1643412](https://github.com/BlackGlory/parse-favicon/commit/1643412e4b563768037375dfd5f9eba5a183b7d7)) 125 | 126 | ### [3.0.8](https://github.com/BlackGlory/parse-favicon/compare/v3.0.7...v3.0.8) (2021-01-04) 127 | 128 | 129 | ### Bug Fixes 130 | 131 | * browser field ([f851530](https://github.com/BlackGlory/parse-favicon/commit/f8515309504ac181246ae81225d7580d9e4d58d6)) 132 | 133 | ### [3.0.7](https://github.com/BlackGlory/parse-favicon/compare/v3.0.6...v3.0.7) (2021-01-04) 134 | 135 | ### [3.0.6](https://github.com/BlackGlory/parse-favicon/compare/v3.0.5...v3.0.6) (2021-01-04) 136 | 137 | ### [3.0.5](https://github.com/BlackGlory/parse-favicon/compare/v3.0.4...v3.0.5) (2020-10-10) 138 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 BlackGlory 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # parse-favicon 2 | Parse HTML to get icon information. 3 | 4 | ## Install 5 | ```sh 6 | npm install --save parse-favicon 7 | # or 8 | yarn add parse-favicon 9 | ``` 10 | 11 | ## Usage 12 | ```js 13 | import { parseFavicon } from 'parse-favicon' 14 | 15 | const pageURL = 'https://github.com' 16 | 17 | parseFavicon(pageURL, fetchText, fetchBuffer) 18 | .subscribe(icon => console.log(icon)) 19 | 20 | function fetchText(url: string): Promise { 21 | return fetch(url) 22 | .then(res => res.text()) 23 | } 24 | 25 | function fetchBuffer(url: string): Promise { 26 | return fetch(url) 27 | .then(res => res.arrayBuffer()) 28 | } 29 | ``` 30 | 31 | ## API 32 | ### parseFavicon 33 | ```ts 34 | type TextFetcher = (url: string) => Awaitable // string | PromiseLike 35 | type BufferFetcher = (url: string) => Awaitable // ArrayBuffer | PromiseLike 36 | 37 | interface IIcon { 38 | url: string 39 | reference: string 40 | type: null | string 41 | size: null | 'any' | ISize | ISize[] 42 | } 43 | 44 | interface ISize { 45 | width: number 46 | height: number 47 | } 48 | 49 | function parseFavicon( 50 | pageURL: string 51 | , textFetcher: TextFetcher 52 | , bufferFetcher?: BufferFetcher 53 | ): Observable 54 | ``` 55 | 56 | `parseFavicon` accepts `textFetcher` and `bufferFetcher` for further fetching requests when parsing icons, `bufferFetcher` is optional. 57 | If you need actual icon sizes and type, should provide `bufferFetcher`. 58 | 59 | References related to `textFetcher`: 60 | - `` 61 | - `` 62 | 63 | References related to `bufferFetcher`: 64 | - `/favicon.ico` 65 | - `/apple-touch-icon-57x57-precomposed.png` 66 | - `/apple-touch-icon-57x57.png` 67 | - `/apple-touch-icon-72x72-precomposed.png` 68 | - `/apple-touch-icon-72x72.png` 69 | - `/apple-touch-icon-114x114-precomposed.png` 70 | - `/apple-touch-icon-114x114.png` 71 | - `/apple-touch-icon-120x120-precomposed.png` 72 | - `/apple-touch-icon-120x120.png` 73 | - `/apple-touch-icon-144x144-precomposed.png` 74 | - `/apple-touch-icon-144x144.png` 75 | - `/apple-touch-icon-152x152-precomposed.png` 76 | - `/apple-touch-icon-152x152.png` 77 | - `/apple-touch-icon-180x180-precomposed.png` 78 | - `/apple-touch-icon-180x180.png` 79 | - `/apple-touch-icon-precomposed.png` 80 | - `/apple-touch-icon.png` 81 | 82 | ## Supported references 83 | - `` 84 | - `` 85 | - `` 86 | - `` 87 | - `` 88 | - `` 89 | - `` 90 | - `` 91 | - `` 92 | - `` 93 | - `` 94 | - `` 95 | - `` 96 | - `/favicon.ico` 97 | - `/apple-touch-icon-57x57-precomposed.png` 98 | - `/apple-touch-icon-57x57.png` 99 | - `/apple-touch-icon-72x72-precomposed.png` 100 | - `/apple-touch-icon-72x72.png` 101 | - `/apple-touch-icon-114x114-precomposed.png` 102 | - `/apple-touch-icon-114x114.png` 103 | - `/apple-touch-icon-120x120-precomposed.png` 104 | - `/apple-touch-icon-120x120.png` 105 | - `/apple-touch-icon-144x144-precomposed.png` 106 | - `/apple-touch-icon-144x144.png` 107 | - `/apple-touch-icon-152x152-precomposed.png` 108 | - `/apple-touch-icon-152x152.png` 109 | - `/apple-touch-icon-180x180-precomposed.png` 110 | - `/apple-touch-icon-180x180.png` 111 | - `/apple-touch-icon-precomposed.png` 112 | - `/apple-touch-icon.png` 113 | 114 | ## Related projects 115 | - [favicon-detector](https://github.com/BlackGlory/favicon-detector) 116 | -------------------------------------------------------------------------------- /__tests__/fixtures/duplicated-sizes.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BlackGlory/parse-favicon/73a1e1246b1d7afce4709789e77783c7753ea236/__tests__/fixtures/duplicated-sizes.ico -------------------------------------------------------------------------------- /__tests__/fixtures/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BlackGlory/parse-favicon/73a1e1246b1d7afce4709789e77783c7753ea236/__tests__/fixtures/favicon.ico -------------------------------------------------------------------------------- /__tests__/fixtures/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BlackGlory/parse-favicon/73a1e1246b1d7afce4709789e77783c7753ea236/__tests__/fixtures/favicon.png -------------------------------------------------------------------------------- /__tests__/fixtures/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /__tests__/fixtures/favicon.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BlackGlory/parse-favicon/73a1e1246b1d7afce4709789e77783c7753ea236/__tests__/fixtures/favicon.txt -------------------------------------------------------------------------------- /__tests__/fixtures/favicon.xml.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 12 | -------------------------------------------------------------------------------- /__tests__/parse-apple-touch-icons.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseAppleTouchIcons } from '@src/parse-apple-touch-icons.js' 2 | 3 | describe('parseAppleTouchIcons', () => { 4 | describe('apple-touch-icon', () => { 5 | test('basic', () => { 6 | const html = ` 7 | 8 | 9 | ` 10 | 11 | const result = parseAppleTouchIcons(html) 12 | 13 | expect(result).toMatchObject([ 14 | { 15 | url: 'path/to/icon.png' 16 | , reference: 'apple-touch-icon' 17 | , size: null 18 | , type: null 19 | } 20 | ]) 21 | }) 22 | 23 | test('type', () => { 24 | const html = ` 25 | 26 | 27 | ` 28 | 29 | const result = parseAppleTouchIcons(html) 30 | 31 | expect(result).toMatchObject([ 32 | { 33 | url: 'path/to/icon-1.png' 34 | , reference: 'apple-touch-icon' 35 | , type: null 36 | , size: null 37 | } 38 | , { 39 | url: 'path/to/icon-2.png' 40 | , reference: 'apple-touch-icon' 41 | , size: null 42 | , type: 'image/png' 43 | } 44 | ]) 45 | }) 46 | 47 | test('sizes', () => { 48 | const html = ` 49 | 50 | 51 | 52 | 53 | 54 | ` 55 | 56 | const result = parseAppleTouchIcons(html) 57 | 58 | expect(result).toMatchObject([ 59 | { 60 | url: 'path/to/icon-1.png' 61 | , reference: 'apple-touch-icon' 62 | , size: null 63 | , type: null 64 | } 65 | , { 66 | url: 'path/to/icon-2.png' 67 | , reference: 'apple-touch-icon' 68 | , size: null 69 | , type: null 70 | } 71 | , { 72 | url: 'path/to/icon-3.png' 73 | , reference: 'apple-touch-icon' 74 | , size: { width: 72, height: 72 } 75 | , type: null 76 | } 77 | , { 78 | url: 'path/to/icon-4.png' 79 | , reference: 'apple-touch-icon' 80 | , size: [{ width: 72, height: 72 }, { width: 144, height: 144 }] 81 | , type: null 82 | } 83 | , { 84 | url: 'path/to/icon-5.svg' 85 | , reference: 'apple-touch-icon' 86 | , size: 'any' 87 | , type: null 88 | } 89 | ]) 90 | }) 91 | }) 92 | 93 | describe('apple-touch-icon-precomposed', () => { 94 | test('basic', () => { 95 | const html = ` 96 | 97 | 98 | ` 99 | 100 | const result = parseAppleTouchIcons(html) 101 | expect(result).toMatchObject([ 102 | { 103 | url: 'path/to/icon.png' 104 | , reference: 'apple-touch-icon-precomposed' 105 | , size: null 106 | , type: null 107 | } 108 | ]) 109 | }) 110 | 111 | test('type', () => { 112 | const html = ` 113 | 114 | 115 | ` 116 | 117 | const result = parseAppleTouchIcons(html) 118 | expect(result).toMatchObject([ 119 | { 120 | url: 'path/to/icon-1.png' 121 | , reference: 'apple-touch-icon-precomposed' 122 | , size: null 123 | , type: null 124 | } 125 | , { 126 | url: 'path/to/icon-2.png' 127 | , reference: 'apple-touch-icon-precomposed' 128 | , size: null 129 | , type: 'image/png' 130 | } 131 | ]) 132 | }) 133 | 134 | test('sizes', () => { 135 | const html = ` 136 | 137 | 138 | 139 | 140 | 141 | ` 142 | 143 | const result = parseAppleTouchIcons(html) 144 | 145 | expect(result).toMatchObject([ 146 | { 147 | url: 'path/to/icon-1.png' 148 | , reference: 'apple-touch-icon-precomposed' 149 | , size: null 150 | , type: null 151 | } 152 | , { 153 | url: 'path/to/icon-2.png' 154 | , reference: 'apple-touch-icon-precomposed' 155 | , size: null 156 | , type: null 157 | } 158 | , { 159 | url: 'path/to/icon-3.png' 160 | , reference: 'apple-touch-icon-precomposed' 161 | , size: { width: 72, height: 72 } 162 | , type: null 163 | } 164 | , { 165 | url: 'path/to/icon-4.png' 166 | , reference: 'apple-touch-icon-precomposed' 167 | , size: [{ width: 72, height: 72 }, { width: 144, height: 144 }] 168 | , type: null 169 | } 170 | , { 171 | url: 'path/to/icon-5.svg' 172 | , reference: 'apple-touch-icon-precomposed' 173 | , size: 'any' 174 | , type: null 175 | } 176 | ]) 177 | }) 178 | }) 179 | }) 180 | -------------------------------------------------------------------------------- /__tests__/parse-fluid-icons.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseFluidIcons } from '@src/parse-fluid-icons.js' 2 | 3 | describe('parseFluidIcons', () => { 4 | test('basic', () => { 5 | const html = ` 6 | 7 | 8 | ` 9 | 10 | const result = parseFluidIcons(html) 11 | 12 | expect(result).toMatchObject([ 13 | { 14 | url: 'path/to/icon.png' 15 | , reference: 'fluid-icon' 16 | , type: null 17 | , size: null 18 | } 19 | ]) 20 | }) 21 | 22 | test('type', () => { 23 | const html = ` 24 | 25 | 26 | ` 27 | 28 | const result = parseFluidIcons(html) 29 | 30 | expect(result).toMatchObject([ 31 | { 32 | url: 'path/to/icon-1.png' 33 | , reference: 'fluid-icon' 34 | , type: null 35 | , size: null 36 | } 37 | , { 38 | url: 'path/to/icon-2.png' 39 | , reference: 'fluid-icon' 40 | , type: 'image/png' 41 | , size: null 42 | } 43 | ]) 44 | }) 45 | 46 | test('size', () => { 47 | const html = ` 48 | 49 | 50 | 51 | 52 | ` 53 | 54 | const result = parseFluidIcons(html) 55 | 56 | expect(result).toMatchObject([ 57 | { 58 | url: 'path/to/icon-1.png' 59 | , reference: 'fluid-icon' 60 | , type: null 61 | , size: null 62 | } 63 | , { 64 | url: 'path/to/icon-2.png' 65 | , reference: 'fluid-icon' 66 | , type: null 67 | , size: null 68 | } 69 | , { 70 | url: 'path/to/icon-3.png' 71 | , reference: 'fluid-icon' 72 | , type: null 73 | , size: { width: 128, height: 128 } 74 | } 75 | , { 76 | url: 'path/to/icon-4.png' 77 | , reference: 'fluid-icon' 78 | , type: null 79 | , size: [{ width: 128, height: 128 }, { width: 256, height: 256 }] 80 | } 81 | ]) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /__tests__/parse-icons.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseIcons } from '@src/parse-icons.js' 2 | 3 | describe('parseIcons', () => { 4 | test('basic', () => { 5 | const html = ` 6 | 7 | 8 | 9 | 10 | ` 11 | 12 | const result = parseIcons(html) 13 | 14 | expect(result).toMatchObject([ 15 | { 16 | url: 'path/to/icon.png' 17 | , reference: 'icon' 18 | , size: null 19 | , type: null 20 | } 21 | , { 22 | url: 'path/to/icon.ico' 23 | , reference: 'icon' 24 | , size: null 25 | , type: null 26 | } 27 | ]) 28 | }) 29 | 30 | test('type', () => { 31 | const html = ` 32 | 33 | 34 | 35 | 36 | ` 37 | 38 | const result = parseIcons(html) 39 | 40 | expect(result).toMatchObject([ 41 | { 42 | url: 'path/to/icon-1.png' 43 | , reference: 'icon' 44 | , type: null 45 | , size: null 46 | } 47 | , { 48 | url: 'path/to/icon-2.svg' 49 | , reference: 'icon' 50 | , size: null 51 | , type: 'image/svg+xml' 52 | } 53 | , { 54 | url: 'path/to/icon-1.png' 55 | , reference: 'icon' 56 | , size: null 57 | , type: null 58 | } 59 | , { 60 | url: 'path/to/icon-2.ico' 61 | , reference: 'icon' 62 | , size: null 63 | , type: 'image/ico' 64 | } 65 | ]) 66 | }) 67 | 68 | test('sizes', () => { 69 | const html = ` 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | ` 81 | 82 | const result = parseIcons(html) 83 | 84 | expect(result).toMatchObject([ 85 | { 86 | url: 'path/to/icon-1.png' 87 | , reference: 'icon' 88 | , size: null 89 | , type: null 90 | } 91 | , { 92 | url: 'path/to/icon-2.png' 93 | , reference: 'icon' 94 | , size: null 95 | , type: null 96 | } 97 | , { 98 | url: 'path/to/icon-3.png' 99 | , reference: 'icon' 100 | , size: { width: 72, height: 72 } 101 | , type: null 102 | } 103 | , { 104 | url: 'path/to/icon-4.png' 105 | , reference: 'icon' 106 | , size: [{ width: 72, height: 72 }, { width: 144, height: 144 }] 107 | , type: null 108 | } 109 | , { 110 | url: 'path/to/icon-5.svg' 111 | , reference: 'icon' 112 | , size: 'any' 113 | , type: null 114 | } 115 | , { 116 | url: 'path/to/icon-1.ico' 117 | , reference: 'icon' 118 | , size: null 119 | , type: null 120 | } 121 | , { 122 | url: 'path/to/icon-2.ico' 123 | , reference: 'icon' 124 | , size: null 125 | , type: null 126 | } 127 | , { 128 | url: 'path/to/icon-3.ico' 129 | , reference: 'icon' 130 | , size: { width: 16, height: 16 } 131 | , type: null 132 | } 133 | , { 134 | url: 'path/to/icon-4.icns' 135 | , reference: 'icon' 136 | , size: [{ width: 16, height: 16 }, { width: 32, height: 32 }] 137 | , type: null 138 | } 139 | , { 140 | url: 'path/to/icon-5.svg' 141 | , reference: 'icon' 142 | , size: 'any' 143 | , type: null 144 | } 145 | ]) 146 | }) 147 | }) 148 | -------------------------------------------------------------------------------- /__tests__/parse-ie-config.spec.ts: -------------------------------------------------------------------------------- 1 | import { getErrorAsync } from 'return-style' 2 | import { dedent } from 'extra-tags' 3 | import { parseIEConfig } from '@src/parse-ie-config.js' 4 | import { jest } from '@jest/globals' 5 | 6 | describe('parseIEConfig', () => { 7 | it('call fetcher to get resource', async () => { 8 | const fetcher = jest.fn(() => { throw new Error() }) 9 | const html = ` 10 | 11 | ` 12 | 13 | getErrorAsync(() => parseIEConfig(html, fetcher)) 14 | 15 | expect(fetcher).toBeCalledTimes(1) 16 | expect(fetcher).toBeCalledWith('path/to/ieconfig.xml') 17 | }) 18 | 19 | test('config cannot fetch', async () => { 20 | const fetcher = () => { throw new Error() } 21 | const html = ` 22 | 23 | ` 24 | 25 | const result = await parseIEConfig(html, fetcher) 26 | 27 | expect(result).toEqual([]) 28 | }) 29 | 30 | describe('config can fetch', () => { 31 | test('square70x70logo', async () => { 32 | const html = ` 33 | 34 | ` 35 | const config = dedent` 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ` 46 | 47 | const result = await parseIEConfig(html, () => config) 48 | 49 | expect(result).toMatchObject([ 50 | { 51 | url: 'path/to/path/to/icon.png' 52 | , reference: 'msapplication-config' 53 | , type: null 54 | , size: { width: 70, height: 70 } 55 | } 56 | ]) 57 | }) 58 | 59 | test('square150x150logo', async () => { 60 | const html = ` 61 | 62 | ` 63 | const config = dedent` 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | ` 74 | 75 | const result = await parseIEConfig(html, () => config) 76 | 77 | expect(result).toMatchObject([ 78 | { 79 | url: 'path/to/path/to/icon.png' 80 | , reference: 'msapplication-config' 81 | , type: null 82 | , size: { width: 150, height: 150 } 83 | } 84 | ]) 85 | }) 86 | 87 | test('wide310x150logo', async () => { 88 | const html = ` 89 | 90 | ` 91 | const config = dedent` 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | ` 102 | 103 | const result = await parseIEConfig(html, () => config) 104 | 105 | expect(result).toMatchObject([ 106 | { 107 | url: 'path/to/path/to/icon.png' 108 | , reference: 'msapplication-config' 109 | , type: null 110 | , size: { width: 310, height: 150 } 111 | } 112 | ]) 113 | }) 114 | 115 | test('square310x310logo', async () => { 116 | const html = ` 117 | 118 | ` 119 | const config = dedent` 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | ` 130 | 131 | const result = await parseIEConfig(html, () => config) 132 | 133 | expect(result).toMatchObject([ 134 | { 135 | url: 'path/to/path/to/icon.png' 136 | , reference: 'msapplication-config' 137 | , type: null 138 | , size: { width: 310, height: 310 } 139 | } 140 | ]) 141 | }) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /__tests__/parse-ie11-tiles.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseIE11Tiles } from '@src/parse-ie11-tiles.js' 2 | 3 | describe('parseIE11Tiles', () => { 4 | test('square70x70logo', () => { 5 | const html = ` 6 | 7 | 8 | ` 9 | 10 | const result = parseIE11Tiles(html) 11 | 12 | expect(result).toMatchObject([ 13 | { 14 | url: 'path/to/icon.png' 15 | , reference: 'msapplication-square70x70logo' 16 | , size: { width: 70, height: 70 } 17 | , type: null 18 | } 19 | ]) 20 | }) 21 | 22 | test('square150x150logo', async () => { 23 | const html = ` 24 | 25 | 26 | ` 27 | 28 | const result = parseIE11Tiles(html) 29 | 30 | expect(result).toMatchObject([ 31 | { 32 | url: 'path/to/icon.png' 33 | , reference: 'msapplication-square150x150logo' 34 | , size: { width: 150, height: 150 } 35 | , type: null 36 | } 37 | ]) 38 | }) 39 | 40 | test('square310x310logo', async () => { 41 | const html = ` 42 | 43 | 44 | ` 45 | 46 | const result = parseIE11Tiles(html) 47 | 48 | expect(result).toMatchObject([ 49 | { 50 | url: 'path/to/icon.png' 51 | , reference: 'msapplication-square310x310logo' 52 | , size: { width: 310, height: 310 } 53 | , type: null 54 | } 55 | ]) 56 | }) 57 | 58 | test('wide310x150logo', async () => { 59 | const html = ` 60 | 61 | 62 | ` 63 | 64 | const result = parseIE11Tiles(html) 65 | 66 | expect(result).toMatchObject([ 67 | { 68 | url: 'path/to/icon.png' 69 | , reference: 'msapplication-wide310x150logo' 70 | , size: { width: 310, height: 150 } 71 | , type: null 72 | } 73 | ]) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /__tests__/parse-manifest.spec.ts: -------------------------------------------------------------------------------- 1 | import { getErrorAsync } from 'return-style' 2 | import { dedent } from 'extra-tags' 3 | import { parseManifest } from '@src/parse-manifest.js' 4 | import { jest } from '@jest/globals' 5 | 6 | describe('parseManifest', () => { 7 | test('call fetcher to get resource', async () => { 8 | const fetcher = jest.fn(() => { throw new Error() }) 9 | const html = ` 10 | 11 | ` 12 | 13 | await getErrorAsync(() => parseManifest(html, fetcher)) 14 | 15 | expect(fetcher).toBeCalledWith('path/to/manifest.webmanifest') 16 | }) 17 | 18 | test('manifest cannot fetch', async () => { 19 | const fetcher = () => { throw new Error() } 20 | const html = ` 21 | 22 | ` 23 | 24 | const result = await parseManifest(html, fetcher) 25 | 26 | expect(result).toEqual([]) 27 | }) 28 | 29 | describe('manifest can fetch', () => { 30 | test('relative paths', async () => { 31 | const html = ` 32 | 33 | ` 34 | const manifest = dedent` 35 | { 36 | "icons": [ 37 | { 38 | "src": "path/to/icon", 39 | "sizes": "48x48" 40 | }, 41 | { 42 | "src": "path/to/icon.ico", 43 | "sizes": "72x72 128x128" 44 | }, 45 | { 46 | "src": "path/to/icon.webp", 47 | "sizes": "48x48", 48 | "type": "image/webp" 49 | } 50 | ] 51 | } 52 | ` 53 | 54 | const result = await parseManifest(html, () => manifest) 55 | 56 | expect(result).toMatchObject([ 57 | { 58 | url: 'path/to/path/to/icon' 59 | , reference: 'manifest' 60 | , type: null 61 | , size: { width: 48, height: 48 } 62 | } 63 | , { 64 | url: 'path/to/path/to/icon.ico' 65 | , reference: 'manifest' 66 | , type: null 67 | , size: [ 68 | { width: 72, height: 72 } 69 | , { width: 128, height: 128 } 70 | ] 71 | } 72 | , { 73 | url: 'path/to/path/to/icon.webp' 74 | , reference: 'manifest' 75 | , type: 'image/webp' 76 | , size: { width: 48, height: 48 } 77 | } 78 | ]) 79 | }) 80 | 81 | test('absolute path', async () => { 82 | const html = ` 83 | 84 | ` 85 | const manifest = dedent` 86 | { 87 | "icons": [ 88 | { 89 | "src": "/path/to/icon", 90 | "sizes": "48x48" 91 | } 92 | ] 93 | } 94 | ` 95 | 96 | const result = await parseManifest(html, () => manifest) 97 | 98 | expect(result).toMatchObject([ 99 | { 100 | url: '/path/to/icon' 101 | , reference: 'manifest' 102 | , type: null 103 | , size: { width: 48, height: 48 } 104 | } 105 | ]) 106 | }) 107 | 108 | test('invalid manifest', async () => { 109 | const html = ` 110 | 111 | ` 112 | const manifest = dedent`{}` 113 | 114 | const result = await parseManifest(html, () => manifest) 115 | 116 | expect(result).toMatchObject([]) 117 | }) 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /__tests__/parse-mask-icons.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseMaskIcons } from '@src/parse-mask-icons.js' 2 | 3 | describe('parseMackIcons', () => { 4 | test('basic', () => { 5 | const html = ` 6 | 7 | 8 | ` 9 | 10 | const result = parseMaskIcons(html) 11 | 12 | expect(result).toMatchObject([ 13 | { 14 | url: 'path/to/icon.svg' 15 | , reference: 'mask-icon' 16 | , type: null 17 | , size: null 18 | } 19 | ]) 20 | }) 21 | 22 | test('type', () => { 23 | const html = ` 24 | 25 | 26 | ` 27 | 28 | const result = parseMaskIcons(html) 29 | 30 | expect(result).toMatchObject([ 31 | { 32 | url: 'path/to/icon-1.svg' 33 | , reference: 'mask-icon' 34 | , type: null 35 | , size: null 36 | } 37 | , { 38 | url: 'path/to/icon-2.svg' 39 | , reference: 'mask-icon' 40 | , type: 'image/svg+xml' 41 | , size: null 42 | } 43 | ]) 44 | }) 45 | 46 | test('size', () => { 47 | const html = ` 48 | 49 | 50 | 51 | 52 | 53 | ` 54 | 55 | const result = parseMaskIcons(html) 56 | 57 | expect(result).toMatchObject([ 58 | { 59 | url: 'path/to/icon-1.svg' 60 | , reference: 'mask-icon' 61 | , type: null 62 | , size: null 63 | } 64 | , { 65 | url: 'path/to/icon-2.svg' 66 | , reference: 'mask-icon' 67 | , type: null 68 | , size: null 69 | } 70 | , { 71 | url: 'path/to/icon-3.svg' 72 | , reference: 'mask-icon' 73 | , type: null 74 | , size: { width: 128, height: 128 } 75 | } 76 | , { 77 | url: 'path/to/icon-4.svg' 78 | , reference: 'mask-icon' 79 | , type: null 80 | , size: [{ width: 128, height: 128 }, { width: 256, height: 256 }] 81 | } 82 | , { 83 | url: 'path/to/icon-5.svg' 84 | , reference: 'mask-icon' 85 | , type: null 86 | , size: 'any' 87 | } 88 | ]) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /__tests__/parse-windows8-tiles.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseWindows8Tiles } from '@src/parse-windows8-tiles.js' 2 | 3 | test('parseWindows8Tiles', () => { 4 | const html = ` 5 | 6 | 7 | ` 8 | 9 | const result = parseWindows8Tiles(html) 10 | 11 | expect(result).toMatchObject([ 12 | { 13 | url: 'path/to/icon.png' 14 | , reference: 'msapplication-TileImage' 15 | , type: null 16 | , size: null 17 | } 18 | ]) 19 | }) 20 | -------------------------------------------------------------------------------- /__tests__/utils/parse-image.spec.ts: -------------------------------------------------------------------------------- 1 | import { getErrorAsync } from 'return-style' 2 | import { parseImage, UnknownImageFormatError } from '@utils/parse-image.js' 3 | import * as path from 'path' 4 | import { promises as fs } from 'fs' 5 | import { fileURLToPath } from 'url' 6 | 7 | describe('parseImage', () => { 8 | describe('resource is a known image format', () => { 9 | test('resource is svg+xml', async () => { 10 | const buffer = await fetchBuffer('favicon.xml.svg') 11 | 12 | const result = await parseImage(buffer) 13 | 14 | expect(result).toStrictEqual({ 15 | type: 'image/svg+xml' 16 | , size: { width: 36 , height: 36 } 17 | }) 18 | }) 19 | 20 | test('resource is svg', async () => { 21 | const buffer = await fetchBuffer('favicon.svg') 22 | 23 | const result = await parseImage(buffer) 24 | 25 | expect(result).toStrictEqual({ 26 | type: 'image/svg+xml' 27 | , size: { width: 32 , height: 32 } 28 | }) 29 | }) 30 | 31 | test('resource is ico', async () => { 32 | const buffer = await fetchBuffer('favicon.ico') 33 | 34 | const result = await parseImage(buffer) 35 | 36 | expect(result).toStrictEqual({ 37 | type: 'image/x-icon' 38 | , size: [ 39 | { width: 16 , height: 16 } 40 | , { width: 32 , height: 32 } 41 | ] 42 | }) 43 | }) 44 | 45 | test('resource is duplicated-sizes.ico', async () => { 46 | const buffer = await fetchBuffer('duplicated-sizes.ico') 47 | 48 | const result = await parseImage(buffer) 49 | 50 | expect(result).toStrictEqual({ 51 | type: 'image/x-icon' 52 | , size: [ 53 | { width: 16 , height: 16 } 54 | , { width: 24 , height: 24 } 55 | , { width: 32 , height: 32 } 56 | , { width: 48 , height: 48 } 57 | , { width: 64 , height: 64 } 58 | ] 59 | }) 60 | }) 61 | 62 | test('resource is png', async () => { 63 | const buffer = await fetchBuffer('favicon.png') 64 | 65 | const result = await parseImage(buffer) 66 | 67 | expect(result).toStrictEqual({ 68 | type: 'image/png' 69 | , size: { width: 32 , height: 32 } 70 | }) 71 | }) 72 | }) 73 | 74 | test('resource isnt a known image format', async () => { 75 | const buffer = await fetchBuffer('favicon.txt') 76 | 77 | const err = await getErrorAsync(() => parseImage(buffer)) 78 | 79 | expect(err).toBeInstanceOf(UnknownImageFormatError) 80 | }) 81 | }) 82 | 83 | function getFixturePath(filename: string) { 84 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 85 | return path.join(__dirname, `../fixtures/${filename}`) 86 | } 87 | 88 | function fetchBuffer(path: string): Promise { 89 | return fs.readFile(getFixturePath(path)) 90 | } 91 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'] 3 | } 4 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require('ts-jest') 2 | const { compilerOptions } = require('./tsconfig.base.json') 3 | 4 | module.exports = { 5 | preset: 'ts-jest/presets/default-esm' 6 | , resolver: '@blackglory/jest-resolver' 7 | , testEnvironment: 'node' 8 | , testMatch: ['**/__tests__/**/?(*.)+(spec|test).[jt]s?(x)'] 9 | , moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 10 | prefix: '/' 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parse-favicon", 3 | "version": "7.0.2", 4 | "description": "Parse HTML to get icon information", 5 | "keywords": [ 6 | "favicon", 7 | "icon", 8 | "shortcut", 9 | "apple-touch-icon", 10 | "manifest", 11 | "fluid-icon", 12 | "mask-icon", 13 | "msapplication" 14 | ], 15 | "files": [ 16 | "lib", 17 | "src" 18 | ], 19 | "type": "module", 20 | "main": "lib/index.js", 21 | "types": "lib/index.d.ts", 22 | "sideEffects": false, 23 | "engines": { 24 | "node": ">=18.17.0" 25 | }, 26 | "repository": "git@github.com:BlackGlory/parse-favicon.git", 27 | "author": "BlackGlory ", 28 | "license": "MIT", 29 | "scripts": { 30 | "prepare": "ts-patch install -s", 31 | "lint": "eslint --ext .js,.jsx,.ts,.tsx --quiet src __tests__", 32 | "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --runInBand --no-cache --config jest.config.cjs", 33 | "test:debug": "cross-env NODE_OPTIONS=--experimental-vm-modules node --inspect-brk jest --runInBand --config jest.config.cjs", 34 | "test:coverage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage --config jest.config.cjs", 35 | "prepublishOnly": "run-s prepare clean build", 36 | "clean": "rimraf lib", 37 | "build": "tsc --project tsconfig.build.json", 38 | "release": "standard-version" 39 | }, 40 | "husky": { 41 | "hooks": { 42 | "pre-commit": "run-s prepare clean lint build test", 43 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 44 | } 45 | }, 46 | "devDependencies": { 47 | "@blackglory/jest-resolver": "^0.3.0", 48 | "@commitlint/cli": "^18.4.3", 49 | "@commitlint/config-conventional": "^18.4.3", 50 | "@types/jest": "^29.4.0", 51 | "@types/urijs": "^1.19.19", 52 | "@typescript-eslint/eslint-plugin": "^6.15.0", 53 | "@typescript-eslint/parser": "^6.15.0", 54 | "cross-env": "^7.0.3", 55 | "eslint": "^8.33.0", 56 | "extra-tags": "^0.4.2", 57 | "husky": "^4.3.8", 58 | "jest": "^29.4.2", 59 | "jest-resolve": "^29.4.2", 60 | "npm-run-all": "^4.1.5", 61 | "return-style": "^3.0.1", 62 | "rimraf": "^5.0.5", 63 | "standard-version": "^9.5.0", 64 | "ts-jest": "^29.0.5", 65 | "ts-patch": "^3.1.1", 66 | "typescript": "5.3.3", 67 | "typescript-transform-paths": "^3.4.6" 68 | }, 69 | "dependencies": { 70 | "@blackglory/errors": "^3.0.0", 71 | "@blackglory/prelude": "^0.3.1", 72 | "@blackglory/query": "^0.5.6", 73 | "extra-dom": "^0.6.1", 74 | "extra-promise": "^6.0.3", 75 | "extra-utils": "^5.0.1", 76 | "file-type": "^18.2.0", 77 | "image-size": "^1.0.2", 78 | "is-svg": "^5.0.0", 79 | "iterable-operator": "^4.0.3", 80 | "rxjs": "^7.8.0", 81 | "urijs": "^1.19.11" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { getResultAsync } from 'return-style' 2 | import { parseAppleTouchIcons } from '@src/parse-apple-touch-icons.js' 3 | import { parseFluidIcons } from '@src/parse-fluid-icons.js' 4 | import { parseIcons } from '@src/parse-icons.js' 5 | import { parseIEConfig } from '@src/parse-ie-config.js' 6 | import { parseIE11Tiles } from '@src/parse-ie11-tiles.js' 7 | import { parseManifest } from '@src/parse-manifest.js' 8 | import { parseMaskIcons } from '@src/parse-mask-icons.js' 9 | import { parseWindows8Tiles } from '@src/parse-windows8-tiles.js' 10 | import { parseImage, IImage } from '@utils/parse-image.js' 11 | import { Observable } from 'rxjs' 12 | import { flatten, each } from 'iterable-operator' 13 | import { IIcon, TextFetcher, BufferFetcher } from './types.js' 14 | import { Awaitable } from '@blackglory/prelude' 15 | export { IIcon, TextFetcher, BufferFetcher } from './types.js' 16 | 17 | export function parseFavicon( 18 | pageURL: string 19 | , _fetchText: TextFetcher 20 | , _fetchBuffer?: BufferFetcher 21 | ): Observable { 22 | const fetchText: TextFetcher = (url: string): Awaitable => { 23 | const absoluteURL = new URL(url, pageURL).href 24 | return _fetchText(absoluteURL) 25 | } 26 | const fetchBuffer: BufferFetcher | undefined = 27 | _fetchBuffer 28 | ? (url: string): Awaitable => { 29 | const absoluteURL = new URL(url, pageURL).href 30 | return _fetchBuffer(absoluteURL) 31 | } 32 | : undefined 33 | 34 | return new Observable(observer => { 35 | parse(icon => observer.next(icon)) 36 | .then(() => observer.complete()) 37 | .catch(err => observer.error(err)) 38 | }) 39 | 40 | async function parse(publish: (icon: IIcon) => void) { 41 | const html = await fetchText(pageURL) 42 | 43 | const icons = [ 44 | ...parseAppleTouchIcons(html) 45 | , ...parseFluidIcons(html) 46 | , ...parseIcons(html) 47 | , ...parseIE11Tiles(html) 48 | , ...parseMaskIcons(html) 49 | , ...parseWindows8Tiles(html) 50 | ] 51 | 52 | if (fetchBuffer) { 53 | const imagePromisePool = new Map>() 54 | 55 | icons.forEach(async icon => publish( 56 | await tryUpdateIcon(fetchBuffer, imagePromisePool, icon) 57 | )) 58 | 59 | const results = await Promise.all([ 60 | parseIEConfig(html, fetchText) 61 | , parseManifest(html, fetchText) 62 | ]) 63 | each(flatten(results), async icon => { 64 | publish(await tryUpdateIcon(fetchBuffer, imagePromisePool, icon)) 65 | }) 66 | 67 | getDefaultIconUrls().forEach(async url => { 68 | if (!imagePromisePool.has(url)) { 69 | imagePromisePool.set(url, fetchImage(fetchBuffer, url)) 70 | } 71 | const image = await imagePromisePool.get(url) 72 | if (image) { 73 | publish({ 74 | url 75 | , reference: url 76 | , type: image.type 77 | , size: image.size 78 | }) 79 | } 80 | }) 81 | 82 | await Promise.all(imagePromisePool.values()) 83 | } else { 84 | icons.forEach(publish) 85 | 86 | const results = await Promise.all([ 87 | parseIEConfig(html, fetchText) 88 | , parseManifest(html, fetchText) 89 | ]) 90 | each(flatten(results), publish) 91 | } 92 | } 93 | 94 | async function tryUpdateIcon( 95 | fetchBuffer: BufferFetcher 96 | , imagePromisePool: Map> 97 | , icon: IIcon 98 | ): Promise { 99 | if (!imagePromisePool.has(icon.url)) { 100 | imagePromisePool.set( 101 | icon.url 102 | , fetchImage(fetchBuffer, new URL(icon.url, pageURL).href) 103 | ) 104 | } 105 | const image = await imagePromisePool.get(icon.url) 106 | if (image) { 107 | return updateIcon(icon, image) 108 | } else { 109 | return icon 110 | } 111 | } 112 | 113 | async function fetchImage( 114 | fetchBuffer: BufferFetcher 115 | , url: string 116 | ): Promise { 117 | const arrayBuffer = await getResultAsync(() => fetchBuffer(url)) 118 | if (!arrayBuffer) return null 119 | const buffer = Buffer.from(arrayBuffer) 120 | 121 | try { 122 | return await parseImage(buffer) 123 | } catch { 124 | return null 125 | } 126 | } 127 | 128 | function updateIcon(icon: IIcon, image: IImage): IIcon { 129 | return { 130 | ...icon 131 | , type: image.type 132 | , size: image.size 133 | } 134 | } 135 | } 136 | 137 | function getDefaultIconUrls() { 138 | return [ 139 | '/favicon.ico' 140 | , '/apple-touch-icon-57x57-precomposed.png' 141 | , '/apple-touch-icon-57x57.png' 142 | , '/apple-touch-icon-72x72-precomposed.png' 143 | , '/apple-touch-icon-72x72.png' 144 | , '/apple-touch-icon-114x114-precomposed.png' 145 | , '/apple-touch-icon-114x114.png' 146 | , '/apple-touch-icon-120x120-precomposed.png' 147 | , '/apple-touch-icon-120x120.png' 148 | , '/apple-touch-icon-144x144-precomposed.png' 149 | , '/apple-touch-icon-144x144.png' 150 | , '/apple-touch-icon-152x152-precomposed.png' 151 | , '/apple-touch-icon-152x152.png' 152 | , '/apple-touch-icon-180x180-precomposed.png' 153 | , '/apple-touch-icon-180x180.png' 154 | , '/apple-touch-icon-precomposed.png' 155 | , '/apple-touch-icon.png' 156 | ] 157 | } 158 | -------------------------------------------------------------------------------- /src/parse-apple-touch-icons.ts: -------------------------------------------------------------------------------- 1 | import { parseHTML } from '@utils/parse-html.js' 2 | import { extractIconsFromLinkElements } from '@utils/link-element-utils.js' 3 | import { IIcon } from '@src/types.js' 4 | 5 | export function parseAppleTouchIcons(html: string): IIcon[] { 6 | const document = parseHTML(html) 7 | 8 | return [ 9 | ...extractIconsFromLinkElements( 10 | document 11 | , 'link[rel="apple-touch-icon"]' 12 | , 'apple-touch-icon' 13 | ) 14 | , ...extractIconsFromLinkElements( 15 | document 16 | , 'link[rel="apple-touch-icon-precomposed"]' 17 | , 'apple-touch-icon-precomposed' 18 | ) 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/parse-fluid-icons.ts: -------------------------------------------------------------------------------- 1 | import { parseHTML } from '@utils/parse-html.js' 2 | import { extractIconsFromLinkElements } from '@utils/link-element-utils.js' 3 | import { IIcon } from '@src/types.js' 4 | 5 | export function parseFluidIcons(html: string): IIcon[] { 6 | const document = parseHTML(html) 7 | 8 | return extractIconsFromLinkElements( 9 | document 10 | , 'link[rel="fluid-icon"]' 11 | , 'fluid-icon' 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/parse-icons.ts: -------------------------------------------------------------------------------- 1 | import { parseHTML } from '@utils/parse-html.js' 2 | import { extractIconsFromLinkElements } from '@utils/link-element-utils.js' 3 | import { IIcon } from '@src/types.js' 4 | 5 | export function parseIcons(html: string): IIcon[] { 6 | const document = parseHTML(html) 7 | 8 | return extractIconsFromLinkElements( 9 | document 10 | , 'link[rel~="icon"]' 11 | , 'icon' 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/parse-ie-config.ts: -------------------------------------------------------------------------------- 1 | import { queryAll, xpath, css } from '@blackglory/query' 2 | import { map } from 'extra-promise' 3 | import * as Iter from 'iterable-operator' 4 | import { pipe } from 'extra-utils' 5 | import { parseHTML } from '@utils/parse-html.js' 6 | import { parseXML } from '@utils/parse-xml.js' 7 | import { isURLString } from '@utils/is-url-string.js' 8 | import { mergeRelativeURLs } from '@utils/merge-relative-urls.js' 9 | import { extractAttributes } from '@utils/extract-attributes.js' 10 | import { IIcon, TextFetcher } from '@src/types.js' 11 | import { isElement } from 'extra-dom' 12 | import { flatten, toArray } from 'iterable-operator' 13 | import { getResultAsync } from 'return-style' 14 | 15 | export async function parseIEConfig( 16 | html: string 17 | , fetchText: TextFetcher 18 | ): Promise { 19 | const document = parseHTML(html) 20 | const configUrls = extractConfigURLs(document) 21 | const icons = await map(configUrls, extractIconsFromURL) 22 | return toArray(flatten(icons)) 23 | 24 | async function extractIconsFromURL(url: string): Promise { 25 | const text = await getResultAsync(() => fetchText(url)) 26 | if (text) { 27 | return parseIEConfigIcons(text, url) 28 | } else { 29 | return [] 30 | } 31 | } 32 | } 33 | 34 | function extractConfigURLs(document: Document): string[] { 35 | const elements = queryAll.call( 36 | document 37 | , css`meta[name="msapplication-config"]` 38 | ) as Element[] 39 | 40 | return pipe( 41 | elements 42 | , elements => extractAttributes(elements, 'content') 43 | , contents => Iter.filter(contents, isURLString) 44 | , toArray 45 | ) 46 | } 47 | 48 | function parseIEConfigIcons(xml: string, configUrl: string): IIcon[] { 49 | const document = parseXML(xml) 50 | return [ 51 | ...extractIEConfigIcons( 52 | document 53 | , '/browserconfig/msapplication/tile/square70x70logo' 54 | , { width: 70, height: 70 } 55 | ) 56 | , ...extractIEConfigIcons( 57 | document 58 | , '/browserconfig/msapplication/tile/square150x150logo' 59 | , { width: 150, height: 150 } 60 | ) 61 | , ...extractIEConfigIcons( 62 | document 63 | , '/browserconfig/msapplication/tile/wide310x150logo' 64 | , { width: 310, height: 150 } 65 | ) 66 | , ...extractIEConfigIcons( 67 | document 68 | , '/browserconfig/msapplication/tile/square310x310logo' 69 | , { width: 310, height: 310 } 70 | ) 71 | ].map(combineIconUrlWithConfigUrl) 72 | 73 | function combineIconUrlWithConfigUrl(icon: IIcon): IIcon { 74 | return { 75 | ...icon 76 | , url: mergeRelativeURLs(configUrl, icon.url) 77 | } 78 | } 79 | } 80 | 81 | function extractIEConfigIcons( 82 | document: Document 83 | , selector: string 84 | , size: { width: number, height: number } 85 | ): IIcon[] { 86 | const nodes = queryAll.call(document, xpath`.${selector}`) as Node[] 87 | 88 | return pipe( 89 | nodes 90 | , nodes => Iter.filter(nodes, isElement) 91 | , elements => extractAttributes(elements, 'src') 92 | , srcURLs => Iter.map(srcURLs, url => createIcon(url, size)) 93 | , toArray 94 | ) 95 | 96 | function createIcon(url: string, size: { width: number, height: number }): IIcon { 97 | return { 98 | reference: 'msapplication-config' 99 | , url 100 | , type: null 101 | , size 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/parse-ie11-tiles.ts: -------------------------------------------------------------------------------- 1 | import { queryAll, css } from '@blackglory/query' 2 | import { filter, map, toArray } from 'iterable-operator' 3 | import { pipe } from 'extra-utils' 4 | import { parseHTML } from '@utils/parse-html.js' 5 | import { extractAttributes } from '@utils/extract-attributes.js' 6 | import { IIcon } from '@src/types.js' 7 | import { isURLString } from '@utils/is-url-string.js' 8 | 9 | export function parseIE11Tiles(html: string): IIcon[] { 10 | const document = parseHTML(html) 11 | 12 | return [ 13 | ...extractIE11TileIcons( 14 | document 15 | , 'meta[name="msapplication-square70x70logo"]' 16 | , 'msapplication-square70x70logo' 17 | , { width: 70, height: 70 } 18 | ) 19 | , ...extractIE11TileIcons( 20 | document 21 | , 'meta[name="msapplication-square150x150logo"]' 22 | , 'msapplication-square150x150logo' 23 | , { width: 150, height: 150 } 24 | ) 25 | , ...extractIE11TileIcons( 26 | document 27 | , 'meta[name="msapplication-wide310x150logo"]' 28 | , 'msapplication-wide310x150logo' 29 | , { width: 310, height: 150 } 30 | ) 31 | , ...extractIE11TileIcons( 32 | document 33 | , 'meta[name="msapplication-square310x310logo"]' 34 | , 'msapplication-square310x310logo' 35 | , { width: 310, height: 310 } 36 | ) 37 | ] 38 | } 39 | 40 | function extractIE11TileIcons( 41 | document: Document 42 | , selector: string 43 | , reference: string 44 | , size: { width: number; height: number } 45 | ): IIcon[] { 46 | const elements = queryAll.call(document, css`${selector}`) as Element[] 47 | 48 | return pipe( 49 | elements 50 | , elements => extractAttributes(elements, 'content') 51 | , contents => filter(contents, isURLString) 52 | , urls => map(urls, url => createIcon(reference, url, size)) 53 | , toArray 54 | ) 55 | 56 | function createIcon( 57 | reference: string 58 | , url: string 59 | , size: { width: number; height: number } 60 | ): IIcon { 61 | return { 62 | reference 63 | , url 64 | , type: null 65 | , size 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/parse-manifest.ts: -------------------------------------------------------------------------------- 1 | import { queryAll, css } from '@blackglory/query' 2 | import { map } from 'extra-promise' 3 | import * as Iter from 'iterable-operator' 4 | import { pipe } from 'extra-utils' 5 | import { parseHTML } from '@utils/parse-html.js' 6 | import { isURLString } from '@utils/is-url-string.js' 7 | import { mergeRelativeURLs } from '@utils/merge-relative-urls.js' 8 | import { parseSpaceSeparatedSizes } from '@utils/parse-space-separated-sizes.js' 9 | import { IIcon, TextFetcher } from '@src/types.js' 10 | import { extractAttributes } from '@utils/extract-attributes.js' 11 | import { isObject, isArray, isString } from '@blackglory/prelude' 12 | import { getResultAsync } from 'return-style' 13 | 14 | interface IManifest { 15 | icons: Array<{ 16 | src: string 17 | sizes: string 18 | type?: string 19 | }> 20 | } 21 | 22 | export async function parseManifest( 23 | html: string 24 | , fetchText: TextFetcher 25 | ): Promise { 26 | const document = parseHTML(html) 27 | const manifestUrls = extractManifestURLs(document) 28 | const results = await map(manifestUrls, async url => { 29 | const text = await getResultAsync(() => fetchText(url)) 30 | if (text) { 31 | return extractManifestIcons(text, url) 32 | } else { 33 | return [] 34 | } 35 | }) 36 | return ([] as IIcon[]).concat(...results) 37 | } 38 | 39 | function isManifest(val: unknown): val is IManifest { 40 | return isObject(val) 41 | && 'icons' in val 42 | && isArray(val.icons) 43 | && val.icons.every(icon => { 44 | return isObject(icon) 45 | && ('src' in icon && isString(icon.src)) 46 | && ('sizes' in icon && isString(icon.sizes)) 47 | && ( 48 | !('type' in icon) || 49 | ('type' in icon && isString(icon.type)) 50 | ) 51 | }) 52 | } 53 | 54 | function extractManifestURLs(document: Document): string[] { 55 | const links = queryAll.call(document, css`link[rel="manifest"]`) as HTMLLinkElement[] 56 | 57 | return pipe( 58 | links 59 | , links => extractAttributes(links, 'href') 60 | , urls => Iter.filter(urls, isURLString) 61 | , Iter.toArray 62 | ) 63 | } 64 | 65 | function extractManifestIcons(json: string, baseURI: string): IIcon[] { 66 | const manifest = JSON.parse(json) 67 | 68 | if (isManifest(manifest)) { 69 | return manifest.icons 70 | .map(icon => createManifestIcon(icon.src, parseSpaceSeparatedSizes(icon.sizes), icon.type)) 71 | .map(combineIconUrlWithManifestUrl) 72 | } else { 73 | return [] 74 | } 75 | 76 | function combineIconUrlWithManifestUrl(icon: IIcon): IIcon { 77 | return { 78 | ...icon 79 | , url: combineRelativeUrlsForManifest(baseURI, icon.url) 80 | } 81 | } 82 | 83 | function createManifestIcon( 84 | url: string 85 | , sizes: Array<{ width: number, height: number }> 86 | , type?: string 87 | ): IIcon { 88 | return { 89 | url 90 | , reference: 'manifest' 91 | , type: type ?? null 92 | , size: createSize() 93 | } 94 | 95 | function createSize(): IIcon['size'] { 96 | if (sizes.length === 0) return null 97 | if (sizes.length === 1) return sizes[0] 98 | return sizes 99 | } 100 | } 101 | } 102 | 103 | function combineRelativeUrlsForManifest(baseURI: string, relativeUrl: string): string { 104 | return mergeRelativeURLs(baseURI, relativeUrl) 105 | } 106 | -------------------------------------------------------------------------------- /src/parse-mask-icons.ts: -------------------------------------------------------------------------------- 1 | import { parseHTML } from '@utils/parse-html.js' 2 | import { extractIconsFromLinkElements } from '@utils/link-element-utils.js' 3 | import { IIcon } from '@src/types.js' 4 | 5 | export function parseMaskIcons(html: string): IIcon[] { 6 | const document = parseHTML(html) 7 | return extractIconsFromLinkElements( 8 | document 9 | , 'link[rel="mask-icon"]' 10 | , 'mask-icon' 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/parse-windows8-tiles.ts: -------------------------------------------------------------------------------- 1 | import { queryAll, css } from '@blackglory/query' 2 | import { filter, map, toArray } from 'iterable-operator' 3 | import { pipe } from 'extra-utils' 4 | import { parseHTML } from '@utils/parse-html.js' 5 | import { extractAttributes } from '@utils/extract-attributes.js' 6 | import { isURLString } from '@utils/is-url-string.js' 7 | import { IIcon } from '@src/types.js' 8 | 9 | export function parseWindows8Tiles(html: string): IIcon[] { 10 | const document = parseHTML(html) 11 | return extractWindows8TileIcons(document) 12 | } 13 | 14 | function extractWindows8TileIcons(document: Document): IIcon[] { 15 | const elements = queryAll.call( 16 | document 17 | , css`meta[name="msapplication-TileImage"]` 18 | ) as Element[] 19 | 20 | return pipe( 21 | elements 22 | , elements => extractAttributes(elements, 'content') 23 | , contents => filter(contents, isURLString) 24 | , urls => map(urls, createWindows8TileIcon) 25 | , toArray 26 | ) 27 | 28 | function createWindows8TileIcon(url: string): IIcon { 29 | return { 30 | url 31 | , reference: 'msapplication-TileImage' 32 | , type: null 33 | , size: null 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Awaitable } from '@blackglory/prelude' 2 | 3 | export interface IIcon { 4 | url: string 5 | reference: string 6 | type: null | string 7 | size: null | 'any' | ISize | ISize[] 8 | } 9 | 10 | export interface ISize { 11 | width: number 12 | height: number 13 | } 14 | 15 | export type TextFetcher = (url: string) => Awaitable 16 | export type BufferFetcher = (url: string) => Awaitable 17 | -------------------------------------------------------------------------------- /src/utils/extract-attributes.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from 'extra-utils' 2 | import { map, filter } from 'iterable-operator' 3 | 4 | export function extractAttributes( 5 | elements: Iterable 6 | , attributeName: string 7 | ): IterableIterator { 8 | return pipe( 9 | elements 10 | , elements => map(elements, element => element.getAttribute(attributeName)) 11 | , attributeValue => filter(attributeValue, isTruthy) 12 | ) 13 | } 14 | 15 | function isTruthy(val: unknown): boolean { 16 | return !!val 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/is-url-string.ts: -------------------------------------------------------------------------------- 1 | import { isSuccess } from 'return-style' 2 | import { isString } from '@blackglory/prelude' 3 | 4 | export function isURLString(val: unknown): boolean { 5 | const base = 'http://localhost' 6 | return isString(val) 7 | && isSuccess(() => new URL(val.toString(), base)) 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/link-element-utils.ts: -------------------------------------------------------------------------------- 1 | import { queryAll, css } from '@blackglory/query' 2 | import { filter, map, toArray } from 'iterable-operator' 3 | import { pipe } from 'extra-utils' 4 | import { parseSpaceSeparatedSizes } from '@utils/parse-space-separated-sizes.js' 5 | import { IIcon } from '@src/types.js' 6 | 7 | /** 8 | * @param reference 表示该图标的来源, 例如`apple-touch-icon`, 没有太大实际意义. 9 | */ 10 | export function extractIconsFromLinkElements( 11 | document: Document 12 | , linkElementSelector: string 13 | , reference: string 14 | ): IIcon[] { 15 | const links = queryAll.call(document, css`${linkElementSelector}`) as HTMLLinkElement[] 16 | 17 | return pipe( 18 | links 19 | , links => filter(links, hasHref) 20 | , links => map(links, x => createIcon(reference, getHref(x)!, getType(x), getSize(x))) 21 | , toArray 22 | ) 23 | } 24 | 25 | function getSize(element: HTMLLinkElement): IIcon['size'] { 26 | const sizes = element.getAttribute('sizes') 27 | if (sizes) { 28 | if (sizes === 'any') return 'any' 29 | const results = parseSpaceSeparatedSizes(sizes) 30 | if (results.length === 0) return null 31 | if (results.length === 1) return results[0] 32 | return results 33 | } 34 | return null 35 | } 36 | 37 | function getHref(element: HTMLLinkElement): string | null{ 38 | return element.getAttribute('href') || null 39 | } 40 | 41 | function getType(element: HTMLLinkElement): string | null { 42 | return element.getAttribute('type') || null 43 | } 44 | 45 | function hasHref(element: HTMLLinkElement): boolean { 46 | return !!element.getAttribute('href') 47 | } 48 | 49 | function createIcon( 50 | reference: string 51 | , url: string 52 | , type: string | null 53 | , size: IIcon['size'] | null 54 | ): IIcon { 55 | return { reference, url, type, size } 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/merge-relative-urls.ts: -------------------------------------------------------------------------------- 1 | import URI from 'urijs' 2 | 3 | /** 4 | * `new URL(url, base)`支持不了base是一个相对URL的情况, 而`urijs`可以. 5 | */ 6 | export function mergeRelativeURLs(baseURI: string, relativeUrl: string): string { 7 | return new URI(relativeUrl) 8 | .absoluteTo(baseURI) 9 | .href() 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/parse-html.ts: -------------------------------------------------------------------------------- 1 | import { createDOMParser } from 'extra-dom' 2 | 3 | export function parseHTML(html: string): Document { 4 | const parser = createDOMParser() 5 | return parser.parseFromString(html, 'text/html') 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/parse-image.ts: -------------------------------------------------------------------------------- 1 | import { fileTypeFromBuffer } from 'file-type' 2 | import { imageSize } from 'image-size' 3 | import isSvg from 'is-svg' 4 | import { ISize } from '@src/types.js' 5 | import { map, uniqBy, toArray, filter } from 'iterable-operator' 6 | import { isntUndefined, pipe } from 'extra-utils' 7 | import { CustomError } from '@blackglory/errors' 8 | 9 | export interface IImage { 10 | type: string 11 | size: 'any' | ISize | ISize[] 12 | } 13 | 14 | export class UnknownImageFormatError extends CustomError {} 15 | 16 | export async function parseImage(buffer: Buffer): Promise { 17 | const type = await fileTypeFromBuffer(buffer) 18 | if (type) { 19 | if (isImage(type.mime)) { 20 | return { 21 | type: type.mime 22 | , size: parseImageSize(buffer) 23 | } 24 | } 25 | if (isXML(type.mime) && isSvg(buffer.toString('utf-8'))) { 26 | return parseAsSvg(buffer) 27 | } 28 | } else { 29 | if (isSvg(buffer.toString('utf-8'))) { 30 | return parseAsSvg(buffer) 31 | } 32 | } 33 | throw new UnknownImageFormatError() 34 | } 35 | 36 | function parseAsSvg(buffer: Buffer): IImage { 37 | return { 38 | type: 'image/svg+xml' 39 | , size: parseImageSize(buffer) 40 | } 41 | } 42 | 43 | function parseImageSize(buffer: Buffer): IImage['size'] { 44 | const result = imageSize(buffer) 45 | if (result.images) { 46 | return pipe( 47 | result.images 48 | , imageSizes => filter(imageSizes, x => { 49 | return isntUndefined(x.width) 50 | && isntUndefined(x.height) 51 | }) 52 | , imageSizes => map(imageSizes, x => { 53 | return { 54 | width: x.width! 55 | , height: x.height! 56 | } 57 | }) 58 | , xs => uniqBy(xs, sizeToString) 59 | , toArray 60 | ) 61 | } else { 62 | return { 63 | width: result.width! 64 | , height: result.height! 65 | } 66 | } 67 | 68 | function sizeToString(size: { width: number; height: number }): string { 69 | return `${size.width}x${size.height}` 70 | } 71 | } 72 | 73 | function isXML(mime: string): boolean { 74 | return mime === 'application/xml' 75 | || mime === 'text/xml' 76 | } 77 | 78 | function isImage(mime: string): boolean { 79 | return mime.startsWith('image') 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/parse-space-separated-sizes.ts: -------------------------------------------------------------------------------- 1 | import { map, toArray } from 'iterable-operator' 2 | import { pipe } from 'extra-utils' 3 | import { ISize } from '@src/types.js' 4 | 5 | /** 6 | * @param sizes 例子`32x32`, `32x32 64x64` 7 | */ 8 | export function parseSpaceSeparatedSizes(sizes: string): ISize[] { 9 | if (/^\d+[xX]\d+(?:\s+\d+[xX]\d+)*$/.test(sizes)) { 10 | const re = /(?\d+)[x|X](?\d+)/g 11 | const matches = sizes.matchAll(re) 12 | 13 | return pipe( 14 | matches 15 | , matches => map(matches, match => { 16 | const width = Number.parseInt(match.groups!.width, 10) 17 | const height = Number.parseInt(match.groups!.height, 10) 18 | return { width, height } 19 | }) 20 | , toArray 21 | ) 22 | } else { 23 | return [] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/parse-xml.ts: -------------------------------------------------------------------------------- 1 | import { createDOMParser } from 'extra-dom' 2 | 3 | export function parseXML(xml: string): Document { 4 | const parser = createDOMParser() 5 | return parser.parseFromString(xml, 'text/xml') 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018" 4 | , "module": "NodeNext" 5 | , "moduleResolution": "NodeNext" 6 | , "esModuleInterop": true 7 | , "skipLibCheck": true 8 | , "strict": true 9 | , "lib": ["DOM"] 10 | , "noUnusedLocals": true 11 | , "noUnusedParameters": true 12 | , "baseUrl": "." 13 | , "paths": { 14 | "@src/*": ["src/*"] 15 | , "@test/*": ["__tests__/*"] 16 | , "@utils/*": ["src/utils/*"] 17 | } 18 | , "plugins": [ 19 | { "transform": "typescript-transform-paths" } 20 | , { "transform": "typescript-transform-paths", "afterDeclarations": true } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json" 3 | , "compilerOptions": { 4 | "declaration": true 5 | , "removeComments": true 6 | , "sourceMap": true 7 | , "outDir": "lib" 8 | } 9 | , "include": [ 10 | "src" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json" 3 | , "include": [ 4 | "src" 5 | , "__tests__" 6 | ] 7 | , "exclude": [ 8 | "node_modules" 9 | , "lib" 10 | ] 11 | } 12 | --------------------------------------------------------------------------------