├── .assetWrapper.js ├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .husky ├── .gitignore └── commit-msg ├── .prettierignore ├── .releaserc.yml ├── .vscode ├── settings.json └── tasks.json ├── README.md ├── examples ├── google.svg └── hackernews.svg ├── images ├── icon_128.png ├── icon_256.png ├── large_promo_tile_920.png ├── logos.ai ├── marquee_promo_tile_1400.png ├── screenshot.png └── small_promo_tile_440.png ├── manifest.json ├── package-lock.json ├── package.json ├── prettier.config.js ├── renovate.json ├── src ├── background.ts ├── content.ts ├── minify.ts ├── polyfill.ts ├── popup-dark.css ├── popup.css ├── popup.html ├── popup.ts ├── shared.ts ├── types │ ├── clipboard.d.ts │ ├── svgo │ │ └── index.d.ts │ └── webextension-polyfill │ │ └── index.d.ts └── util.ts └── tsconfig.json /.assetWrapper.js: -------------------------------------------------------------------------------- 1 | // Needed for Firefox so result of content script is structured-clonable. 2 | module.exports = ({ name }) => (name.split('/').pop() == 'content.js' ? { footer: ', undefined' } : undefined) 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | end_of_line = lf 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | charset = utf-8 7 | 8 | [*.yml] 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sourcegraph/eslint-config", 3 | "parserOptions": { 4 | "project": "tsconfig.json" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | FORCE_COLOR: 3 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: '15.2.0' 17 | - run: npm ci 18 | - run: npm run prettier 19 | - run: npm run typecheck 20 | - run: npm run eslint 21 | - run: npm run build 22 | # Upload a build for this branch to download and test 23 | - run: zip -r svg-screenshots-chrome.zip dist 24 | if: always() 25 | - name: Upload Chrome artifact 26 | if: always() 27 | uses: actions/upload-artifact@v2 28 | with: 29 | name: svg-screenshots-chrome 30 | path: svg-screenshots-chrome.zip 31 | - run: zip -r svg-screenshots-firefox.xpi dist 32 | if: always() 33 | - name: Upload Firefox artifact 34 | if: always() 35 | uses: actions/upload-artifact@v2 36 | with: 37 | name: svg-screenshots-firefox 38 | path: svg-screenshots-firefox.xpi 39 | # Release to Chrome and Firefox store on release branch 40 | - name: release 41 | if: github.event_name == 'push' && github.repository_owner == 'felixfbecker' && github.ref == 'refs/heads/release' 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | # Google web store 45 | GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} 46 | GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} 47 | GOOGLE_REFRESH_TOKEN: ${{ secrets.GOOGLE_REFRESH_TOKEN }} 48 | # Firefox addons store 49 | FIREFOX_EMAIL: ${{ secrets.FIREFOX_EMAIL }} 50 | FIREFOX_PASSWORD: ${{ secrets.FIREFOX_PASSWORD }} 51 | FIREFOX_TOTP_SECRET: ${{ secrets.FIREFOX_TOTP_SECRET }} 52 | run: npm run semantic-release 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .cache/ 4 | .DS_Store 5 | *.zip 6 | web-ext-artifacts/ 7 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .cache/ 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /.releaserc.yml: -------------------------------------------------------------------------------- 1 | branches: 2 | - release 3 | verifyConditions: 4 | - 'semantic-release-chrome' 5 | - 'semantic-release-firefox' 6 | - '@semantic-release/github' 7 | prepare: 8 | # Create Chrome ZIP 9 | - path: semantic-release-chrome 10 | distFolder: &distFolder dist 11 | asset: &chromeZipPath svg-screenshots-chrome.zip 12 | # Create Firefox xpi and zip sources 13 | - path: semantic-release-firefox 14 | distFolder: *distFolder 15 | xpiPath: &firefoxXpiPath svg-screenshots-firefox.xpi 16 | sourcesArchivePath: &sourcesZipPath sources.zip 17 | sourcesGlobOptions: 18 | dot: true # Files like .assetWrapper.js are needed to build 19 | ignore: 20 | - .cache 21 | - .cache/** 22 | - .git 23 | - .git/** 24 | - .github 25 | - .github 26 | - .github/** 27 | - .github/** 28 | - .npmrc # This is added by semantic-release and would cause npm install to fail for the reviewer because it references NPM_TOKEN 29 | - dist 30 | - dist/** 31 | - node_modules 32 | - node_modules/** 33 | - *sourcesZipPath 34 | - web-ext-artifacts 35 | - web-ext-artifacts/** 36 | publish: 37 | # Publish to GitHub releases for sideloading 38 | - path: '@semantic-release/github' 39 | assets: 40 | - path: *chromeZipPath 41 | - path: *firefoxXpiPath 42 | # Publish to Mozilla Add-on store 43 | - path: semantic-release-firefox 44 | xpiPath: *firefoxXpiPath 45 | distFolder: *distFolder 46 | addOnSlug: svg-screenshots 47 | notesToReviewer: | 48 | This extension is open source at https://github.com/felixfbecker/svg-screenshots 49 | 50 | Needed to build: 51 | NodeJS ^15.2.0 52 | NPM ^7.0.8 53 | 54 | Install dependencies with `npm install`. 55 | To build the extension into the dist/ folder run: npm run build 56 | To then build an extension ZIP for Firefox, run afterwards: npm run firefox:build 57 | The extension ZIP will be placed in `web-ext-artifacts/`. 58 | 59 | The extension contributes an action button that opens a popup. From there, you can select further actions to take a screenshot of the current page (Example: www.google.com). 60 | 61 | Permissions: 62 | - The extension needs the all_urls permission because it needs to fetch images and fonts from webpages in the background page to inline them as Base64 into the SVG. 63 | - It uses `storage` to persist user settings. 64 | 65 | The warning about usage of the `Function` constructor is a popular polyfill for Function.prototype.bind in the dependency chain. 66 | # Publish to the Chrome web store 67 | - path: semantic-release-chrome 68 | asset: *chromeZipPath 69 | extensionId: nfakpcpmhhilkdpphcjgnokknpbpdllg 70 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "npm.packageManager": "npm", 4 | "json.schemas": [ 5 | { 6 | "fileMatch": ["manifest.json"], 7 | "url": "https://json.schemastore.org/webextension", 8 | }, 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "TypeScript watch", 6 | "detail": "Watch files and typecheck them on file changes", 7 | "type": "typescript", 8 | "tsconfig": "tsconfig.json", 9 | "option": "watch", 10 | "problemMatcher": ["$tsc-watch"], 11 | "isBackground": true, 12 | "group": "build", 13 | "runOptions": { 14 | "runOn": "folderOpen", 15 | }, 16 | }, 17 | { 18 | "label": "Parcel watch", 19 | "detail": "Watch files and build extension on file changes", 20 | "type": "npm", 21 | "script": "watch", 22 | "isBackground": true, 23 | "group": "build", 24 | "problemMatcher": [], 25 | "runOptions": { 26 | "runOn": "folderOpen", 27 | }, 28 | }, 29 | { 30 | "label": "Firefox watch", 31 | "detail": "Watch files and build Firefox extension ZIP on file changes", 32 | "type": "npm", 33 | "script": "firefox:watch", 34 | "group": "build", 35 | "problemMatcher": [], 36 | "isBackground": true, 37 | }, 38 | ], 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # SVG Screenshots Browser Extension 4 | 5 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/v/nfakpcpmhhilkdpphcjgnokknpbpdllg?logo=google-chrome&logoColor=white)](https://chrome.google.com/webstore/detail/svg-screenshot/nfakpcpmhhilkdpphcjgnokknpbpdllg) 6 | [![Firefox Add-on](https://img.shields.io/amo/v/svg-screenshots?logo=firefox&logoColor=white&label=firefox+add-on)](https://addons.mozilla.org/en-US/firefox/addon/svg-screenshots/) 7 | [![main build status](https://img.shields.io/github/actions/workflow/status/felixfbecker/svg-screenshots/build.yml?branch=main&label=main&logo=github)](https://github.com/felixfbecker/svg-screenshots/actions?query=branch%3Amain) 8 | [![release build status](https://img.shields.io/github/actions/workflow/status/felixfbecker/svg-screenshots/build.yml?branch=main&label=release&logo=github)](https://github.com/felixfbecker/svg-screenshots/actions/build.yml?query=branch%3Arelease) 9 | ![license: MIT](https://img.shields.io/github/license/felixfbecker/dom-to-svg) 10 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 11 | 12 | Browser extension to take semantic, scalable, accessible screenshots of websites, as SVG - as simple as taking a PNG screenshot. 13 | 14 | ## Why use this? 15 | 16 | SVG screenshots offer various benefits over normal PNG screenshots, while keeping the good parts: 17 | 18 | - **🖼 Flexible**: Freely select the region of the website you want to capture or capture the whole page. 19 | - **💢 Scalable graphics**: Screenshots never get pixelated when zooming in. 20 | - **📝 Semantic**: Text can still be selected and copied to clipboard. 21 | - **🦻 Accessible**: SVG is annotated with ARIA attributes and can be read by screen readers. 22 | - **🖥 Paste into design tools**: SVGs will work in design tools like Illustrator, Figma, Sketch etc. 23 | - **🔗 Interactive**: Links are still clickable. 24 | - **📦 Self-contained**: Inlines external resources like images, fonts, etc. 25 | - **📸 Static**: Styles and layout are recorded at the time of snapshot and will not change. 26 | - **🗜 Small**: Depending on the content, SVGs can be magnitudes smaller than PNGs and compress loslessly. 27 | - **🛡 Secure**: The SVG will not run any JavaScript. 28 | 29 | ## Install 30 | 31 | Install from the official extension stores: 32 | 33 | - [Chrome](https://chrome.google.com/webstore/detail/svg-screenshot/nfakpcpmhhilkdpphcjgnokknpbpdllg) 34 | - [Firefox](https://addons.mozilla.org/en-US/firefox/addon/svg-screenshots/) 35 | 36 | ## Examples 37 | 38 | These full-page SVG screenshots were taken with the browser extension: 39 | 40 | ![Google](examples/google.svg) 41 | 42 | ![Hacker News](examples/hackernews.svg) 43 | -------------------------------------------------------------------------------- /examples/google.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | About 37 | 38 | 39 | 40 | 41 | Store 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | Gmail 61 | 62 | 63 | 64 | 65 | 66 | 67 | Images 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | Sign in 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | Google Search 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | I'm Feeling Lucky 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | Search 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | Google offered in: 447 | 448 | 449 | 450 | Deutsch 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | Privacy 474 | 475 | 476 | 477 | 478 | Terms 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 497 | 498 | 499 | 500 | 501 | 502 | Advertising 503 | 504 | 505 | 506 | 507 | Business 508 | 509 | 510 | 511 | 512 | How Search works 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | Germany 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | Google uses cookies to deliver its services, to personalise ads and to analyse traffic. You can adjust your privacy controls at any time in your 534 | 535 | 536 | 537 | Google settings 538 | 539 | 540 | 541 | . 542 | 543 | 544 | 545 | 546 | Learn more 547 | 548 | 549 | 550 | 551 | Clicking Got it dismisses this notice. 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | Got it 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | -------------------------------------------------------------------------------- /images/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixfbecker/svg-screenshots/5682484e9747e0db4f63bcfe84695139fed292ef/images/icon_128.png -------------------------------------------------------------------------------- /images/icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixfbecker/svg-screenshots/5682484e9747e0db4f63bcfe84695139fed292ef/images/icon_256.png -------------------------------------------------------------------------------- /images/large_promo_tile_920.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixfbecker/svg-screenshots/5682484e9747e0db4f63bcfe84695139fed292ef/images/large_promo_tile_920.png -------------------------------------------------------------------------------- /images/logos.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixfbecker/svg-screenshots/5682484e9747e0db4f63bcfe84695139fed292ef/images/logos.ai -------------------------------------------------------------------------------- /images/marquee_promo_tile_1400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixfbecker/svg-screenshots/5682484e9747e0db4f63bcfe84695139fed292ef/images/marquee_promo_tile_1400.png -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixfbecker/svg-screenshots/5682484e9747e0db4f63bcfe84695139fed292ef/images/screenshot.png -------------------------------------------------------------------------------- /images/small_promo_tile_440.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixfbecker/svg-screenshots/5682484e9747e0db4f63bcfe84695139fed292ef/images/small_promo_tile_440.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "SVG Screenshot", 4 | "description": "Take scalable, semantic, accessible screenshots, in SVG format.", 5 | "version": "0.0.0", 6 | "permissions": ["activeTab", "", "storage"], 7 | "icons": { 8 | "128": "/images/icon_128.png", 9 | "256": "/images/icon_256.png" 10 | }, 11 | "web_accessible_resources": ["src/content.ts"], 12 | "browser_action": { 13 | "default_popup": "src/popup.html", 14 | "default_title": "Capture SVG screenshot", 15 | "default_icon": { 16 | "128": "/images/icon_128.png", 17 | "256": "/images/icon_256.png" 18 | } 19 | }, 20 | "background": { 21 | "scripts": ["src/background.ts"] 22 | }, 23 | "browser_specific_settings": { 24 | "gecko": { 25 | "id": "svg-screenshots@felixfbecker" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "engines": { 4 | "node": "^15.2.0", 5 | "npm": "^7.0.8" 6 | }, 7 | "scripts": { 8 | "prettier": "prettier --check '**/*.{yml,ts,json}'", 9 | "typecheck": "tsc -p .", 10 | "eslint": "eslint 'src/**/*.ts'", 11 | "build": "parcel build manifest.json --no-minify --no-source-maps", 12 | "watch": "parcel watch manifest.json --no-hmr --no-source-maps", 13 | "firefox:start": "web-ext run", 14 | "firefox:build": "web-ext build --overwrite-dest", 15 | "firefox:watch": "web-ext build --as-needed --overwrite-dest", 16 | "semantic-release": "semantic-release", 17 | "prepare": "husky install" 18 | }, 19 | "webExt": { 20 | "sourceDir": "./dist/" 21 | }, 22 | "browserslist": [ 23 | "last 3 Chrome versions", 24 | "last 3 Firefox versions" 25 | ], 26 | "commitlint": { 27 | "extends": [ 28 | "@commitlint/config-conventional" 29 | ] 30 | }, 31 | "dependencies": { 32 | "delay": "^5.0.0", 33 | "dom-to-svg": "^0.12.0", 34 | "file-saver": "^2.0.5", 35 | "pretty-bytes": "^5.4.1", 36 | "svgo": "^2.3.0", 37 | "webextension-polyfill": "^0.8.0", 38 | "xml-formatter": "^2.4.0" 39 | }, 40 | "devDependencies": { 41 | "@commitlint/cli": "^12.1.4", 42 | "@commitlint/config-conventional": "^12.1.4", 43 | "@semantic-release/github": "^7.2.3", 44 | "@sourcegraph/eslint-config": "^0.25.0", 45 | "@sourcegraph/prettierrc": "^3.0.3", 46 | "@types/file-saver": "^2.0.5", 47 | "@types/firefox-webext-browser": "^82.0.1", 48 | "eslint": "^7.27.0", 49 | "husky": "^6.0.0", 50 | "parcel-bundler": "^1.12.5", 51 | "parcel-plugin-web-extension": "^1.6.1", 52 | "parcel-plugin-wrapper": "^0.2.3", 53 | "prettier": "^2.2.1", 54 | "semantic-release": "^17.4.3", 55 | "semantic-release-chrome": "^1.1.3", 56 | "semantic-release-firefox": "^2.0.10", 57 | "typescript": "^4.2.4", 58 | "web-ext": "^6.1.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@sourcegraph/prettierrc') 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "timezone": "Europe/Berlin", 4 | "schedule": ["on the 1st through 7th day of the month"], 5 | "rangeStrategy": "bump", 6 | "semanticCommits": true, 7 | "prCreation": "not-pending", 8 | "masterIssue": true, 9 | "prHourlyLimit": 0, 10 | "node": { 11 | "supportPolicy": ["all"], 12 | "major": { 13 | "enabled": true 14 | } 15 | }, 16 | "regexManagers": [ 17 | { 18 | "fileMatch": ["^.github/workflows/.+\\.ya?ml$"], 19 | "matchStrings": ["node-version: ['\"]?(?[^'\"]*)['\"]?"], 20 | "depNameTemplate": "node", 21 | "lookupNameTemplate": "nodejs/node", 22 | "datasourceTemplate": "github-tags", 23 | "versioningTemplate": "node" 24 | } 25 | ], 26 | "packageRules": [ 27 | { 28 | "packagePatterns": ["^@types/"], 29 | "automerge": true 30 | }, 31 | { 32 | "packageNames": ["dom-to-svg"], 33 | "updateTypes": ["minor"], 34 | "prCreation": "immediate", 35 | "schedule": [], 36 | "semanticCommitType": "feat" 37 | }, 38 | { 39 | "packageNames": ["dom-to-svg"], 40 | "prCreation": "immediate", 41 | "schedule": [] 42 | }, 43 | { 44 | "packageNames": ["semantic-release-firefox"], 45 | "prCreation": "immediate", 46 | "schedule": [] 47 | }, 48 | { 49 | "packageNames": ["node"], 50 | "extractVersion": "^v(?.*)$", 51 | "commitMessageTopic": "Node.js", 52 | "major": { 53 | "enabled": true 54 | } 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import './polyfill' 2 | 3 | import { inlineResources } from 'dom-to-svg' 4 | 5 | browser.runtime.onMessage.addListener(async (message, sender) => { 6 | const { method, payload } = message 7 | switch (method) { 8 | // Disable action while a page is capturing 9 | case 'started': { 10 | await browser.browserAction.disable(sender.tab!.id!) 11 | return 12 | } 13 | case 'finished': { 14 | await browser.browserAction.enable(sender.tab!.id!) 15 | return 16 | } 17 | case 'postProcessSVG': { 18 | return postProcessSVG(payload) 19 | } 20 | } 21 | }) 22 | 23 | async function postProcessSVG(svg: string): Promise { 24 | const svgDocument = new DOMParser().parseFromString(svg, 'image/svg+xml') 25 | const svgRootElement = svgDocument.documentElement as Element as SVGSVGElement 26 | // Append to DOM so SVG elements are attached to a window/have defaultView, so window.getComputedStyle() works 27 | // This is safe, the generated SVG contains no JavaScript and even if it did, the background page CSP disallows any external or inline scripts. 28 | document.body.prepend(svgRootElement) 29 | try { 30 | await inlineResources(svgRootElement) 31 | } finally { 32 | svgRootElement.remove() 33 | } 34 | return new XMLSerializer().serializeToString(svgRootElement) 35 | } 36 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | import './polyfill' 2 | 3 | import delay from 'delay' 4 | import { documentToSVG } from 'dom-to-svg' 5 | import { saveAs } from 'file-saver' 6 | import prettyBytes from 'pretty-bytes' 7 | import formatXML from 'xml-formatter' 8 | 9 | import { minifySvg } from './minify' 10 | import { applyDefaults, CaptureArea, Settings, SETTINGS_KEYS } from './shared' 11 | import { AbortError, svgNamespace, once } from './util' 12 | 13 | async function main(): Promise { 14 | console.log('Content script running') 15 | const captureMessage = once(browser.runtime.onMessage, message => message.method === 'capture') 16 | await browser.runtime.sendMessage({ method: 'started' }) 17 | try { 18 | const [ 19 | { 20 | payload: { area }, 21 | }, 22 | ] = await captureMessage 23 | await capture(area) 24 | } catch (error) { 25 | if (error?.name === 'AbortError') { 26 | return 27 | } 28 | console.error(error) 29 | const errorMessage = String(error.message) 30 | alert( 31 | `An error happened while capturing the page: ${errorMessage}\nCheck the developer console for more information.` 32 | ) 33 | } finally { 34 | await browser.runtime.sendMessage({ method: 'finished' }) 35 | } 36 | } 37 | 38 | /** 39 | * Captures the DOM as the user requested and downloads the result. 40 | */ 41 | async function capture(area: CaptureArea): Promise { 42 | console.log('Capturing', area) 43 | 44 | const captureArea = area === 'captureArea' ? await letUserSelectCaptureArea() : undefined 45 | 46 | document.documentElement.style.cursor = 'wait' 47 | try { 48 | // Give browser chance to render 49 | await delay(0) 50 | 51 | const settings = applyDefaults((await browser.storage.sync.get(SETTINGS_KEYS)) as Settings) 52 | 53 | const svgDocument = documentToSVG(document, { 54 | captureArea, 55 | keepLinks: settings.keepLinks, 56 | }) 57 | 58 | let svgString = new XMLSerializer().serializeToString(svgDocument) 59 | 60 | if (settings.inlineResources) { 61 | console.log('Inlining resources') 62 | // Do post-processing in the background page 63 | svgString = await browser.runtime.sendMessage({ 64 | method: 'postProcessSVG', 65 | payload: svgString, 66 | }) 67 | } 68 | 69 | if (settings.minifySvg) { 70 | console.log('Minifying') 71 | svgString = await minifySvg(svgString) 72 | } 73 | 74 | if (settings.prettyPrintSvg && !settings.minifySvg) { 75 | console.log('Pretty-printing SVG') 76 | svgString = formatXML(svgString) 77 | } 78 | 79 | const blob = new Blob([svgString], { type: 'image/svg+xml' }) 80 | console.log('SVG size after minification:', prettyBytes(blob.size)) 81 | if (settings.target === 'download') { 82 | console.log('Downloading') 83 | saveAs(blob, `${document.title.replace(/["'/]/g, '')} Screenshot.svg`) 84 | } else if (settings.target === 'clipboard') { 85 | console.log('Copying to clipboard') 86 | await navigator.clipboard.writeText(svgString) 87 | // const plainTextBlob = new Blob([svgString], { type: 'text/plain' }) 88 | // Copying image/svg+xml is not yet supported in Chrome and crashes the tab 89 | // await navigator.clipboard.write([ 90 | // new ClipboardItem({ 91 | // [blob.type]: blob, 92 | // 'text/plain': plainTextBlob, 93 | // }), 94 | // ]) 95 | } else if (settings.target === 'tab') { 96 | console.log('Opening in new tab') 97 | const url = window.URL.createObjectURL(blob) 98 | window.open(url, '_blank', 'noopener') 99 | } else { 100 | throw new Error(`Unexpected SVG target ${String(settings.target)}`) 101 | } 102 | } finally { 103 | document.documentElement.style.cursor = '' 104 | } 105 | } 106 | 107 | /** 108 | * Creates a UI to let the user select the capture area and returns the result. 109 | */ 110 | async function letUserSelectCaptureArea(): Promise { 111 | const { clientWidth, clientHeight } = document.documentElement 112 | 113 | const svgElement = document.createElementNS(svgNamespace, 'svg') 114 | svgElement.id = 'svg-screenshot-selector' 115 | svgElement.setAttribute('viewBox', `0 0 ${clientWidth} ${clientHeight}`) 116 | svgElement.style.position = 'fixed' 117 | svgElement.style.top = '0px' 118 | svgElement.style.left = '0px' 119 | svgElement.style.width = `${clientWidth}px` 120 | svgElement.style.height = `${clientHeight}px` 121 | svgElement.style.cursor = 'crosshair' 122 | svgElement.style.zIndex = '99999999' 123 | 124 | const backdrop = document.createElementNS(svgNamespace, 'rect') 125 | backdrop.setAttribute('x', '0') 126 | backdrop.setAttribute('y', '0') 127 | backdrop.setAttribute('width', clientWidth.toString()) 128 | backdrop.setAttribute('height', clientHeight.toString()) 129 | backdrop.setAttribute('fill', 'rgba(0, 0, 0, 0.5)') 130 | backdrop.setAttribute('mask', 'url(#svg-screenshot-cutout)') 131 | svgElement.append(backdrop) 132 | 133 | const mask = document.createElementNS(svgNamespace, 'mask') 134 | svgElement.prepend(mask) 135 | mask.id = 'svg-screenshot-cutout' 136 | 137 | const maskBackground = document.createElementNS(svgNamespace, 'rect') 138 | maskBackground.setAttribute('fill', 'white') 139 | maskBackground.setAttribute('x', '0') 140 | maskBackground.setAttribute('y', '0') 141 | maskBackground.setAttribute('width', clientWidth.toString()) 142 | maskBackground.setAttribute('height', clientHeight.toString()) 143 | mask.append(maskBackground) 144 | 145 | const maskCutout = document.createElementNS(svgNamespace, 'rect') 146 | maskCutout.setAttribute('fill', 'black') 147 | mask.append(maskCutout) 148 | 149 | let captureArea: DOMRectReadOnly 150 | try { 151 | await new Promise((resolve, reject) => { 152 | window.addEventListener('keyup', event => { 153 | if (event.key === 'Escape') { 154 | reject(new AbortError('Aborted with Escape')) 155 | } 156 | }) 157 | svgElement.addEventListener('mousedown', event => { 158 | event.preventDefault() 159 | const { clientX: startX, clientY: startY } = event 160 | svgElement.addEventListener('mousemove', event => { 161 | event.preventDefault() 162 | const positionX = Math.min(startX, event.clientX) 163 | const positionY = Math.min(startY, event.clientY) 164 | maskCutout.setAttribute('x', positionX.toString()) 165 | maskCutout.setAttribute('y', positionY.toString()) 166 | maskCutout.setAttribute('width', Math.abs(event.clientX - startX).toString()) 167 | maskCutout.setAttribute('height', Math.abs(event.clientY - startY).toString()) 168 | }) 169 | svgElement.addEventListener( 170 | 'mouseup', 171 | event => { 172 | event.preventDefault() 173 | resolve() 174 | }, 175 | { once: true } 176 | ) 177 | }) 178 | document.body.append(svgElement) 179 | }) 180 | // Note: Need to build the DOMRect from the properties, 181 | // getBoundingClientRect() returns collapsed rectangle in Firefox 182 | captureArea = new DOMRectReadOnly( 183 | maskCutout.x.baseVal.value, 184 | maskCutout.y.baseVal.value, 185 | maskCutout.width.baseVal.value, 186 | maskCutout.height.baseVal.value 187 | ) 188 | } finally { 189 | svgElement.remove() 190 | } 191 | 192 | return captureArea 193 | } 194 | 195 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 196 | main() 197 | -------------------------------------------------------------------------------- /src/minify.ts: -------------------------------------------------------------------------------- 1 | import { SvgoPlugin } from 'svgo' 2 | import js2svg from 'svgo/lib/svgo/js2svg' 3 | import plugins from 'svgo/lib/svgo/plugins' 4 | import svg2js from 'svgo/lib/svgo/svg2js' 5 | import cleanupAttrs from 'svgo/plugins/cleanupAttrs' 6 | import cleanupEnableBackground from 'svgo/plugins/cleanupEnableBackground' 7 | import cleanupIDs from 'svgo/plugins/cleanupIDs' 8 | import cleanupListOfValues from 'svgo/plugins/cleanupListOfValues' 9 | import cleanupNumericValues from 'svgo/plugins/cleanupNumericValues' 10 | import collapseGroups from 'svgo/plugins/collapseGroups' 11 | import convertColors from 'svgo/plugins/convertColors' 12 | import convertEllipseToCircle from 'svgo/plugins/convertEllipseToCircle' 13 | import convertPathData from 'svgo/plugins/convertPathData' 14 | import convertStyleToAttrs from 'svgo/plugins/convertStyleToAttrs' 15 | import convertTransform from 'svgo/plugins/convertTransform' 16 | import inlineStyles from 'svgo/plugins/inlineStyles' 17 | import mergePaths from 'svgo/plugins/mergePaths' 18 | import minifyStyles from 'svgo/plugins/minifyStyles' 19 | import moveElemsAttrsToGroup from 'svgo/plugins/moveElemsAttrsToGroup' 20 | import moveGroupAttrsToElems from 'svgo/plugins/moveGroupAttrsToElems' 21 | import removeAttrs from 'svgo/plugins/removeAttrs' 22 | import removeComments from 'svgo/plugins/removeComments' 23 | import removeDoctype from 'svgo/plugins/removeDoctype' 24 | import removeEditorsNSData from 'svgo/plugins/removeEditorsNSData' 25 | import removeEmptyAttrs from 'svgo/plugins/removeEmptyAttrs' 26 | import removeEmptyContainers from 'svgo/plugins/removeEmptyContainers' 27 | import removeEmptyText from 'svgo/plugins/removeEmptyText' 28 | import removeMetadata from 'svgo/plugins/removeMetadata' 29 | import removeNonInheritableGroupAttrs from 'svgo/plugins/removeNonInheritableGroupAttrs' 30 | import removeScriptElement from 'svgo/plugins/removeScriptElement' 31 | import removeUnknownsAndDefaults from 'svgo/plugins/removeUnknownsAndDefaults' 32 | import removeUselessDefs from 'svgo/plugins/removeUselessDefs' 33 | import removeUselessStrokeAndFill from 'svgo/plugins/removeUselessStrokeAndFill' 34 | import removeViewBox from 'svgo/plugins/removeViewBox' 35 | import removeXMLProcInst from 'svgo/plugins/removeXMLProcInst' 36 | import reusePaths from 'svgo/plugins/reusePaths' 37 | 38 | removeAttrs.params.attrs = ['data-.*', 'class'] 39 | 40 | const pluginsArray = [ 41 | removeDoctype, 42 | removeXMLProcInst, 43 | removeComments, 44 | removeMetadata, 45 | removeEditorsNSData, 46 | cleanupAttrs, 47 | inlineStyles, 48 | minifyStyles, 49 | convertStyleToAttrs, 50 | cleanupIDs, 51 | removeUselessDefs, 52 | cleanupNumericValues, 53 | cleanupListOfValues, 54 | convertColors, 55 | removeUnknownsAndDefaults, 56 | removeNonInheritableGroupAttrs, 57 | removeUselessStrokeAndFill, 58 | removeViewBox, 59 | cleanupEnableBackground, 60 | // Bug: removes 61 | // removeHiddenElems, 62 | removeEmptyText, 63 | moveElemsAttrsToGroup, 64 | moveGroupAttrsToElems, 65 | collapseGroups, 66 | convertPathData, 67 | convertTransform, 68 | convertEllipseToCircle, 69 | removeEmptyAttrs, 70 | removeEmptyContainers, 71 | mergePaths, 72 | // This currently throws an error 73 | // removeOffCanvasPaths, 74 | reusePaths, 75 | removeAttrs, 76 | removeScriptElement, 77 | // Bug: when this is run it removes the xlink namespace, but reusePaths adds elements with xlink:href 78 | // without making sure the namespace exists 79 | // removeUnusedNS, 80 | ] 81 | for (const plugin of pluginsArray) { 82 | plugin.active = true 83 | } 84 | const pluginsData = optimizePluginsArray(pluginsArray) 85 | 86 | /** 87 | * Group by type (perItem, full etc) 88 | */ 89 | function optimizePluginsArray(plugins: SvgoPlugin[]): SvgoPlugin[][] { 90 | return plugins 91 | .map(item => [item]) 92 | .reduce((array: SvgoPlugin[][], item) => { 93 | const last = array[array.length - 1] 94 | if (last && item[0]!.type === last[0]!.type) { 95 | last.push(item[0]!) 96 | } else { 97 | array.push(item) 98 | } 99 | return array 100 | }, []) 101 | } 102 | 103 | export async function minifySvg(svgString: string): Promise { 104 | const parsedSvg = await new Promise<{ error?: any }>(resolve => svg2js(svgString, resolve)) 105 | if (parsedSvg.error) { 106 | throw parsedSvg.error 107 | } 108 | 109 | plugins(parsedSvg, { input: 'string' }, pluginsData) 110 | 111 | return js2svg(parsedSvg, {}).data 112 | } 113 | -------------------------------------------------------------------------------- /src/polyfill.ts: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill' 2 | globalThis.browser = browser 3 | -------------------------------------------------------------------------------- /src/popup-dark.css: -------------------------------------------------------------------------------- 1 | html { 2 | background: #35363a; 3 | border: 1px solid #181a1b; 4 | color: white; 5 | } 6 | 7 | button, 8 | input[type='checkbox'], 9 | input[type='radio'] { 10 | filter: invert(85%) hue-rotate(180deg); /* Make sure colors stay the same despite inverting */ 11 | } 12 | -------------------------------------------------------------------------------- /src/popup.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | width: 192px; 7 | font-size: 12px; 8 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 9 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 10 | } 11 | 12 | button { 13 | width: 100%; 14 | margin-top: 0.5em; 15 | padding: 0.25em; 16 | display: block; 17 | } 18 | 19 | fieldset { 20 | border: none; 21 | padding: 0; 22 | margin: 0; 23 | } 24 | 25 | h2, 26 | h3 { 27 | margin-bottom: 0.5em; 28 | font-weight: 400; 29 | } 30 | -------------------------------------------------------------------------------- /src/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SVG Screenshot Options 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |

Options

15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 | 27 |
28 |
29 |

Target

30 |
31 | 32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 |
40 |
41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/popup.ts: -------------------------------------------------------------------------------- 1 | import './polyfill' 2 | 3 | import { applyDefaults, CaptureArea, Settings, SETTINGS_KEYS } from './shared' 4 | import { assert, logErrors, once } from './util' 5 | 6 | document.addEventListener('DOMContentLoaded', logErrors(main)) 7 | 8 | const createCaptureButtonHandler = 9 | (area: CaptureArea): (() => void) => 10 | async () => { 11 | try { 12 | console.log('Executing content script in tab') 13 | const [activeTab] = await browser.tabs.query({ active: true, currentWindow: true }) 14 | console.log('activeTab', activeTab) 15 | if (!activeTab?.id) { 16 | return 17 | } 18 | const started = once( 19 | browser.runtime.onMessage, 20 | (message, sender) => message.method === 'started' && sender.tab?.id === activeTab.id 21 | ) 22 | await browser.tabs.executeScript(activeTab.id, { 23 | file: '/src/content.js', 24 | }) 25 | const captureMessage = { 26 | method: 'capture', 27 | payload: { 28 | area, 29 | }, 30 | } 31 | console.log('Waiting for content page to start capturing') 32 | await started 33 | console.log('Received started message, sending capture message', captureMessage) 34 | await browser.tabs.sendMessage(activeTab.id, captureMessage) 35 | window.close() 36 | } catch (error) { 37 | console.error(error) 38 | alert(error.message) 39 | } 40 | } 41 | 42 | async function main(): Promise { 43 | document 44 | .querySelector('#capture-area-btn')! 45 | .addEventListener('click', createCaptureButtonHandler('captureArea')) 46 | document 47 | .querySelector('#capture-viewport-btn')! 48 | .addEventListener('click', createCaptureButtonHandler('captureViewport')) 49 | 50 | const optionsForm = document.forms.namedItem('options')! 51 | 52 | // Set initial settings in the DOM 53 | const settings = applyDefaults((await browser.storage.sync.get(SETTINGS_KEYS)) as Settings) 54 | for (const key of SETTINGS_KEYS) { 55 | const value = settings[key] 56 | const element = optionsForm.elements.namedItem(key) 57 | assert(element, `Expected ${key} to exist in options form`) 58 | if (typeof value === 'boolean') { 59 | assert( 60 | element instanceof HTMLInputElement && element.type === 'checkbox', 61 | 'Expected element to be checkbox' 62 | ) 63 | const checkbox = element as HTMLInputElement 64 | checkbox.checked = value 65 | } else if (typeof value === 'string') { 66 | assert(element instanceof RadioNodeList, 'Expected element to be RadioNodeList') 67 | element.value = value 68 | } 69 | } 70 | // Sync form changes to settings 71 | optionsForm.addEventListener( 72 | 'change', 73 | logErrors(async ({ target }) => { 74 | if (!(target instanceof HTMLInputElement)) { 75 | return 76 | } 77 | if (target.type === 'checkbox') { 78 | await browser.storage.sync.set({ [target.name]: target.checked }) 79 | } else if (target.type === 'radio') { 80 | await browser.storage.sync.set({ [target.name]: target.value }) 81 | } else { 82 | throw new Error(`Unexpected form element ${target.type}`) 83 | } 84 | }) 85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /src/shared.ts: -------------------------------------------------------------------------------- 1 | export type CaptureArea = 'captureArea' | 'captureViewport' 2 | export type Target = 'download' | 'tab' | 'clipboard' 3 | 4 | export const SETTINGS_KEYS: readonly (keyof Settings)[] = [ 5 | 'minifySvg', 6 | 'keepLinks', 7 | 'inlineResources', 8 | 'prettyPrintSvg', 9 | 'target', 10 | ] 11 | 12 | /** 13 | * The user settings stored in `browser.storage.sync` 14 | */ 15 | export interface Settings { 16 | minifySvg?: boolean 17 | inlineResources?: boolean 18 | prettyPrintSvg?: boolean 19 | keepLinks?: boolean 20 | target?: Target 21 | } 22 | 23 | export const applyDefaults = ({ 24 | inlineResources = true, 25 | minifySvg = false, 26 | prettyPrintSvg = true, 27 | keepLinks = true, 28 | target = 'download', 29 | }: Settings): Required => ({ 30 | inlineResources, 31 | minifySvg, 32 | keepLinks, 33 | prettyPrintSvg, 34 | target, 35 | }) 36 | -------------------------------------------------------------------------------- /src/types/clipboard.d.ts: -------------------------------------------------------------------------------- 1 | class ClipboardItem { 2 | readonly types: string[] 3 | constructor(data: { [mimeType: string]: Blob }) 4 | getType(mimeType: string): Promise 5 | } 6 | 7 | interface Clipboard { 8 | write(data: ClipboardItem[]): Promise 9 | } 10 | -------------------------------------------------------------------------------- /src/types/svgo/index.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-duplicate-imports */ 2 | /* eslint-disable import/no-duplicates */ 3 | 4 | declare module 'svgo' { 5 | interface SvgoPlugin { 6 | type: unknown 7 | active: boolean 8 | params: any 9 | } 10 | } 11 | 12 | declare module 'svgo/plugins/*' { 13 | // eslint-disable-next-line import/order 14 | import { SvgoPlugin } from 'svgo' 15 | var plugin: SvgoPlugin 16 | export = plugin 17 | } 18 | 19 | declare module 'svgo/lib/svgo/svg2js' { 20 | function svg2js(svg: string, callback: (ast: object) => void): void 21 | export = svg2js 22 | } 23 | 24 | declare module 'svgo/lib/svgo/js2svg' { 25 | interface OptimizedSvg { 26 | data: string 27 | } 28 | function js2svg(ast: object, options?: { pretty?: boolean }): OptimizedSvg 29 | export = js2svg 30 | } 31 | 32 | declare module 'svgo/lib/svgo/plugins' { 33 | import { SvgoPlugin } from 'svgo' 34 | function plugins(ast: object, info: { input: 'string' }, pluginsData: SvgoPlugin[][]): void 35 | export = plugins 36 | } 37 | -------------------------------------------------------------------------------- /src/types/webextension-polyfill/index.d.ts: -------------------------------------------------------------------------------- 1 | var browser: typeof globalThis.browser 2 | export = browser 3 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export const svgNamespace = 'http://www.w3.org/2000/svg' 2 | 3 | export class AbortError extends Error { 4 | public readonly name = 'AbortError' 5 | constructor(message: string = 'Aborted') { 6 | super(message) 7 | } 8 | } 9 | 10 | export const logErrors = 11 | (func: (...args: A) => Promise) => 12 | (...args: A): void => { 13 | func(...args).catch(console.error) 14 | } 15 | 16 | /** 17 | * Returns a Promise that resolves once the given event emits. 18 | */ 19 | export const once = ( 20 | emitter: WebExtEvent<(...args: T) => any>, 21 | filter: (...args: T) => boolean = () => true 22 | ): Promise => 23 | new Promise(resolve => { 24 | const listener = (...args: T): void => { 25 | if (!filter(...args)) { 26 | return 27 | } 28 | emitter.removeListener(listener) 29 | resolve(args) 30 | } 31 | emitter.addListener(listener) 32 | }) 33 | 34 | class AssertionError extends Error { 35 | public readonly name = 'AssertionError' 36 | } 37 | 38 | export function assert(condition: any, message: string): asserts condition { 39 | if (!condition) { 40 | throw new AssertionError(message) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ES2020", 5 | "noEmit": true, 6 | "moduleResolution": "node", 7 | "rootDir": "src", 8 | "inlineSourceMap": true, 9 | "inlineSources": true, 10 | "esModuleInterop": true, 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "skipDefaultLibCheck": true, 14 | "noUncheckedIndexedAccess": true, 15 | "paths": { 16 | "*": ["./src/types/*", "./*"], 17 | }, 18 | }, 19 | "include": ["src/**/*"], 20 | } 21 | --------------------------------------------------------------------------------