├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── static.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package.json ├── packages └── www │ ├── .gitignore │ ├── astro.config.mjs │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ ├── favicon.svg │ ├── features │ │ ├── autofixes │ │ │ ├── index.md │ │ │ └── source │ │ │ │ ├── index.html │ │ │ │ ├── music_500x447.png │ │ │ │ └── redpanda_500x335.jpg │ │ ├── browser-compatibility │ │ │ ├── index.md │ │ │ ├── jampack.config.js │ │ │ └── source │ │ │ │ ├── anemones.jpg │ │ │ │ ├── index.html │ │ │ │ └── styles.css │ │ ├── compress-all │ │ │ ├── index.md │ │ │ └── source │ │ │ │ ├── index.html │ │ │ │ ├── unreferenced.css │ │ │ │ ├── unreferenced.jpg │ │ │ │ ├── unreferenced.js │ │ │ │ ├── unreferenced.png │ │ │ │ ├── unreferenced.svg │ │ │ │ └── unreferenced.webp │ │ ├── embed-small-images │ │ │ ├── index.md │ │ │ └── source │ │ │ │ ├── bass.svg │ │ │ │ ├── index.html │ │ │ │ ├── small.svg │ │ │ │ └── smilley.png │ │ ├── iframe │ │ │ ├── index.md │ │ │ └── source │ │ │ │ └── index.html │ │ ├── images-max-width │ │ │ ├── index.md │ │ │ ├── jampack.config.js │ │ │ └── source │ │ │ │ ├── index.html │ │ │ │ └── redpanda.jpg │ │ ├── inline-critical-css │ │ │ ├── index.md │ │ │ ├── jampack.config.js │ │ │ └── source │ │ │ │ ├── index.html │ │ │ │ └── styles.css │ │ ├── optimize-above-the-fold │ │ │ ├── index.md │ │ │ └── source │ │ │ │ ├── html.to.design.png │ │ │ │ ├── index.html │ │ │ │ ├── music_500x447.png │ │ │ │ ├── redpanda_500x335.jpg │ │ │ │ └── water.jpg │ │ ├── optimize-images-cdn │ │ │ ├── index.md │ │ │ ├── jampack.config.js │ │ │ └── source │ │ │ │ └── index.html │ │ ├── optimize-images-external │ │ │ ├── index.md │ │ │ ├── jampack.config.js │ │ │ └── source │ │ │ │ ├── foo │ │ │ │ └── bar.html │ │ │ │ └── index.html │ │ ├── optimize-images │ │ │ ├── index.md │ │ │ └── source │ │ │ │ ├── img_bass.svg │ │ │ │ ├── img_jam.svg │ │ │ │ ├── img_music.png │ │ │ │ ├── img_screenshot.png │ │ │ │ ├── img_water.jpg │ │ │ │ ├── index.html │ │ │ │ ├── long-cat.jpg │ │ │ │ ├── picture_music.png │ │ │ │ ├── picture_redpanda.jpg │ │ │ │ ├── picture_screenshot.png │ │ │ │ ├── plane.jpg │ │ │ │ └── tall-cat.jpg │ │ ├── prefetch-links │ │ │ ├── index.md │ │ │ ├── jampack.config.js │ │ │ └── source │ │ │ │ ├── index.html │ │ │ │ ├── styles.css │ │ │ │ └── subfolder │ │ │ │ └── index.html │ │ ├── video │ │ │ ├── index.md │ │ │ └── source │ │ │ │ └── index.html │ │ └── warnings │ │ │ ├── index.md │ │ │ └── source │ │ │ ├── index.html │ │ │ └── jam.svg │ ├── make-scrollable-code-focusable.js │ ├── og-image.jpg │ └── robots.txt │ ├── src │ ├── components │ │ ├── HeadCommon.astro │ │ ├── HeadSEO.astro │ │ ├── Header │ │ │ ├── Header.astro │ │ │ ├── LanguageSelect.css │ │ │ ├── LanguageSelect.tsx │ │ │ ├── Search.css │ │ │ ├── Search.tsx │ │ │ ├── SidebarToggle.tsx │ │ │ ├── SkipToContent.astro │ │ │ ├── ThemeToggleButton.css │ │ │ ├── ThemeToggleButton.tsx │ │ │ ├── logo-dark.svg │ │ │ └── logo-light.svg │ │ ├── LeftSidebar │ │ │ ├── LeftSidebar.astro │ │ │ └── divRIOTS.svg │ │ ├── PageContent │ │ │ └── PageContent.astro │ │ ├── RightSidebar │ │ │ ├── MoreMenu.astro │ │ │ ├── RightSidebar.astro │ │ │ └── TableOfContents.tsx │ │ └── Window │ │ │ ├── Window.astro │ │ │ └── external-link.svg │ ├── config.ts │ ├── content │ │ ├── config.ts │ │ └── devlog │ │ │ ├── adding-config.mdx │ │ │ ├── external-images.mdx │ │ │ ├── improving-how-images-are-embedded │ │ │ ├── index.mdx │ │ │ ├── requests-after.png │ │ │ ├── requests-before.png │ │ │ └── wcd.png │ │ │ ├── inline-critical-css.mdx │ │ │ ├── longer-life-cache.mdx │ │ │ ├── prefetch-links.mdx │ │ │ ├── swyx-personal-site │ │ │ ├── 20min.jpg │ │ │ ├── index.mdx │ │ │ ├── jampack-waterfall-s1-static.png │ │ │ ├── jampack-waterfall-s2-jp091.png │ │ │ ├── jampack-waterfall-s3-jp093.png │ │ │ ├── jampack-waterfall-s4-jp093.png │ │ │ ├── original-waterfall.png │ │ │ └── original-www.png │ │ │ └── why-a-devlog.mdx │ ├── env.d.ts │ ├── languages.ts │ ├── layouts │ │ └── MainLayout.astro │ ├── pages │ │ ├── cache.md │ │ ├── chat.html │ │ ├── cli-options.md │ │ ├── configuration.mdx │ │ ├── devlog │ │ │ └── [...slug].astro │ │ ├── features │ │ │ └── [...feature].astro │ │ ├── index.astro │ │ └── installation.md │ └── styles │ │ ├── index.css │ │ └── theme.css │ └── tsconfig.json ├── pnpm-lock.yaml ├── src ├── cache.ts ├── compress.ts ├── compressors │ ├── css.ts │ ├── html.ts │ ├── images.ts │ └── js.ts ├── config-default.ts ├── config-fast.ts ├── config-types.ts ├── config.ts ├── index.ts ├── logger.ts ├── optimize.ts ├── optimizers │ ├── img-external.ts │ ├── inline-critical-css.ts │ ├── prefetch-links.ts │ ├── process-iframe.ts │ └── process-video.ts ├── packagejson.ts ├── proload │ ├── README.md │ ├── error.cjs │ ├── error.cjs.d.ts │ ├── esm │ │ ├── index.mjs │ │ ├── requireOrImport.mjs │ │ └── requireOrImport.mjs.d.ts │ └── index.d.ts ├── state.ts ├── utils.ts └── utils │ ├── cache-control-parser.ts │ ├── install-dep.ts │ ├── polyfill-fetch.ts │ └── resource.ts ├── tsconfig.json └── tsconfig.tsbuildinfo /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | versioning-strategy: increase 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | labels: 9 | - "dependencies" 10 | open-pull-requests-limit: 100 11 | pull-request-branch-name: 12 | separator: "-" 13 | ignore: 14 | - dependency-name: "fs-extra" 15 | - dependency-name: "*" 16 | update-types: ["version-update:semver-major"] 17 | -------------------------------------------------------------------------------- /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | name: Github Pages Astro CI 2 | 3 | on: 4 | # Trigger the workflow every time you push to the `main` branch 5 | # Using a different branch name? Replace `main` with your branch’s name 6 | push: 7 | branches: [main] 8 | # Allows you to run this workflow manually from the Actions tab on GitHub. 9 | workflow_dispatch: 10 | 11 | # Allow this job to clone the repo and create a page deployment 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout your repository using git 22 | uses: actions/checkout@v4 23 | - name: Install, build, and upload your site 24 | uses: withastro/action@v2 25 | with: 26 | path: ./packages/www 27 | package-manager: pnpm@v7 28 | 29 | deploy: 30 | needs: build 31 | runs-on: ubuntu-latest 32 | environment: 33 | name: github-pages 34 | url: ${{ steps.deployment.outputs.page_url }} 35 | steps: 36 | - name: Deploy to GitHub Pages 37 | id: deployment 38 | uses: actions/deploy-pages@v4 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *-debug.log 2 | *-error.log 3 | .DS_Store 4 | /dist 5 | node_modules 6 | .jampack 7 | demo** 8 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": true, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2022 ‹div›RIOTS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@divriots/jampack", 3 | "version": "0.33.1", 4 | "cache-version": { 5 | "img": "v1", 6 | "img-ext": "v1" 7 | }, 8 | "description": "Packer for static websites", 9 | "author": "Georges Gomes @georges-gomes", 10 | "bin": { 11 | "jampack": "dist/index.js" 12 | }, 13 | "homepage": "https://github.com/divriots/jampack", 14 | "license": "MIT", 15 | "main": "dist/index.js", 16 | "type": "module", 17 | "repository": "divriots/jampack", 18 | "files": [ 19 | "/bin", 20 | "/dist" 21 | ], 22 | "exports": { 23 | "./optimize": "./dist/optimize.js", 24 | "./compress": "./dist/compress.js", 25 | "./config": "./dist/config.js", 26 | "./state": "./dist/state.js" 27 | }, 28 | "dependencies": { 29 | "@commander-js/extra-typings": "^12.0.0", 30 | "@divriots/cheerio": "1.0.0-rc.12", 31 | "@swc/core": "^1.4.16", 32 | "add": "^2.0.6", 33 | "browserslist": "^4.23.0", 34 | "commander": "^12.0.0", 35 | "critters": "^0.0.22", 36 | "deepmerge": "^4.3.1", 37 | "esbuild": "^0.20.2", 38 | "escalade": "^3.2.0", 39 | "file-type": "^19.0.0", 40 | "globby": "^14.0.1", 41 | "hasha": "^6.0.0", 42 | "html-minifier-terser": "^7.2.0", 43 | "kleur": "^4.1.5", 44 | "lightningcss": "^1.25.1", 45 | "lozad": "^1.16.0", 46 | "mini-svg-data-uri": "^1.4.4", 47 | "ora": "^8.0.1", 48 | "quicklink": "^2.3.0", 49 | "sharp": "^0.33.3", 50 | "svgo": "^3.2.0", 51 | "table": "^6.8.2", 52 | "undici": "^6.13.0", 53 | "unpic": "^3.18.0" 54 | }, 55 | "devDependencies": { 56 | "@types/html-minifier-terser": "^7.0.2", 57 | "@types/node": "^20.12.7", 58 | "shx": "^0.3.3", 59 | "ts-node": "^10.9.2", 60 | "tslib": "^2.6.2", 61 | "typescript": "^5.4.5" 62 | }, 63 | "scripts": { 64 | "watch": "pnpm build --watch", 65 | "try": "shx rm -fr demo && cp -R demo_drc demo && node ./dist/index.js ./demo --nocache", 66 | "build": "shx rm -rf dist && tsc -b", 67 | "lint": "eslint . --ext .ts --config .eslintrc", 68 | "prepublishOnly": "pnpm build", 69 | "cleanAllDotJampack": "find -s . | grep '.jampack$' | xargs rm -fr" 70 | }, 71 | "engines": { 72 | "node": ">=14.0.0" 73 | }, 74 | "bugs": "https://github.com/divriots/jampack/issues", 75 | "keywords": [], 76 | "types": "dist/index.d.ts" 77 | } -------------------------------------------------------------------------------- /packages/www/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .astro 3 | dist 4 | public/features/*/packed 5 | -------------------------------------------------------------------------------- /packages/www/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | import preact from '@astrojs/preact'; 3 | import mdx from '@astrojs/mdx'; 4 | 5 | export default defineConfig({ 6 | integrations: [ 7 | // Enable Preact to support Preact JSX components. 8 | preact(), 9 | mdx(), 10 | ], 11 | site: `https://jampack.divRIOTS.com`, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@divriots/jampack-www", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "private": true, 6 | "scripts": { 7 | "dev": "astro dev", 8 | "start": "astro dev", 9 | "check": "astro check && tsc", 10 | "build-jampack": "cd ../.. && pnpm i && pnpm build && cd packages/www", 11 | "build": "pnpm build-jampack && astro build && cp -fr public/features dist && pnpm build-optimize", 12 | "build-optimize": "node ../../dist/index.js dist --exclude features/** --fail", 13 | "preview": "astro preview", 14 | "astro": "astro" 15 | }, 16 | "dependencies": { 17 | "@algolia/client-search": "^4.23.3", 18 | "@astrojs/mdx": "2.3.1", 19 | "@astrojs/preact": "^3.2.0", 20 | "@astrojs/react": "^3.3.0", 21 | "@docsearch/css": "^3.6.0", 22 | "@docsearch/react": "^3.6.0", 23 | "@types/node": "^20.12.7", 24 | "@types/react": "^18.2.79", 25 | "@types/react-dom": "^18.2.25", 26 | "ansi-to-html": "^0.7.2", 27 | "astro": "^4.6.3", 28 | "dir-compare": "^4.2.0", 29 | "front-matter": "^4.0.2", 30 | "preact": "^10.20.2", 31 | "react": "^18.2.0", 32 | "react-dom": "^18.2.0", 33 | "sass": "^1.75.0" 34 | }, 35 | "devDependencies": { 36 | "ultrahtml": "^1.5.3" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/www/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /packages/www/public/features/autofixes/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Auto-fixes 3 | jampack: "--onlyoptim" 4 | --- 5 | 6 | `jampack` will fix issues in assets automatically. 7 | 8 | ## Wrong format for `width` and `height` in ``s 9 | 10 | Should be numerical values only. 11 | 12 | `jampack` will fix either or both values based on the dimensions of the image. 13 | -------------------------------------------------------------------------------- /packages/www/public/features/autofixes/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Fix image dimensions with wong format

8 | 9 |

Image with wrong [width] or [height] format

10 | Red panada in the bush 16 | 17 | 18 | -------------------------------------------------------------------------------- /packages/www/public/features/autofixes/source/music_500x447.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/autofixes/source/music_500x447.png -------------------------------------------------------------------------------- /packages/www/public/features/autofixes/source/redpanda_500x335.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/autofixes/source/redpanda_500x335.jpg -------------------------------------------------------------------------------- /packages/www/public/features/browser-compatibility/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Browser compatibility 3 | --- 4 | 5 | `jampack` can improve browser compatibility by adding fallbacks, vendor prefixes or lowering syntax of cutting edge CSS features. 6 | 7 | 8 | ## CSS transpilation 9 | 10 | You can configure your level of compatibility by configuring the browser targets using the [browserslist query syntax](https://browserslist.dev/). 11 | 12 | ```js 13 | { 14 | "general": { 15 | "browserslist": 'last 12 versions' 16 | } 17 | } 18 | ``` 19 | 20 | See [browserslist](https://browserslist.dev/) for more details about the query syntax. 21 | See [ligthningcss transpilation](https://lightningcss.dev/transpilation.html) for examples of transpilation. -------------------------------------------------------------------------------- /packages/www/public/features/browser-compatibility/jampack.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | general: { 3 | browserslist: 'defaults', 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/www/public/features/browser-compatibility/source/anemones.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/browser-compatibility/source/anemones.jpg -------------------------------------------------------------------------------- /packages/www/public/features/browser-compatibility/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Browser compability 5 | 6 | 7 | 8 |
9 |
10 |

backdrop-filter: blur(10px)

11 |
12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/www/public/features/browser-compatibility/source/styles.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background-size: cover; 3 | align-items: center; 4 | display: flex; 5 | justify-content: center; 6 | height: 100%; 7 | width: 100%; 8 | } 9 | 10 | .box { 11 | border-radius: 5px; 12 | font-family: sans-serif; 13 | text-align: center; 14 | max-width: 50%; 15 | max-height: 50%; 16 | padding: 20px 40px; 17 | } 18 | 19 | .box { 20 | background-color: rgb(255 255 255 / 0.3); 21 | backdrop-filter: blur(10px); 22 | } 23 | 24 | body { 25 | background-image: url('anemones.jpg'); 26 | } 27 | -------------------------------------------------------------------------------- /packages/www/public/features/compress-all/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Compress all (PASS 2)' 3 | jampack: 4 | --- 5 | 6 | In a PASS 2, `jampack` compresses all untouched assets and keep the same name and the same format. 7 | 8 | | Extension | Compressor | 9 | | --------------- | --------------------- | 10 | | `.html`,`.htm` | [`html-minifier-terser`](https://github.com/terser/html-minifier-terser) | 11 | | `.css` | [`lightningCSS`](https://lightningcss.dev) | 12 | | `.js` | [`esbuild`](https://esbuild.github.io/) or [`swc`](https://swc.rs/) | 13 | | `.svg` | [`svgo`](https://github.com/svg/svgo) | 14 | | `.jpg`,`.jpeg` | [`sharp`](https://sharp.pixelplumbing.com/) | 15 | | `.png` | [`sharp`](https://sharp.pixelplumbing.com/) | 16 | | `.webp` | [`sharp`](https://sharp.pixelplumbing.com/) | 17 | | `.avif` | [`sharp`](https://sharp.pixelplumbing.com/) | 18 | -------------------------------------------------------------------------------- /packages/www/public/features/compress-all/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 14 | 15 | 16 |

Compress all untouched assets

17 | 18 |

19 | 20 | 25 | 26 | -------------------------------------------------------------------------------- /packages/www/public/features/compress-all/source/unreferenced.css: -------------------------------------------------------------------------------- 1 | @import url(http://fonts.googleapis.com/css?family=Merriweather:300italic,300); 2 | 3 | html { 4 | font-size: 12px; 5 | } 6 | 7 | @media screen and (min-width: 32rem) and (max-width: 48rem) { 8 | html { 9 | font-size: 15px; 10 | } 11 | } 12 | 13 | @media screen and (min-width: 48rem) { 14 | html { 15 | font-size: 16px; 16 | } 17 | } 18 | 19 | body { 20 | line-height: 1.85; 21 | } 22 | 23 | p, 24 | .splendor-p { 25 | font-size: 1rem; 26 | margin-bottom: 1.3rem; 27 | } 28 | 29 | h1, 30 | .splendor-h1, 31 | h2, 32 | .splendor-h2, 33 | h3, 34 | .splendor-h3, 35 | h4, 36 | .splendor-h4 { 37 | margin: 1.414rem 0 0.5rem; 38 | font-weight: inherit; 39 | line-height: 1.42; 40 | } 41 | 42 | h1, 43 | .splendor-h1 { 44 | margin-top: 0; 45 | font-size: 3.998rem; 46 | } 47 | 48 | h2, 49 | .splendor-h2 { 50 | font-size: 2.827rem; 51 | } 52 | 53 | h3, 54 | .splendor-h3 { 55 | font-size: 1.999rem; 56 | } 57 | 58 | h4, 59 | .splendor-h4 { 60 | font-size: 1.414rem; 61 | } 62 | 63 | h5, 64 | .splendor-h5 { 65 | font-size: 1.121rem; 66 | } 67 | 68 | h6, 69 | .splendor-h6 { 70 | font-size: 0.88rem; 71 | } 72 | 73 | small, 74 | .splendor-small { 75 | font-size: 0.707em; 76 | } 77 | 78 | /* https://github.com/mrmrs/fluidity */ 79 | 80 | img, 81 | canvas, 82 | iframe, 83 | video, 84 | svg, 85 | select, 86 | textarea { 87 | max-width: 100%; 88 | } 89 | 90 | html { 91 | font-size: 18px; 92 | max-width: 100%; 93 | } 94 | 95 | body { 96 | color: #444; 97 | font-family: 'Merriweather', Georgia, serif; 98 | margin: 0; 99 | max-width: 100%; 100 | } 101 | 102 | /* === A bit of a gross hack so we can have bleeding divs/blockquotes. */ 103 | 104 | p, 105 | *:not(div):not(img):not(body):not(html):not(li):not(blockquote):not(p) { 106 | margin: 1rem auto 1rem; 107 | max-width: 36rem; 108 | padding: 0.25rem; 109 | } 110 | 111 | div { 112 | width: 100%; 113 | } 114 | 115 | div img { 116 | width: 100%; 117 | } 118 | 119 | blockquote p { 120 | font-size: 1.5rem; 121 | font-style: italic; 122 | margin: 1rem auto 1rem; 123 | max-width: 48rem; 124 | } 125 | 126 | li { 127 | margin-left: 2rem; 128 | } 129 | 130 | /* Counteract the specificity of the gross *:not() chain. */ 131 | 132 | h1 { 133 | padding: 4rem 0 !important; 134 | } 135 | 136 | /* === End gross hack */ 137 | 138 | p { 139 | color: #555; 140 | height: auto; 141 | line-height: 1.45; 142 | } 143 | 144 | pre, 145 | code { 146 | font-family: Menlo, Monaco, 'Courier New', monospace; 147 | } 148 | 149 | pre { 150 | background-color: #fafafa; 151 | font-size: 0.8rem; 152 | overflow-x: scroll; 153 | padding: 1.125em; 154 | } 155 | 156 | a, 157 | a:visited { 158 | color: #3498db; 159 | } 160 | 161 | a:hover, 162 | a:focus, 163 | a:active { 164 | color: #2980b9; 165 | } 166 | -------------------------------------------------------------------------------- /packages/www/public/features/compress-all/source/unreferenced.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/compress-all/source/unreferenced.jpg -------------------------------------------------------------------------------- /packages/www/public/features/compress-all/source/unreferenced.js: -------------------------------------------------------------------------------- 1 | // This function isn't used anywhere, so 2 | // Rollup excludes it from the bundle... 3 | export function square ( x ) { 4 | return x * x; 5 | } 6 | 7 | // This function gets included 8 | export function cube ( x ) { 9 | // rewrite this as `square( x ) * x` 10 | // and see what happens! 11 | return x * x * x; 12 | } 13 | -------------------------------------------------------------------------------- /packages/www/public/features/compress-all/source/unreferenced.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/compress-all/source/unreferenced.png -------------------------------------------------------------------------------- /packages/www/public/features/compress-all/source/unreferenced.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/www/public/features/compress-all/source/unreferenced.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/compress-all/source/unreferenced.webp -------------------------------------------------------------------------------- /packages/www/public/features/embed-small-images/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Embed small images' 3 | jampack: '--onlyoptim' 4 | --- 5 | 6 | If the image is [above the fold](/features/optimize-above-the-fold/) and smaller than 1500 bytes, `jampack` embeds the image into `html` using [`data URIs`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs). 7 | 8 | -------------------------------------------------------------------------------- /packages/www/public/features/embed-small-images/source/bass.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/www/public/features/embed-small-images/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Small images (<1500 bytes) are embed in HTML directly

8 | 9 |

Small SVG

10 | Small arrow 11 | 12 |

Small PNG

13 | Small Yellow smilley 14 |

15 | This PNG is bigger than 1500 Bytes in PNG but is smaller than 200 Bytes in 16 | optimised WebP. So it's embedded as WebP. 17 |

18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/www/public/features/embed-small-images/source/small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/www/public/features/embed-small-images/source/smilley.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/embed-small-images/source/smilley.png -------------------------------------------------------------------------------- /packages/www/public/features/iframe/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: iframe 3 | jampack: "--onlyoptim" 4 | --- 5 | 6 | `jampack` lazy loads iframes below [the fold](/features/optimize-above-the-fold/). 7 | 8 | -------------------------------------------------------------------------------- /packages/www/public/features/iframe/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Lazy load iframes below the fold

8 | 17 | 18 |
The fold
19 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/www/public/features/images-max-width/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Images max width' 3 | jampack: '--onlyoptim' 4 | --- 5 | 6 | Images referenced in `.html` files can be limited to a max width. 7 | 8 | ```js 9 | export default { 10 | image: { 11 | srcset_max_width: 1300, // Max width used in srcset 12 | max_width: 1300, // Max width of output image 13 | }, 14 | }; 15 | ``` 16 | 17 | See [configuration](/configuration/) for default values. 18 | 19 | -------------------------------------------------------------------------------- /packages/www/public/features/images-max-width/jampack.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | image: { 3 | srcset_max_width: 1300, 4 | max_width: 1300, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/www/public/features/images-max-width/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 |

Image max_width

13 |

Original image is 3872×2592

14 |

jampack is configured here with:
15 |

23 |

24 | 25 | Red panda original image of 3872×2592 26 | 27 | 28 | -------------------------------------------------------------------------------- /packages/www/public/features/images-max-width/source/redpanda.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/images-max-width/source/redpanda.jpg -------------------------------------------------------------------------------- /packages/www/public/features/inline-critical-css/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Inline critical CSS 3 | jampack: "--onlyoptim" 4 | --- 5 | 6 | `jampack` uses [`critters`](https://github.com/GoogleChromeLabs/critters) to inline critical CSS and lazy-loads the rest. 7 | 8 | - Avoids a [FOUC](https://en.wikipedia.org/wiki/Flash_of_unstyled_content) while the stylesheet is remotely downloaded after the html content. 9 | - Improves [CLS](https://web.dev/cls/) score of [Core Web Vitals](https://web.dev/vitals/). 10 | 11 | ## Configuration 12 | 13 | ```js 14 | { 15 | css: { 16 | inline_critical_css: true, 17 | } 18 | } 19 | ``` 20 | 21 | ## Example 22 | 23 | When a stylesheet is loaded on another file like here: 24 | 25 | ```html 26 | 27 | 28 | Testing 29 | 30 | 31 | 32 |
33 |
34 | 35 |
36 |
37 |
38 |

Hello World!

39 |

This is a paragraph

40 | 41 |
42 |
43 |
44 | 45 | 46 | 47 | 48 | ``` 49 | 50 | Resulting into this `HTML` where only the relevant critical `CSS` rules for the first paint are inlined into the ``: 51 | 52 | ```html 53 | 54 | 55 | Testing 56 | 98 | 99 | 100 | 101 |
102 |
103 | 104 |
105 |
106 |
107 |

Hello World!

108 |

This is a paragraph

109 | 110 |
111 |
112 |
113 | 114 | 115 | 116 | ``` 117 | 118 | The rest of the stylesheet is lazy-loaded at the end of the file: 119 | 120 | ```html 121 | 122 | ``` 123 | 124 | but preloaded in header for maximum performance: 125 | 126 | ```html 127 | 128 | ``` 129 | -------------------------------------------------------------------------------- /packages/www/public/features/inline-critical-css/jampack.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | css: { 3 | inline_critical_css: true, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/www/public/features/inline-critical-css/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Testing 5 | 6 | 7 | 8 |
9 |
10 | 11 |
12 |
13 |
14 |

Hello World!

15 |

This is a paragraph

16 | 17 |
18 |
19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /packages/www/public/features/inline-critical-css/source/styles.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: blue; 3 | } 4 | 5 | h2.unused { 6 | color: red; 7 | } 8 | 9 | p { 10 | color: purple; 11 | } 12 | 13 | p.unused { 14 | color: orange; 15 | } 16 | 17 | header { 18 | padding: 0 50px; 19 | } 20 | 21 | .banner { 22 | font-family: sans-serif; 23 | } 24 | 25 | .contents { 26 | padding: 50px; 27 | text-align: center; 28 | } 29 | 30 | .input-field { 31 | padding: 10px; 32 | } 33 | 34 | footer { 35 | margin-top: 10px; 36 | } 37 | 38 | .container { 39 | border: 1px solid; 40 | } 41 | 42 | div:is(:hover, .active) { 43 | color: #000; 44 | } 45 | 46 | div:is(.selected, :hover) { 47 | color: #fff; 48 | } 49 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-above-the-fold/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Optimize above the fold" 3 | jampack: "--onlyoptim" 4 | --- 5 | 6 | `jampack` can do extra optimizations to content and assets above the fold. 7 | 8 | ## How is the fold positionned? 9 | 10 | The fold position is determined by the following: 11 | - At the tag `` if present (recommended) 12 | - OR, at the tag `
` + 5,000 bytes, if tag `
` is present. 13 | - OR, at the tag `` + 10,000 bytes, if tag `` is present. 14 | 15 | > Nothing will be treated as above-the-fold if none of the tags above have been found. 16 | 17 | The tag `` should be placed in your HTML pages where you think the fold will be. 18 | The tag `` will be removed from the final output. 19 | 20 | ## What is done differently above-the-fold? 21 | 22 | `jampack` will prioritize content and assets above the fold: 23 | 24 | - Images will be eagerly loaded: no `loading="lazy"` attribute. 25 | - Images will have higher priority: set `fetchpriority="high"` attribute. 26 | - [Small images will be embed in HTML](/features/embed-small-images/) 27 | 28 | ## Recommended use 29 | 30 | ```html 31 | > index.html 32 | ... 33 | ... content above the fold 34 | ... 35 | 36 | ... 37 | ... content below the fold 38 | ... 39 | ``` 40 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-above-the-fold/source/html.to.design.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-above-the-fold/source/html.to.design.png -------------------------------------------------------------------------------- /packages/www/public/features/optimize-above-the-fold/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 |

Above the fold optimizations

13 | 14 |

You want images above the fold to arrive as soon as possible.

15 | 16 |

Non-progressive JPEG

17 | Red panada in the bush 18 | 19 |

Transparent PNG

20 | Girl listening to music 21 | 22 |

Non-transparent PNG

23 | html.to.design banner 24 | 25 |

JPEG above the fold

26 | 27 | Beach 28 | 29 | 30 |
31 |
32 | The fold is marked here.
33 | Everything after that is "below the fold" 34 | 35 |
36 |
37 | 38 |

JPEG below the fold

39 | 40 | Beach 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-above-the-fold/source/music_500x447.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-above-the-fold/source/music_500x447.png -------------------------------------------------------------------------------- /packages/www/public/features/optimize-above-the-fold/source/redpanda_500x335.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-above-the-fold/source/redpanda_500x335.jpg -------------------------------------------------------------------------------- /packages/www/public/features/optimize-above-the-fold/source/water.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-above-the-fold/source/water.jpg -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images-cdn/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Optimize CDN images' 3 | jampack: '--onlyoptim' 4 | --- 5 | 6 | `jampack` can optionally optimizes CDN images for faster download on any device and better [Core Web Vitals](https://web.dev/learn-core-web-vitals/) scores. 7 | 8 | `jampack` will use the transform capabilities of the CDN to resize the images for the srcsets. This doesn't require any download of the images. 9 | 10 | ## Supported CDN providers 11 | 12 | - Adobe Dynamic Media (Scene7) 13 | - Builder.io 14 | - Bunny.net 15 | - Cloudflare 16 | - Contentful 17 | - Cloudinary 18 | - Directus 19 | - Imgix 20 | - Unsplash 21 | - DatoCMS 22 | - Sanity 23 | - Prismic 24 | - Kontent.ai 25 | - Netlify 26 | - Shopify 27 | - Storyblok 28 | - Vercel / Next.js 29 | - WordPress.com and Jetpack Site Accelerator 30 | 31 | > This feature is powered by [unpic](https://unpic.pics/) to detect and transform URLs. See [unpic's GitHub project](https://github.com/ascorbic/unpic) for more details. 32 | 33 | ## Configuration 34 | 35 | ```js 36 | image: { 37 | cdn: { 38 | process: 'off' | 'optimize', 39 | }, 40 | } 41 | ``` 42 | 43 | | process: | Description | 44 | |-----------|-------------| 45 | | `'off'` | It will ignore cdn images and treat them as [external images](/features/features/optimize-images-external). | 46 | | `'optimize'` | It will detect external images as coming from CDN and generate srcsets images url on the CDN. | 47 | 48 | ## Example 49 | 50 | `` and `` elements with CDN images like: 51 | 52 | ```html 53 | Clouds in the sky by Taylor Van Riper 59 | ``` 60 | 61 | > For the moment, `width` and `height` image attributes are mandatory for `jampack` to work CDN images. 62 | 63 | becomes 64 | 65 | ```html 66 | Clouds in the sky by Taylor Van Riper 84 | ``` 85 | 86 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images-cdn/jampack.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | image: { 3 | cdn: { 4 | process: 'optimize', 5 | }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images-cdn/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 |

Optimize CDN images without downloading them

14 | 15 | 16 | 17 | Clouds in the sky by Taylor Van Riper 23 | 24 | 25 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images-external/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Optimize external images' 3 | jampack: '--onlyoptim' 4 | --- 5 | 6 | > This feature is experimental 7 | 8 | `jampack` can optionally optimizes external images for faster download on any device and better [Core Web Vitals](https://web.dev/learn-core-web-vitals/) scores. 9 | 10 | ## Configuration 11 | 12 | ```js 13 | image: { 14 | external: { 15 | process: 'off' | 'download', 16 | }, 17 | } 18 | ``` 19 | 20 | | process: | Description | 21 | |-----------|-------------| 22 | | `'off'` | It will ignore external images and only [optimize local images](/features/optimize-images/). | 23 | | `'download'` | It will download external images [optimize them like local images](/features/optimize-images/). | 24 | 25 | ## Example 26 | 27 | `` and `` elements with external images like: 28 | 29 | ```html 30 | Clouds in the sky by Taylor Van Riper 34 | ``` 35 | 36 | becomes 37 | 38 | ```html 39 | Clouds in the sky by Taylor Van Riper 53 | ``` 54 | 55 | For more details, see [how local images are optimized](/features/optimize-images). 56 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images-external/jampack.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | image: { 3 | external: { 4 | process: 'download', 5 | }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images-external/source/foo/bar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | Blue balloons 17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images-external/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 |

Optimize external images by downloading them

13 | 14 | 15 | 16 | Clouds in the sky by Taylor Van Riper 20 | 21 |
22 | 23 | next page 24 | 25 | 26 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Optimize local images' 3 | jampack: '--onlyoptim' 4 | --- 5 | 6 | `jampack` optimizes local images for faster download on any device and better [Core Web Vitals](https://web.dev/learn-core-web-vitals/) scores. 7 | 8 | - Compresses images using better compressors or modern formats. 9 | - Generates responsive image sets for smaller devices. 10 | - Adds image dimensions if missing to [avoid CLS issues](https://web.dev/optimize-cls/#images-without-dimensions). 11 | - Sets images to lazy loading (with [exceptions](#exceptions)) 12 | 13 | ## `` tags 14 | 15 | ```html 16 | Red panda 17 | ``` 18 | 19 | becomes 20 | 21 | ```html 22 | Red panda 29 | ``` 30 | 31 | ### `src` image is compressed 32 | 33 | - `JPEG` images are compressed into lossly `WebP` using [`sharp`](https://sharp.pixelplumbing.com). 34 | - `PNG` images are compressed into near lossless `WebP` using the [near_lossless option of the sharp library](https://sharp.pixelplumbing.com/api-output#webp). 35 | - `AVIF` images are compressed using [`sharp`](https://sharp.pixelplumbing.com). 36 | - `SVG` images are compressed using [svgo](https://github.com/svg/svgo) 37 | 38 | ### `srcset` of smaller images are generated 39 | 40 | `jampack` will generate a set of smaller images by reducing the width of the images by steps of 300px. 41 | 42 | - Images above the fold generate [progressives `JPEG`](https://www.thewebmaster.com/progressive-jpegs/) as described in the [above the fold optimization](../optimize-above-the-fold/). 43 | - `PNG` and `JPEG` images will generate `WebP` responsive images. 44 | - `AVIF` images will generate `AVIF` responsive images. 45 | 46 | In other words, you can have a single image in your static site and `jampack` will create the different smaller versions to serve the most optimized image for smaller devices. 47 | 48 | If `srcset` is already present in source `` then nothing is done and images are just compressed in 49 | [PASS 2](../compress-all/). 50 | 51 | #### Responsives sizes 52 | 53 | You can set `sizes` attribute to fine tune image selection in the `srcset`. 54 | See [`sizes` attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-sizes) for more details. 55 | 56 | `sizes="100vw"` will be added by default when `srcset` is set. 57 | 58 | 59 | ### Lazy loading 60 | 61 | `jampack` will set all images to load in lazy by default. This gives the browser the opportunity to load more critical data instead. 62 | 63 | Images will have new attributes: 64 | - `loading="lazy"` 65 | - `decoding="async"` 66 | 67 | #### Exceptions 68 | 69 | Images will not be lazy loaded in the following conditions: 70 | 71 | - Image has attribute `loading="eager"`. Explicitly requesting for no lazy loading. 72 | - Image is marked [above the fold](../optimize-above-the-fold/). 73 | 74 | #### Note 75 | 76 | [Lazy loading images above the fold can cause LCP issues](https://web.dev/lazy-loading-images/#effects-on-largest-contentful-paint-lcp), 77 | we recommend to use the `jampack`'s [`` feature](../optimize-above-the-fold/) or mark images with `loading="eager"` for images above the fold. 78 | 79 | ### Sets `width` and `height` attributes 80 | 81 | `jampack` will set [image dimensions to avoid CLS issues](https://web.dev/optimize-cls/#images-without-dimensions). 82 | 83 | `jampack` will also fix images with invalid format for attributes `width` or `height`. 84 | 85 | ## `` tags 86 | 87 | `jampack` will enrich `` tags with `AVIF` and `WebP` sources when they are missing. 88 | 89 | ```html 90 | 91 | Red panda 92 | 93 | ``` 94 | 95 | will become 96 | 97 | ```html 98 | 99 | 100 | 101 | Red panda 102 | 103 | ``` 104 | 105 | If the original image is lossless (`PNG` or `WebP lossless`) then: 106 | - The `WebP` image set will be compressed with [near_lossless option](https://sharp.pixelplumbing.com/api-output#webp). 107 | - No `AVIF` image set will be created because AVIF lossless is not very good [(1)](https://www.reddit.com/r/jpegxl/comments/l9ta2u/how_does_lossless_jpegxl_compared_to_png/) [2](https://twitter.com/jonsneyers/status/1346389917816008704?s=19). 108 | 109 | If the original image is lossly (`JPEG` or `WebP lossly`) then: 110 | - The `AVIF` image set will be compressed with normal quality settings. 111 | - The `WebP` image set will be lossly compressed. 112 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/source/img_bass.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/source/img_music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-images/source/img_music.png -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/source/img_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-images/source/img_screenshot.png -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/source/img_water.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-images/source/img_water.jpg -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 14 | 15 |

Responsive images generation

16 |

17 | The right image size will be used by the browser based on the device of 18 | the user. 19 |

20 | 21 |

PICTURE tag

22 | 23 |

picture_redpanda.jpg

24 | 25 |

WebP and AVIF image sources with srcset created.

26 | 27 | 28 | Red panda 29 | 30 | 31 |

picture_music.png

32 | 33 |

image too small -> no srcset

34 | 35 | 36 | Girl listening to music 37 | 38 | 39 |

picture_screenshot.png

40 | 41 | 42 | Girl listening to music 43 | 44 | 45 |

Picture tag with media sources

46 | 47 | 48 | 49 | Image 50 | 51 | 52 |

IMG tag

53 | 54 |

JPEG image

55 | 56 | beach 57 | 58 |

PNG image

59 | 60 | First step image (@1066w) is bigger than original size WebP, so step is ignore. 61 | story.to.design screeshot 62 | 63 |

Image without [width] attribute

64 | Girl listening to music 65 | 66 |

Image without [height] attribute

67 | Girl listening to music 68 | 69 |

Jam SVG

70 | Jam pot 71 | 72 |

Bass SVG

73 | Green bass 74 | 75 |

Image with EXIF rotation should output correctly

76 | Plane seats 77 | 78 | 79 | -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/source/long-cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-images/source/long-cat.jpg -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/source/picture_music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-images/source/picture_music.png -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/source/picture_redpanda.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-images/source/picture_redpanda.jpg -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/source/picture_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-images/source/picture_screenshot.png -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/source/plane.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-images/source/plane.jpg -------------------------------------------------------------------------------- /packages/www/public/features/optimize-images/source/tall-cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/features/optimize-images/source/tall-cat.jpg -------------------------------------------------------------------------------- /packages/www/public/features/prefetch-links/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Prefetch links 3 | jampack: "--onlyoptim" 4 | --- 5 | 6 | `jampack` can prefetch links on the page for faster future navigation. 7 | 8 | Read more about [why you need link prefetch on web.dev](https://web.dev/link-prefetch). 9 | 10 | ## Configuration 11 | 12 | ```js 13 | { 14 | misc: { 15 | prefetch_links: 'in-viewport', 16 | }, 17 | } 18 | ``` 19 | 20 | ## Possible options 21 | 22 | ### `prefetch_links: 'off'` 23 | 24 | No prefetch of links are added to the pages. 25 | 26 | ### `prefetch_links: 'in-viewport'` 27 | 28 | `jampack` adds [quicklink](https://github.com/GoogleChromeLabs/quicklink) to all the html page. 29 | 30 | [quicklink](https://github.com/GoogleChromeLabs/quicklink) prefetches links that appear in viewport during idle time. 31 | 32 | > [quicklink](https://github.com/GoogleChromeLabs/quicklink) is a ~2K (minified/gzipped) Javascript module. This Javascript is asynchronously loaded by `jampack` at low priority and doesn't affect the performance of the pages. The quicklink module is loaded once by the browser and cached for new pages. 33 | 34 | See [quicklink website](https://getquick.link/) for more information. 35 | -------------------------------------------------------------------------------- /packages/www/public/features/prefetch-links/jampack.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | misc: { 3 | prefetch_links: 'in-viewport', 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/www/public/features/prefetch-links/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Testing 5 | 6 | 7 | 8 |
9 |
10 | 11 |
12 |
13 |
14 |

Hello World!

15 |

This is a paragraph

16 | 17 |
18 |
19 |
20 | Page 2 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /packages/www/public/features/prefetch-links/source/styles.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: blue; 3 | } 4 | 5 | h2.unused { 6 | color: red; 7 | } 8 | 9 | p { 10 | color: purple; 11 | } 12 | 13 | p.unused { 14 | color: orange; 15 | } 16 | 17 | header { 18 | padding: 0 50px; 19 | } 20 | 21 | .banner { 22 | font-family: sans-serif; 23 | } 24 | 25 | .contents { 26 | padding: 50px; 27 | text-align: center; 28 | } 29 | 30 | .input-field { 31 | padding: 10px; 32 | } 33 | 34 | footer { 35 | margin-top: 10px; 36 | } 37 | 38 | /* critters:exclude */ 39 | .container { 40 | border: 1px solid; 41 | } 42 | 43 | /* critters:include */ 44 | .custom-element::part(tab) { 45 | color: #0c0dcc; 46 | border-bottom: transparent solid 2px; 47 | } 48 | 49 | .custom-element::part(tab):hover { 50 | background-color: #0c0d19; 51 | color: #ffffff; 52 | border-color: #0c0d33; 53 | } 54 | 55 | /* critters:include start */ 56 | .custom-element::part(tab):hover:active { 57 | background-color: #0c0d33; 58 | color: #ffffff; 59 | } 60 | 61 | .custom-element::part(tab):focus { 62 | box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 63 | 0 0 0 4px rgba(10, 132, 255, 0.3); 64 | } 65 | /* critters:include end */ 66 | 67 | .custom-element::part(active) { 68 | color: #0060df; 69 | border-color: #0a84ff !important; 70 | } 71 | 72 | div:is(:hover, .active) { 73 | color: #000; 74 | } 75 | 76 | div:is(.selected, :hover) { 77 | color: #fff; 78 | } 79 | -------------------------------------------------------------------------------- /packages/www/public/features/prefetch-links/source/subfolder/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Page 2 4 | 5 | 6 |

Page 2

7 | Back 8 | 9 | 10 | -------------------------------------------------------------------------------- /packages/www/public/features/video/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Video 3 | jampack: "--onlyoptim" 4 | --- 5 | 6 | `jampack` optimize videos below [the fold](/features/optimize-above-the-fold/). 7 | 8 | ## Autoplay videos 9 | 10 | Videos below the fold with attribute `autoplay` are lazy loaded using JavaScript. 11 | 12 | ## Click-to-play videos 13 | 14 | Videos without `autoplay` and with a `poster` get a `preload="none"` attribute to postpone the loading of the video until user request. 15 | 16 | As of today, `jampack` doesn't automatically create posters for video. It's a TODO. 17 | -------------------------------------------------------------------------------- /packages/www/public/features/video/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Lazy load videos below the fold

8 | 16 | 17 |
The fold
18 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /packages/www/public/features/warnings/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Warnings' 3 | jampack: '--onlyoptim' 4 | --- 5 | 6 | `jampack` will raise warning when discovering non-blocking issues that require your attention and that you should fix. 7 | 8 | ## Accessibility 9 | 10 | ### `alt` attribute is missing an tag `` 11 | 12 | Spec > https://html.spec.whatwg.org/multipage/images.html#alt 13 | 14 | `jampack` will add an empty attribute `alt=""` because it can be valid for [decorative images](https://www.w3.org/WAI/tutorials/images/decorative/). 15 | But you should always fix the warning by adding a descriptive `alt` or an empty attribute for decorative images. 16 | 17 | 18 | -------------------------------------------------------------------------------- /packages/www/public/features/warnings/source/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Accessibility warnings

8 | 9 |

img missing `alt` attribute

10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/www/public/make-scrollable-code-focusable.js: -------------------------------------------------------------------------------- 1 | Array.from(document.getElementsByTagName('pre')).forEach((element) => { 2 | element.setAttribute('tabindex', '0'); 3 | }); 4 | -------------------------------------------------------------------------------- /packages/www/public/og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/public/og-image.jpg -------------------------------------------------------------------------------- /packages/www/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | User-agent: * 5 | Disallow: /features/*/source/index.html 6 | Disallow: /features/*/packed/index.html 7 | -------------------------------------------------------------------------------- /packages/www/src/components/HeadCommon.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '../styles/theme.css'; 3 | import '../styles/index.css'; 4 | --- 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | 24 | 27 | 28 | 29 | 38 | 39 | 40 | 47 | -------------------------------------------------------------------------------- /packages/www/src/components/HeadSEO.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { SITE, OPEN_GRAPH, Frontmatter } from '../config'; 3 | 4 | export interface Props { 5 | frontmatter: Frontmatter; 6 | canonicalUrl: URL; 7 | } 8 | 9 | const { frontmatter, canonicalUrl } = Astro.props as Props; 10 | const formattedContentTitle = `${frontmatter.title} | ${SITE.title}`; 11 | const imageSrc = frontmatter.image?.src ?? OPEN_GRAPH.image.src; 12 | const canonicalImageSrc = new URL(imageSrc, Astro.site); 13 | const imageAlt = frontmatter.image?.alt ?? OPEN_GRAPH.image.alt; 14 | --- 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /packages/www/src/components/Header/Header.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getLanguageFromURL } from '../../languages'; 3 | import SkipToContent from './SkipToContent.astro'; 4 | import SidebarToggle from './SidebarToggle'; 5 | import ThemeToggleButton from './ThemeToggleButton'; 6 | import logoSVGdark from './logo-dark.svg'; 7 | import logoSVGlight from './logo-light.svg'; 8 | 9 | type Props = { 10 | currentPage: string; 11 | }; 12 | 13 | const { currentPage } = Astro.props as Props; 14 | const lang = getLanguageFromURL(currentPage); 15 | --- 16 | 17 |
18 | 19 | 46 |
47 | 48 | 154 | 155 | 176 | -------------------------------------------------------------------------------- /packages/www/src/components/Header/LanguageSelect.css: -------------------------------------------------------------------------------- 1 | .language-select { 2 | flex-grow: 1; 3 | width: 48px; 4 | box-sizing: border-box; 5 | margin: 0; 6 | padding: 0.33em 0.5em; 7 | overflow: visible; 8 | font-weight: 500; 9 | font-size: 1rem; 10 | font-family: inherit; 11 | line-height: inherit; 12 | background-color: var(--theme-bg); 13 | border-color: var(--theme-text-lighter); 14 | color: var(--theme-text-light); 15 | border-style: solid; 16 | border-width: 1px; 17 | border-radius: 0.25rem; 18 | outline: 0; 19 | cursor: pointer; 20 | transition-timing-function: ease-out; 21 | transition-duration: 0.2s; 22 | transition-property: border-color, color; 23 | -webkit-font-smoothing: antialiased; 24 | padding-left: 30px; 25 | padding-right: 1rem; 26 | } 27 | .language-select-wrapper .language-select:hover, 28 | .language-select-wrapper .language-select:focus { 29 | color: var(--theme-text); 30 | border-color: var(--theme-text-light); 31 | } 32 | .language-select-wrapper { 33 | color: var(--theme-text-light); 34 | position: relative; 35 | } 36 | .language-select-wrapper > svg { 37 | position: absolute; 38 | top: 7px; 39 | left: 10px; 40 | pointer-events: none; 41 | } 42 | 43 | @media (min-width: 50em) { 44 | .language-select { 45 | width: 100%; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/www/src/components/Header/LanguageSelect.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource react */ 2 | import type { FunctionComponent } from 'react'; 3 | import './LanguageSelect.css'; 4 | import { KNOWN_LANGUAGES, langPathRegex } from '../../languages'; 5 | 6 | const LanguageSelect: FunctionComponent<{ lang: string }> = ({ lang }) => { 7 | return ( 8 |
9 | 27 | 45 |
46 | ); 47 | }; 48 | 49 | export default LanguageSelect; 50 | -------------------------------------------------------------------------------- /packages/www/src/components/Header/Search.css: -------------------------------------------------------------------------------- 1 | /** Style Algolia */ 2 | :root { 3 | --docsearch-primary-color: var(--theme-accent); 4 | --docsearch-logo-color: var(--theme-text); 5 | } 6 | .search-input { 7 | flex-grow: 1; 8 | box-sizing: border-box; 9 | width: 100%; 10 | margin: 0; 11 | padding: 0.33em 0.5em; 12 | overflow: visible; 13 | font-weight: 500; 14 | font-size: 1rem; 15 | font-family: inherit; 16 | line-height: inherit; 17 | background-color: var(--theme-divider); 18 | border-color: var(--theme-divider); 19 | color: var(--theme-text-light); 20 | border-style: solid; 21 | border-width: 1px; 22 | border-radius: 0.25rem; 23 | outline: 0; 24 | cursor: pointer; 25 | transition-timing-function: ease-out; 26 | transition-duration: 0.2s; 27 | transition-property: border-color, color; 28 | -webkit-font-smoothing: antialiased; 29 | } 30 | .search-input:hover, 31 | .search-input:focus { 32 | color: var(--theme-text); 33 | border-color: var(--theme-text-light); 34 | } 35 | .search-input:hover::placeholder, 36 | .search-input:focus::placeholder { 37 | color: var(--theme-text-light); 38 | } 39 | .search-input::placeholder { 40 | color: var(--theme-text-light); 41 | } 42 | .search-hint { 43 | position: absolute; 44 | top: 7px; 45 | right: 19px; 46 | padding: 3px 5px; 47 | display: none; 48 | display: none; 49 | align-items: center; 50 | justify-content: center; 51 | letter-spacing: 0.125em; 52 | font-size: 13px; 53 | font-family: var(--font-mono); 54 | pointer-events: none; 55 | border-color: var(--theme-text-lighter); 56 | color: var(--theme-text-light); 57 | border-style: solid; 58 | border-width: 1px; 59 | border-radius: 0.25rem; 60 | line-height: 14px; 61 | } 62 | 63 | @media (min-width: 50em) { 64 | .search-hint { 65 | display: flex; 66 | } 67 | } 68 | 69 | /* ------------------------------------------------------------ *\ 70 | DocSearch (Algolia) 71 | \* ------------------------------------------------------------ */ 72 | 73 | .DocSearch-Modal .DocSearch-Hit a { 74 | box-shadow: none; 75 | border: 1px solid var(--theme-accent); 76 | } 77 | -------------------------------------------------------------------------------- /packages/www/src/components/Header/Search.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource react */ 2 | import { useState, useCallback, useRef } from 'react'; 3 | import { ALGOLIA } from '../../config'; 4 | import '@docsearch/css'; 5 | import './Search.css'; 6 | 7 | import { createPortal } from 'react-dom'; 8 | import * as docSearchReact from '@docsearch/react'; 9 | 10 | /** FIXME: This is still kinda nasty, but DocSearch is not ESM ready. */ 11 | const DocSearchModal = 12 | docSearchReact.DocSearchModal || (docSearchReact as any).default.DocSearchModal; 13 | const useDocSearchKeyboardEvents = 14 | docSearchReact.useDocSearchKeyboardEvents || 15 | (docSearchReact as any).default.useDocSearchKeyboardEvents; 16 | 17 | export default function Search() { 18 | const [isOpen, setIsOpen] = useState(false); 19 | const searchButtonRef = useRef(null); 20 | const [initialQuery, setInitialQuery] = useState(''); 21 | 22 | const onOpen = useCallback(() => { 23 | setIsOpen(true); 24 | }, [setIsOpen]); 25 | 26 | const onClose = useCallback(() => { 27 | setIsOpen(false); 28 | }, [setIsOpen]); 29 | 30 | const onInput = useCallback( 31 | (e) => { 32 | setIsOpen(true); 33 | setInitialQuery(e.key); 34 | }, 35 | [setIsOpen, setInitialQuery] 36 | ); 37 | 38 | useDocSearchKeyboardEvents({ 39 | isOpen, 40 | onOpen, 41 | onClose, 42 | onInput, 43 | searchButtonRef, 44 | }); 45 | 46 | return ( 47 | <> 48 | 69 | 70 | {isOpen && 71 | createPortal( 72 | { 80 | return items.map((item) => { 81 | // We transform the absolute URL into a relative URL to 82 | // work better on localhost, preview URLS. 83 | const a = document.createElement('a'); 84 | a.href = item.url; 85 | const hash = a.hash === '#overview' ? '' : a.hash; 86 | return { 87 | ...item, 88 | url: `${a.pathname}${hash}`, 89 | }; 90 | }); 91 | }} 92 | />, 93 | document.body 94 | )} 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /packages/www/src/components/Header/SidebarToggle.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource preact */ 2 | import type { FunctionalComponent } from 'preact'; 3 | import { useState, useEffect } from 'preact/hooks'; 4 | 5 | const MenuToggle: FunctionalComponent = () => { 6 | const [sidebarShown, setSidebarShown] = useState(false); 7 | 8 | useEffect(() => { 9 | const body = document.querySelector('body')!; 10 | if (sidebarShown) { 11 | body.classList.add('mobile-sidebar-toggle'); 12 | } else { 13 | body.classList.remove('mobile-sidebar-toggle'); 14 | } 15 | }, [sidebarShown]); 16 | 17 | return ( 18 | 42 | ); 43 | }; 44 | 45 | export default MenuToggle; 46 | -------------------------------------------------------------------------------- /packages/www/src/components/Header/SkipToContent.astro: -------------------------------------------------------------------------------- 1 | --- 2 | type Props = {}; 3 | --- 4 | 5 | 6 | 7 | 27 | -------------------------------------------------------------------------------- /packages/www/src/components/Header/ThemeToggleButton.css: -------------------------------------------------------------------------------- 1 | .theme-toggle { 2 | display: inline-flex; 3 | align-items: center; 4 | gap: 0.25em; 5 | padding: 0.33em 0.67em; 6 | border-radius: 99em; 7 | background-color: var(--theme-code-inline-bg); 8 | } 9 | 10 | .theme-toggle > label:focus-within { 11 | outline: 2px solid transparent; 12 | box-shadow: 0 0 0 0.08em var(--theme-accent), 0 0 0 0.12em white; 13 | } 14 | 15 | .theme-toggle > label { 16 | color: var(--theme-code-inline-text); 17 | position: relative; 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | opacity: 0.5; 22 | } 23 | 24 | .theme-toggle .checked { 25 | color: var(--theme-accent); 26 | opacity: 1; 27 | } 28 | 29 | input[name='theme-toggle'] { 30 | position: absolute; 31 | opacity: 0; 32 | top: 0; 33 | right: 0; 34 | bottom: 0; 35 | left: 0; 36 | z-index: -1; 37 | } 38 | -------------------------------------------------------------------------------- /packages/www/src/components/Header/ThemeToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import type { FunctionalComponent } from 'preact'; 2 | import { useState, useEffect } from 'preact/hooks'; 3 | import './ThemeToggleButton.css'; 4 | 5 | const themes = ['light', 'dark']; 6 | 7 | const icons = [ 8 | 15 | 20 | , 21 | 28 | 29 | , 30 | ]; 31 | 32 | const ThemeToggle: FunctionalComponent = () => { 33 | const [theme, setTheme] = useState(() => { 34 | if (import.meta.env.SSR) { 35 | return undefined; 36 | } 37 | if (typeof localStorage !== undefined && localStorage.getItem('theme')) { 38 | return localStorage.getItem('theme'); 39 | } 40 | if (window.matchMedia('(prefers-color-scheme: dark)').matches) { 41 | return 'dark'; 42 | } 43 | return 'light'; 44 | }); 45 | 46 | useEffect(() => { 47 | const root = document.documentElement; 48 | if (theme === 'light') { 49 | root.classList.remove('theme-dark'); 50 | } else { 51 | root.classList.add('theme-dark'); 52 | } 53 | }, [theme]); 54 | 55 | return ( 56 |
57 | {themes.map((t, i) => { 58 | const icon = icons[i]; 59 | const checked = t === theme; 60 | return ( 61 | 76 | ); 77 | })} 78 |
79 | ); 80 | }; 81 | 82 | export default ThemeToggle; 83 | -------------------------------------------------------------------------------- /packages/www/src/components/Header/logo-dark.svg: -------------------------------------------------------------------------------- 1 | |YY>><> 2 | -------------------------------------------------------------------------------- /packages/www/src/components/Header/logo-light.svg: -------------------------------------------------------------------------------- 1 | |YY>><> 2 | -------------------------------------------------------------------------------- /packages/www/src/components/LeftSidebar/LeftSidebar.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getLanguageFromURL } from '../../languages'; 3 | import { SIDEBAR } from '../../config'; 4 | import logoRiots from './divRIOTS.svg?raw'; 5 | import { getCollection } from 'astro:content'; 6 | 7 | type Props = { 8 | currentPage: string; 9 | }; 10 | 11 | const { currentPage } = Astro.props as Props; 12 | const currentPageMatch = currentPage.endsWith('/') 13 | ? currentPage.slice(1, -1) 14 | : currentPage.slice(1); 15 | const langCode = getLanguageFromURL(currentPage); 16 | const sidebar = SIDEBAR[langCode]; 17 | 18 | // Dev logs 19 | // Get all entries from a collection. Requires the name of the collection as an argument. 20 | const logs = (await getCollection('devlog')).sort( (a, b) => 21 | b.data.date.getTime() - a.data.date.getTime() 22 | ); 23 | 24 | const isDevLog = currentPageMatch.startsWith('devlog/'); 25 | --- 26 | 27 | 71 | 72 | 80 | 81 | 97 | 98 | 203 | 204 | 209 | -------------------------------------------------------------------------------- /packages/www/src/components/PageContent/PageContent.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { Frontmatter } from '../../config'; 3 | 4 | type Props = { 5 | frontmatter: Frontmatter; 6 | githubEditUrl: string; 7 | }; 8 | 9 | const { frontmatter } = Astro.props as Props; 10 | const title = frontmatter.title; 11 | 12 | const dateLog = frontmatter?.date && frontmatter.date.toISOString().slice(0,10); 13 | const dateFormated = dateLog && new Date(dateLog).toDateString(); 14 | --- 15 | 16 |
17 |
18 |
19 | { dateLog && 20 |
Date
21 |
} 22 | { frontmatter.author && 23 |
Author
24 |
25 | 26 | 27 | {frontmatter.author} 28 | 29 |
30 | } 31 |
32 |

{title}

33 | 34 |
35 |
36 | 37 | 68 | -------------------------------------------------------------------------------- /packages/www/src/components/RightSidebar/MoreMenu.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import * as CONFIG from '../../config'; 3 | 4 | type Props = { 5 | editHref: string; 6 | }; 7 | 8 | const { editHref } = Astro.props as Props; 9 | const showMoreSection = CONFIG.COMMUNITY_INVITE_URL; 10 | --- 11 | 12 | {showMoreSection &&

More

} 13 | 63 |
64 |
65 | 66 | 74 | -------------------------------------------------------------------------------- /packages/www/src/components/RightSidebar/RightSidebar.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import TableOfContents from './TableOfContents'; 3 | import MoreMenu from './MoreMenu.astro'; 4 | import type { MarkdownHeading } from 'astro'; 5 | 6 | type Props = { 7 | headings: MarkdownHeading[]; 8 | githubEditUrl: string; 9 | }; 10 | 11 | const { headings, githubEditUrl } = Astro.props as Props; 12 | --- 13 | 14 | 20 | 21 | 35 | -------------------------------------------------------------------------------- /packages/www/src/components/RightSidebar/TableOfContents.tsx: -------------------------------------------------------------------------------- 1 | import type { FunctionalComponent } from 'preact'; 2 | import { useState, useEffect, useRef } from 'preact/hooks'; 3 | import type { MarkdownHeading } from 'astro'; 4 | 5 | type ItemOffsets = { 6 | id: string; 7 | topOffset: number; 8 | }; 9 | 10 | const TableOfContents: FunctionalComponent<{ headings: MarkdownHeading[] }> = ({ 11 | headings = [], 12 | }) => { 13 | const itemOffsets = useRef([]); 14 | // FIXME: Not sure what this state is doing. It was never set to anything truthy. 15 | const [activeId] = useState(''); 16 | useEffect(() => { 17 | const getItemOffsets = () => { 18 | const titles = document.querySelectorAll('article :is(h1, h2, h3, h4)'); 19 | itemOffsets.current = Array.from(titles).map((title) => ({ 20 | id: title.id, 21 | topOffset: title.getBoundingClientRect().top + window.scrollY, 22 | })); 23 | }; 24 | 25 | getItemOffsets(); 26 | window.addEventListener('resize', getItemOffsets); 27 | 28 | return () => { 29 | window.removeEventListener('resize', getItemOffsets); 30 | }; 31 | }, []); 32 | 33 | return ( 34 | <> 35 |

On this page

36 |
    37 |
  • 38 | Overview 39 |
  • 40 | {headings 41 | .filter(({ depth }) => depth > 1 && depth < 4) 42 | .map((heading) => ( 43 |
  • 48 | {heading.text} 49 |
  • 50 | ))} 51 |
52 | 53 | ); 54 | }; 55 | 56 | export default TableOfContents; 57 | -------------------------------------------------------------------------------- /packages/www/src/components/Window/Window.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import externalSvg from './external-link.svg'; 3 | 4 | export interface Props { 5 | title: string; 6 | open_url?: string; 7 | } 8 | 9 | const { title, open_url } = Astro.props as Props; 10 | --- 11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | { title } 20 | { open_url ? Open in new tab :
} 21 |
22 |
23 | 24 |
25 |
26 | 27 | 98 | -------------------------------------------------------------------------------- /packages/www/src/components/Window/external-link.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/www/src/config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import fm from 'front-matter'; 3 | 4 | export const SITE = { 5 | title: 'Jampack', 6 | description: 7 | 'Optimizes static websites for best user experience and best Core Web Vitals scores.', 8 | defaultLanguage: 'en_US', 9 | }; 10 | 11 | export const OPEN_GRAPH = { 12 | image: { 13 | src: '/og-image.jpg', 14 | alt: '', 15 | }, 16 | twitter: 'divRIOTS', 17 | }; 18 | 19 | // This is the type of the frontmatter you put in the docs markdown files. 20 | export type Frontmatter = { 21 | title: string; 22 | description: string; 23 | image?: { src: string; alt: string }; 24 | dir?: 'ltr' | 'rtl'; 25 | ogLocale?: string; 26 | lang?: string; 27 | author?: string; 28 | date?: Date; 29 | }; 30 | 31 | export const KNOWN_LANGUAGES = { 32 | English: 'en', 33 | } as const; 34 | 35 | export const KNOWN_LANGUAGE_CODES = Object.values(KNOWN_LANGUAGES); 36 | 37 | export const GITHUB_EDIT_URL = `https://github.com/divriots/jampack/tree/main/packages/www`; 38 | 39 | export const COMMUNITY_INVITE_URL = `https://jampack.divriots.com/chat`; 40 | 41 | // See "Algolia" section of the README for more information. 42 | export const ALGOLIA = { 43 | indexName: 'XXXXXXXXXX', 44 | appId: 'XXXXXXXXXX', 45 | apiKey: 'XXXXXXXXXX', 46 | }; 47 | 48 | export type Sidebar = Record< 49 | (typeof KNOWN_LANGUAGE_CODES)[number], 50 | Record 51 | >; 52 | 53 | export const featuresDirs = [ 54 | 'optimize-images', 55 | 'optimize-images-cdn', 56 | 'optimize-images-external', 57 | 'optimize-above-the-fold', 58 | 'embed-small-images', 59 | 'images-max-width', 60 | 'inline-critical-css', 61 | 'video', 62 | 'iframe', 63 | 'prefetch-links', 64 | 'browser-compatibility', 65 | 'compress-all', 66 | 'autofixes', 67 | 'warnings', 68 | ]; 69 | 70 | const getTitle = (file: string): string => { 71 | // @ts-ignore 72 | return fm(fs.readFileSync(file, 'utf8')).attributes['title']; 73 | }; 74 | 75 | export const SIDEBAR: Sidebar = { 76 | en: { 77 | 'Getting started': [ 78 | { text: 'Introduction', link: '' }, 79 | { text: 'Installation', link: 'installation' }, 80 | { text: 'CLI Options', link: 'cli-options' }, 81 | { text: 'Configuration', link: 'configuration' }, 82 | ], 83 | Features: featuresDirs.map((dir) => ({ 84 | text: getTitle('./public/features/' + dir + '/index.md'), 85 | link: 'features/' + dir, 86 | })), 87 | Advanced: [{ text: 'Cache', link: 'cache' }], 88 | Community: [ 89 | { text: 'GitHub', link: 'https://github.com/divriots/jampack' }, 90 | { text: 'Discord', link: 'https://jampack.divriots.com/chat' }, 91 | ], 92 | }, 93 | }; 94 | -------------------------------------------------------------------------------- /packages/www/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { z, defineCollection } from 'astro:content'; 2 | 3 | export const collections = { 4 | 'devlog': defineCollection({ 5 | type: 'content', 6 | schema: z.object({ 7 | title: z.string(), 8 | date: z.date(), 9 | author: z.array(z.string()), 10 | }), 11 | }), 12 | }; 13 | -------------------------------------------------------------------------------- /packages/www/src/content/devlog/adding-config.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2023-05-12 3 | author: ['georges-gomes'] 4 | title: Adding config 5 | --- 6 | 7 | [Jampack](/) is now using [Nate Moore](https://github.com/natemoo-re)'s 8 | [proload](https://github.com/natemoo-re/proload) package to load a configuration file. 9 | 10 | It supports any of the following files: 11 | 12 | - `jampack.config.js` (in ESM or CJS format) 13 | - `jampack.config.mjs` 14 | - `jampack.config.cjs` 15 | - `config/jampack.config.js` (in ESM or CJS format) 16 | - `config/jampack.config.mjs` 17 | - `config/jampack.config.cjs` 18 | 19 | Or in a top-level `jampack` property in your `package.json`. 20 | 21 | This will be great to offer some flexibility and also add new experimental features behind flags! 22 | 23 | See the [Configuration](/configuration) page for the available options so far. 24 | 25 | ## Released 26 | 27 | - `jampack 0.10.0+` 28 | -------------------------------------------------------------------------------- /packages/www/src/content/devlog/external-images.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2023-05-29 3 | author: ['georges-gomes'] 4 | title: Optimizing external images 5 | --- 6 | 7 | As of today, [jampack](/) only processes and optimizes local images available in the static source of files. 8 | 9 | What about external images stored in a CDN or remote storage? 10 | 11 | ## Step 1: Config 12 | 13 | Because this may not be suitable for everybody we will use our brand new config to optionaly make it available. 14 | 15 | ```js 16 | image: { 17 | external: { 18 | process: 'off' | 'download', 19 | }, 20 | } 21 | ``` 22 | 23 | Later, I would like to introduce other options like: 24 | 25 | - `add-dimensions-only`: Only add dimensions to the images when missing and no image is downloaded. 26 | - `cdn-srcset-when-possible`: using image CDN capabilities for resize and image format for `srcset` images. 27 | 28 | ## Step 2: Download 29 | 30 | External images will be download and stored in folder `_jampack/` at the root of the static website and 31 | they will be processed and optimized as local images. 32 | 33 | And with this, [we have the demo working](/features/optimize-images-external/)! 34 | 35 | ## Step 3: Caching 36 | 37 | We don't want to download all images and reprocess them for every run of `jampack`. 38 | 39 | If the image didn't change we should not re-download. 40 | 41 | Let's use [HTTP Caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching)! 42 | 43 | ### Adding downloaded images to the cache 44 | 45 | `jampack` already has a [cache for processed images](/cache) located in folder `.jampack`. 46 | 47 | Let use it as well for downloaded images. 48 | 49 | - Processed images by `sharp` will go to subfolder `img`. 50 | - Downloaded images will go to subfolder `img-ext`. 51 | 52 | ### Let's ask if the image has changed 53 | 54 | `jampack` will call all external images with `If-Modified-Since` HTTP header (when image is in cache). 55 | The server should respond status `200 OK` if a new image exist or `304 Not modified` if the image is the same. 56 | CDNs are good at that! 57 | 58 | If the server respond with `304 Not modified` we will then source the image directly from the cache. 59 | 60 | ``` 61 | # Performance results (Processing 10 external images) 62 | - Without cache => ~8s 63 | - With 304 => ~2.5s 64 | (this will obviously vary with image size and network speed) 65 | ``` 66 | 67 | **Success!** 68 | 69 | But this means we still have to make a HTTP request for each image. 70 | For websites with thousands of external images this could still be 71 | a performance issue. 72 | 73 | ### If we know the image is still fresh, don't even ask 74 | 75 | In the HTTP response, the server tells us how long we can cache the image and don't even ask for it. 76 | 77 | This is done though headers properties [`Expires` or `Cache-Control: max-age`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#expires_or_max-age). 78 | 79 | `jampack` will calculate the expiration time of the image and take the image directly from the cache without any HTTP call. Exactly like a browser. 80 | 81 | **Success!** 82 | 83 | ``` 84 | # Performance results (Processing 10 external images) 85 | - Without cache => ~8s 86 | - With 304 => ~2.5s 87 | - Direct from cache => ~0.25s 88 | ``` 89 | 90 | We have now an efficient external image download 👍 91 | 92 | External images are usually immutable when served from a CDN for example. So we can expect very high rate of cache use. 93 | Only new external images are likely to be downloaded. 94 | 95 | ### Support for no-cache directive 96 | 97 | Sometimes images should not be cache because they are generated on demand. 98 | 99 | So `jampack` implements the following directives in HTTP Header: 100 | 101 | - `Cache-Control: no-cache` 102 | - `Cache-Control: max-age=0, must-revalidate` 103 | 104 | As explained in [HTTP Caching "Force Revalidation"](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#force_revalidation). 105 | 106 | ### What about the same image on multiple pages? 107 | 108 | The same image should be downloaded only once. The cache will take care of this usecase 👍 109 | 110 | ## It's not perfect 111 | 112 | There are a little bit more subtleties in proper HTTP cache management. But this first version should be good 113 | enough to cover the common ones. 114 | 115 | We will improve the cache as we go and as we encounter performance issues or bugs. 116 | 117 | ## The result 118 | 119 | `jampack` has a now an [configuration](/configuration/) to [download and process external images](/features/optimize-images-external/): 120 | 121 | ```js 122 | image: { 123 | external: { 124 | process: 'off' | 'download', 125 | }, 126 | } 127 | ``` 128 | 129 | combined with a cache that tries to make it not too slow 💪 130 | 131 | ## Released 132 | 133 | - `jampack 0.12.0+` 134 | -------------------------------------------------------------------------------- /packages/www/src/content/devlog/improving-how-images-are-embedded/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2023-02-07 3 | author: ['georges-gomes'] 4 | title: Improving how images are embedded 5 | --- 6 | 7 | import wcd from './wcd.png'; 8 | import requestsBefore from './requests-before.png'; 9 | import requestsAfter from './requests-after.png'; 10 | 11 | Up until now, `jampack v0.8.1` embeds **ALL** small images (<400 bytes). 12 | 13 | The reasoning was: An HTTP header response is already around 400 bytes, why bother with an HTTP 14 | roundtrip? This is also using an HTTP request that could be used to retrieve something more important for the page. 15 | 16 | Actually, all this concern is only valuable above the fold. Below the fold, images are only 17 | loaded when scrolling with `loading="lazy"`. So, embedding images after the fold is actually not an improvement. 18 | 19 | ## Target 20 | 21 | I'm gonna use one of our websites to test it out: [https://webcomponents.dev](https://webcomponents.dev) 22 | 23 | 24 | Landing screenshot of WebComponents.dev 25 | 26 | 27 | In this landing, `jampack` embedded the WebComponents.dev logo but not much. 28 | 29 | Good performance already 😋. 30 | 31 | | First Byte | Start Render | FCP | Speed Index | LCP | CLS | TBT | Total Bytes | 32 | | ---------- | ------------ | ----- | ----------- | ----- | --- | --- | ----------- | 33 | | .191S | .300S | .339S | .382S | .341S | 0 | 0S | 65KB | 34 | 35 | 36 | Browser request view from WebPageTest.org 40 | 41 | 42 | A lot of waiting presumably because a lot of requests. 43 | 44 | A lot of these images are above the fold. 45 | 46 | ## Change 1: Embedding images only above the fold 47 | 48 | Just add a little branch. Easy. 49 | 50 | ## Change 2: Bumping the embedding threshold 51 | 52 | Let's bump the embedding limit to 1500 bytes when above the fold. We will embed more 53 | images but only where it matters: above the fold. 54 | 55 | ## Change 3: Uncompressed images should be embedded too 56 | 57 | `jampack v0.8.1` would embed images only if they were successfully compressed to WebP or smaller SVG. 58 | For example, uncompressible SVGs were not embedded - even it below 400 bytes. 59 | 60 | ## Change 4: Image dimensions 61 | 62 | I initially thought that image dimensions were not necessary when the image is embedded. 63 | But I may be wrong. 64 | 65 | Lighthouse is complaining and apparently images are loaded asynchronously anyway. 66 | https://github.com/GoogleChrome/lighthouse/issues/12233 67 | 68 | Let's add image dimension to embedded images too! 69 | 70 | ## Results 71 | 72 | 73 | Browser request view from WebPageTest.org after new version 77 | 78 | 79 | ### Requests 80 | 81 | From 23 requests to 19 requests. 82 | Freeing request capacity for more important resources. 83 | 84 | ### CLS improvements? 85 | 86 | No improvement on CLS as it's dominated by font loading today. Something for later :) 87 | 88 | ### HTML size 89 | 90 | What is the impact of embedding these small images into HTML? 91 | In this particular example, a lot of small SVG images. 92 | 93 | Before: 94 | 95 | - HTML Uncompressed = 30.4 KB 96 | - HTML Download = 4.0 KB (brotli) 97 | 98 | After: 99 | 100 | - HTML Uncompressed = 38.4 KB 101 | - HTML Download = 5.6 KB (brotli) 102 | 103 | So in this example, `jampack` removed 4 requests of images above the fold for maximum 104 | performance and added 1.6 KB of data into the HTML download. 105 | 106 | Knowing that, anyway, an HTTP header would cost ~400 bytes of download, makes me feel good about the trade-off. 107 | 108 | ## Released 109 | 110 | - `jampack 0.9.0` 111 | - `jampack 0.9.1` 112 | -------------------------------------------------------------------------------- /packages/www/src/content/devlog/improving-how-images-are-embedded/requests-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/src/content/devlog/improving-how-images-are-embedded/requests-after.png -------------------------------------------------------------------------------- /packages/www/src/content/devlog/improving-how-images-are-embedded/requests-before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/src/content/devlog/improving-how-images-are-embedded/requests-before.png -------------------------------------------------------------------------------- /packages/www/src/content/devlog/improving-how-images-are-embedded/wcd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/src/content/devlog/improving-how-images-are-embedded/wcd.png -------------------------------------------------------------------------------- /packages/www/src/content/devlog/inline-critical-css.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2023-09-14 3 | author: ['georges-gomes'] 4 | title: Inline critical CSS 5 | --- 6 | 7 | [jampack](/) has a new option to inline critical CSS. 8 | 9 | - Avoids a [FOUC](https://en.wikipedia.org/wiki/Flash_of_unstyled_content) while the stylesheet is remotely downloaded after the html content. 10 | - Improves [CLS](https://web.dev/cls/) score of [Core Web Vitals](https://web.dev/vitals/). 11 | 12 | ```js 13 | { 14 | css: { 15 | inline_critical_css: true, 16 | } 17 | } 18 | ``` 19 | 20 | See [Inline critical CSS](/features/inline-critical-css/) for more details. 21 | 22 | ## Released 23 | 24 | - `jampack 0.21.0+` 25 | -------------------------------------------------------------------------------- /packages/www/src/content/devlog/longer-life-cache.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2023-06-10 3 | author: ['georges-gomes'] 4 | title: Longer life for cache 5 | --- 6 | 7 | Up until now (`jampack v0.12.2`), the cache was versionned with the version of `jampack`. 8 | This was a guarantee that the cache consumed by `jampack` was always up-to-date with the code. 9 | Avoiding bugs to stay in the cache. 10 | 11 | This means that everytime, the user upgraded to a new `jampack` version, everything would need to processed 12 | and cached again. And when you have thousands of images, it's not a small job. 13 | 14 | It was OK at the begining because so much changes were going on in with the image processing that 15 | pretty much every new version required a fresh new cache. 16 | 17 | But now, new versions are just adding new features or fixing bugs that are unrelated to images. Reprocessing 18 | the cache for these updates is a waste of time. 19 | 20 | Also, with the recent addition of new cache category for [external images](/devlog/external-images), 21 | the cache is splitted in different types of data and they don't need to be affected together. 22 | 23 | ## Cache structure before 24 | 25 | ``` 26 | /.jampack/cache/0.12.2/img/... 27 | /.jampack/cache/0.12.2/img-ext/... 28 | ``` 29 | 30 | ## Cache structure now (0.13.0+) 31 | 32 | ``` 33 | /.jampack/cache/img/v1/... 34 | /.jampack/cache/img-ext/v1/... 35 | ``` 36 | 37 | `v1` is the version number of the cache and it can now be adjusted individually for `img` (local image processing cache) and `img-ext` (external images). 38 | 39 | ## Migration 40 | 41 | `jampack` will automatically delete all cache structure and create the new one. There is no need to delete the old cache manually. 42 | 43 | ## Downside 44 | 45 | The downside is that the version number of the caches must be manually ajusted before release if bug fixes or features 46 | affecting the cache are not backward compatible with older caches. 47 | 48 | It requires more discipline. But it's easy to fix in a patch. 49 | 50 | ## Release 51 | 52 | - `0.13.0` 53 | -------------------------------------------------------------------------------- /packages/www/src/content/devlog/prefetch-links.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2023-09-13 3 | author: ['georges-gomes'] 4 | title: Prefetch links 5 | --- 6 | 7 | [jampack](/) has a new option to prefetch links on pages. 8 | 9 | This makes navigation to subsequent pages load faster. 10 | 11 | ```js 12 | { 13 | misc: { 14 | prefetch_links: 'in-viewport' | 'off'; 15 | } 16 | } 17 | ``` 18 | 19 | - `off`: No prefetch of links. 20 | - `in-viewport`: Links are prefetched when entering viewport. 21 | 22 | See [Prefetch links](/features/prefetch-links/) for more details. 23 | 24 | ## Released 25 | 26 | - `jampack 0.20.0+` 27 | -------------------------------------------------------------------------------- /packages/www/src/content/devlog/swyx-personal-site/20min.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/src/content/devlog/swyx-personal-site/20min.jpg -------------------------------------------------------------------------------- /packages/www/src/content/devlog/swyx-personal-site/jampack-waterfall-s1-static.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/src/content/devlog/swyx-personal-site/jampack-waterfall-s1-static.png -------------------------------------------------------------------------------- /packages/www/src/content/devlog/swyx-personal-site/jampack-waterfall-s2-jp091.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/src/content/devlog/swyx-personal-site/jampack-waterfall-s2-jp091.png -------------------------------------------------------------------------------- /packages/www/src/content/devlog/swyx-personal-site/jampack-waterfall-s3-jp093.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/src/content/devlog/swyx-personal-site/jampack-waterfall-s3-jp093.png -------------------------------------------------------------------------------- /packages/www/src/content/devlog/swyx-personal-site/jampack-waterfall-s4-jp093.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/src/content/devlog/swyx-personal-site/jampack-waterfall-s4-jp093.png -------------------------------------------------------------------------------- /packages/www/src/content/devlog/swyx-personal-site/original-waterfall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/src/content/devlog/swyx-personal-site/original-waterfall.png -------------------------------------------------------------------------------- /packages/www/src/content/devlog/swyx-personal-site/original-www.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/divriots/jampack/9e82e758d4837ffdb37bbd0046a54a41adc8c475/packages/www/src/content/devlog/swyx-personal-site/original-www.png -------------------------------------------------------------------------------- /packages/www/src/content/devlog/why-a-devlog.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | date: 2023-02-05 3 | author: ['georges-gomes'] 4 | title: Why a Devlog? 5 | --- 6 | 7 | I want to document the journey on `jampack`. 8 | 9 | I'm not into streaming or vlogs. I clearly don't have time to edit videos. 10 | 11 | This will feel like a journal. 12 | -------------------------------------------------------------------------------- /packages/www/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/www/src/languages.ts: -------------------------------------------------------------------------------- 1 | import { KNOWN_LANGUAGES, KNOWN_LANGUAGE_CODES } from './config'; 2 | export { KNOWN_LANGUAGES, KNOWN_LANGUAGE_CODES }; 3 | 4 | export const langPathRegex = /\/([a-z]{2}-?[A-Z]{0,2})\//; 5 | 6 | export function getLanguageFromURL(pathname: string) { 7 | const langCodeMatch = pathname.match(langPathRegex); 8 | const langCode = langCodeMatch ? langCodeMatch[1] : 'en'; 9 | return langCode as typeof KNOWN_LANGUAGE_CODES[number]; 10 | } 11 | -------------------------------------------------------------------------------- /packages/www/src/layouts/MainLayout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import HeadCommon from '../components/HeadCommon.astro'; 3 | import HeadSEO from '../components/HeadSEO.astro'; 4 | import Header from '../components/Header/Header.astro'; 5 | import PageContent from '../components/PageContent/PageContent.astro'; 6 | import LeftSidebar from '../components/LeftSidebar/LeftSidebar.astro'; 7 | import * as CONFIG from '../config'; 8 | 9 | type Props = { 10 | frontmatter: CONFIG.Frontmatter; 11 | }; 12 | 13 | const { frontmatter } = Astro.props as Props; 14 | const canonicalURL = new URL(Astro.url.pathname, Astro.site); 15 | const currentPage = Astro.url.pathname; 16 | const currentFile = `src/pages${currentPage.replace(/\/$/, '')}.md`; 17 | const githubEditUrl = `${CONFIG.GITHUB_EDIT_URL}/${currentFile}`; 18 | --- 19 | 20 | 21 | 22 | 23 | 24 | 25 | { frontmatter.title ? `${frontmatter.title} | ${CONFIG.SITE.title}` : `${CONFIG.SITE.title} | ${CONFIG.SITE.description}` } 26 | 27 | 89 | 104 | 105 | 106 | 107 |
108 |
109 | 112 |
113 | 114 | 115 | 116 |
117 | 120 |
121 | 122 | 123 | -------------------------------------------------------------------------------- /packages/www/src/pages/cache.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Cache 3 | description: Data processing is cached to avoid long and redundant processing. 4 | layout: ../layouts/MainLayout.astro 5 | --- 6 | 7 | `jampack` caches image processing to avoid lengthy and redundant image resizing and compression. 8 | The cache dramatically reduces the processing time of `jampack`. 9 | 10 | The cache is stored in the folder `.jampack/cache/`. 11 | 12 | ## CI Builds 13 | 14 | We recommend that you save and restore folder `.jampack/cache/` in your CI workflow 15 | to benefit from the cache between builds. This way, `jampack` will only process new images when present. 16 | 17 | ### Github Actions example 18 | 19 | This is fairly easy to do with Github Actions, using the [actions/cache@v3](https://github.com/actions/cache) action: 20 | 21 | ```yml 22 | - uses: actions/cache@v3 23 | with: 24 | path: '.jampack' 25 | key: jampack-${{ github.run_id }} 26 | restore-keys: | 27 | jampack 28 | - name: Build 29 | shell: bash 30 | run: npm run build 31 | ``` 32 | 33 | [This is the recommended setup](https://github.com/actions/cache/blob/main/tips-and-workarounds.md#update-a-cache) as at the moment there is no easy way to compute a hash for the `jampack` cache. This will effectively: 34 | - save the `.jampack` folder to a new cache named 'jampack' suffixed by the run ID, after the job runs. Do notice that you may want to have different keys if running `jampack` on different sites. Caches that have not been used for the longest time are evicted automatically, so this is safe 35 | - restore the last saved cached `.jampack` folder before the `Build` step, allowing `jampack` to reuse the cache 36 | 37 | ## Options 38 | 39 | See [CLI Options](./cli-options/) for options around cache management. -------------------------------------------------------------------------------- /packages/www/src/pages/chat.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/www/src/pages/cli-options.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: CLI Options 3 | description: List of options for `jampack` command-line. 4 | layout: ../layouts/MainLayout.astro 5 | --- 6 | 7 | | Options______ | Description | 8 | | ------------- | ------------------------------ | 9 | | `--onlyoptim` | Only runs optimization (PASS 1) | 10 | | `--onlycomp` | Only runs compression (PASS 2) | 11 | | `--fast` | Go fast. Mostly no compression just checks for issues. | 12 | | `--fail` | Exits with a non-zero return code if issues. | 13 | | `--nowrite` | Don't write anything to disk (for testing) | 14 | | `--include` | HTML files to include - by default all *.htm and *.html are included. Expect glob format like `--exclude 'blog/post100/index.html'` | 15 | | `--exclude` | Files to exclude from processing. Expect glob format like `--exclude 'blog/**'` | 16 | | `--cleancache`| Clean cache before running | 17 | | `--nocache` | Don't use the cache (no read or write to cache) | 18 | | `--cache_folder` | followed by the cache folder location. By default '.jampack/cache' | 19 | -------------------------------------------------------------------------------- /packages/www/src/pages/configuration.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configuration 3 | description: Configuration file for `jampack`. 4 | layout: ../layouts/MainLayout.astro 5 | --- 6 | 7 | import config_type from '../../../../src/config-types.ts?raw'; 8 | import config_default from '../../../../src/config-default.ts?raw'; 9 | 10 | The configuration file can be one of these files 11 | 12 | - `jampack.config.js` (in ESM or CJS format) 13 | - `jampack.config.mjs` 14 | - `jampack.config.cjs` 15 | - `config/jampack.config.js` (in ESM or CJS format) 16 | - `config/jampack.config.mjs` 17 | - `config/jampack.config.cjs` 18 | 19 | Or in a top-level `jampack` property in your `package.json`. 20 | 21 | ### Example 22 | 23 | ```js 24 | // jampack.config.js 25 | 26 | export default { 27 | image: { 28 | compress: false, 29 | }, 30 | }; 31 | ``` 32 | 33 | ## Options 34 | 35 | Available in [config-types.js](https://github.com/divriots/jampack/blob/main/src/config-types.ts). 36 | 37 |
38 |   {config_type}
39 | 
40 | 41 | ## Default values 42 | 43 | Available in [config-default.js](https://github.com/divriots/jampack/blob/main/src/config-default.ts). 44 | 45 |
46 |   {config_default}
47 | 
48 | 49 | ## Technical notes 50 | 51 | [Jampack](/) is using [Nate Moore](https://github.com/natemoo-re)'s [proload](https://github.com/natemoo-re/proload) package to load configuration. 52 | -------------------------------------------------------------------------------- /packages/www/src/pages/devlog/[...slug].astro: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | import { getCollection } from 'astro:content'; 4 | import MainLayout from '../../layouts/MainLayout.astro'; 5 | 6 | export async function getStaticPaths() { 7 | const blogEntries = await getCollection('devlog'); 8 | return blogEntries.map(entry => ({ 9 | params: { slug: entry.slug }, props: { entry }, 10 | })); 11 | } 12 | 13 | const { entry } = Astro.props; 14 | const { Content } = await entry.render(); 15 | --- 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/www/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { attrs, html, transform } from "ultrahtml"; 3 | import sanitize from "ultrahtml/transformers/sanitize"; 4 | import swap from "ultrahtml/transformers/swap"; 5 | import { compiledContent, getHeadings } from '../../../../README.md'; 6 | import MainLayout from '../layouts/MainLayout.astro'; 7 | import * as CONFIG from '../config'; 8 | 9 | // Clean content of

because already created by the layout 10 | // Clean
to remove the ‹div›RIOTS banner 11 | const content = compiledContent(); 12 | const output = await transform(content, [ 13 | swap({ 14 | a: (props, children) => { 15 | const newProps = {...props}; 16 | const href = props.href; 17 | if (!href.startsWith('https://jampack.divriots.com')) { 18 | newProps.rel = "nofollow"; 19 | } 20 | return html`${children}` 21 | }, 22 | }), 23 | sanitize({ dropElements: ["h1", "div"] }), 24 | ]); 25 | --- 26 | 27 | 28 |
29 | -------------------------------------------------------------------------------- /packages/www/src/pages/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | description: Installation process 4 | layout: ../layouts/MainLayout.astro 5 | --- 6 | 7 | Let us start by installing `jampack` as a development dependency: 8 | 9 | ```sh 10 | npm install -D @divriots/jampack 11 | ``` 12 | 13 | You can then either run `jampack` on its own, using `npm exec jampack [dir]` or add it to your build script. 14 | 15 | A simple build script like this one: 16 | 17 | ``` json 18 | "scripts": { 19 | "build": "vite build", 20 | }, 21 | ``` 22 | 23 | could then become 24 | 25 | ``` json 26 | "scripts": { 27 | "build": "vite build && jampack ./dist", 28 | }, 29 | ``` 30 | 31 | ...so `jampack` runs on all your builds! -------------------------------------------------------------------------------- /packages/www/src/styles/theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-fallback: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, 3 | sans-serif, Apple Color Emoji, Segoe UI Emoji; 4 | --font-body: system-ui, var(--font-fallback); 5 | --font-mono: 'IBM Plex Mono', Consolas, 'Andale Mono WT', 'Andale Mono', 6 | 'Lucida Console', 'Lucida Sans Typewriter', 'DejaVu Sans Mono', 7 | 'Bitstream Vera Sans Mono', 'Liberation Mono', 'Nimbus Mono L', Monaco, 8 | 'Courier New', Courier, monospace; 9 | 10 | /* 11 | * Variables with --color-base prefix define 12 | * the hue, and saturation values to be used for 13 | * hsla colors. 14 | * 15 | * ex: 16 | * 17 | * --color-base-{color}: {hue}, {saturation}; 18 | * 19 | */ 20 | 21 | --color-base-white: 0, 0%; 22 | --color-base-black: 240, 100%; 23 | --color-base-gray: 215, 14%; 24 | --color-base-blue: 212, 100%; 25 | --color-base-blue-dark: 212, 72%; 26 | --color-base-green: 158, 79%; 27 | --color-base-orange: 22, 100%; 28 | --color-base-purple: 269, 79%; 29 | --color-base-red: 351, 100%; 30 | --color-base-yellow: 41, 100%; 31 | 32 | /* 33 | * Color palettes are made using --color-base 34 | * variables, along with a lightness value to 35 | * define different variants. 36 | * 37 | */ 38 | 39 | --color-gray-5: var(--color-base-gray), 5%; 40 | --color-gray-10: var(--color-base-gray), 10%; 41 | --color-gray-20: var(--color-base-gray), 20%; 42 | --color-gray-30: var(--color-base-gray), 30%; 43 | --color-gray-40: var(--color-base-gray), 40%; 44 | --color-gray-50: var(--color-base-gray), 50%; 45 | --color-gray-60: var(--color-base-gray), 60%; 46 | --color-gray-70: var(--color-base-gray), 70%; 47 | --color-gray-80: var(--color-base-gray), 80%; 48 | --color-gray-90: var(--color-base-gray), 90%; 49 | --color-gray-95: var(--color-base-gray), 95%; 50 | 51 | --color-blue: var(--color-base-blue), 61%; 52 | --color-blue-dark: var(--color-base-blue-dark), 39%; 53 | --color-green: var(--color-base-green), 42%; 54 | --color-orange: var(--color-base-orange), 50%; 55 | --color-purple: var(--color-base-purple), 54%; 56 | --color-red: var(--color-base-red), 54%; 57 | --color-yellow: var(--color-base-yellow), 59%; 58 | } 59 | 60 | :root { 61 | color-scheme: light; 62 | --theme-accent: hsla(var(--color-blue), 1); 63 | --theme-text-accent: hsla(var(--color-blue), 1); 64 | --theme-accent-opacity: 0.15; 65 | --theme-divider: hsla(var(--color-gray-95), 1); 66 | --theme-text: hsla(var(--color-gray-10), 1); 67 | --theme-text-light: hsla(var(--color-gray-40), 1); 68 | /* @@@: not used anywhere */ 69 | --theme-text-lighter: hsla(var(--color-gray-80), 1); 70 | --theme-bg: hsla(var(--color-base-white), 100%, 1); 71 | --theme-bg-hover: hsla(var(--color-gray-95), 1); 72 | --theme-bg-offset: hsla(var(--color-gray-90), 1); 73 | --theme-bg-accent: hsla(var(--color-blue), var(--theme-accent-opacity)); 74 | --theme-code-inline-bg: hsla(var(--color-gray-95), 1); 75 | --theme-code-inline-text: var(--theme-text); 76 | --theme-code-bg: hsla(217, 19%, 27%, 1); 77 | --theme-code-text: hsla(var(--color-gray-95), 1); 78 | --theme-navbar-bg: var(--theme-bg); 79 | --theme-navbar-height: 5rem; 80 | --theme-selection-color: hsla(var(--color-blue), 1); 81 | --theme-selection-bg: hsla(var(--color-blue), var(--theme-accent-opacity)); 82 | } 83 | 84 | body { 85 | background: var(--theme-bg); 86 | color: var(--theme-text); 87 | } 88 | 89 | :root.theme-dark { 90 | color-scheme: dark; 91 | --theme-accent-opacity: 0.15; 92 | --theme-accent: hsla(var(--color-blue), 1); 93 | --theme-text-accent: hsla(var(--color-blue), 1); 94 | --theme-divider: hsla(var(--color-gray-10), 1); 95 | --theme-text: hsla(var(--color-gray-90), 1); 96 | --theme-text-light: hsla(var(--color-gray-80), 1); 97 | 98 | /* @@@: not used anywhere */ 99 | --theme-text-lighter: hsla(var(--color-gray-40), 1); 100 | --theme-bg: hsla(215, 28%, 10%, 1); 101 | --theme-bg-hover: hsla(var(--color-gray-40), 1); 102 | --theme-bg-offset: hsla(var(--color-gray-5), 1); 103 | --theme-code-inline-bg: hsla(var(--color-gray-20), 1); 104 | --theme-code-inline-text: hsla(var(--color-base-white), 100%, 1); 105 | --theme-code-bg: hsla(var(--color-gray-5), 1); 106 | --theme-code-text: hsla(var(--color-base-white), 100%, 1); 107 | --theme-selection-color: hsla(var(--color-base-white), 100%, 1); 108 | --theme-selection-bg: hsla(var(--color-purple), var(--theme-accent-opacity)); 109 | 110 | /* DocSearch [Algolia] */ 111 | --docsearch-modal-background: var(--theme-bg); 112 | --docsearch-searchbox-focus-background: var(--theme-divider); 113 | --docsearch-footer-background: var(--theme-divider); 114 | --docsearch-text-color: var(--theme-text); 115 | --docsearch-hit-background: var(--theme-divider); 116 | --docsearch-hit-shadow: none; 117 | --docsearch-hit-color: var(--theme-text); 118 | --docsearch-footer-shadow: inset 0 2px 10px #000; 119 | --docsearch-modal-shadow: inset 0 0 8px #000; 120 | } 121 | 122 | ::selection { 123 | color: var(--theme-selection-color); 124 | background-color: var(--theme-selection-bg); 125 | } 126 | -------------------------------------------------------------------------------- /packages/www/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/base", 3 | "compilerOptions": { 4 | "strictNullChecks": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import { hashSync as hasha } from 'hasha'; 2 | import path from 'path'; 3 | import * as fsp from 'fs/promises'; 4 | import { GlobalState } from './state.js'; 5 | import { CACHE_VERSIONS } from './packagejson.js'; 6 | 7 | const listOfCategories = ['img', 'img-ext'] as const; 8 | 9 | export type Category = (typeof listOfCategories)[number]; 10 | 11 | export type CacheData = { buffer: Buffer; meta: any }; 12 | 13 | function getCacheFolder(state: GlobalState): string { 14 | return state.args.cache_folder || '.jampack/cache'; 15 | } 16 | 17 | async function cleanCache(state: GlobalState, full?: boolean) { 18 | const CACHE_FOLDER = getCacheFolder(state); 19 | const fs = state.vfs ?? fsp; 20 | 21 | if (full) { 22 | try { 23 | await fs.rm(CACHE_FOLDER, { recursive: true }); 24 | } catch (e) { 25 | // Nothing to do, probably not present 26 | } 27 | return; 28 | } 29 | 30 | // Delete old cache category 31 | let catFolders: string[] = []; 32 | try { 33 | catFolders = await fs.readdir(CACHE_FOLDER); 34 | } catch (e) { 35 | // No problem 36 | } 37 | 38 | for (const f of catFolders) { 39 | // @ts-ignore 40 | if (!listOfCategories.includes(f)) 41 | fs.rm(path.join(CACHE_FOLDER, f), { recursive: true }); 42 | } 43 | 44 | // Loop cache folders 45 | for (const cat of listOfCategories) { 46 | const location = path.join(CACHE_FOLDER, cat); 47 | // List versions in cache 48 | let folders: string[]; 49 | try { 50 | folders = await fs.readdir(location); 51 | } catch (e) { 52 | continue; 53 | } 54 | 55 | // Delete old cache versions 56 | for (const f of folders) { 57 | if (f !== CACHE_VERSIONS[cat]) 58 | fs.rm(path.join(location, f), { recursive: true }); 59 | } 60 | } 61 | } 62 | 63 | function computeCacheHash(state: GlobalState, buffer: Buffer, options?: any) { 64 | if (state.args.nocache) { 65 | return ''; 66 | } 67 | 68 | let hash = `${hasha(buffer, { algorithm: 'sha256' })}`; 69 | if (options) { 70 | hash += '/' + hasha(JSON.stringify(options), { algorithm: 'md5' }); 71 | } 72 | return hash; 73 | } 74 | 75 | function getVersionOfCategory(category: Category): string { 76 | return CACHE_VERSIONS[category]; 77 | } 78 | 79 | function getLocation( 80 | state: GlobalState, 81 | hash: string, 82 | category: Category 83 | ): string { 84 | const CACHE_FOLDER = getCacheFolder(state); 85 | 86 | return path.join( 87 | CACHE_FOLDER, 88 | category, 89 | getVersionOfCategory(category), 90 | hash 91 | ); 92 | } 93 | 94 | async function getFromCache( 95 | state: GlobalState, 96 | category: Category, 97 | hash: string 98 | ): Promise { 99 | if (state.args.nocache) { 100 | return undefined; 101 | } 102 | const fs = state.vfs ?? fsp; 103 | 104 | const location = getLocation(state, hash, category); 105 | 106 | try { 107 | const buffer = await fs.readFile(path.join(location, 'data')); 108 | const meta = JSON.parse( 109 | (await fs.readFile(path.join(location, 'meta'))).toString() 110 | ); 111 | 112 | return { buffer, meta }; 113 | } catch (e) { 114 | // Problem during cache loading or not in cache 115 | } 116 | 117 | return undefined; 118 | } 119 | 120 | async function addToCache( 121 | state: GlobalState, 122 | category: Category, 123 | hash: string, 124 | data: CacheData 125 | ): Promise { 126 | if (state.args.nocache) { 127 | return; 128 | } 129 | const fs = state.vfs ?? fsp; 130 | 131 | const location = getLocation(state, hash, category); 132 | await fs.mkdir(location, { recursive: true }); 133 | await fs.writeFile(path.join(location, 'data'), data.buffer); 134 | await fs.writeFile(path.join(location, 'meta'), JSON.stringify(data.meta)); 135 | } 136 | 137 | export { cleanCache, computeCacheHash, getFromCache, addToCache }; 138 | -------------------------------------------------------------------------------- /src/compress.ts: -------------------------------------------------------------------------------- 1 | import { Stats } from 'fs'; 2 | import * as fsp from 'fs/promises'; 3 | import * as path from 'path'; 4 | import { formatBytes } from './utils.js'; 5 | import { GlobalState, ReportItem } from './state.js'; 6 | import { globby } from 'globby'; 7 | import ora from 'ora'; 8 | import { compressCSS } from './compressors/css.js'; 9 | import { compressJS } from './compressors/js.js'; 10 | import { compressHTML } from './compressors/html.js'; 11 | import { compressImage } from './compressors/images.js'; 12 | 13 | const processFile = async ( 14 | state: GlobalState, 15 | file: string, 16 | stats: Stats 17 | ): Promise => { 18 | let writeData: Buffer | string | undefined = undefined; 19 | const fs = state.vfs ?? fsp; 20 | 21 | try { 22 | const ext = path.extname(file); 23 | 24 | switch (ext) { 25 | case '.png': 26 | case '.jpg': 27 | case '.jpeg': 28 | case '.svg': 29 | case '.webp': 30 | case '.avif': 31 | if (state.options.image.compress) { 32 | const imgData = await fs.readFile(file); 33 | const newImage = await compressImage(state, imgData, {}); 34 | if (newImage?.data && newImage.data.length < stats.size) { 35 | writeData = newImage.data; 36 | } 37 | } 38 | break; 39 | case '.html': 40 | case '.htm': 41 | const htmldata = await fs.readFile(file); 42 | const newhtmlData = await compressHTML(state, htmldata); 43 | writeData = newhtmlData; 44 | break; 45 | case '.css': 46 | const cssdata = await fs.readFile(file); 47 | const newCSS = await compressCSS(state, cssdata); 48 | if (newCSS && newCSS.length < cssdata.length) { 49 | writeData = newCSS; 50 | } 51 | break; 52 | case '.js': 53 | const jsdata = await fs.readFile(file); 54 | const newJS = await compressJS(state, jsdata.toString()); 55 | if (newJS && newJS.length < jsdata.length) { 56 | writeData = newJS; 57 | } 58 | break; 59 | } 60 | } catch (e) { 61 | // console error for the moment 62 | console.error(`\n${file}`); 63 | console.error(e); 64 | } 65 | 66 | const result: ReportItem = { 67 | action: path.extname(file), 68 | originalSize: stats.size, 69 | compressedSize: stats.size, 70 | }; 71 | 72 | // Writedata 73 | if (writeData && writeData.length < result.originalSize) { 74 | result.compressedSize = writeData.length; 75 | 76 | if (!state.args.nowrite) { 77 | await fs.writeFile(file, writeData); 78 | } 79 | } 80 | 81 | state.compressedFiles.add(file); 82 | state.reportSummary(result); 83 | }; 84 | 85 | export async function compressFolder( 86 | state: GlobalState, 87 | exclude?: string 88 | ): Promise { 89 | const fs = state.vfs ?? fsp; 90 | const spinner = ora(getProgressText(state)).start(); 91 | 92 | const globs = ['**/**', '!_jampack/**']; // Exclude jampack folder because already compressed 93 | if (exclude) globs.push('!' + exclude); 94 | const paths = await globby(globs, { cwd: state.dir, absolute: true }); 95 | 96 | async function compressFile(file: string) { 97 | if (!state.compressedFiles.has(file)) { 98 | await processFile(state, file, await fs.stat(file)); 99 | spinner.text = getProgressText(state); 100 | } 101 | } 102 | 103 | if (!state.args.sequential_compress) { 104 | // "Parallel" processing 105 | await Promise.all(paths.map(compressFile)); 106 | } else { 107 | for (const file of paths) await compressFile(file); 108 | } 109 | 110 | spinner.text = getProgressText(state); 111 | spinner.succeed(); 112 | } 113 | 114 | const getProgressText = (state: GlobalState): string => { 115 | const gain = 116 | state.summary.dataLenUncompressed - state.summary.dataLenCompressed; 117 | return `${state.summary.nbFiles} files | ${formatBytes( 118 | state.summary.dataLenUncompressed 119 | )} → ${formatBytes(state.summary.dataLenCompressed)} | -${formatBytes( 120 | gain 121 | )} `; 122 | }; 123 | -------------------------------------------------------------------------------- /src/compressors/css.ts: -------------------------------------------------------------------------------- 1 | import browserslist from 'browserslist'; 2 | import { 3 | browserslistToTargets, 4 | transform as lightcss, 5 | transformStyleAttribute as lightcssStyleAttribute, 6 | } from 'lightningcss'; 7 | import { GlobalState } from '../state.js'; 8 | 9 | export const defaultTargets = () => 10 | browserslistToTargets(browserslist('defaults')); 11 | 12 | export async function compressCSS( 13 | { targets }: GlobalState, 14 | originalCode: Buffer, 15 | type?: 'inline' | undefined 16 | ): Promise { 17 | // Compress with lightningcss 18 | let lightCSSData: Uint8Array | undefined = undefined; 19 | try { 20 | const options = { 21 | code: originalCode, 22 | minify: true, 23 | sourceMap: false, 24 | targets, 25 | }; 26 | if (type === 'inline') { 27 | lightCSSData = lightcssStyleAttribute(options).code; 28 | } else { 29 | lightCSSData = lightcss({ 30 | filename: 'style.css', 31 | ...options, 32 | }).code; 33 | } 34 | } catch (e) { 35 | // Error while processing with lightningcss 36 | // Take original code 37 | // TODO catch SyntaxError and report a Warning 38 | } 39 | 40 | let resultBuffer: Buffer | undefined = undefined; 41 | if (lightCSSData && lightCSSData.length < originalCode.length) { 42 | resultBuffer = Buffer.from(lightCSSData); 43 | } 44 | 45 | return resultBuffer || originalCode; 46 | } 47 | 48 | export function loadConfigCSS(state: GlobalState): void { 49 | const { options } = state; 50 | } 51 | -------------------------------------------------------------------------------- /src/compressors/html.ts: -------------------------------------------------------------------------------- 1 | import { minify } from 'html-minifier-terser'; 2 | import { compressCSS } from './css.js'; 3 | import { compressJS } from './js.js'; 4 | import { GlobalState } from '../state.js'; 5 | 6 | async function minifyJSinHTML( 7 | state: GlobalState, 8 | originalCode: string 9 | ): Promise { 10 | const newCode = await compressJS(state, originalCode); 11 | if (newCode && newCode.length < originalCode.length) return newCode; 12 | return originalCode; 13 | } 14 | 15 | async function minifyCSSinHTML( 16 | state: GlobalState, 17 | originalCode: string, 18 | type: string | undefined 19 | ): Promise { 20 | // Don't compress media 21 | if (type !== undefined && type !== 'inline') return originalCode; 22 | 23 | const originalBuffer = Buffer.from(originalCode); 24 | const newCSS = await compressCSS(state, originalBuffer, type); 25 | if (newCSS && newCSS.length > 0 && newCSS.length < originalBuffer.length) 26 | return newCSS.toString(); 27 | return originalCode; 28 | } 29 | 30 | export async function compressHTML( 31 | state: GlobalState, 32 | originalCode: Buffer 33 | ): Promise { 34 | const newhtmlData = await minify(originalCode.toString(), { 35 | minifyCSS: (text, type) => minifyCSSinHTML(state, text, type), 36 | minifyJS: (text) => minifyJSinHTML(state, text), 37 | sortClassName: true, 38 | sortAttributes: state.options.html.sort_attributes, 39 | }); 40 | 41 | if (newhtmlData) return Buffer.from(newhtmlData, 'utf-8'); 42 | 43 | return originalCode; 44 | } 45 | -------------------------------------------------------------------------------- /src/compressors/images.ts: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp'; 2 | import { optimize as svgo } from 'svgo'; 3 | import { getFromCache, addToCache, computeCacheHash } from '../cache.js'; 4 | import { WebpOptions } from '../config-types.js'; 5 | import { MimeType } from 'file-type'; 6 | import { GlobalState } from '../state.js'; 7 | 8 | export type ImageMimeType = MimeType | 'image/svg+xml'; 9 | 10 | export const AllImageFormat = ['webp', 'svg', 'jpg', 'png', 'avif']; 11 | export type ImageFormat = (typeof AllImageFormat)[number] | undefined; 12 | 13 | export type Image = { 14 | format: ImageFormat; 15 | data: Buffer; 16 | }; 17 | 18 | export type ImageOutputOptions = { 19 | resize?: sharp.ResizeOptions; 20 | toFormat?: 'webp' | 'avif' | 'png' | 'jpeg' | 'unchanged'; 21 | }; 22 | 23 | function createWebpOptions(opt: WebpOptions | undefined): sharp.WebpOptions { 24 | return { 25 | nearLossless: opt!.mode === 'lossless', 26 | quality: opt!.quality, 27 | effort: opt!.effort, 28 | }; 29 | } 30 | 31 | export async function compressImage( 32 | state: GlobalState, 33 | data: Buffer, 34 | options: ImageOutputOptions 35 | ): Promise { 36 | const cacheHash = computeCacheHash(state, data, options); 37 | const imageFromCache = await getFromCache(state, 'img', cacheHash); 38 | if (imageFromCache) { 39 | return { data: imageFromCache.buffer, format: imageFromCache.meta }; 40 | } 41 | 42 | // Load modifiable toFormat 43 | let toFormat = options.toFormat || 'unchanged'; 44 | 45 | let sharpFile = sharp(data, { animated: true }); 46 | sharpFile = sharpFile.rotate(); // Rotate image based on EXIF data (because EXIF data is removed) 47 | const meta = await sharpFile.metadata(); 48 | 49 | if (meta.pages && meta.pages > 1) { 50 | // Skip animated images for the moment. 51 | return undefined; 52 | } 53 | 54 | let outputFormat: ImageFormat; 55 | const imageOptions = state.options.image; 56 | // Special case for svg 57 | if (meta.format === 'svg') { 58 | if (!imageOptions.svg.optimization) return undefined; 59 | 60 | try { 61 | const output = svgo(data.toString(), { 62 | multipass: true, 63 | plugins: [ 64 | { 65 | name: 'preset-default', 66 | params: { 67 | overrides: { 68 | removeViewBox: false, 69 | }, 70 | }, 71 | }, 72 | ], 73 | }); 74 | return { format: 'svg', data: Buffer.from(output.data, 'utf8') }; 75 | } catch (e) { 76 | // In case of any issue with svg compression: 77 | return undefined; 78 | } 79 | } 80 | 81 | // TODO 82 | // Use information of input image to the destination format 83 | // - Progressive (unless overriden) 84 | // - Lossless 85 | 86 | // The bitmap images 87 | if (toFormat === 'unchanged') { 88 | switch (meta.format) { 89 | case 'png': 90 | sharpFile = sharpFile.png(imageOptions.png.options || {}); 91 | outputFormat = 'png'; 92 | break; 93 | case 'jpeg': 94 | case 'jpg': 95 | sharpFile = sharpFile.jpeg(imageOptions.jpeg.options || {}); 96 | outputFormat = 'jpg'; 97 | break; 98 | case 'webp': 99 | sharpFile = sharpFile.webp( 100 | createWebpOptions(imageOptions.webp.options_lossly) || {} 101 | ); 102 | outputFormat = 'webp'; 103 | break; 104 | case 'heif': 105 | case 'avif': 106 | sharpFile = sharpFile.avif({ effort: 4, quality: 50 }); // TODO create config for avif 107 | outputFormat = 'avif'; 108 | break; 109 | } 110 | } else { 111 | // To format 112 | switch (toFormat) { 113 | case 'jpeg': 114 | sharpFile = sharpFile.jpeg({ ...imageOptions.jpeg.options }); 115 | outputFormat = 'jpg'; 116 | break; 117 | case 'png': 118 | sharpFile = sharpFile.png({ ...imageOptions.png.options }); 119 | outputFormat = 'png'; 120 | break; 121 | case 'webp': 122 | sharpFile = sharpFile.webp( 123 | createWebpOptions( 124 | meta.format === 'png' 125 | ? imageOptions.webp.options_lossless 126 | : imageOptions.webp.options_lossly 127 | ) 128 | ); 129 | outputFormat = 'webp'; 130 | break; 131 | case 'avif': 132 | sharpFile = sharpFile.avif({ 133 | effort: 4, 134 | quality: meta.format === 'png' ? 80 : 60, // don't use lossless avif it doesn't compress well in most png uses cases 135 | }); 136 | outputFormat = 'avif'; 137 | break; 138 | } 139 | } 140 | 141 | // Unknow input format or output format 142 | // Can't do 143 | if (!outputFormat) return undefined; 144 | 145 | // If resize is requested 146 | if (options.resize?.width || options.resize?.height) { 147 | const resize = options.resize; 148 | sharpFile = sharpFile.resize({ 149 | ...resize, 150 | withoutEnlargement: true, 151 | }); 152 | } 153 | 154 | // 155 | // Output image processed 156 | // 157 | 158 | // Add to cache 159 | const outputImage: Image = { 160 | format: outputFormat, 161 | data: await sharpFile.toBuffer(), 162 | }; 163 | 164 | await addToCache(state, 'img', cacheHash, { 165 | buffer: outputImage.data, 166 | meta: outputImage.format, 167 | }); 168 | 169 | // Go 170 | return outputImage; 171 | } 172 | -------------------------------------------------------------------------------- /src/compressors/js.ts: -------------------------------------------------------------------------------- 1 | import swc from '@swc/core'; 2 | import * as esbuild from 'esbuild'; 3 | import { GlobalState } from '../state.js'; 4 | 5 | export async function compressJS( 6 | { options }: GlobalState, 7 | originalCode: string 8 | ): Promise { 9 | let resultCode = originalCode; 10 | 11 | switch (options.js.compressor) { 12 | case 'esbuild': 13 | resultCode = ( 14 | await esbuild.transform(originalCode, { 15 | minify: true, 16 | }) 17 | ).code; 18 | break; 19 | case 'swc': 20 | resultCode = ( 21 | await swc.minify(originalCode, { 22 | compress: true, 23 | mangle: true, 24 | }) 25 | ).code; 26 | break; 27 | } 28 | return resultCode; 29 | } 30 | -------------------------------------------------------------------------------- /src/config-default.ts: -------------------------------------------------------------------------------- 1 | import { Options } from './config-types.js'; 2 | 3 | const default_options: Options = { 4 | general: { 5 | browserslist: 'defaults', // defaults = '> 0.5%, last 2 versions, Firefox ESR, not dead' 6 | }, 7 | html: { 8 | add_css_reset_as: 'off', 9 | sort_attributes: false, 10 | }, 11 | css: { 12 | inline_critical_css: false, 13 | }, 14 | js: { 15 | compressor: 'esbuild', 16 | }, 17 | image: { 18 | embed_size: 1500, 19 | srcset_min_width: 390 * 2, // HiDPI phone 20 | srcset_max_width: 1920 * 2, // 4K 21 | srcset_step: 300, 22 | max_width: 99999, 23 | src_include: /^.*$/, 24 | src_exclude: /^\/vercel\/image\?/, // Ignore /vervel/image? URLs because not local and most likely already optimized, 25 | external: { 26 | process: 'off', 27 | src_include: /^.*$/, 28 | src_exclude: null, 29 | }, 30 | cdn: { 31 | process: 'off', 32 | src_include: null, 33 | src_exclude: null, 34 | }, 35 | compress: true, 36 | jpeg: { 37 | options: { 38 | quality: 75, 39 | mozjpeg: true, 40 | }, 41 | }, 42 | png: { 43 | options: { 44 | compressionLevel: 9, 45 | }, 46 | }, 47 | webp: { 48 | options_lossless: { 49 | effort: 4, 50 | quality: 77, 51 | mode: 'lossless', 52 | }, 53 | options_lossly: { 54 | effort: 4, 55 | quality: 77, 56 | mode: 'lossly', 57 | }, 58 | }, 59 | svg: { 60 | optimization: true, 61 | add_width_and_height: false, 62 | }, 63 | }, 64 | iframe: { 65 | lazyload: { 66 | when: 'below-the-fold', 67 | how: 'native', 68 | }, 69 | }, 70 | video: { 71 | autoplay_lazyload: { 72 | when: 'below-the-fold', 73 | how: 'js', 74 | }, 75 | }, 76 | misc: { 77 | prefetch_links: 'off', 78 | }, 79 | }; 80 | 81 | export default default_options; 82 | -------------------------------------------------------------------------------- /src/config-fast.ts: -------------------------------------------------------------------------------- 1 | //import { Options } from './config-types.js'; 2 | 3 | const fast_options_override: any = { 4 | image: { 5 | embed_size: 0, 6 | srcset_min_width: 99999, 7 | compress: false, 8 | jpeg: { 9 | options: { 10 | mozjpeg: false, 11 | }, 12 | }, 13 | png: { 14 | options: { 15 | compressionLevel: 0, 16 | }, 17 | }, 18 | webp: { 19 | options_lossless: { 20 | effort: 0, 21 | }, 22 | options_lossly: { 23 | effort: 0, 24 | }, 25 | }, 26 | svg: { 27 | optimization: false, 28 | }, 29 | }, 30 | misc: { 31 | prefetch_links: 'none', 32 | }, 33 | }; 34 | 35 | export default fast_options_override; 36 | -------------------------------------------------------------------------------- /src/config-types.ts: -------------------------------------------------------------------------------- 1 | import type { UrlTransformer } from 'unpic'; 2 | 3 | export type WebpOptions = { 4 | effort: number; 5 | mode: 'lossless' | 'lossly'; 6 | quality: number; 7 | }; 8 | 9 | export type Options = { 10 | general: { 11 | browserslist: string; // browserslist query string 12 | }; 13 | html: { 14 | add_css_reset_as: 'inline' | 'off'; // 'inline': adds "" on top of the 15 | sort_attributes: boolean; 16 | }; 17 | js: { 18 | compressor: 'esbuild' | 'swc'; // swc have smaller result but can break code (seen with SvelteKit code) 19 | }; 20 | css: { 21 | inline_critical_css: boolean; 22 | browserslist?: string; // If present, overrides general.browserslist just for CSS 23 | }; 24 | image: { 25 | embed_size: number; // Embed above the fold images if size < embed_size 26 | srcset_min_width: number; // Minimum width of generate image in srcset 27 | srcset_max_width: number; // Maximum width of generate image in srcset 28 | srcset_step: number; // Number of pixels between sizes in srcset 29 | max_width: number; // Maximum width of original images - if bigger => resized output 30 | src_include: RegExp; 31 | src_exclude: RegExp | null; 32 | external: { 33 | process: 34 | | 'off' // Default 35 | | 'download' // Experimental 36 | | ((attrib_src: string) => Promise); // Experimental 37 | src_include: RegExp; 38 | src_exclude: RegExp | null; 39 | }; 40 | cdn: { 41 | process: 42 | | 'off' //default 43 | | 'optimize'; 44 | src_include: RegExp | null; 45 | src_exclude: RegExp | null; 46 | transformer?: UrlTransformer; // Custom 'unpic' cdn url transformer, if not present it will be determined by 'unpic' based on original url 47 | }; 48 | compress: boolean; 49 | jpeg: { 50 | options: { 51 | quality: number; 52 | mozjpeg: boolean; 53 | }; 54 | }; 55 | png: { 56 | options: { 57 | compressionLevel: number; 58 | }; 59 | }; 60 | webp: { 61 | options_lossless: WebpOptions; 62 | options_lossly: WebpOptions; 63 | }; 64 | svg: { 65 | optimization: boolean; 66 | add_width_and_height: boolean; 67 | }; 68 | }; 69 | iframe: { 70 | lazyload: { 71 | when: // Default: 'below-the-fold' 72 | | 'never' // All iframes are loaded eagerly 73 | | 'below-the-fold' // Iframe are lazy loaded only if they are below the fold 74 | | 'always'; // Not recommended, but if you want to lazy load all iframes 75 | how: // Default: 'native' 76 | | 'native' // Using `loading="lazy" attribue on iframe tag 77 | | 'js'; // Using IntersectionObserver. Requires ~1Ko of JS but is more precise than native lazyload 78 | }; 79 | }; 80 | video: { 81 | autoplay_lazyload: { 82 | // Only for videos with autoplay 83 | when: // Default: 'below-the-fold' 84 | | 'never' // All video are loaded eagerly 85 | | 'below-the-fold' // videos are lazy loaded only if they are below the fold 86 | | 'always'; // Not recommended 87 | how: 'js'; // Using IntersectionObserver. Requires ~1Ko of JS 88 | }; 89 | }; 90 | misc: { 91 | prefetch_links: 'in-viewport' | 'off'; 92 | }; 93 | }; 94 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import load from './proload/esm/index.mjs'; 3 | import deepmerge from 'deepmerge'; 4 | 5 | import default_options from './config-default.js'; 6 | import fast_options_override from './config-fast.js'; 7 | import { GlobalState } from './state.js'; 8 | import { loadConfigCSS } from './compressors/css.js'; 9 | 10 | export function fast(state: GlobalState) { 11 | const options = default_options; 12 | Object.assign(state.options, deepmerge(options, fast_options_override)); 13 | } 14 | 15 | export async function loadConfig(state: GlobalState) { 16 | const options = default_options; 17 | const proload = await load('jampack', { mustExist: false }); 18 | if (proload) { 19 | console.log('Merging default config with:'); 20 | console.log(JSON.stringify(proload.value, null, 2)); 21 | Object.assign(state.options, deepmerge(options, proload.value)); 22 | } 23 | loadConfigCSS(state); 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Command } from '@commander-js/extra-typings'; 4 | import { compressFolder } from './compress.js'; 5 | import { optimize } from './optimize.js'; 6 | import { GlobalState } from './state.js'; 7 | import { table, TableUserConfig } from 'table'; 8 | import { formatBytes } from './utils.js'; 9 | import { fast, loadConfig } from './config.js'; 10 | import { printTitle } from './logger.js'; 11 | import { exit } from 'process'; 12 | import kleur from 'kleur'; 13 | import { cleanCache } from './cache.js'; 14 | import { VERSION } from './packagejson.js'; 15 | import { mkdirSync } from 'fs'; 16 | import { join } from 'path'; 17 | 18 | const logo = ` __ __ 19 | |__|____ _____ ___________ ____ | | __ 20 | | \\__ \\ / \\\\____ \\__ \\ _/ ___\\| |/ / 21 | | |/ __ \\| Y Y \\ |_> > __ \\\\ \\___| < 22 | /\\__| (____ /__|_| / __(____ /\\___ >__|_ \\ 23 | \\______| \\/ \\/| | \\/ \\/ \\/ 24 | v${VERSION.padEnd(14)} |__| by ‹div›RIOTS 25 | `; 26 | 27 | console.log(logo); 28 | 29 | const program = new Command(); 30 | 31 | program 32 | .name('jampack') 33 | .description('Static website Optimizer') 34 | .version(VERSION); 35 | 36 | program 37 | .command('pack', { isDefault: true }) 38 | .description('todo') 39 | .argument('', 'Directory to pack') 40 | .option('--include ', 'Glob to include') 41 | .option('--exclude ', 'Glob to exclude') 42 | .option('--nowrite', 'No write') 43 | .option('--fast', 'Go fast. Mostly no compression just checks for issues.') 44 | .option('--fail', 'Exits with a non-zero return code if issues.') 45 | .option('--onlyoptim', 'Only optimize (PASS 1).') 46 | .option('--onlycomp', 'Only compress (PASS 2).') 47 | .option('--cache_folder ', 'Default: .jampack/cache') 48 | .option( 49 | '--sequential_compress', 50 | 'Whether to perform folder compression sequentially. Reduces memoru footprint on compress. Default: false' 51 | ) 52 | .option('--cleancache', 'Clean cache before running') 53 | .option('--nocache', 'Run with no use of cache') 54 | .action(async (dir, options) => { 55 | const state = new GlobalState(); 56 | 57 | // Arguments 58 | state.dir = dir; 59 | state.args = options; 60 | 61 | // Print options 62 | if (options) { 63 | console.log('Options:'); 64 | console.log(options); 65 | console.log(''); 66 | } 67 | 68 | // Override default config with config file 69 | await loadConfig(state); 70 | 71 | // Override config with fast options if set 72 | if (options.fast) { 73 | fast(state); 74 | } 75 | 76 | // Clean cache 77 | await cleanCache(state, options.cleancache); 78 | 79 | // Make _jampack folder 80 | try { 81 | mkdirSync(join(dir, '_jampack')); 82 | } catch (e) { 83 | console.error( 84 | 'Folder `_jampack` is present in target folder. This means that jampack has already processed this folder. You should always run jampack on clean build of the static website.' 85 | ); 86 | exit(1); 87 | } 88 | 89 | if (!options.onlycomp) { 90 | printTitle('PASS 1 - Optimizing'); 91 | console.time('Done'); 92 | await optimize(state, options.include, options.exclude); 93 | console.timeEnd('Done'); 94 | } 95 | 96 | if (!options.onlyoptim && !options.fast) { 97 | printTitle('PASS 2 - Compressing the rest'); 98 | console.time('Done'); 99 | await compressFolder(state, options.exclude); 100 | console.timeEnd('Done'); 101 | } 102 | 103 | printSummary(state); 104 | 105 | printIssues(state); 106 | 107 | if (options.fail && state.issues.size > 0) { 108 | exit(1); 109 | } 110 | exit(0); 111 | }); 112 | 113 | program.parse(); 114 | 115 | function printSummary($state: GlobalState) { 116 | if ($state.summary.nbFiles > 0) { 117 | printTitle('Summary'); 118 | 119 | const dataTable: any[] = [ 120 | ['Action', 'Compressed', 'Original', 'Compressed', 'Gain'], 121 | ]; 122 | const config: TableUserConfig = { 123 | columns: [ 124 | { alignment: 'left' }, 125 | { alignment: 'right' }, 126 | { alignment: 'right' }, 127 | { alignment: 'right' }, 128 | { alignment: 'right' }, 129 | ], 130 | drawHorizontalLine: (lineIndex, rowCount) => { 131 | return ( 132 | lineIndex === 0 || 133 | lineIndex === 1 || 134 | lineIndex === rowCount - 1 || 135 | lineIndex === rowCount 136 | ); 137 | }, 138 | }; 139 | 140 | const unCompressedDataRows: any[] = []; 141 | 142 | Object.entries($state.summaryByExtension).forEach(([ext, summary]) => { 143 | const gain = summary.dataLenUncompressed - summary.dataLenCompressed; 144 | 145 | const row = [ 146 | ext, 147 | `${summary.nbFilesCompressed} / ${summary.nbFiles}`, 148 | formatBytes(summary.dataLenUncompressed), 149 | formatBytes(summary.dataLenCompressed), 150 | 151 | gain > 0 ? '-' + formatBytes(gain) : '', 152 | ]; 153 | 154 | if (gain > 0) { 155 | dataTable.push(row); 156 | } else { 157 | unCompressedDataRows.push(row); 158 | } 159 | }); 160 | 161 | // Add uncompress rows at the end 162 | dataTable.push(...unCompressedDataRows); 163 | 164 | const total = [ 165 | 'Total', 166 | `${$state.summary.nbFilesCompressed} / ${$state.summary.nbFiles}`, 167 | formatBytes($state.summary.dataLenUncompressed), 168 | formatBytes($state.summary.dataLenCompressed), 169 | '-' + 170 | formatBytes( 171 | $state.summary.dataLenUncompressed - $state.summary.dataLenCompressed 172 | ), 173 | ]; 174 | dataTable.push(total); 175 | 176 | console.log(table(dataTable, config)); 177 | } 178 | } 179 | 180 | function printIssues(state: GlobalState) { 181 | let issueCount = 0; 182 | 183 | if (state.issues.size === 0) { 184 | printTitle('✔ No issues'); 185 | } else { 186 | printTitle('Issues', kleur.bgRed); 187 | console.log(''); 188 | for (let [file, list] of state.issues) { 189 | console.log('▶ ' + file + '\n'); 190 | list.forEach((issue) => { 191 | issueCount++; 192 | console.log(`${kleur.bgYellow(` ${issue.type} `)} ${issue.msg}\n`); 193 | }); 194 | } 195 | printTitle( 196 | `${issueCount} issue(s) over ${state.issues.size} files`, 197 | kleur.bgYellow 198 | ); 199 | console.log(''); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import kleur from 'kleur'; 2 | 3 | export class Logger { 4 | 5 | private prefix: string = ''; 6 | 7 | Logger(prefix: string = '') { 8 | this.prefix = prefix; 9 | } 10 | 11 | debug(message: string): void { 12 | console.debug(this.prefix+message); 13 | } 14 | 15 | info(message: string): void { 16 | console.info(this.prefix+message); 17 | } 18 | 19 | warn(message: string): void { 20 | console.warn(this.prefix+message); 21 | } 22 | 23 | error(message: string): void { 24 | console.error(this.prefix+message); 25 | } 26 | 27 | } 28 | 29 | export function printTitle(msg: string, bgColor: (x: string | number) => string = kleur.bgGreen) { 30 | console.log(''); 31 | console.log(kleur.black(bgColor(` ${msg} `))); 32 | } 33 | -------------------------------------------------------------------------------- /src/optimizers/img-external.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fsp from 'fs/promises'; 3 | import { GlobalState } from '../state.js'; 4 | import { hashSync as hasha } from 'hasha'; 5 | import { fileTypeFromBuffer } from 'file-type'; 6 | import { addToCache, getFromCache } from '../cache.js'; 7 | import { parse } from '../utils/cache-control-parser.js'; 8 | import '../utils/polyfill-fetch.js'; 9 | 10 | export async function downloadExternalImage( 11 | state: GlobalState, 12 | htmlfile: string, 13 | href: string 14 | ): Promise { 15 | const hash = hasha(href, { 16 | algorithm: 'md5', 17 | }); 18 | 19 | // Default with values from cache 20 | let buffer: Buffer | undefined; 21 | let ext: string | undefined; 22 | 23 | // Image is in cache? 24 | const dataFromCache = await getFromCache(state, 'img-ext', hash); 25 | const cacheControl = dataFromCache?.meta?.CacheControl; 26 | 27 | if (dataFromCache && cacheControl) { 28 | const date = dataFromCache?.meta?.Date; 29 | const maxAge = cacheControl['max-age']; 30 | const expires = dataFromCache?.meta?.Expires; 31 | let expireTime = NaN; 32 | if (date && maxAge) { 33 | expireTime = Date.parse(date) + maxAge * 1000; 34 | } else if (expires) { 35 | expireTime = Date.parse(expires); 36 | } else { 37 | // No max-age with Date and no expires 38 | // No information for cache validity 39 | // We have to make a request 40 | } 41 | // Is cache still valid? 42 | if (expireTime && Date.now() < expireTime) { 43 | buffer = dataFromCache.buffer; 44 | ext = dataFromCache.meta.Extension; 45 | } 46 | } 47 | 48 | // No buffer, then no cache hit so let's HTTP request 49 | if (!buffer) { 50 | const lastdate = dataFromCache?.meta?.Date; 51 | const fetchOption: RequestInit = lastdate 52 | ? { headers: { 'If-Modified-Since': lastdate } } 53 | : {}; 54 | 55 | console.log('Downloading ' + href); 56 | const resp = await fetch(href, fetchOption); 57 | 58 | switch (resp.status) { 59 | case 200: // New image 60 | const responseBuffer = await resp.arrayBuffer(); 61 | 62 | // Detect extension 63 | ext = (await fileTypeFromBuffer(responseBuffer))?.ext; 64 | 65 | buffer = Buffer.from(responseBuffer); 66 | 67 | // Did the server requested no cache? 68 | const cacheControl = parse(resp.headers.get('Cache-Control') || ''); 69 | const maxAge = cacheControl['max-age']; 70 | if ( 71 | cacheControl['no-store'] || 72 | cacheControl['no-cache'] || 73 | cacheControl['must-revalidate'] || 74 | (maxAge !== undefined && maxAge < 1) 75 | ) { 76 | // No cache requested 77 | } else { 78 | // Add downloaded image to cache 79 | await addToCache(state, 'img-ext', hash, { 80 | buffer, 81 | meta: { 82 | Date: resp.headers.get('Date'), 83 | Extension: ext, 84 | CacheControl: cacheControl, 85 | }, 86 | }); 87 | } 88 | 89 | break; 90 | case 304: // Not modified - image in cache is good to use 91 | if (dataFromCache) { 92 | buffer = dataFromCache.buffer; 93 | ext = dataFromCache.meta.Extension; 94 | } else { 95 | // This is not possible 96 | throw new Error('Assert error: 304 responses but no data in cache'); 97 | } 98 | break; 99 | default: // Otherwise something is wrong 100 | throw new Error(resp.statusText); 101 | } 102 | } 103 | 104 | // buffer can't be undefined here 105 | if (!buffer) throw new Error('Buffer is undefined'); 106 | 107 | // Construct contenthash 108 | const contentHash = hasha(buffer, { algorithm: 'md5' }); 109 | 110 | // Construct local filename relative to root dir 111 | if (!ext) throw new Error('Unknown image format'); 112 | const htmlFolder = path.dirname(htmlfile); 113 | const filename = path.relative( 114 | path.join(state.dir, htmlFolder), 115 | path.join(state.dir, `_jampack/${contentHash}.${ext}`) 116 | ); 117 | 118 | await (state.vfs??fsp).writeFile(path.join(state.dir, htmlFolder, filename), buffer); 119 | 120 | return filename; 121 | } 122 | -------------------------------------------------------------------------------- /src/optimizers/inline-critical-css.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import Critters from 'critters'; 3 | 4 | export function inlineCriticalCss(path: string, html: string) { 5 | const critters = new Critters({ 6 | compress: false, 7 | fonts: false, 8 | reduceInlineStyles: false, 9 | inlineThreshold: 0, 10 | logLevel: 'info', 11 | path, 12 | }); 13 | return critters.process(html); 14 | } 15 | -------------------------------------------------------------------------------- /src/optimizers/prefetch-links.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'node:module'; 2 | import { GlobalState } from '../state.js'; 3 | import { install_dependency } from '../utils/install-dep.js'; 4 | 5 | const require = createRequire(import.meta.url); 6 | 7 | export async function prefetch_links_in_viewport( 8 | state: GlobalState, 9 | html_file: string, 10 | appendToBody: Record 11 | ): Promise { 12 | await install_dependency( 13 | state, 14 | html_file, 15 | { 16 | source: { 17 | npm_package_name: 'quicklink', 18 | absolute_path_to_file: '/dist', 19 | filename: 'quicklink.mjs', 20 | }, 21 | destination: { 22 | folder_name: 'quicklink-2.3.0', 23 | code_loader: `import { listen } from "./quicklink.mjs"; 24 | listen();`, 25 | }, 26 | }, 27 | appendToBody 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/optimizers/process-iframe.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from '@divriots/cheerio'; 2 | import type { GlobalState } from '../state.js'; 3 | import { install_dependency, install_lozad } from '../utils/install-dep.js'; 4 | 5 | export async function processIframe( 6 | state: GlobalState, 7 | htmlfile: string, 8 | iframe: cheerio.Cheerio, 9 | isAboveTheFold: boolean, 10 | appendToBody: Record 11 | ): Promise { 12 | const lazyloadOptions = state.options.iframe.lazyload; 13 | if (lazyloadOptions.when === 'never' || iframe.attr('loading') === 'eager') { 14 | // If lazy loading is set to 'never' or 'eager', do not modify the iframe 15 | return; 16 | } 17 | 18 | if ( 19 | lazyloadOptions.when === 'always' || 20 | (lazyloadOptions.when === 'below-the-fold' && !isAboveTheFold) 21 | ) { 22 | if (lazyloadOptions.how === 'native') { 23 | iframe.attr('loading', 'lazy'); 24 | } else if (lazyloadOptions.how === 'js') { 25 | const src = iframe.attr('src'); 26 | if (src) { 27 | iframe.attr('class', 'jampack-lozad'); 28 | iframe.attr('data-src', src); 29 | iframe.removeAttr('src'); 30 | await install_lozad(state, htmlfile, appendToBody); 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/optimizers/process-video.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from '@divriots/cheerio'; 2 | import type { GlobalState } from '../state.js'; 3 | import { install_lozad } from '../utils/install-dep.js'; 4 | 5 | export async function processVideo( 6 | state: GlobalState, 7 | htmlfile: string, 8 | video: cheerio.Cheerio, 9 | isAboveTheFold: boolean, 10 | appendToBody: Record 11 | ): Promise { 12 | const autoplay = video.attr('autoplay'); 13 | if (!autoplay || autoplay === '0' || autoplay === 'false') { 14 | // If the video is not autoplay we can postpone loading 15 | // if there is a poster 16 | if (video.attr('poster')) { 17 | video.attr('preload', 'none'); 18 | return; 19 | } 20 | 21 | // TODO create poster for videos without poster 22 | } 23 | 24 | const lazyloadOptions = state.options.video.autoplay_lazyload; 25 | if (lazyloadOptions.when === 'never') { 26 | return; 27 | } 28 | 29 | if ( 30 | lazyloadOptions.when === 'always' || 31 | (lazyloadOptions.when === 'below-the-fold' && !isAboveTheFold) 32 | ) { 33 | if (lazyloadOptions.how === 'js') { 34 | video.attr('class', 'jampack-lozad'); 35 | const src = video.attr('src'); 36 | if (src) { 37 | video.attr('data-src', src); 38 | video.removeAttr('src'); 39 | } 40 | await install_lozad(state, htmlfile, appendToBody); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/packagejson.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 6 | const pkgjson = JSON.parse( 7 | fs.readFileSync(path.join(__dirname, '../package.json')).toString() 8 | ); 9 | 10 | export const VERSION: string = pkgjson.version; 11 | export const CACHE_VERSIONS: Record = pkgjson['cache-version']; 12 | -------------------------------------------------------------------------------- /src/proload/README.md: -------------------------------------------------------------------------------- 1 | Copied and modified from 2 | https://github.com/natemoo-re/proload/tree/main/packages/core 3 | Because not compatible with Node 23 anymore -------------------------------------------------------------------------------- /src/proload/error.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type import('./error.cjs').ProloadError 3 | */ 4 | class ProloadError extends Error { 5 | constructor(opts={}) { 6 | super(opts.message); 7 | this.name = 'ProloadError'; 8 | this.code = opts.code || 'ERR_PROLOAD_INVALID'; 9 | if (Error.captureStackTrace) { 10 | Error.captureStackTrace(this, this.constructor); 11 | } 12 | } 13 | } 14 | 15 | /** 16 | * @type import('./error.cjs').assert 17 | */ 18 | function assert(bool, message, code) { 19 | if (bool) return; 20 | if (message instanceof Error) throw message; 21 | throw new ProloadError({ message, code }); 22 | } 23 | 24 | module.exports.ProloadError = ProloadError; 25 | module.exports.assert = assert; 26 | -------------------------------------------------------------------------------- /src/proload/error.cjs.d.ts: -------------------------------------------------------------------------------- 1 | export type Message = string | Error; 2 | 3 | export type PROLOAD_ERROR_CODE = 'ERR_PROLOAD_INVALID' | 'ERR_PROLOAD_NOT_FOUND'; 4 | 5 | export class ProloadError extends Error { 6 | name: 'ProloadError'; 7 | code: PROLOAD_ERROR_CODE; 8 | message: string; 9 | constructor(options?: { 10 | message: string; 11 | code?: string 12 | }); 13 | } 14 | 15 | export function assert(condition: boolean, message: Message, code?: PROLOAD_ERROR_CODE): asserts condition; 16 | -------------------------------------------------------------------------------- /src/proload/esm/requireOrImport.mjs: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | import { createRequire } from 'module'; 3 | import { pathToFileURL } from 'url'; 4 | let require = createRequire(import.meta.url); 5 | 6 | /** 7 | * 8 | * @param {string} filePath 9 | */ 10 | export default async function requireOrImport(filePath, { middleware = [] } = {}) { 11 | await Promise.all(middleware.map(plugin => plugin.register(filePath))); 12 | 13 | return new Promise(async (resolve, reject) => { 14 | const fileUrl = pathToFileURL(filePath).toString(); 15 | try { 16 | const mdl = await import(fileUrl); 17 | return resolve(mdl); 18 | } catch (e) { 19 | reject(e); 20 | } 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/proload/esm/requireOrImport.mjs.d.ts: -------------------------------------------------------------------------------- 1 | export default function requireOrImport(filePath: string, opts?: { middleware: any[] }): Promise; 2 | -------------------------------------------------------------------------------- /src/proload/index.d.ts: -------------------------------------------------------------------------------- 1 | export { ProloadError } from './error.cjs'; 2 | 3 | export interface Config { 4 | /** An absolute path to a resolved configuration file */ 5 | filePath: string; 6 | /** The raw value of a resolved configuration file, before being merged with any `extends` configurations */ 7 | raw: any; 8 | /** The final, resolved value of a resolved configuration file */ 9 | value: T; 10 | } 11 | 12 | export interface ResolveOptions { 13 | /** 14 | * An exact filePath to a configuration file which should be loaded. If passed, this will keep proload from searching 15 | * for matching files. 16 | * 17 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#filepath) 18 | */ 19 | filePath?: string; 20 | /** 21 | * The location from which to begin searching up the directory tree 22 | * 23 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#cwd) 24 | */ 25 | cwd?: string; 26 | /** 27 | * If a configuration _must_ be resolved. If `true`, Proload will throw an error when a configuration is not found 28 | * 29 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#mustExist) 30 | */ 31 | mustExist?: boolean; 32 | /** 33 | * A function to completely customize module resolution 34 | * 35 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#accept) 36 | */ 37 | accept?(fileName: string, context: { directory: string }): boolean | void; 38 | } 39 | 40 | export interface LoadOptions { 41 | /** 42 | * An exact filePath to a configuration file which should be loaded. If passed, this will keep proload from searching 43 | * for matching files. 44 | * 45 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#filepath) 46 | */ 47 | filePath?: string; 48 | /** 49 | * The location from which to begin searching up the directory tree 50 | * 51 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#cwd) 52 | */ 53 | cwd?: string; 54 | /** 55 | * If a configuration _must_ be resolved. If `true`, Proload will throw an error when a configuration is not found 56 | * 57 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#mustExist) 58 | */ 59 | mustExist?: boolean; 60 | /** 61 | * If a resolved configuration file exports a factory function, this value will be passed as arguments to the function 62 | * 63 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#context) 64 | */ 65 | context?: any; 66 | /** 67 | * A function to customize the `merge` behavior when a config with `extends` is encountered 68 | * 69 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#merge) 70 | */ 71 | merge?(x: Partial, y: Partial): Partial; 72 | /** 73 | * A function to completely customize module resolution 74 | * 75 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#accept) 76 | */ 77 | accept?(fileName: string, context: { directory: string }): boolean | void; 78 | } 79 | 80 | export interface Plugin { 81 | /** a unique identifier for your plugin */ 82 | name: string; 83 | /** extensions which should be resolved, including the leading period */ 84 | extensions?: string[]; 85 | /** fileName patterns which should be resolved, excluding the trailing extension */ 86 | fileNames?: string[]; 87 | /** Executed before require/import of config file */ 88 | register?(filePath: string): Promise; 89 | /** Modify the config file before passing it along */ 90 | transform?(module: any): Promise; 91 | } 92 | 93 | /** 94 | * An `async` function which searches for a configuration file 95 | * 96 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#resolve) 97 | */ 98 | export function resolve( 99 | namespace: string, 100 | opts?: ResolveOptions 101 | ): Promise; 102 | 103 | interface Load = Record> { 104 | /** 105 | * @param namespace The namespace which will be searched for the configuration file. 106 | * 107 | * For example, passing `"donut"` would resolve a files like `donut.config.js`, `donut.config.cjs`, and `donut.config.mjs` as well as a `package.json` with a `donut` property. 108 | * 109 | * @param opts Options to customize loader behavior 110 | */ 111 | (namespace: string, opts?: LoadOptions): Promise | undefined>; 112 | use(plugins: Plugin[]): void; 113 | } 114 | 115 | /** 116 | * An `async` function which searches for and loads a configuration file 117 | * 118 | * [Read the `@proload/core` docs](https://github.com/natemoo-re/proload/tree/main/packages/core#load) 119 | */ 120 | declare const load: Load; 121 | 122 | export default load; 123 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { defaultTargets } from './compressors/css.js'; 2 | import default_options from './config-default.js'; 3 | 4 | export type Args = { 5 | nowrite?: boolean; 6 | nocache?: boolean; 7 | cache_folder?: string; 8 | sequential_compress?: boolean; 9 | }; 10 | 11 | export type ReportItem = { 12 | action: string; 13 | originalSize: number; 14 | compressedSize: number; 15 | }; 16 | 17 | type Summary = { 18 | nbFiles: number; 19 | nbFilesCompressed: number; 20 | dataLenUncompressed: number; 21 | dataLenCompressed: number; 22 | }; 23 | 24 | type Issue = { 25 | type: 'invalid' | 'a11y' | 'perf' | 'erro' | 'fix' | 'warn'; 26 | msg: string; 27 | }; 28 | 29 | export class GlobalState { 30 | dir: string = 'dist'; 31 | args: Args = {}; 32 | options = default_options; 33 | targets = defaultTargets(); 34 | 35 | compressedFiles: Set = new Set(); 36 | 37 | issues: Map = new Map(); 38 | 39 | installed_dependencies: Set = new Set(); 40 | 41 | summary: Summary = { 42 | nbFiles: 0, 43 | nbFilesCompressed: 0, 44 | dataLenCompressed: 0, 45 | dataLenUncompressed: 0, 46 | }; 47 | summaryByExtension: Record = {}; 48 | 49 | vfs?: typeof import('fs/promises'); 50 | 51 | onAnalysedFile?: (file: string) => void; 52 | 53 | reportIssue(sourceFile: string, issue: Issue) { 54 | let issueList = this.issues.get(sourceFile); 55 | if (issueList === undefined) { 56 | issueList = []; 57 | this.issues.set(sourceFile, issueList); 58 | } 59 | issueList.push(issue); 60 | } 61 | 62 | reportSummary(r: ReportItem) { 63 | const isCompressed = r.compressedSize < r.originalSize ? 1 : 0; 64 | 65 | this.summary.nbFiles++; 66 | this.summary.nbFilesCompressed += isCompressed; 67 | this.summary.dataLenUncompressed += r.originalSize; 68 | this.summary.dataLenCompressed += r.compressedSize; 69 | 70 | if (r.action) { 71 | let summary = this.summaryByExtension[r.action]; 72 | if (!summary) { 73 | summary = { 74 | nbFiles: 0, 75 | nbFilesCompressed: 0, 76 | dataLenUncompressed: 0, 77 | dataLenCompressed: 0, 78 | }; 79 | this.summaryByExtension[r.action] = summary; 80 | } 81 | summary.nbFiles++; 82 | summary.nbFilesCompressed += isCompressed; 83 | summary.dataLenUncompressed += r.originalSize; 84 | summary.dataLenCompressed += r.compressedSize; 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function formatBytes(bytes: number, decimals = 2) { 2 | if (!+bytes) return '0 Bytes'; 3 | 4 | const k = 1024; 5 | const dm = decimals < 0 ? 0 : decimals; 6 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 7 | 8 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 9 | 10 | return `${(bytes / Math.pow(k, i)).toFixed(dm)} ${sizes[i]}`; 11 | } 12 | 13 | export function isNumeric(value: string) { 14 | return /^\d+$/.test(value); 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/cache-control-parser.ts: -------------------------------------------------------------------------------- 1 | // Copied from https://github.com/etienne-martin/cache-control-parser (MIT licence) 2 | // The npm package was not ESM - can't be used 3 | 4 | export interface CacheControl { 5 | 'max-age'?: number; 6 | 's-maxage'?: number; 7 | 'stale-while-revalidate'?: number; 8 | 'stale-if-error'?: number; 9 | public?: boolean; 10 | private?: boolean; 11 | 'no-store'?: boolean; 12 | 'no-cache'?: boolean; 13 | 'must-revalidate'?: boolean; 14 | 'proxy-revalidate'?: boolean; 15 | immutable?: boolean; 16 | 'no-transform'?: boolean; 17 | } 18 | 19 | const SUPPORTED_DIRECTIVES: (keyof CacheControl)[] = [ 20 | 'max-age', 21 | 's-maxage', 22 | 'stale-while-revalidate', 23 | 'stale-if-error', 24 | 'public', 25 | 'private', 26 | 'no-store', 27 | 'no-cache', 28 | 'must-revalidate', 29 | 'proxy-revalidate', 30 | 'immutable', 31 | 'no-transform', 32 | ]; 33 | 34 | export const parse = (cacheControlHeader: string): CacheControl => { 35 | const cacheControl: CacheControl = {}; 36 | 37 | const directives = cacheControlHeader 38 | .toLowerCase() 39 | .split(',') 40 | .map((str) => 41 | str 42 | .trim() 43 | .split('=') 44 | .map((str) => str.trim()) 45 | ); 46 | 47 | for (const [directive, value] of directives) { 48 | switch (directive) { 49 | case 'max-age': 50 | const maxAge = parseInt(value, 10); 51 | 52 | if (isNaN(maxAge)) continue; 53 | 54 | cacheControl['max-age'] = maxAge; 55 | 56 | break; 57 | case 's-maxage': 58 | const sharedMaxAge = parseInt(value, 10); 59 | 60 | if (isNaN(sharedMaxAge)) continue; 61 | 62 | cacheControl['s-maxage'] = sharedMaxAge; 63 | break; 64 | case 'stale-while-revalidate': 65 | const staleWhileRevalidate = parseInt(value, 10); 66 | 67 | if (isNaN(staleWhileRevalidate)) continue; 68 | 69 | cacheControl['stale-while-revalidate'] = staleWhileRevalidate; 70 | break; 71 | case 'stale-if-error': 72 | const staleIfError = parseInt(value, 10); 73 | 74 | if (isNaN(staleIfError)) continue; 75 | 76 | cacheControl['stale-if-error'] = staleIfError; 77 | break; 78 | case 'public': 79 | cacheControl.public = true; 80 | break; 81 | case 'private': 82 | cacheControl.private = true; 83 | break; 84 | case 'no-store': 85 | cacheControl['no-store'] = true; 86 | break; 87 | case 'no-cache': 88 | cacheControl['no-cache'] = true; 89 | break; 90 | case 'must-revalidate': 91 | cacheControl['must-revalidate'] = true; 92 | break; 93 | case 'proxy-revalidate': 94 | cacheControl['proxy-revalidate'] = true; 95 | break; 96 | case 'immutable': 97 | cacheControl.immutable = true; 98 | break; 99 | case 'no-transform': 100 | cacheControl['no-transform'] = true; 101 | break; 102 | } 103 | } 104 | 105 | return cacheControl; 106 | }; 107 | 108 | export const stringify = (cacheControl: CacheControl) => { 109 | const directives: string[] = []; 110 | 111 | for (const [key, value] of Object.entries(cacheControl)) { 112 | if (!SUPPORTED_DIRECTIVES.includes(key as keyof CacheControl)) continue; 113 | 114 | switch (typeof value) { 115 | case 'boolean': 116 | directives.push(`${key}`); 117 | break; 118 | case 'number': 119 | directives.push(`${key}=${value}`); 120 | break; 121 | } 122 | } 123 | 124 | return directives.join(', '); 125 | }; 126 | -------------------------------------------------------------------------------- /src/utils/install-dep.ts: -------------------------------------------------------------------------------- 1 | import * as fsp from 'fs/promises'; 2 | import * as path from 'path'; 3 | import { createRequire } from 'node:module'; 4 | import { GlobalState } from '../state.js'; 5 | 6 | const require = createRequire(import.meta.url); 7 | 8 | export async function install_dependency( 9 | state: GlobalState, 10 | html_file: string, 11 | options: { 12 | source: { 13 | npm_package_name: string; 14 | absolute_path_to_file: string; 15 | filename: string; 16 | }; 17 | destination: { 18 | folder_name: string; 19 | code_loader: string; 20 | }; 21 | }, 22 | appendToBody: Record 23 | ): Promise { 24 | const path_html = path.dirname('/' + html_file); 25 | const folder = `/_jampack/${options.destination.folder_name}`; 26 | const src_filename = options.source.filename; 27 | const url_loader = `${folder}/loader.js`; 28 | const fs = state.vfs ?? fsp; 29 | // Install dependency in /_jampack if not done yet 30 | if (!state.installed_dependencies.has(options.source.npm_package_name)) { 31 | const quickLinkDestination = path.join(state.dir, folder, src_filename); 32 | 33 | const path_loader = path.join(state.dir, url_loader); 34 | 35 | await fs.mkdir(path.join(state.dir, folder), { recursive: true }); 36 | 37 | // Write loader 38 | const code_loader = options.destination.code_loader; 39 | await fs.writeFile(path_loader, code_loader); 40 | 41 | // Write quicklink code 42 | const source = require.resolve( 43 | `${options.source.npm_package_name}${options.source.absolute_path_to_file}/${src_filename}` 44 | ); 45 | if (state.vfs) { 46 | await state.vfs.writeFile(quickLinkDestination, await fsp.readFile(source)); 47 | } else { 48 | await fs.copyFile(source, quickLinkDestination); 49 | } 50 | 51 | 52 | state.installed_dependencies.add(options.source.npm_package_name); 53 | } 54 | 55 | // Add custom code to the end of body if not done yet 56 | if (!(options.source.npm_package_name in appendToBody)) { 57 | appendToBody[ 58 | options.source.npm_package_name 59 | ] = ``; 63 | } 64 | } 65 | 66 | export async function install_lozad( 67 | state: GlobalState, 68 | html_file: string, 69 | appendToBody: Record 70 | ): Promise { 71 | return install_dependency( 72 | state, 73 | html_file, 74 | { 75 | source: { 76 | npm_package_name: 'lozad', 77 | absolute_path_to_file: '/dist', 78 | filename: 'lozad.es.js', 79 | }, 80 | destination: { 81 | folder_name: 'lozad-1.16', 82 | code_loader: `import lozad from "./lozad.es.js"; 83 | const observer = lozad('.jampack-lozad', { rootMargin: '100px 0px', threshold: [0.1] }); 84 | observer.observe();`, 85 | }, 86 | }, 87 | appendToBody 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/utils/polyfill-fetch.ts: -------------------------------------------------------------------------------- 1 | import { fetch } from 'undici'; 2 | 3 | if (!Object.keys(global).includes('fetch')) { 4 | Object.defineProperty(global, 'fetch', { value: fetch }); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/resource.ts: -------------------------------------------------------------------------------- 1 | import * as url from 'url'; 2 | import * as path from 'path'; 3 | import * as fsp from 'fs/promises'; 4 | import { fileTypeFromBuffer, FileExtension, MimeType } from 'file-type'; 5 | import sharp from 'sharp'; 6 | import { AllImageFormat, ImageFormat } from '../compressors/images.js'; 7 | import { GlobalState } from '../state.js'; 8 | 9 | type ImageMeta = { 10 | width: number | undefined; 11 | height: number | undefined; 12 | isProgressive: boolean; 13 | isOpaque: boolean; 14 | isLossless: boolean; 15 | }; 16 | 17 | /** 18 | * File extension, mime and data are loaded only on demand, then cached. 19 | */ 20 | export class Resource { 21 | private ext: FileExtension | 'svg' | undefined; 22 | private mime: MimeType | 'image/svg+xml' | undefined; 23 | private data: Buffer | undefined; 24 | private image_meta: ImageMeta | null | undefined; 25 | 26 | constructor(private state: GlobalState, public readonly src: string, public readonly filePathAbsolute: string) { 27 | } 28 | 29 | public async getData(): Promise { 30 | if (this.data === undefined) { 31 | this.data = await (this.state.vfs ?? fsp).readFile(this.filePathAbsolute); 32 | } 33 | 34 | return this.data; 35 | } 36 | 37 | public async getImageMeta() { 38 | if (this.image_meta === undefined) { 39 | const ext = await this.getExt(); 40 | let isLossless = false; 41 | switch (ext) { 42 | case 'svg': 43 | case 'gif': 44 | case 'png': 45 | case 'tif': 46 | isLossless = true; 47 | case 'webp': 48 | if (!isLossless) { 49 | try { 50 | // TODO check if webp is lossless 51 | // const info = await WebPInfo.from(await this.getData()); 52 | // isLossless = info.summary.isLossless; 53 | } catch (e) { 54 | console.warn('Failed to get WebP info'); 55 | } 56 | } 57 | case 'avif': 58 | // TODO 59 | // Check for lossless avif 60 | case 'jpg': 61 | let sharpFile = sharp(await this.getData(), { 62 | animated: true, 63 | }); 64 | const meta = await sharpFile.metadata(); 65 | const stats = await sharpFile.stats(); 66 | 67 | this.image_meta = { 68 | width: meta.width, 69 | height: meta.height, 70 | isProgressive: meta.isProgressive || false, 71 | isOpaque: stats.isOpaque, 72 | isLossless, 73 | }; 74 | break; 75 | } 76 | } 77 | 78 | if (!this.image_meta) { 79 | console.log(await this.getExt()); 80 | } 81 | 82 | return this.image_meta; 83 | } 84 | 85 | public async getLen(): Promise { 86 | return (await this.getData()).length; 87 | } 88 | 89 | public async getExt(): Promise { 90 | if (this.ext === undefined) { 91 | await this.loadFileType(); 92 | } 93 | 94 | return this.ext!; 95 | } 96 | 97 | public async getImageFormat(): Promise { 98 | const ext = (await this.getExt()) as string; 99 | if (AllImageFormat.includes(ext)) return ext as ImageFormat; 100 | return undefined; 101 | } 102 | 103 | public async getMime(): Promise { 104 | if (this.mime === undefined) { 105 | await this.loadFileType(); 106 | } 107 | 108 | return this.mime!; 109 | } 110 | 111 | private async loadFileType() { 112 | const fileType = await fileTypeFromBuffer(await this.getData()); 113 | if (this.filePathAbsolute.endsWith('.svg')) { 114 | this.ext = 'svg'; 115 | this.mime = 'image/svg+xml'; 116 | } else if (fileType) { 117 | this.ext = fileType.ext; 118 | this.mime = fileType.mime; 119 | } else { 120 | throw new Error(`Unknown file type "${this.src}"`); 121 | } 122 | } 123 | 124 | static async loadResource( 125 | state: GlobalState, 126 | relativeFile: string, 127 | src: string 128 | ): Promise { 129 | if (!isLocal(src)) { 130 | throw new Error('src should be local'); 131 | } 132 | 133 | const u = url.parse(src); 134 | 135 | if (!u.pathname) { 136 | throw new Error(`Invalid src format "${src}"`); 137 | } 138 | 139 | const relativePath = path.join( 140 | state.dir, 141 | src.startsWith('/') ? '' : path.dirname(relativeFile), 142 | u.pathname 143 | ); 144 | let absolutePath = path.resolve(relativePath); 145 | 146 | if (await fileExists(state, absolutePath)) { 147 | return new Resource(state, src, absolutePath); 148 | } 149 | 150 | return undefined; 151 | } 152 | } 153 | 154 | export function isLocal(src: string) { 155 | const u = url.parse(src); 156 | return !u.host; 157 | } 158 | 159 | async function fileExists(state: GlobalState, path: string): Promise { 160 | try { 161 | await (state.vfs??fsp).stat(path); 162 | } catch (e) { 163 | return false; 164 | } 165 | return true; 166 | } 167 | 168 | export function translateSrc( 169 | projectRoot: string, 170 | htmlRelativePath: string, 171 | src: string 172 | ) { 173 | if (!isLocal(src)) { 174 | throw new Error('Source should be local'); 175 | } 176 | 177 | const srcAbsolutePath = path.join( 178 | projectRoot, 179 | src.startsWith('/') ? '' : htmlRelativePath, 180 | src 181 | ); 182 | 183 | return path.resolve(srcAbsolutePath); 184 | } 185 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "Node16", 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "strict": true, 9 | "target": "es2019", 10 | "resolveJsonModule": true, 11 | "allowJs": true 12 | }, 13 | "include": ["src/**/*"] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./src/cache.ts","./src/compress.ts","./src/config-default.ts","./src/config-fast.ts","./src/config-types.ts","./src/config.ts","./src/index.ts","./src/logger.ts","./src/optimize.ts","./src/packagejson.ts","./src/state.ts","./src/utils.ts","./src/compressors/css.ts","./src/compressors/html.ts","./src/compressors/images.ts","./src/compressors/js.ts","./src/optimizers/img-external.ts","./src/optimizers/inline-critical-css.ts","./src/optimizers/prefetch-links.ts","./src/optimizers/process-iframe.ts","./src/optimizers/process-video.ts","./src/proload/error.cjs","./src/proload/error.cjs.d.ts","./src/proload/index.d.ts","./src/proload/esm/index.mjs","./src/proload/esm/requireorimport.mjs","./src/proload/esm/requireorimport.mjs.d.ts","./src/utils/cache-control-parser.ts","./src/utils/install-dep.ts","./src/utils/polyfill-fetch.ts","./src/utils/resource.ts"],"version":"5.7.2"} --------------------------------------------------------------------------------