-
19 | { dateLog &&
20 |
- Date 21 | } 22 | { frontmatter.author && 23 |
- Author 24 |
-
25 |
26 |
27 | {frontmatter.author} 28 | 29 |
30 | }
31 |
Above the fold optimizations
13 | 14 |You want images above the fold to arrive as soon as possible.
15 | 16 |Non-progressive JPEG
17 |
Transparent PNG
20 |
Non-transparent PNG
23 |
JPEG above the fold
26 |
32 | The fold is marked here.
33 | Everything after that is "below the fold" 34 |
36 |
JPEG below the fold
39 |
Optimize CDN images without downloading them
14 | 15 |
Optimize external images by downloading them
13 | 14 |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 | ## `




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 |
picture_music.png
32 | 33 |image too small -> no srcset
34 | 35 |
picture_screenshot.png
40 | 41 |
Picture tag with media sources
46 | 47 |
IMG tag
53 | 54 |JPEG image
55 |
56 |
57 |
58 | PNG image
59 |
60 | First step image (@1066w) is bigger than original size WebP, so step is ignore.
61 |
62 |
63 | Image without [width] attribute
64 |
65 |
66 | Image without [height] attribute
67 |
68 |
69 | Jam SVG
70 |
71 |
72 | Bass SVG
73 |
74 |
75 | Image with EXIF rotation should output correctly
76 |
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 |
12 |
11 |
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 | Skip to Content
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 | ,
21 | ,
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 |
2 |
--------------------------------------------------------------------------------
/packages/www/src/components/Header/logo-light.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
14 | {editHref && (
15 | -
16 |
17 |
34 | Edit this page
35 |
36 |
37 | )}
38 | {CONFIG.COMMUNITY_INVITE_URL && (
39 | -
40 |
41 |
58 | Join our community
59 |
60 |
61 | )}
62 |
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 |
22 |
19 |
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 |
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 |
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 |
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"}
--------------------------------------------------------------------------------
Hello World!
15 |This is a paragraph
16 | 17 | 18 | 19 |More
} 13 |-
14 | {editHref && (
15 |
- 16 | 17 | 34 | Edit this page 35 | 36 | 37 | )} 38 | {CONFIG.COMMUNITY_INVITE_URL && ( 39 |
- 40 | 41 | 58 | Join our community 59 | 60 | 61 | )} 62 |
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 |
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 |