├── .eslintignore
├── .eslintrc.cjs
├── .gitignore
├── .npmrc
├── .prettierignore
├── README.md
├── TODO.md
├── example
├── .eslintignore
├── .eslintrc.cjs
├── .gitignore
├── .node-version
├── .prettierignore
├── README.md
├── adapters
│ └── cloudflare-pages
│ │ └── vite.config.ts
├── package.json
├── pnpm-lock.yaml
├── public
│ ├── _headers
│ ├── _redirects
│ ├── favicon.svg
│ ├── fonts
│ │ ├── poppins-400.woff2
│ │ ├── poppins-500.woff2
│ │ └── poppins-700.woff2
│ ├── manifest.json
│ ├── robots.txt
│ └── screenshot.png
├── pwa-assets.config.ts
├── src
│ ├── components
│ │ ├── router-head
│ │ │ └── router-head.tsx
│ │ └── starter
│ │ │ ├── counter
│ │ │ ├── counter.module.css
│ │ │ └── counter.tsx
│ │ │ ├── footer
│ │ │ ├── footer.module.css
│ │ │ └── footer.tsx
│ │ │ ├── gauge
│ │ │ ├── gauge.module.css
│ │ │ └── index.tsx
│ │ │ ├── header
│ │ │ ├── header.module.css
│ │ │ └── header.tsx
│ │ │ ├── hero
│ │ │ ├── hero.module.css
│ │ │ └── hero.tsx
│ │ │ ├── icons
│ │ │ └── qwik.tsx
│ │ │ ├── infobox
│ │ │ ├── infobox.module.css
│ │ │ └── infobox.tsx
│ │ │ └── next-steps
│ │ │ ├── next-steps.module.css
│ │ │ └── next-steps.tsx
│ ├── entry.cloudflare-pages.tsx
│ ├── entry.dev.tsx
│ ├── entry.preview.tsx
│ ├── entry.ssr.tsx
│ ├── global.css
│ ├── media
│ │ └── thunder.png
│ ├── root.tsx
│ └── routes
│ │ ├── demo
│ │ ├── flower
│ │ │ ├── flower.css
│ │ │ └── index.tsx
│ │ └── todolist
│ │ │ ├── index.tsx
│ │ │ └── todolist.module.css
│ │ ├── dynamic
│ │ └── [id]
│ │ │ └── index.tsx
│ │ ├── index.tsx
│ │ ├── layout.tsx
│ │ ├── service-worker.ts
│ │ ├── static
│ │ └── [id]
│ │ │ └── index.tsx
│ │ └── styles.css
├── tsconfig.json
└── vite.config.ts
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── scripts
└── postbuild.mjs
├── src
├── assets
│ ├── build.ts
│ ├── config.ts
│ ├── dev.ts
│ ├── generator.ts
│ ├── html.ts
│ ├── manifest.ts
│ ├── options.ts
│ ├── types.ts
│ └── utils.ts
├── context.ts
├── head.ts
├── index.ts
├── plugins
│ ├── assets.ts
│ ├── client.ts
│ ├── main.ts
│ └── ssr.ts
├── sw.ts
└── types.ts
├── tsconfig.json
└── vite.config.ts
/.eslintignore:
--------------------------------------------------------------------------------
1 | **/*.log
2 | **/.DS_Store
3 | *.
4 | .vscode/settings.json
5 | .history
6 | .yarn
7 | bazel-*
8 | bazel-bin
9 | bazel-out
10 | bazel-qwik
11 | bazel-testlogs
12 | dist
13 | dist-dev
14 | lib
15 | lib-types
16 | etc
17 | external
18 | node_modules
19 | temp
20 | tsc-out
21 | tsdoc-metadata.json
22 | target
23 | output
24 | rollup.config.js
25 | build
26 | .cache
27 | .vscode
28 | .rollup.cache
29 | dist
30 | tsconfig.tsbuildinfo
31 | vite.config.ts
32 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | browser: true,
5 | es2021: true,
6 | node: true,
7 | },
8 | extends: [
9 | "eslint:recommended",
10 | "plugin:@typescript-eslint/recommended",
11 | "plugin:qwik/recommended",
12 | ],
13 | parser: "@typescript-eslint/parser",
14 | parserOptions: {
15 | tsconfigRootDir: __dirname,
16 | project: ["./tsconfig.json"],
17 | ecmaVersion: 2021,
18 | sourceType: "module",
19 | ecmaFeatures: {
20 | jsx: true,
21 | },
22 | },
23 | plugins: ["@typescript-eslint"],
24 | rules: {
25 | "@typescript-eslint/no-explicit-any": "off",
26 | "@typescript-eslint/explicit-module-boundary-types": "off",
27 | "@typescript-eslint/no-inferrable-types": "off",
28 | "@typescript-eslint/no-non-null-assertion": "off",
29 | "@typescript-eslint/no-empty-interface": "off",
30 | "@typescript-eslint/no-namespace": "off",
31 | "@typescript-eslint/no-empty-function": "off",
32 | "@typescript-eslint/no-this-alias": "off",
33 | "@typescript-eslint/ban-types": "off",
34 | "@typescript-eslint/ban-ts-comment": "off",
35 | "prefer-spread": "off",
36 | "no-case-declarations": "off",
37 | "no-console": "off",
38 | "@typescript-eslint/no-unused-vars": ["error"],
39 | },
40 | };
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Build
2 | dist
3 | lib
4 | lib-types
5 | server
6 |
7 | # Development
8 | node_modules
9 | # Cache
10 | .cache
11 | .mf
12 | .vscode
13 | .rollup.cache
14 | tsconfig.tsbuildinfo
15 |
16 | # Logs
17 | logs
18 | *.log
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 | pnpm-debug.log*
23 | lerna-debug.log*
24 |
25 | # Editor
26 | !.vscode/extensions.json
27 | .idea
28 | .DS_Store
29 | *.suo
30 | *.ntvs*
31 | *.njsproj
32 | *.sln
33 | *.sw?
34 |
35 | # Yarn
36 | .yarn/*
37 | !.yarn/releases
38 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | shell-emulator=true
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Files Prettier should not format
2 | **/*.log
3 | **/.DS_Store
4 | *.
5 | dist
6 | node_modules
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Qwik PWA 📱
2 |
3 | Turn your Qwik Application into an offline compatible PWA (Progressive Web Application) using Workbox but without the hassle.
4 |
5 | ## Installation
6 |
7 | ```sh
8 | npm install --save-dev @qwikdev/pwa
9 | ```
10 |
11 | `vite.config.ts`:
12 |
13 | ```ts
14 | import { qwikPwa } from "@qwikdev/pwa";
15 |
16 | export default defineConfig(() => {
17 | return {
18 | define: {
19 | // (optional) enables debugging in workbox
20 | "process.env.NODE_ENV": JSON.stringify("development"),
21 | },
22 | plugins: [
23 | qwikCity(),
24 | qwikVite(),
25 | // The options are set by default
26 | qwikPwa({
27 | /* options */
28 | }),
29 | ],
30 | };
31 | });
32 | ```
33 |
34 | `src/routes/service-worker.ts`:
35 |
36 | ```diff
37 | import { setupServiceWorker } from "@builder.io/qwik-city/service-worker";
38 | import { setupPwa } from "@qwikdev/pwa/sw";
39 |
40 | setupServiceWorker();
41 |
42 | +setupPwa();
43 |
44 | - addEventListener("install", () => self.skipWaiting());
45 |
46 | - addEventListener("activate", () => self.clients.claim());
47 |
48 | - declare const self: ServiceWorkerGlobalScope;
49 | ```
50 |
51 | By default, your application will be auto-updated when there's a new version of the service worker available and it is installed: in a future version, you will be able to customize this behavior to use `prompt` for update:
52 | ```ts
53 | import { setupServiceWorker } from "@builder.io/qwik-city/service-worker";
54 | import { setupPwa } from "@qwikdev/pwa/sw";
55 |
56 | setupServiceWorker();
57 | setupPwa("prompt");
58 | ```
59 |
60 | `public/manifest.json`:
61 | ```diff
62 | "background_color": "#fff",
63 | + "theme_color": "#fff",
64 | ```
65 |
66 | For more information, check the following pages:
67 | - [PWA Minimal Icons Requirements](https://vite-pwa-org.netlify.app/assets-generator/#pwa-minimal-icons-requirements)
68 | - [PWA Minimal Requirements](https://vite-pwa-org.netlify.app/guide/pwa-minimal-requirements.html)
69 | - [Add a web app manifest](https://web.dev/articles/add-manifest)
70 |
71 | `src/components/router-head/router-head.tsx`:
72 |
73 | ```tsx
74 | // PWA compatible generated icons for different browsers
75 | import * as pwaHead from "@qwikdev/pwa/head";
76 |
77 | export const RouterHead = component$(() => {
78 | ...
79 | {pwaHead.meta.map((l) => (
80 |
81 | ))}
82 | {pwaHead.links.map((l) => (
83 |
84 | ))}
85 | ...
86 | ```
87 |
88 | Make sure you remove the ` ` line in your router-head file.
89 |
90 | Now your application is PWA-friendly.
91 |
92 | ## Precache
93 |
94 | > One feature of service workers is the ability to save a set of files to the cache when the service worker is installing. This is often referred to as "precaching", since you are caching content ahead of the service worker being used. [Chrome for Developers](https://developer.chrome.com/docs/workbox/modules/workbox-precaching/)
95 |
96 | ### Assets
97 |
98 | Assets like js modules generated by qwik (`q-*.js`), images, `public/` assets, or any file that's emitted in the `dist/` directory by the build step would be precached when the service worker instantiates, so the plugin makes sure it provides the best client-side offline experience.
99 |
100 | ### Routes
101 |
102 | By default in this plugin, every route that does not include params (`/` or `/demo/flower`) is precached on the first run of the application when the browser registers the service worker.
103 |
104 | For the rest of the defined routes (routes with params like `/dynamic/[id]`, SSG routes, or API routes), they are not precached, but there are [workbox navigation routes](https://developer.chrome.com/docs/workbox/modules/workbox-routing) defined that would cache them on-demand and per request, the reason is precaching too many assets on the first run would cause a laggy experience for the user, especially when these kind of routes have the potential to generate so many more files.
105 |
106 | Imagine there's an SSG `/blog/[id]` route that generates 120 blog posts, in this case, fetching 120 pages of blog posts in the application startup would not seem ideal.
107 |
108 | #### Solution
109 |
110 | Just fetch the desired page or asset so it gets cached for later uses.
111 |
112 | ```ts
113 | fetch("/blog/how-to-use-qwik");
114 | ```
115 |
116 | ### API Routes
117 |
118 | For API routes and any other routes that do not meet the conditions mentioned above, there's a [Network-First](https://developer.chrome.com/docs/workbox/modules/workbox-strategies/#network_first_network_falling_back_to_cach) handler.
119 |
120 | ## Manifest
121 |
122 | The plugin would generate all of the [adaptive](https://web.dev/articles/maskable-icon) icons needed for different devices with different ratios in `manifest.json` based on your main icon in the build process using [@vite-pwa/assets-generator](https://vite-pwa-org.netlify.app/assets-generator/api.html#api).
123 |
124 | ### Screenshots
125 |
126 | For full PWA compatibility, you can put your [screenshots](https://developer.mozilla.org/en-US/docs/Web/Manifest/screenshots) with the following pattern in the `public/manifest.json` file.
127 |
128 | ```json
129 | ...
130 | "screenshots": [
131 | {
132 | "src": "/screenshot.png",
133 | "type": "image/png",
134 | "sizes": "862x568"
135 | },
136 | {
137 | "src": "/screenshot.png",
138 | "type": "image/png",
139 | "sizes": "862x568",
140 | "form_factor": "wide"
141 | }
142 | ]
143 | ```
144 |
145 | For beautiful screenshots, you can use [Progressier Screenshots Generator](https://progressier.com/pwa-screenshots-generator).
146 |
147 | ### Cloudflare deployment
148 |
149 | **PNPM**
150 | 1. Need set resolutions w/ `"sharp": "0.32.6"` on **package.json**;
151 | 2. It is necessary to create a file **npm-lock.yaml** by `pnpm install`.
152 |
153 | **Bun**
154 | 1. Need set resolutions w/ `"sharp": "0.32.6"` on **package.json**;
155 | 2. It is necessary to create a file **bun.lockb** by `bun install`.
156 | 3. Set [ENV](https://developers.cloudflare.com/pages/configuration/language-support-and-tools/#supported-languages-and-tools) BUN_VERSION=1.0.5 (or higher) in Cloudflare. Cuz the default version of **Bun** - 1.0.1 doesn't work.
157 |
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
1 | # TODO LIST
2 |
3 | - [ ] fix eslint/prettier: it is a pain to have to include the type in static imports
4 | - [x] fix workbox runtime warnings: there are a few workbox runtime warnings in the example that should be checked (build/q-*.[webp|css])
5 | - [ ] feat add prompt for update strategy: the user can lost form data if filling a form when the update is triggered
6 | - [x] test custom pwa assets generator config file: on change the app should receive a page reload (no dev server restart), maybe with a new example
7 | - [x] don't inject web manifest icons when present in the manifest
8 | - [x] include id and scope in the web manifest when missing
9 | - [x] warn when missing `theme_color` in the web manifest
10 |
11 | ## Fix workbox runtime warnings
12 |
13 | We must include the revision with `null` value in the precache manifest for `build/q-**` assets.
14 |
--------------------------------------------------------------------------------
/example/.eslintignore:
--------------------------------------------------------------------------------
1 | **/*.log
2 | **/.DS_Store
3 | *.
4 | .vscode/settings.json
5 | .history
6 | .yarn
7 | bazel-*
8 | bazel-bin
9 | bazel-out
10 | bazel-qwik
11 | bazel-testlogs
12 | dist
13 | dist-dev
14 | lib
15 | lib-types
16 | etc
17 | external
18 | node_modules
19 | temp
20 | tsc-out
21 | tsdoc-metadata.json
22 | target
23 | output
24 | rollup.config.js
25 | build
26 | .cache
27 | .vscode
28 | .rollup.cache
29 | dist
30 | tsconfig.tsbuildinfo
31 | vite.config.ts
32 | *.spec.tsx
33 | *.spec.ts
34 | .netlify
35 | pnpm-lock.yaml
36 | package-lock.json
37 | yarn.lock
38 | server
39 |
--------------------------------------------------------------------------------
/example/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | browser: true,
5 | es2021: true,
6 | node: true,
7 | },
8 | extends: [
9 | "eslint:recommended",
10 | "plugin:@typescript-eslint/recommended",
11 | "plugin:qwik/recommended",
12 | ],
13 | parser: "@typescript-eslint/parser",
14 | parserOptions: {
15 | tsconfigRootDir: __dirname,
16 | project: ["./tsconfig.json"],
17 | ecmaVersion: 2021,
18 | sourceType: "module",
19 | ecmaFeatures: {
20 | jsx: true,
21 | },
22 | },
23 | plugins: ["@typescript-eslint"],
24 | rules: {
25 | "@typescript-eslint/no-explicit-any": "off",
26 | "@typescript-eslint/explicit-module-boundary-types": "off",
27 | "@typescript-eslint/no-inferrable-types": "off",
28 | "@typescript-eslint/no-non-null-assertion": "off",
29 | "@typescript-eslint/no-empty-interface": "off",
30 | "@typescript-eslint/no-namespace": "off",
31 | "@typescript-eslint/no-empty-function": "off",
32 | "@typescript-eslint/no-this-alias": "off",
33 | "@typescript-eslint/ban-types": "off",
34 | "@typescript-eslint/ban-ts-comment": "off",
35 | "prefer-spread": "off",
36 | "no-case-declarations": "off",
37 | "no-console": "off",
38 | "@typescript-eslint/no-unused-vars": ["error"],
39 | "@typescript-eslint/consistent-type-imports": "warn",
40 | "@typescript-eslint/no-unnecessary-condition": "warn",
41 | },
42 | };
43 |
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | # Cloudflare
2 | functions/**/*.js
3 |
--------------------------------------------------------------------------------
/example/.node-version:
--------------------------------------------------------------------------------
1 | 16
2 |
--------------------------------------------------------------------------------
/example/.prettierignore:
--------------------------------------------------------------------------------
1 | **/*.log
2 | **/.DS_Store
3 | *.
4 | .vscode/settings.json
5 | .history
6 | .yarn
7 | bazel-*
8 | bazel-bin
9 | bazel-out
10 | bazel-qwik
11 | bazel-testlogs
12 | dist
13 | dist-dev
14 | lib
15 | lib-types
16 | etc
17 | external
18 | node_modules
19 | temp
20 | tsc-out
21 | tsdoc-metadata.json
22 | target
23 | output
24 | rollup.config.js
25 | build
26 | .cache
27 | .vscode
28 | .rollup.cache
29 | dist
30 | tsconfig.tsbuildinfo
31 | vite.config.ts
32 | *.spec.tsx
33 | *.spec.ts
34 | .netlify
35 | pnpm-lock.yaml
36 | package-lock.json
37 | yarn.lock
38 | server
39 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | # Qwik City App ⚡️
2 |
3 | - [Qwik Docs](https://qwik.builder.io/)
4 | - [Discord](https://qwik.builder.io/chat)
5 | - [Qwik GitHub](https://github.com/BuilderIO/qwik)
6 | - [@QwikDev](https://twitter.com/QwikDev)
7 | - [Vite](https://vitejs.dev/)
8 |
9 | ---
10 |
11 | ## Project Structure
12 |
13 | This project is using Qwik with [QwikCity](https://qwik.builder.io/qwikcity/overview/). QwikCity is just an extra set of tools on top of Qwik to make it easier to build a full site, including directory-based routing, layouts, and more.
14 |
15 | Inside your project, you'll see the following directory structure:
16 |
17 | ```
18 | ├── public/
19 | │ └── ...
20 | └── src/
21 | ├── components/
22 | │ └── ...
23 | └── routes/
24 | └── ...
25 | ```
26 |
27 | - `src/routes`: Provides the directory-based routing, which can include a hierarchy of `layout.tsx` layout files, and an `index.tsx` file as the page. Additionally, `index.ts` files are endpoints. Please see the [routing docs](https://qwik.builder.io/qwikcity/routing/overview/) for more info.
28 |
29 | - `src/components`: Recommended directory for components.
30 |
31 | - `public`: Any static assets, like images, can be placed in the public directory. Please see the [Vite public directory](https://vitejs.dev/guide/assets.html#the-public-directory) for more info.
32 |
33 | ## Add Integrations and deployment
34 |
35 | Use the `pnpm qwik add` command to add additional integrations. Some examples of integrations includes: Cloudflare, Netlify or Express Server, and the [Static Site Generator (SSG)](https://qwik.builder.io/qwikcity/guides/static-site-generation/).
36 |
37 | ```shell
38 | pnpm qwik add # or `pnpm qwik add`
39 | ```
40 |
41 | ## Development
42 |
43 | Development mode uses [Vite's development server](https://vitejs.dev/). The `dev` command will server-side render (SSR) the output during development.
44 |
45 | ```shell
46 | npm start # or `pnpm start`
47 | ```
48 |
49 | > Note: during dev mode, Vite may request a significant number of `.js` files. This does not represent a Qwik production build.
50 |
51 | ## Preview
52 |
53 | The preview command will create a production build of the client modules, a production build of `src/entry.preview.tsx`, and run a local server. The preview server is only for convenience to preview a production build locally and should not be used as a production server.
54 |
55 | ```shell
56 | pnpm preview # or `pnpm preview`
57 | ```
58 |
59 | ## Production
60 |
61 | The production build will generate client and server modules by running both client and server build commands. The build command will use Typescript to run a type check on the source code.
62 |
63 | ```shell
64 | pnpm build # or `pnpm build`
65 | ```
66 |
67 | ## Static Site Generator (Node.js)
68 |
69 | ```shell
70 | pnpm build.server
71 | ```
72 |
73 | ## Cloudflare Pages
74 |
75 | Cloudflare's [wrangler](https://github.com/cloudflare/wrangler) CLI can be used to preview a production build locally. To start a local server, run:
76 |
77 | ```
78 | npm run serve
79 | ```
80 |
81 | Then visit [http://localhost:8787/](http://localhost:8787/)
82 |
83 | ### Deployments
84 |
85 | [Cloudflare Pages](https://pages.cloudflare.com/) are deployable through their [Git provider integrations](https://developers.cloudflare.com/pages/platform/git-integration/).
86 |
87 | If you don't already have an account, then [create a Cloudflare account here](https://dash.cloudflare.com/sign-up/pages). Next go to your dashboard and follow the [Cloudflare Pages deployment guide](https://developers.cloudflare.com/pages/framework-guides/deploy-anything/).
88 |
89 | Within the projects "Settings" for "Build and deployments", the "Build command" should be `npm run build`, and the "Build output directory" should be set to `dist`.
90 |
91 | ### Function Invocation Routes
92 |
93 | Cloudflare Page's [function-invocation-routes config](https://developers.cloudflare.com/pages/platform/functions/routing/#functions-invocation-routes) can be used to include, or exclude, certain paths to be used by the worker functions. Having a `_routes.json` file gives developers more granular control over when your Function is invoked.
94 | This is useful to determine if a page response should be Server-Side Rendered (SSR) or if the response should use a static-site generated (SSG) `index.html` file.
95 |
96 | By default, the Cloudflare pages adaptor _does not_ include a `public/_routes.json` config, but rather it is auto-generated from the build by the Cloudflare adaptor. An example of an auto-generate `dist/_routes.json` would be:
97 |
98 | ```
99 | {
100 | "include": [
101 | "/*"
102 | ],
103 | "exclude": [
104 | "/_headers",
105 | "/_redirects",
106 | "/build/*",
107 | "/favicon.ico",
108 | "/manifest.json",
109 | "/service-worker.js",
110 | "/about"
111 | ],
112 | "version": 1
113 | }
114 | ```
115 |
116 | In the above example, it's saying _all_ pages should be SSR'd. However, the root static files such as `/favicon.ico` and any static assets in `/build/*` should be excluded from the Functions, and instead treated as a static file.
117 |
118 | In most cases the generated `dist/_routes.json` file is ideal. However, if you need more granular control over each path, you can instead provide you're own `public/_routes.json` file. When the project provides its own `public/_routes.json` file, then the Cloudflare adaptor will not auto-generate the routes config and instead use the committed one within the `public` directory.
119 |
--------------------------------------------------------------------------------
/example/adapters/cloudflare-pages/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { cloudflarePagesAdapter } from "@builder.io/qwik-city/adapters/cloudflare-pages/vite";
2 | import { extendConfig } from "@builder.io/qwik-city/vite";
3 | import baseConfig from "../../vite.config";
4 |
5 | export default extendConfig(baseConfig, () => {
6 | return {
7 | build: {
8 | ssr: true,
9 | rollupOptions: {
10 | input: ["src/entry.cloudflare-pages.tsx", "@qwik-city-plan"],
11 | },
12 | },
13 | plugins: [cloudflarePagesAdapter()],
14 | };
15 | });
16 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example",
3 | "description": "Demo App with Routing built-in (recommended)",
4 | "engines": {
5 | "node": ">=15.0.0"
6 | },
7 | "trustedDependencies": [
8 | "sharp"
9 | ],
10 | "scripts": {
11 | "build": "qwik build",
12 | "build.client": "vite build",
13 | "build.preview": "vite build --ssr src/entry.preview.tsx",
14 | "build.server": "vite build -c adapters/cloudflare-pages/vite.config.ts",
15 | "deploy": "wrangler pages publish ./dist",
16 | "dev": "vite --mode ssr",
17 | "dev.custom": "CUSTOM_CONFIG=true vite --mode ssr",
18 | "dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force",
19 | "fmt": "prettier --write .",
20 | "fmt.check": "prettier --check .",
21 | "preview": "qwik build preview && vite preview",
22 | "serve": "wrangler pages dev ./dist",
23 | "start": "vite --open --mode ssr",
24 | "qwik": "qwik"
25 | },
26 | "devDependencies": {
27 | "@builder.io/qwik": "^1.4.5",
28 | "@builder.io/qwik-city": "^1.4.5",
29 | "@qwikdev/pwa": "workspace:../",
30 | "@types/eslint": "^8.56.3",
31 | "@types/node": "^20.11.20",
32 | "@typescript-eslint/eslint-plugin": "^6.21.0",
33 | "@typescript-eslint/parser": "^6.21.0",
34 | "@vite-pwa/assets-generator": "^0.2.4",
35 | "eslint": "^8.57.0",
36 | "eslint-plugin-qwik": "^1.4.5",
37 | "fast-glob": "^3.3.2",
38 | "prettier": "^3.2.5",
39 | "typescript": "^5.3.3",
40 | "undici": "^5.28.3",
41 | "vite": "^4.5.2",
42 | "vite-tsconfig-paths": "^4.3.1",
43 | "wrangler": "^3.29.0"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/example/public/_headers:
--------------------------------------------------------------------------------
1 | # https://developers.cloudflare.com/pages/platform/headers/
2 |
3 | /build/*
4 | Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable
5 |
--------------------------------------------------------------------------------
/example/public/_redirects:
--------------------------------------------------------------------------------
1 | # https://developers.cloudflare.com/pages/platform/redirects/
2 |
--------------------------------------------------------------------------------
/example/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/public/fonts/poppins-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QwikCommunity/pwa/775f6f1ab2fc3762dfaca44b28782ea18472213a/example/public/fonts/poppins-400.woff2
--------------------------------------------------------------------------------
/example/public/fonts/poppins-500.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QwikCommunity/pwa/775f6f1ab2fc3762dfaca44b28782ea18472213a/example/public/fonts/poppins-500.woff2
--------------------------------------------------------------------------------
/example/public/fonts/poppins-700.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QwikCommunity/pwa/775f6f1ab2fc3762dfaca44b28782ea18472213a/example/public/fonts/poppins-700.woff2
--------------------------------------------------------------------------------
/example/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/web-manifest-combined.json",
3 | "name": "qwik-project-name",
4 | "short_name": "Welcome to Qwik",
5 | "start_url": ".",
6 | "display": "standalone",
7 | "background_color": "#fff",
8 | "theme_color": "#fff",
9 | "description": "A Qwik project app.",
10 | "screenshots": [
11 | {
12 | "src": "/screenshot.png",
13 | "type": "image/png",
14 | "sizes": "862x568"
15 | },
16 | {
17 | "src": "/screenshot.png",
18 | "type": "image/png",
19 | "sizes": "862x568",
20 | "form_factor": "wide"
21 | }
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/example/public/robots.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QwikCommunity/pwa/775f6f1ab2fc3762dfaca44b28782ea18472213a/example/public/robots.txt
--------------------------------------------------------------------------------
/example/public/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QwikCommunity/pwa/775f6f1ab2fc3762dfaca44b28782ea18472213a/example/public/screenshot.png
--------------------------------------------------------------------------------
/example/pwa-assets.config.ts:
--------------------------------------------------------------------------------
1 | import {
2 | defineConfig,
3 | minimal2023Preset as preset,
4 | } from "@vite-pwa/assets-generator/config";
5 |
6 | export default defineConfig({
7 | headLinkOptions: {
8 | preset: "2023",
9 | },
10 | preset,
11 | images: ["public/favicon.svg"],
12 | });
13 |
--------------------------------------------------------------------------------
/example/src/components/router-head/router-head.tsx:
--------------------------------------------------------------------------------
1 | import { useDocumentHead, useLocation } from "@builder.io/qwik-city";
2 |
3 | import { component$ } from "@builder.io/qwik";
4 | import * as pwaHead from "@qwikdev/pwa/head";
5 |
6 | /**
7 | * The RouterHead component is placed inside of the document `
` element.
8 | */
9 | export const RouterHead = component$(() => {
10 | const head = useDocumentHead();
11 | const loc = useLocation();
12 |
13 | return (
14 | <>
15 | {head.title}
16 |
17 |
18 | {head.meta.map((m) => (
19 |
20 | ))}
21 | {pwaHead.meta.map((m) => (
22 |
23 | ))}
24 | {pwaHead.links.map((l) => (
25 |
26 | ))}
27 | {head.links.map((l) => (
28 |
29 | ))}
30 | {head.styles.map((s) => (
31 |
32 | ))}
33 | {head.scripts.map((s) => (
34 |
35 | ))}
36 | >
37 | );
38 | });
39 |
--------------------------------------------------------------------------------
/example/src/components/starter/counter/counter.module.css:
--------------------------------------------------------------------------------
1 | .counter-wrapper {
2 | margin-top: 50px;
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | gap: 10px;
7 | }
8 |
9 | @media screen and (min-width: 768px) {
10 | .counter-wrapper {
11 | gap: 30px;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/example/src/components/starter/counter/counter.tsx:
--------------------------------------------------------------------------------
1 | import { component$, useSignal, $ } from "@builder.io/qwik";
2 | import styles from "./counter.module.css";
3 | import Gauge from "../gauge";
4 |
5 | export default component$(() => {
6 | const count = useSignal(70);
7 |
8 | const setCount = $((newValue: number) => {
9 | if (newValue < 0 || newValue > 100) {
10 | return;
11 | }
12 | count.value = newValue;
13 | });
14 |
15 | return (
16 |
17 | setCount(count.value - 1)}
20 | >
21 | -
22 |
23 |
24 | setCount(count.value + 1)}
27 | >
28 | +
29 |
30 |
31 | );
32 | });
33 |
--------------------------------------------------------------------------------
/example/src/components/starter/footer/footer.module.css:
--------------------------------------------------------------------------------
1 | .anchor {
2 | color: white !important;
3 | display: block;
4 | font-size: 0.8rem;
5 | text-align: center;
6 | text-decoration: none;
7 | line-height: 1.5;
8 | }
9 |
10 | .anchor span:not(.spacer) {
11 | display: block;
12 | }
13 |
14 | .spacer {
15 | display: none;
16 | padding: 0 15px;
17 | }
18 |
19 | @media screen and (min-width: 768px) {
20 | .anchor span {
21 | display: inline !important;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/example/src/components/starter/footer/footer.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from "@builder.io/qwik";
2 | import { useServerTimeLoader } from "~/routes/layout";
3 | import styles from "./footer.module.css";
4 |
5 | export default component$(() => {
6 | const serverTime = useServerTimeLoader();
7 |
8 | return (
9 |
18 | );
19 | });
20 |
--------------------------------------------------------------------------------
/example/src/components/starter/gauge/gauge.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | position: relative;
3 | }
4 |
5 | .gauge {
6 | width: 160px;
7 | }
8 |
9 | .value {
10 | position: absolute;
11 | top: 50%;
12 | left: 50%;
13 | color: white;
14 | font-size: 3rem;
15 | transform: translate(-50%, -50%);
16 | width: 200px;
17 | text-align: center;
18 | }
19 |
20 | @media screen and (min-width: 768px) {
21 | .gauge {
22 | width: 400px;
23 | }
24 | .value {
25 | font-size: 7rem;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/example/src/components/starter/gauge/index.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from "@builder.io/qwik";
2 | import styles from "./gauge.module.css";
3 |
4 | export default component$(({ value = 50 }: { value?: number }) => {
5 | const safeValue = value < 0 || value > 100 ? 50 : value;
6 |
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
24 |
25 |
34 |
35 | {safeValue}
36 |
37 | );
38 | });
39 |
--------------------------------------------------------------------------------
/example/src/components/starter/header/header.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | display: flex;
3 | align-items: center;
4 | justify-content: space-between;
5 | }
6 |
7 | .logo {
8 | display: inline-block;
9 | }
10 | .logo a {
11 | display: block;
12 | }
13 |
14 | .header ul {
15 | margin: 0;
16 | padding: 0;
17 | list-style: none;
18 | display: flex;
19 | gap: 30px;
20 | }
21 |
22 | .header li {
23 | display: none;
24 | margin: 0;
25 | padding: 0;
26 | font-size: 0.7rem;
27 | }
28 |
29 | .header li a {
30 | color: white;
31 | display: inline-block;
32 | padding: 0;
33 | text-decoration: none;
34 | }
35 |
36 | .header li a:hover {
37 | color: var(--qwik-light-blue);
38 | }
39 |
40 | @media (min-width: 450px) {
41 | .header li {
42 | display: inline-block;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/example/src/components/starter/header/header.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from "@builder.io/qwik";
2 | import { QwikLogo } from "../icons/qwik";
3 | import styles from "./header.module.css";
4 |
5 | export default component$(() => {
6 | return (
7 |
42 | );
43 | });
44 |
--------------------------------------------------------------------------------
/example/src/components/starter/hero/hero.module.css:
--------------------------------------------------------------------------------
1 | .hero {
2 | display: flex;
3 | vertical-align: middle;
4 | flex-direction: column;
5 | flex-wrap: nowrap;
6 | align-items: center;
7 | height: 450px;
8 | justify-content: center;
9 | gap: 40px;
10 | }
11 |
12 | .hero-image {
13 | width: 100%;
14 | position: absolute;
15 | height: auto;
16 | object-fit: cover;
17 | z-index: -1;
18 | opacity: 0.2;
19 | pointer-events: none;
20 | }
21 |
22 | .hero p {
23 | color: white;
24 | margin: 0;
25 | font-size: 1rem;
26 | }
27 |
28 | .button-group {
29 | display: flex;
30 | flex-direction: row;
31 | gap: 24px;
32 | }
33 |
34 | @media screen and (min-width: 768px) {
35 | .hero {
36 | gap: 60px;
37 | height: 500px;
38 | }
39 |
40 | .hero p {
41 | font-size: 1.3rem;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/example/src/components/starter/hero/hero.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from "@builder.io/qwik";
2 | import styles from "./hero.module.css";
3 | import ImgThunder from "~/media/thunder.png?jsx";
4 |
5 | export default component$(() => {
6 | return (
7 |
8 |
9 |
10 | So fantastic
11 |
12 | to have you here
13 |
14 |
Have fun building your App with Qwik.
15 |
80 |
81 | );
82 | });
83 |
--------------------------------------------------------------------------------
/example/src/components/starter/icons/qwik.tsx:
--------------------------------------------------------------------------------
1 | export const QwikLogo = ({
2 | width = 100,
3 | height = 35,
4 | }: {
5 | width?: number;
6 | height?: number;
7 | }) => (
8 |
15 |
19 |
23 |
27 |
31 |
35 |
39 |
43 |
44 | );
45 |
--------------------------------------------------------------------------------
/example/src/components/starter/infobox/infobox.module.css:
--------------------------------------------------------------------------------
1 | .infobox {
2 | color: white;
3 | font-size: 0.8rem;
4 | line-height: 2;
5 | margin: 0 0 40px;
6 | }
7 |
8 | .infobox h3 {
9 | font-size: 1rem;
10 | font-weight: 400;
11 | margin: 0 0 15px;
12 | padding: 0;
13 | }
14 |
15 | .infobox li {
16 | line-height: 2.5;
17 | }
18 |
19 | @media screen and (min-width: 600px) {
20 | .infobox {
21 | margin: 0;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/example/src/components/starter/infobox/infobox.tsx:
--------------------------------------------------------------------------------
1 | import { Slot, component$ } from "@builder.io/qwik";
2 | import styles from "./infobox.module.css";
3 |
4 | export default component$(() => {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 | );
13 | });
14 |
--------------------------------------------------------------------------------
/example/src/components/starter/next-steps/next-steps.module.css:
--------------------------------------------------------------------------------
1 | .gettingstarted {
2 | display: flex;
3 | color: white;
4 | flex-direction: column;
5 | justify-content: center;
6 | align-items: center;
7 | height: 280px;
8 | line-height: 1.5;
9 | gap: 10px;
10 | max-width: 600px;
11 | margin: 0 auto;
12 | }
13 |
14 | .gettingstarted .intro {
15 | font-size: 1rem;
16 | width: 100%;
17 | word-break: break-word;
18 | }
19 | .gettingstarted .hint {
20 | font-size: 0.8rem;
21 | }
22 | .gettingstarted .hint a {
23 | color: var(--qwik-dark-purple);
24 | }
25 |
26 | @media screen and (min-width: 768px) {
27 | .gettingstarted {
28 | height: 180px;
29 | }
30 | .gettingstarted .intro {
31 | font-size: 1.2rem;
32 | }
33 | .gettingstarted .hint {
34 | font-size: 1rem;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/example/src/components/starter/next-steps/next-steps.tsx:
--------------------------------------------------------------------------------
1 | import { component$, $, useOnWindow, useSignal } from "@builder.io/qwik";
2 | import styles from "./next-steps.module.css";
3 |
4 | export const GETTING_STARTED_STEPS = [
5 | {
6 | message:
7 | "Press and hold the ALT key to activate 'Click-to-Source' mode",
8 | },
9 | {
10 | message:
11 | "Select the title of this page while keeping the ALT key pressed",
12 | hint: 'Edit the title and save the changes. If your editor does not open, have a look at this page to set the correct LAUNCH_EDITOR
value.',
13 | },
14 | {
15 | message:
16 | "Update now the routeLoader$
defined in the src/routes/layout.tsx
file",
17 | hint: "Instead of returning the current date, you could return any possible string. The output is displayed in the footer.",
18 | },
19 | {
20 | message: "Create a new Route called /me
",
21 | hint: 'Create a new directory called me
in src/routes
. Within this directory create a index.tsx
file or copy the src/routes/index.tsx
file. Your new route is now accessible here ✨',
22 | },
23 | {
24 | message: "Time to have a look at Forms ",
25 | hint: 'Open the TODO list App and add some items to the list. Try the same with disabled JavaScript 🐰',
26 | },
27 | {
28 | message: "Congratulations! You are now familiar with the basics! 🎉",
29 | hint: "If you need further info on how to use qwik, have a look at qwik.builder.io or join the Discord channel .",
30 | },
31 | ];
32 |
33 | export default component$(() => {
34 | const gettingStartedStep = useSignal(0);
35 |
36 | useOnWindow(
37 | "keydown",
38 | $((e) => {
39 | if ((e as KeyboardEvent).key === "Alt") {
40 | gettingStartedStep.value = 1;
41 | }
42 | }),
43 | );
44 |
45 | return (
46 |
47 |
48 | Time for a
49 |
50 | qwik intro ?
51 |
52 |
66 | {gettingStartedStep.value + 1 < GETTING_STARTED_STEPS.length ? (
67 |
gettingStartedStep.value++}>
68 | Continue with Step {gettingStartedStep.value + 2} of{" "}
69 | {GETTING_STARTED_STEPS.length}
70 |
71 | ) : (
72 |
(gettingStartedStep.value = 0)}
75 | >
76 | Re-Start
77 |
78 | )}
79 |
80 | );
81 | });
82 |
--------------------------------------------------------------------------------
/example/src/entry.cloudflare-pages.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * WHAT IS THIS FILE?
3 | *
4 | * It's the entry point for Cloudflare Pages when building for production.
5 | *
6 | * Learn more about the Cloudflare Pages integration here:
7 | * - https://qwik.builder.io/docs/deployments/cloudflare-pages/
8 | *
9 | */
10 | import {
11 | createQwikCity,
12 | type PlatformCloudflarePages,
13 | } from "@builder.io/qwik-city/middleware/cloudflare-pages";
14 | import qwikCityPlan from "@qwik-city-plan";
15 | import { manifest } from "@qwik-client-manifest";
16 | import render from "./entry.ssr";
17 |
18 | declare global {
19 | interface QwikCityPlatform extends PlatformCloudflarePages {}
20 | }
21 |
22 | const fetch = createQwikCity({ render, qwikCityPlan, manifest });
23 |
24 | export { fetch };
25 |
--------------------------------------------------------------------------------
/example/src/entry.dev.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * WHAT IS THIS FILE?
3 | *
4 | * Development entry point using only client-side modules:
5 | * - Do not use this mode in production!
6 | * - No SSR
7 | * - No portion of the application is pre-rendered on the server.
8 | * - All of the application is running eagerly in the browser.
9 | * - More code is transferred to the browser than in SSR mode.
10 | * - Optimizer/Serialization/Deserialization code is not exercised!
11 | */
12 | import { render, type RenderOptions } from "@builder.io/qwik";
13 | import Root from "./root";
14 |
15 | export default function (opts: RenderOptions) {
16 | return render(document, , opts);
17 | }
18 |
--------------------------------------------------------------------------------
/example/src/entry.preview.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * WHAT IS THIS FILE?
3 | *
4 | * It's the bundle entry point for `npm run preview`.
5 | * That is, serving your app built in production mode.
6 | *
7 | * Feel free to modify this file, but don't remove it!
8 | *
9 | * Learn more about Vite's preview command:
10 | * - https://vitejs.dev/config/preview-options.html#preview-options
11 | *
12 | */
13 | import { createQwikCity } from "@builder.io/qwik-city/middleware/node";
14 | import qwikCityPlan from "@qwik-city-plan";
15 | import render from "./entry.ssr";
16 |
17 | /**
18 | * The default export is the QwikCity adapter used by Vite preview.
19 | */
20 | export default createQwikCity({ render, qwikCityPlan });
21 |
--------------------------------------------------------------------------------
/example/src/entry.ssr.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WHAT IS THIS FILE?
3 | *
4 | * SSR entry point, in all cases the application is rendered outside the browser, this
5 | * entry point will be the common one.
6 | *
7 | * - Server (express, cloudflare...)
8 | * - npm run start
9 | * - npm run preview
10 | * - npm run build
11 | *
12 | */
13 | import {
14 | renderToStream,
15 | type RenderToStreamOptions,
16 | } from "@builder.io/qwik/server";
17 | import { manifest } from "@qwik-client-manifest";
18 | import Root from "./root";
19 |
20 | export default function (opts: RenderToStreamOptions) {
21 | return renderToStream( , {
22 | manifest,
23 | ...opts,
24 | // Use container attributes to set attributes on the html tag.
25 | containerAttributes: {
26 | lang: "en-us",
27 | ...opts.containerAttributes,
28 | },
29 | });
30 | }
31 |
--------------------------------------------------------------------------------
/example/src/global.css:
--------------------------------------------------------------------------------
1 | /**
2 | * WHAT IS THIS FILE?
3 | *
4 | * Globally applied styles. No matter which components are in the page or matching route,
5 | * the styles in here will be applied to the Document, without any sort of CSS scoping.
6 | *
7 | */
8 |
9 | :root {
10 | --qwik-dark-blue: #006ce9;
11 | --qwik-light-blue: #18b6f6;
12 | --qwik-light-purple: #ac7ff4;
13 | --qwik-dark-purple: #713fc2;
14 | --qwik-dirty-black: #1d2033;
15 | --qwik-dark-background: #151934;
16 | --qwik-dark-text: #ffffff;
17 | }
18 |
19 | html {
20 | line-height: 1;
21 | -webkit-text-size-adjust: 100%;
22 | -moz-tab-size: 4;
23 | -o-tab-size: 4;
24 | tab-size: 4;
25 | font-family:
26 | ui-sans-serif,
27 | system-ui,
28 | -apple-system,
29 | BlinkMacSystemFont,
30 | "Segoe UI",
31 | Roboto,
32 | "Helvetica Neue",
33 | Arial,
34 | "Noto Sans",
35 | sans-serif,
36 | "Apple Color Emoji",
37 | "Segoe UI Emoji",
38 | "Segoe UI Symbol",
39 | "Noto Color Emoji";
40 | }
41 |
42 | body {
43 | padding: 0;
44 | margin: 0;
45 | line-height: inherit;
46 | }
47 |
48 | /**
49 | * WHAT IS THIS FILE?
50 | *
51 | * Globally applied styles. No matter which components are in the page or matching route,
52 | * the styles in here will be applied to the Document, without any sort of CSS scoping.
53 | *
54 | */
55 | html {
56 | -webkit-text-size-adjust: 100%;
57 | -moz-tab-size: 4;
58 | -o-tab-size: 4;
59 | tab-size: 4;
60 | font-family:
61 | ui-sans-serif,
62 | system-ui,
63 | -apple-system,
64 | BlinkMacSystemFont,
65 | "Segoe UI",
66 | Roboto,
67 | "Helvetica Neue",
68 | Arial,
69 | "Noto Sans",
70 | sans-serif,
71 | "Apple Color Emoji",
72 | "Segoe UI Emoji",
73 | "Segoe UI Symbol",
74 | "Noto Color Emoji";
75 | }
76 |
77 | body {
78 | padding: 0;
79 | line-height: inherit;
80 | }
81 |
--------------------------------------------------------------------------------
/example/src/media/thunder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QwikCommunity/pwa/775f6f1ab2fc3762dfaca44b28782ea18472213a/example/src/media/thunder.png
--------------------------------------------------------------------------------
/example/src/root.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from "@builder.io/qwik";
2 | import {
3 | QwikCityProvider,
4 | RouterOutlet,
5 | ServiceWorkerRegister,
6 | } from "@builder.io/qwik-city";
7 | import { RouterHead } from "./components/router-head/router-head";
8 |
9 | import "./global.css";
10 |
11 | export default component$(() => {
12 | /**
13 | * The root of a QwikCity site always start with the component,
14 | * immediately followed by the document's and .
15 | *
16 | * Don't remove the `` and `` elements.
17 | */
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 | });
33 |
--------------------------------------------------------------------------------
/example/src/routes/demo/flower/flower.css:
--------------------------------------------------------------------------------
1 | .host {
2 | display: grid;
3 |
4 | align-items: center;
5 | justify-content: center;
6 | justify-items: center;
7 | --rotation: 135deg;
8 | --rotation: 225deg;
9 | --size-step: 10px;
10 | --odd-color-step: 5;
11 | --even-color-step: 5;
12 | --center: 12;
13 |
14 | width: 100%;
15 | height: 500px;
16 |
17 | contain: strict;
18 | }
19 |
20 | h1 {
21 | margin-bottom: 60px;
22 | }
23 |
24 | .input {
25 | width: 60%;
26 | }
27 |
28 | .square {
29 | --size: calc(40px + var(--index) * var(--size-step));
30 |
31 | display: block;
32 | width: var(--size);
33 | height: var(--size);
34 | transform: rotateZ(
35 | calc(var(--rotation) * var(--state) * (var(--center) - var(--index)))
36 | );
37 | transition-property: transform, border-color;
38 | transition-duration: 5s;
39 | transition-timing-function: ease-in-out;
40 | grid-area: 1 / 1;
41 | background: white;
42 | border-width: 2px;
43 | border-style: solid;
44 | border-color: black;
45 | box-sizing: border-box;
46 | will-change: transform, border-color;
47 |
48 | contain: strict;
49 | }
50 |
51 | .square.odd {
52 | --luminance: calc(1 - calc(calc(var(--index) * var(--odd-color-step)) / 256));
53 | background: rgb(
54 | calc(172 * var(--luminance)),
55 | calc(127 * var(--luminance)),
56 | calc(244 * var(--luminance))
57 | );
58 | }
59 |
60 | .pride .square:nth-child(12n + 1) {
61 | background: #e70000;
62 | }
63 | .pride .square:nth-child(12n + 3) {
64 | background: #ff8c00;
65 | }
66 | .pride .square:nth-child(12n + 5) {
67 | background: #ffef00;
68 | }
69 | .pride .square:nth-child(12n + 7) {
70 | background: #00811f;
71 | }
72 | .pride .square:nth-child(12n + 9) {
73 | background: #0044ff;
74 | }
75 | .pride .square:nth-child(12n + 11) {
76 | background: #760089;
77 | }
78 |
--------------------------------------------------------------------------------
/example/src/routes/demo/flower/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | component$,
3 | useVisibleTask$,
4 | useStore,
5 | useStylesScoped$,
6 | } from "@builder.io/qwik";
7 | import { type DocumentHead, useLocation } from "@builder.io/qwik-city";
8 | import styles from "./flower.css?inline";
9 |
10 | export default component$(() => {
11 | useStylesScoped$(styles);
12 | const loc = useLocation();
13 |
14 | const state = useStore({
15 | count: 0,
16 | number: 20,
17 | });
18 |
19 | useVisibleTask$(({ cleanup }) => {
20 | const timeout = setTimeout(() => (state.count = 1), 500);
21 | cleanup(() => clearTimeout(timeout));
22 |
23 | const internal = setInterval(() => state.count++, 7000);
24 | cleanup(() => clearInterval(internal));
25 | });
26 |
27 | return (
28 |
29 |
30 |
31 | Generate Flowers
32 |
33 |
34 |
{
40 | state.number = (ev.target as HTMLInputElement).valueAsNumber;
41 | }}
42 | />
43 |
52 | {Array.from({ length: state.number }, (_, i) => (
53 |
61 | )).reverse()}
62 |
63 |
64 | );
65 | });
66 |
67 | export const head: DocumentHead = {
68 | title: "Qwik Flower",
69 | };
70 |
--------------------------------------------------------------------------------
/example/src/routes/demo/todolist/index.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from "@builder.io/qwik";
2 | import {
3 | type DocumentHead,
4 | routeLoader$,
5 | routeAction$,
6 | zod$,
7 | z,
8 | Form,
9 | } from "@builder.io/qwik-city";
10 | import styles from "./todolist.module.css";
11 |
12 | interface ListItem {
13 | text: string;
14 | }
15 |
16 | export const list: ListItem[] = [];
17 |
18 | export const useListLoader = routeLoader$(() => {
19 | return list;
20 | });
21 |
22 | export const useAddToListAction = routeAction$(
23 | (item) => {
24 | list.push(item);
25 | return {
26 | success: true,
27 | };
28 | },
29 | zod$({
30 | text: z.string().trim().min(1),
31 | }),
32 | );
33 |
34 | export default component$(() => {
35 | const list = useListLoader();
36 | const action = useAddToListAction();
37 |
38 | return (
39 | <>
40 |
41 |
42 | TODO List
43 |
44 |
45 |
46 |
47 |
48 |
49 | {list.value.length === 0 ? (
50 |
No items found
51 | ) : (
52 |
53 | {list.value.map((item, index) => (
54 | {item.text}
55 | ))}
56 |
57 | )}
58 |
59 |
60 |
61 |
67 |
68 |
69 | PS: This little app works even when JavaScript is disabled.
70 |
71 |
72 | >
73 | );
74 | });
75 |
76 | export const head: DocumentHead = {
77 | title: "Qwik Todo List",
78 | };
79 |
--------------------------------------------------------------------------------
/example/src/routes/demo/todolist/todolist.module.css:
--------------------------------------------------------------------------------
1 | .list {
2 | display: flex;
3 | flex-direction: column;
4 | gap: 20px;
5 | color: white;
6 | }
7 |
8 | .list,
9 | .empty {
10 | min-height: 250px;
11 | }
12 |
13 | .list li {
14 | list-style: none;
15 | }
16 |
17 | .empty {
18 | color: white;
19 | display: block;
20 | }
21 |
22 | .input {
23 | background: white;
24 | color: var(--qwik-light-blue);
25 | border: none;
26 | border-radius: 8px;
27 | padding: 15px 20px;
28 | margin-right: 10px;
29 | font-size: 0.8rem;
30 | }
31 |
32 | .hint {
33 | font-size: 0.8rem;
34 | color: white;
35 | margin-top: 30px;
36 | }
37 |
38 | @media screen and (min-width: 768px) {
39 | .input {
40 | padding: 23px 35px;
41 | margin-right: 20px;
42 | font-size: 1rem;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/example/src/routes/dynamic/[id]/index.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from "@builder.io/qwik";
2 | import { useLocation } from "@builder.io/qwik-city";
3 |
4 | export default component$(() => {
5 | const loc = useLocation();
6 | return Hello {loc.params.id}!
;
7 | });
8 |
--------------------------------------------------------------------------------
/example/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from "@builder.io/qwik";
2 | import type { DocumentHead } from "@builder.io/qwik-city";
3 |
4 | import Counter from "~/components/starter/counter/counter";
5 | import Hero from "~/components/starter/hero/hero";
6 | import Infobox from "~/components/starter/infobox/infobox";
7 | import Starter from "~/components/starter/next-steps/next-steps";
8 |
9 | export default component$(() => {
10 | return (
11 | <>
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | You can count
21 | on me
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | CLI Commands
30 |
31 | <>
32 |
33 | npm run dev
34 |
35 | Starts the development server and watches for changes
36 |
37 |
38 | npm run preview
39 |
40 | Creates production build and starts a server to preview it
41 |
42 |
43 | npm run build
44 |
45 | Creates production build
46 |
47 |
48 | npm run qwik add
49 |
50 | Runs the qwik CLI to add integrations
51 |
52 | >
53 |
54 |
55 |
56 |
57 |
58 | Example Apps
59 |
60 |
61 | Have a look at the Flower App or the{" "}
62 | Todo App .
63 |
64 |
65 |
66 |
67 |
70 |
97 |
98 |
99 |
100 | >
101 | );
102 | });
103 |
104 | export const head: DocumentHead = {
105 | title: "Welcome to Qwik",
106 | meta: [
107 | {
108 | name: "description",
109 | content: "Qwik site description",
110 | },
111 | ],
112 | };
113 |
--------------------------------------------------------------------------------
/example/src/routes/layout.tsx:
--------------------------------------------------------------------------------
1 | import { component$, Slot, useStyles$ } from "@builder.io/qwik";
2 | import { routeLoader$ } from "@builder.io/qwik-city";
3 | import type { RequestHandler } from "@builder.io/qwik-city";
4 |
5 | import Header from "~/components/starter/header/header";
6 | import Footer from "~/components/starter/footer/footer";
7 |
8 | import styles from "./styles.css?inline";
9 |
10 | export const onGet: RequestHandler = async ({ cacheControl }) => {
11 | // Control caching for this request for best performance and to reduce hosting costs:
12 | // https://qwik.builder.io/docs/caching/
13 | cacheControl({
14 | // Always serve a cached response by default, up to a week stale
15 | staleWhileRevalidate: 60 * 60 * 24 * 7,
16 | // Max once every 5 seconds, revalidate on the server to get a fresh version of this page
17 | maxAge: 5,
18 | });
19 | };
20 |
21 | export const useServerTimeLoader = routeLoader$(() => {
22 | return {
23 | date: new Date().toISOString(),
24 | };
25 | });
26 |
27 | export default component$(() => {
28 | useStyles$(styles);
29 | return (
30 | <>
31 |
32 |
33 |
34 |
35 |
36 | >
37 | );
38 | });
39 |
--------------------------------------------------------------------------------
/example/src/routes/service-worker.ts:
--------------------------------------------------------------------------------
1 | import { setupServiceWorker } from "@builder.io/qwik-city/service-worker";
2 | import { setupPwa } from "@qwikdev/pwa/sw";
3 |
4 | setupServiceWorker();
5 | setupPwa();
6 |
--------------------------------------------------------------------------------
/example/src/routes/static/[id]/index.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from "@builder.io/qwik";
2 | import { StaticGenerateHandler, useLocation } from "@builder.io/qwik-city";
3 |
4 | export default component$(() => {
5 | const loc = useLocation();
6 | return Hello {loc.params.id}!
;
7 | });
8 |
9 | export const onStaticGenerate: StaticGenerateHandler = async ({ env }) => {
10 | return {
11 | params: ["1", "2", "3"].map((id) => {
12 | return { id };
13 | }),
14 | };
15 | };
16 |
--------------------------------------------------------------------------------
/example/src/routes/styles.css:
--------------------------------------------------------------------------------
1 | /* THIS FILE IS JUST FOR EXAMPLES, DELETE IT IF YOU DON'T NEED IT */
2 |
3 | /* SHELL ---------------------------------------- */
4 | html {
5 | font-family:
6 | ui-sans-serif,
7 | system-ui,
8 | -apple-system,
9 | BlinkMacSystemFont,
10 | "Segoe UI",
11 | Roboto,
12 | "Helvetica Neue",
13 | Arial,
14 | "Noto Sans",
15 | sans-serif,
16 | "Apple Color Emoji",
17 | "Segoe UI Emoji",
18 | "Segoe UI Symbol",
19 | "Noto Color Emoji";
20 | }
21 |
22 | body {
23 | background: var(--qwik-dark-background);
24 | color: var(--qwik-dark-text);
25 | overflow-x: hidden;
26 | }
27 |
28 | /* HEADINGS ------------------------------------- */
29 | h1,
30 | h2,
31 | h3 {
32 | color: white;
33 | margin: 0;
34 | }
35 |
36 | h1 {
37 | font-size: 3.2rem;
38 | text-align: center;
39 | }
40 | h1 .highlight,
41 | h3 .highlight {
42 | color: var(--qwik-light-blue);
43 | }
44 |
45 | h2 {
46 | font-weight: 400;
47 | font-size: 2.4rem;
48 | }
49 | h2 .highlight {
50 | font-weight: 700;
51 | }
52 |
53 | h3 {
54 | font-size: 2rem;
55 | }
56 |
57 | @media screen and (min-width: 768px) {
58 | h1 {
59 | font-size: 5rem;
60 | }
61 | h2 {
62 | font-size: 3.4rem;
63 | }
64 | h3 {
65 | font-size: 3rem;
66 | }
67 | }
68 |
69 | /* TAGS ----------------------------------------- */
70 | a {
71 | text-decoration: none;
72 | color: var(--qwik-light-blue);
73 | }
74 |
75 | code {
76 | background: rgba(230, 230, 230, 0.3);
77 | border-radius: 4px;
78 | padding: 2px 6px;
79 | }
80 |
81 | ul {
82 | margin: 0;
83 | padding-left: 20px;
84 | }
85 |
86 | /* CONTAINER ------------------------------------ */
87 | .container {
88 | margin: 0 auto;
89 | padding: 30px 40px;
90 | }
91 | .container.container-purple {
92 | background: var(--qwik-light-purple);
93 | }
94 | .container.container-dark {
95 | background: var(--qwik-dark-background);
96 | color: var(--qwik-dark-text);
97 | }
98 | .container.container-center {
99 | text-align: center;
100 | }
101 | .container.container-flex {
102 | /* does nothing on mobile */
103 | }
104 | .container.container-spacing-xl {
105 | padding: 50px 40px;
106 | }
107 |
108 | @media screen and (min-width: 768px) {
109 | .container {
110 | padding: 50px 80px;
111 | }
112 | .container.container-spacing-xl {
113 | padding: 100px 60px;
114 | }
115 | .container.container-flex {
116 | display: flex;
117 | justify-content: center;
118 | gap: 60px;
119 | }
120 | }
121 |
122 | /* BUTTONS -------------------------------------- */
123 | a.button,
124 | button {
125 | background: var(--qwik-light-blue);
126 | border: none;
127 | border-radius: 8px;
128 | color: white;
129 | cursor: pointer;
130 | font-size: 0.8rem;
131 | padding: 15px 20px;
132 | text-align: center;
133 | }
134 |
135 | a.button.button-dark,
136 | button.button-dark {
137 | background: var(--qwik-dirty-black);
138 | }
139 |
140 | a.button.button-small,
141 | button.button-small {
142 | padding: 15px 25px;
143 | }
144 |
145 | @media screen and (min-width: 768px) {
146 | a.button,
147 | button {
148 | font-size: 1rem;
149 | padding: 23px 35px;
150 | }
151 | }
152 |
153 | /* DESIGN --------------------------------------- */
154 | .ellipsis {
155 | position: absolute;
156 | top: 100px;
157 | left: -100px;
158 | width: 400px;
159 | height: 400px;
160 | background: radial-gradient(
161 | 57.58% 57.58% at 48.79% 42.42%,
162 | rgba(24, 180, 244, 0.5) 0%,
163 | rgba(46, 55, 114, 0) 63.22%
164 | );
165 | transform: rotate(5deg);
166 | opacity: 0.5;
167 | z-index: -1;
168 | }
169 | .ellipsis.ellipsis-purple {
170 | top: 1350px;
171 | left: -100px;
172 | background: radial-gradient(
173 | 50% 50% at 50% 50%,
174 | rgba(172, 127, 244, 0.5) 0%,
175 | rgba(21, 25, 52, 0) 100%
176 | );
177 | transform: rotate(-5deg);
178 | }
179 |
180 | @media screen and (min-width: 768px) {
181 | .ellipsis {
182 | top: -100px;
183 | left: 350px;
184 | width: 1400px;
185 | height: 800px;
186 | }
187 | .ellipsis.ellipsis-purple {
188 | top: 1300px;
189 | left: -200px;
190 | }
191 | }
192 |
193 | /* used icon pack: https://www.svgrepo.com/collection/phosphor-thin-icons */
194 | .icon:before {
195 | width: 18px;
196 | height: 18px;
197 | content: "";
198 | display: inline-block;
199 | margin-right: 20px;
200 | position: relative;
201 | top: 2px;
202 | }
203 |
204 | .icon-cli:before {
205 | background-image: url("data:image/svg+xml,%3Csvg fill='%23ffffff' width='20px' height='20px' viewBox='0 0 256 256' id='Flat' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M122.499 124.87646a4.00053 4.00053 0 0 1 0 6.24708l-40 32a4.0002 4.0002 0 0 1-4.998-6.24708L113.59668 128 77.501 99.12354a4.0002 4.0002 0 0 1 4.998-6.24708ZM175.99414 156h-40a4 4 0 0 0 0 8h40a4 4 0 1 0 0-8ZM228 56.48535v143.0293A12.49909 12.49909 0 0 1 215.51465 212H40.48535A12.49909 12.49909 0 0 1 28 199.51465V56.48535A12.49909 12.49909 0 0 1 40.48535 44h175.0293A12.49909 12.49909 0 0 1 228 56.48535Zm-8 0A4.49023 4.49023 0 0 0 215.51465 52H40.48535A4.49023 4.49023 0 0 0 36 56.48535v143.0293A4.49023 4.49023 0 0 0 40.48535 204h175.0293A4.49023 4.49023 0 0 0 220 199.51465Z'/%3E%3C/svg%3E");
206 | }
207 |
208 | .icon-apps:before {
209 | background-image: url("data:image/svg+xml,%3Csvg fill='%23ffffff' width='20px' height='20px' viewBox='0 0 256 256' id='Flat' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M216 44.00586H40a12.01375 12.01375 0 0 0-12 12v144a12.01375 12.01375 0 0 0 12 12H216a12.01375 12.01375 0 0 0 12-12v-144A12.01375 12.01375 0 0 0 216 44.00586Zm4 156a4.00458 4.00458 0 0 1-4 4H40a4.00458 4.00458 0 0 1-4-4v-144a4.00458 4.00458 0 0 1 4-4H216a4.00458 4.00458 0 0 1 4 4Zm-144-116a8 8 0 1 1-8-8A7.99977 7.99977 0 0 1 76 84.00586Zm40 0a8 8 0 1 1-8-8A7.99977 7.99977 0 0 1 116 84.00586Z'/%3E%3C/svg%3E");
210 | }
211 |
212 | .icon-community:before {
213 | background-image: url("data:image/svg+xml,%3Csvg fill='%23ffffff' width='20px' height='20px' viewBox='0 0 256 256' id='Flat' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M246.40381 143.19434a4.00061 4.00061 0 0 1-5.60108-.7959A55.57857 55.57857 0 0 0 196 120a4 4 0 0 1 0-8 28 28 0 1 0-27.50732-33.26074 4.00013 4.00013 0 0 1-7.85987-1.49219 36.00191 36.00191 0 1 1 54.06494 37.50513 63.58068 63.58068 0 0 1 32.50147 22.84155A3.99993 3.99993 0 0 1 246.40381 143.19434Zm-57.24268 71.05273a3.9998 3.9998 0 1 1-7.1914 3.50391 60.02582 60.02582 0 0 0-107.93946 0 3.9998 3.9998 0 1 1-7.1914-3.50391 67.56008 67.56008 0 0 1 40.90625-35.20581 44 44 0 1 1 40.50976 0A67.56139 67.56139 0 0 1 189.16113 214.24707ZM128 176a36 36 0 1 0-36-36A36.04061 36.04061 0 0 0 128 176ZM60 112A28 28 0 1 1 87.50732 78.73828a3.99989 3.99989 0 1 0 7.85938-1.49219A36.00177 36.00177 0 1 0 41.30225 114.7522 63.5829 63.5829 0 0 0 8.79883 137.5957a4 4 0 1 0 6.39648 4.80469A55.58072 55.58072 0 0 1 60 120a4 4 0 0 0 0-8Z'/%3E%3C/svg%3E");
214 | }
215 |
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "target": "ES2017",
5 | "module": "ES2022",
6 | "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"],
7 | "jsx": "react-jsx",
8 | "jsxImportSource": "@builder.io/qwik",
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "resolveJsonModule": true,
12 | "moduleResolution": "node",
13 | "esModuleInterop": true,
14 | "skipLibCheck": true,
15 | "incremental": true,
16 | "isolatedModules": true,
17 | "outDir": "tmp",
18 | "noEmit": true,
19 | "types": ["node", "vite/client", "@qwikdev/pwa"],
20 | "paths": {
21 | "~/*": ["./src/*"]
22 | }
23 | },
24 | "files": ["./.eslintrc.cjs"],
25 | "include": ["src", "./*.d.ts", "./*.config.ts"]
26 | }
27 |
--------------------------------------------------------------------------------
/example/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import { qwikVite } from "@builder.io/qwik/optimizer";
3 | import { qwikCity } from "@builder.io/qwik-city/vite";
4 | import tsconfigPaths from "vite-tsconfig-paths";
5 | import { type PWAOptions, qwikPwa } from "@qwikdev/pwa";
6 |
7 | const config: PWAOptions | undefined = process.env.CUSTOM_CONFIG === "true"
8 | ? { config: true }
9 | : undefined;
10 |
11 | export default defineConfig(() => {
12 | return {
13 | define: {
14 | // enables debugging in workbox
15 | "process.env.NODE_ENV": JSON.stringify("development"),
16 | },
17 | plugins: [
18 | qwikCity(),
19 | qwikVite(),
20 | tsconfigPaths(),
21 | qwikPwa(config)
22 | ],
23 | preview: {
24 | headers: {
25 | "Cache-Control": "public, max-age=600",
26 | },
27 | },
28 | };
29 | });
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@qwikdev/pwa",
3 | "type": "module",
4 | "version": "0.0.4",
5 | "packageManager": "pnpm@8.13.1",
6 | "description": "Qwik PWA",
7 | "homepage": "https://github.com/QwikDev/pwa#readme",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/QwikDev/pwa.git"
11 | },
12 | "bugs": "https://github.com/QwikDev/pwa/issues",
13 | "keywords": [
14 | "qwik",
15 | "pwa",
16 | "workbox",
17 | "service worker"
18 | ],
19 | "exports": {
20 | ".": {
21 | "import": {
22 | "types": "./lib-types/index.d.mts",
23 | "default": "./lib/index.qwik.js"
24 | },
25 | "require": {
26 | "types": "./lib-types/index.d.cts",
27 | "default": "./lib/index.qwik.cjs"
28 | }
29 | },
30 | "./head": {
31 | "import": {
32 | "types": "./lib-types/head.d.mts",
33 | "default": "./lib/head.qwik.js"
34 | },
35 | "require": {
36 | "types": "./lib-types/head.d.cts",
37 | "default": "./lib/head.qwik.cjs"
38 | }
39 | },
40 | "./sw": {
41 | "import": {
42 | "types": "./lib-types/sw.d.mts",
43 | "default": "./lib/sw.qwik.js"
44 | },
45 | "require": {
46 | "types": "./lib-types/sw.d.cts",
47 | "default": "./lib/sw.qwik.cjs"
48 | }
49 | },
50 | "./*": "./*"
51 | },
52 | "main": "lib/index.qwik.js",
53 | "qwik": "lib/index.qwik.js",
54 | "types": "lib-types/index.d.ts",
55 | "typesVersions": {
56 | "*": {
57 | "head": [
58 | "./lib-types/head.d.ts"
59 | ],
60 | "sw": [
61 | "./lib-types/sw.d.ts"
62 | ],
63 | "*": [
64 | "./*"
65 | ]
66 | }
67 | },
68 | "files": [
69 | "lib",
70 | "lib-types"
71 | ],
72 | "engines": {
73 | "node": ">=16.8.0 <18.0.0 || >=18.11"
74 | },
75 | "scripts": {
76 | "cleanup": "rimraf lib lib-types tsconfig.tsbuildinfo",
77 | "build": "pnpm cleanup && qwik build && node scripts/postbuild.mjs",
78 | "build.lib.watch": "vite build --mode lib --watch",
79 | "build.lib": "vite build --mode lib",
80 | "build.types": "tsc --emitDeclarationOnly",
81 | "dev": "pnpm build.lib.watch",
82 | "dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force",
83 | "fmt": "prettier --write .",
84 | "fmt.check": "prettier --check .",
85 | "lint": "eslint \"src/**/*.ts*\"",
86 | "release": "np",
87 | "start": "vite --open --mode ssr",
88 | "test": "echo \"No test specified\" && exit 0",
89 | "qwik": "qwik"
90 | },
91 | "dependencies": {
92 | "@vite-pwa/assets-generator": "^0.2.2",
93 | "fast-glob": "^3.3.2"
94 | },
95 | "devDependencies": {
96 | "@builder.io/qwik": "1.2.19",
97 | "@builder.io/qwik-city": "^1.2.19",
98 | "@types/eslint": "^8.44.4",
99 | "@types/node": "^20.8.4",
100 | "@typescript-eslint/eslint-plugin": "^6.7.5",
101 | "@typescript-eslint/parser": "^6.7.5",
102 | "eslint": "^8.51.0",
103 | "eslint-plugin-qwik": "latest",
104 | "np": "^8.0.4",
105 | "npm-run-all": "^4.1.5",
106 | "prettier": "^3.0.3",
107 | "rimraf": "^5.0.5",
108 | "typescript": "^5.3.3",
109 | "ultrahtml": "^1.5.2",
110 | "undici": "^5.26.0",
111 | "vite": "^4.4.11",
112 | "vite-tsconfig-paths": "^4.2.1",
113 | "workbox-precaching": "^7.0.0",
114 | "workbox-routing": "^7.0.0",
115 | "workbox-strategies": "^7.0.0"
116 | },
117 | "resolutions": {
118 | "sharp": "0.32.6"
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - example
3 |
--------------------------------------------------------------------------------
/scripts/postbuild.mjs:
--------------------------------------------------------------------------------
1 | import { readFile, rm, writeFile } from "node:fs/promises";
2 |
3 | async function postBuild() {
4 | await Promise.all([
5 | rm("./lib-types/context.d.ts"),
6 | rm("./lib-types/assets/", { recursive: true }),
7 | rm("./lib-types/plugins/", { recursive: true }),
8 | readFile("./lib-types/head.d.ts", "utf-8").then((content) => {
9 | return Promise.all([
10 | writeFile("./lib-types/head.d.cts", content, "utf-8"),
11 | writeFile("./lib-types/head.d.mts", content, "utf-8"),
12 | ]);
13 | }),
14 | readFile("./lib-types/index.d.ts", "utf-8").then((content) => {
15 | return Promise.all([
16 | writeFile("./lib-types/index.d.cts", content, "utf-8"),
17 | writeFile("./lib-types/index.d.mts", content, "utf-8"),
18 | ]);
19 | }),
20 | readFile("./lib-types/sw.d.ts", "utf-8").then((content) => {
21 | return Promise.all([
22 | writeFile("./lib-types/sw.d.cts", content, "utf-8"),
23 | writeFile("./lib-types/sw.d.mts", content, "utf-8"),
24 | ]);
25 | }),
26 | ]);
27 | // fix mts file, we must use `./types.js` in the static import (with extension)
28 | await readFile("./lib-types/index.d.mts", "utf-8").then((content) => {
29 | return writeFile(
30 | "./lib-types/index.d.mts",
31 | content.replace(/\.\/types/g, "./types.js"),
32 | "utf-8",
33 | );
34 | });
35 | }
36 |
37 | postBuild();
38 |
--------------------------------------------------------------------------------
/src/assets/build.ts:
--------------------------------------------------------------------------------
1 | import { mkdir } from "node:fs/promises";
2 | import { generateAssets } from "@vite-pwa/assets-generator/api/generate-assets";
3 | import { writeWebManifest } from "./manifest";
4 | import { AssetsGeneratorContext } from "./types";
5 | import { QwikPWAContext } from "../context";
6 |
7 | export async function generate(
8 | ctx: QwikPWAContext,
9 | assetsContext: AssetsGeneratorContext,
10 | ) {
11 | await mkdir(assetsContext.imageOutDir, { recursive: true });
12 | await Promise.all([
13 | generateAssets(
14 | assetsContext.assetsInstructions,
15 | true,
16 | assetsContext.imageOutDir,
17 | ),
18 | writeWebManifest(ctx, assetsContext),
19 | ]);
20 | }
21 |
22 | export function resolveSWPrecachingAssets(
23 | assetsContext: AssetsGeneratorContext,
24 | ) {
25 | const resources = new Set();
26 | const instruction = assetsContext.assetsInstructions;
27 | // exclude svg file since it is in the public folder
28 | Array.from(Object.keys(instruction.favicon))
29 | .filter((icon) => !icon.endsWith(".svg"))
30 | .forEach((icon) => resources.add(icon));
31 | Array.from(Object.keys(instruction.transparent)).forEach((icon) =>
32 | resources.add(icon),
33 | );
34 | Array.from(Object.keys(instruction.maskable)).forEach((icon) =>
35 | resources.add(icon),
36 | );
37 | Array.from(Object.keys(instruction.apple)).forEach((icon) =>
38 | resources.add(icon),
39 | );
40 | Array.from(Object.keys(instruction.appleSplashScreen)).forEach((icon) =>
41 | resources.add(icon),
42 | );
43 |
44 | return Array.from(resources);
45 | }
46 |
--------------------------------------------------------------------------------
/src/assets/config.ts:
--------------------------------------------------------------------------------
1 | import type { QwikPWAContext } from "../context";
2 | import { loadConfig, type UserConfig } from "@vite-pwa/assets-generator/config";
3 | import type { AssetsGeneratorContext, ResolvedPWAAsset } from "./types";
4 | import { basename, dirname, relative, resolve } from "node:path";
5 | import { instructions } from "@vite-pwa/assets-generator/api/instructions";
6 | import { readFile } from "node:fs/promises";
7 | import { overrideWebManifestIcons } from "./manifest";
8 |
9 | async function loadConfiguration(root: string, ctx: QwikPWAContext) {
10 | if (ctx.options.config === false) {
11 | return await loadConfig(root, {
12 | config: false,
13 | preset: ctx.options.preset as UserConfig["preset"],
14 | images: ctx.options.images,
15 | logLevel: "silent",
16 | });
17 | }
18 |
19 | return await loadConfig(
20 | root,
21 | typeof ctx.options.config === "boolean"
22 | ? root
23 | : { config: ctx.options.config },
24 | );
25 | }
26 |
27 | export async function loadAssetsGeneratorContext(
28 | ctx: QwikPWAContext,
29 | assetContext?: AssetsGeneratorContext,
30 | ) {
31 | const root = ctx.viteConfig.root ?? process.cwd();
32 | const { config, sources } = await loadConfiguration(root, ctx);
33 | if (!config.preset) {
34 | console.error(
35 | [
36 | "",
37 | `Qwik PWA v${ctx.version}`,
38 | "ERROR: No preset for assets generator found",
39 | ].join("\n"),
40 | );
41 | return;
42 | }
43 |
44 | const { preset, images, headLinkOptions: userHeadLinkOptions } = config;
45 |
46 | if (!images) {
47 | console.error(
48 | [
49 | "",
50 | `Qwik PWA v${ctx.version}`,
51 | "ERROR: No image provided for assets generator",
52 | ].join("\n"),
53 | );
54 | return;
55 | }
56 |
57 | if (Array.isArray(images)) {
58 | if (!images.length) {
59 | console.error(
60 | [
61 | "",
62 | `Qwik PWA v${ctx.version}`,
63 | "ERROR: No image provided for assets generator",
64 | ].join("\n"),
65 | );
66 | return;
67 | }
68 | if (images.length > 1) {
69 | console.error(
70 | [
71 | "",
72 | `Qwik PWA v${ctx.version}`,
73 | "ERROR: Only one image is supported for assets generator",
74 | ].join("\n"),
75 | );
76 | return;
77 | }
78 | }
79 |
80 | const useImage = Array.isArray(images) ? images[0] : images;
81 | const imageFile = resolve(root, useImage);
82 | const publicDir = resolve(root, ctx.publicDir);
83 | const outDir = ctx.clientOutBaseDir;
84 | const imageName = relative(publicDir, imageFile);
85 | const imageOutDir = dirname(resolve(outDir, imageName));
86 |
87 | const xhtml = userHeadLinkOptions?.xhtml === true;
88 | const includeId = userHeadLinkOptions?.includeId === true;
89 | const assetsInstructions = await instructions({
90 | imageResolver: () => readFile(resolve(root, useImage)),
91 | imageName,
92 | // for custom dark apple splash screens image source
93 | originalName: useImage,
94 | preset,
95 | faviconPreset: userHeadLinkOptions?.preset,
96 | htmlLinks: { xhtml, includeId },
97 | basePath: ctx.basePathRelDir || "/",
98 | resolveSvgName:
99 | userHeadLinkOptions?.resolveSvgName ?? ((name) => basename(name)),
100 | });
101 | const resolvedWebManifestFile = resolve(
102 | publicDir,
103 | ctx.options.webManifestFilename,
104 | ).replace(/\\/g, "/");
105 | const {
106 | includeWebManifest,
107 | includeHtmlHeadLinks,
108 | includeThemeColor,
109 | overrideManifestIcons: useOverrideManifestIcons,
110 | } = ctx.options;
111 |
112 | const overrideManifestIcons = useOverrideManifestIcons
113 | ? await overrideWebManifestIcons(resolvedWebManifestFile)
114 | : false;
115 |
116 | if (assetContext === undefined) {
117 | return {
118 | lastModified: Date.now(),
119 | assetsInstructions,
120 | cache: new Map(),
121 | useImage,
122 | imageFile,
123 | publicDir,
124 | outDir,
125 | imageName,
126 | imageOutDir,
127 | xhtml,
128 | includeId,
129 | // normalize sources
130 | sources: sources.map((source) => source.replace(/\\/g, "/")),
131 | includeWebManifest,
132 | includeThemeColor,
133 | includeHtmlHeadLinks,
134 | overrideManifestIcons,
135 | resolvedWebManifestFile,
136 | } satisfies AssetsGeneratorContext;
137 | }
138 |
139 | assetContext.lastModified = Date.now();
140 | assetContext.assetsInstructions = assetsInstructions;
141 | assetContext.useImage = useImage;
142 | assetContext.imageFile = imageFile;
143 | assetContext.outDir = outDir;
144 | assetContext.imageName = imageName;
145 | assetContext.imageOutDir = imageOutDir;
146 | assetContext.xhtml = xhtml;
147 | assetContext.includeId = includeId;
148 | assetContext.includeWebManifest = includeWebManifest;
149 | assetContext.includeThemeColor = includeThemeColor;
150 | assetContext.includeHtmlHeadLinks = includeHtmlHeadLinks;
151 | assetContext.overrideManifestIcons = overrideManifestIcons;
152 | assetContext.resolvedWebManifestFile = resolvedWebManifestFile;
153 | assetContext.cache.clear();
154 | }
155 |
--------------------------------------------------------------------------------
/src/assets/dev.ts:
--------------------------------------------------------------------------------
1 | import { injectWebManifestEntries, readManifestFile } from "./manifest";
2 | import type { AssetsGeneratorContext, ResolvedPWAAsset } from "./types";
3 | import type { QwikPWAContext } from "../context";
4 | import { loadAssetsGeneratorContext } from "./config";
5 |
6 | export async function findPWAAsset(
7 | path: string,
8 | ctx: QwikPWAContext,
9 | assetsContext: AssetsGeneratorContext,
10 | ) {
11 | let resolved = assetsContext.cache.get(path);
12 | if (resolved) {
13 | resolved.age = Date.now() - resolved.lastModified;
14 | return resolved;
15 | }
16 |
17 | if (path === ctx.webManifestUrl) {
18 | const manifest = await readManifestFile(ctx);
19 | if (!manifest) return;
20 |
21 | resolved = {
22 | path,
23 | mimeType: "application/manifest+json",
24 | buffer: injectWebManifestEntries(
25 | ctx,
26 | manifest,
27 | assetsContext.assetsInstructions,
28 | ),
29 | lastModified: Date.now(),
30 | age: 0,
31 | } satisfies ResolvedPWAAsset;
32 | assetsContext.cache.set(path, resolved);
33 | return resolved;
34 | }
35 |
36 | const iconAsset =
37 | assetsContext.assetsInstructions.transparent[path] ??
38 | assetsContext.assetsInstructions.maskable[path] ??
39 | assetsContext.assetsInstructions.apple[path] ??
40 | assetsContext.assetsInstructions.favicon[path] ??
41 | assetsContext.assetsInstructions.appleSplashScreen[path];
42 |
43 | if (!iconAsset) return;
44 |
45 | if (iconAsset) {
46 | resolved = {
47 | path,
48 | mimeType: iconAsset.mimeType,
49 | buffer: iconAsset.buffer(),
50 | lastModified: Date.now(),
51 | age: 0,
52 | } satisfies ResolvedPWAAsset;
53 | assetsContext.cache.set(path, resolved);
54 | return resolved;
55 | }
56 | }
57 |
58 | export async function checkHotUpdate(
59 | file: string,
60 | ctx: QwikPWAContext,
61 | assetsContext: AssetsGeneratorContext,
62 | ) {
63 | // watch web manifest changes
64 | if (file === assetsContext.resolvedWebManifestFile) {
65 | assetsContext.cache.delete(ctx.webManifestUrl);
66 | return "webmanifest";
67 | }
68 |
69 | // watch pwa assets configuration file
70 | const result = assetsContext.sources.includes(file);
71 | if (result) await loadAssetsGeneratorContext(ctx, assetsContext);
72 |
73 | return result ? "configuration" : undefined;
74 | }
75 |
--------------------------------------------------------------------------------
/src/assets/generator.ts:
--------------------------------------------------------------------------------
1 | import type { PWAAssetsGenerator } from "./types";
2 | import type { QwikPWAContext } from "../context";
3 | import { loadAssetsGeneratorContext } from "./config";
4 | import { generate, resolveSWPrecachingAssets } from "./build";
5 | import { checkHotUpdate, findPWAAsset } from "./dev";
6 | import { resolveDevHtmlAssets, resolveHtmlLinks } from "./html";
7 |
8 | export async function loadInstructions(ctx: QwikPWAContext) {
9 | const assetsContext = await loadAssetsGeneratorContext(ctx);
10 | if (!assetsContext) return;
11 |
12 | return {
13 | generate: () => generate(ctx, assetsContext),
14 | resolveSWPrecachingAssets: () => resolveSWPrecachingAssets(assetsContext),
15 | findPWAAsset: (path: string) => findPWAAsset(path, ctx, assetsContext),
16 | resolveHtmlLinks: () => resolveHtmlLinks(ctx, assetsContext),
17 | resolveDevHtmlAssets: () => resolveDevHtmlAssets(ctx, assetsContext),
18 | checkHotUpdate: (file) => checkHotUpdate(file, ctx, assetsContext),
19 | } satisfies PWAAssetsGenerator;
20 | }
21 |
--------------------------------------------------------------------------------
/src/assets/html.ts:
--------------------------------------------------------------------------------
1 | import type { AssetsGeneratorContext, DevHtmlAssets } from "./types";
2 | import { readManifestFile } from "./manifest";
3 | import { mapLink } from "./utils";
4 | import type { QwikPWAContext } from "../context";
5 |
6 | export async function resolveDevHtmlAssets(
7 | ctx: QwikPWAContext,
8 | assetsContext: AssetsGeneratorContext,
9 | ) {
10 | const header: DevHtmlAssets = {
11 | link: [],
12 | meta: [],
13 | };
14 | if (assetsContext.includeThemeColor) {
15 | const manifest = await readManifestFile(ctx);
16 | if (manifest && "theme_color" in manifest)
17 | header.meta.push({
18 | key: "theme-color",
19 | content: manifest.theme_color,
20 | name: "theme-color",
21 | });
22 | }
23 |
24 | if (assetsContext.includeWebManifest) {
25 | header.link.push({
26 | key: "manifest",
27 | rel: "manifest",
28 | href: ctx.webManifestUrl,
29 | });
30 | }
31 |
32 | if (assetsContext.includeHtmlHeadLinks) {
33 | const instruction = assetsContext.assetsInstructions;
34 | const apple = Array.from(Object.values(instruction.apple));
35 | const favicon = Array.from(Object.values(instruction.favicon));
36 | const appleSplashScreen = Array.from(
37 | Object.values(instruction.appleSplashScreen),
38 | );
39 | const includeId = assetsContext.includeId;
40 | favicon.forEach(
41 | (icon) =>
42 | icon.linkObject &&
43 | header.link.push(mapLink(includeId, icon.linkObject)),
44 | );
45 | apple.forEach(
46 | (icon) =>
47 | icon.linkObject &&
48 | header.link.push(mapLink(includeId, icon.linkObject)),
49 | );
50 | appleSplashScreen.forEach(
51 | (icon) =>
52 | icon.linkObject &&
53 | header.link.push(mapLink(includeId, icon.linkObject)),
54 | );
55 | }
56 |
57 | return header;
58 | }
59 |
60 | export async function resolveHtmlLinks(
61 | ctx: QwikPWAContext,
62 | assetsContext: AssetsGeneratorContext,
63 | ) {
64 | const header = await resolveDevHtmlAssets(ctx, assetsContext);
65 | return `export const links = ${JSON.stringify(header.link)};
66 | export const meta = ${JSON.stringify(header.meta)};
67 | `;
68 | }
69 |
--------------------------------------------------------------------------------
/src/assets/manifest.ts:
--------------------------------------------------------------------------------
1 | import type { QwikPWAContext } from "../context";
2 | import { resolve } from "node:path";
3 | import { lstat, readFile, writeFile } from "node:fs/promises";
4 | import type { ImageAssetsInstructions } from "@vite-pwa/assets-generator/api";
5 | import { generateManifestIconsEntry } from "@vite-pwa/assets-generator/api/generate-manifest-icons-entry";
6 | import type { AssetsGeneratorContext } from "./types";
7 |
8 | export async function readManifestFile(ctx: QwikPWAContext) {
9 | return await readWebManifestFile(resolveWebManifestFile(ctx));
10 | }
11 |
12 | export async function injectWebManifestEntries(
13 | ctx: QwikPWAContext,
14 | manifest: any,
15 | assetsInstructions: ImageAssetsInstructions,
16 | ) {
17 | if (ctx.options.overrideManifestIcons) {
18 | manifest.icons = generateManifestIconsEntry(
19 | "object",
20 | assetsInstructions,
21 | ).icons;
22 | }
23 |
24 | if (!("id" in manifest)) {
25 | manifest.id = ctx.basePathRelDir || "/";
26 | }
27 |
28 | if (!("scope" in manifest)) {
29 | manifest.scope = ctx.basePathRelDir || "/";
30 | }
31 |
32 | if (!("theme_color" in manifest)) {
33 | console.warn(
34 | [
35 | "",
36 | `Qwik PWA v${ctx.version}`,
37 | '"theme_color" is missing from the web manifest, your application will not be able to be installed',
38 | ].join("\n"),
39 | );
40 | }
41 |
42 | return Buffer.from(JSON.stringify(manifest, undefined, 2));
43 | }
44 |
45 | export async function writeWebManifest(
46 | ctx: QwikPWAContext,
47 | assetContext: AssetsGeneratorContext,
48 | ) {
49 | const manifest = await readManifestFile(ctx);
50 | if (!manifest) return;
51 |
52 | const buffer = await injectWebManifestEntries(
53 | ctx,
54 | manifest,
55 | assetContext.assetsInstructions,
56 | );
57 | await writeFile(
58 | resolve(ctx.clientOutBaseDir, ctx.options.webManifestFilename),
59 | buffer,
60 | "utf-8",
61 | );
62 | }
63 |
64 | export async function overrideWebManifestIcons(manifestFile: string) {
65 | const manifest = await readWebManifestFile(manifestFile);
66 |
67 | return !!manifest && !("icons" in manifest);
68 | }
69 |
70 | function resolveWebManifestFile(ctx: QwikPWAContext) {
71 | return resolve(ctx.publicDir, ctx.options.webManifestFilename);
72 | }
73 |
74 | async function readWebManifestFile(manifestFile: string) {
75 | const isFile = await lstat(manifestFile)
76 | .then((stat) => stat.isFile())
77 | .catch(() => false);
78 | if (!isFile) return;
79 |
80 | return JSON.parse(await readFile(manifestFile, { encoding: "utf-8" }));
81 | }
82 |
--------------------------------------------------------------------------------
/src/assets/options.ts:
--------------------------------------------------------------------------------
1 | import type { QwikPWAContext } from "../context";
2 |
3 | export function resolveOptions(ctx: QwikPWAContext) {
4 | // don't use ?? in ctx.basePathRelDir because it can be an empty string
5 | ctx.webManifestUrl = `${ctx.basePathRelDir || "/"}${
6 | ctx.userOptions.webManifestFilename ?? "manifest.json"
7 | }`;
8 |
9 | const {
10 | config = false,
11 | preset = "minimal-2023",
12 | overrideAssets = true,
13 | image = "public/favicon.svg",
14 | htmlPreset = "2023",
15 | webManifestFilename = "manifest.json",
16 | overrideManifestIcons = true,
17 | includeHtmlHeadLinks = true,
18 | includeThemeColor = true,
19 | includeWebManifest = false,
20 | } = ctx.userOptions;
21 |
22 | ctx.options = {
23 | config,
24 | preset: config ? false : preset,
25 | overrideAssets,
26 | images: [image],
27 | htmlPreset,
28 | webManifestFilename: webManifestFilename,
29 | overrideManifestIcons,
30 | includeHtmlHeadLinks,
31 | includeThemeColor,
32 | includeWebManifest,
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/src/assets/types.ts:
--------------------------------------------------------------------------------
1 | import type { DocumentLink, DocumentMeta } from "@builder.io/qwik-city";
2 | import type { PWAOptions } from "../types";
3 | import type { ImageAssetsInstructions } from "@vite-pwa/assets-generator/api";
4 |
5 | export interface ResolvedPWAAsset {
6 | path: string;
7 | mimeType: string;
8 | buffer: Promise;
9 | age: number;
10 | lastModified: number;
11 | }
12 |
13 | export interface DevHtmlAssets {
14 | meta: DocumentMeta[];
15 | link: DocumentLink[];
16 | }
17 |
18 | export interface ResolvedPWAOptions
19 | extends Required> {
20 | images: string[];
21 | }
22 |
23 | export interface PWAAssetsGenerator {
24 | generate(): Promise;
25 | findPWAAsset(path: string): Promise;
26 | resolveHtmlLinks(): Promise;
27 | resolveDevHtmlAssets(): Promise;
28 | resolveSWPrecachingAssets(): string[];
29 | checkHotUpdate(
30 | path: string,
31 | ): Promise<"webmanifest" | "configuration" | undefined>;
32 | }
33 |
34 | export interface AssetsGeneratorContext {
35 | lastModified: number;
36 | assetsInstructions: ImageAssetsInstructions;
37 | cache: Map;
38 | useImage: string;
39 | imageFile: string;
40 | publicDir: string;
41 | outDir: string;
42 | imageName: string;
43 | imageOutDir: string;
44 | xhtml: boolean;
45 | includeId: boolean;
46 | sources: string[];
47 | includeWebManifest: boolean | string;
48 | includeThemeColor: boolean;
49 | includeHtmlHeadLinks: boolean;
50 | overrideManifestIcons: boolean;
51 | resolvedWebManifestFile: string;
52 | }
53 |
--------------------------------------------------------------------------------
/src/assets/utils.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | AppleSplashScreenLink,
3 | FaviconLink,
4 | HtmlLink,
5 | } from "@vite-pwa/assets-generator/api";
6 | import type { DocumentLink } from "@builder.io/qwik-city";
7 |
8 | export function mapLink(
9 | includeId: boolean,
10 | link: HtmlLink | FaviconLink | AppleSplashScreenLink,
11 | ) {
12 | const entry: DocumentLink = {
13 | key: link.href,
14 | };
15 | if (includeId && link.id) entry.id = link.id;
16 |
17 | entry.rel = link.rel;
18 |
19 | if ("media" in link && link.media) entry.media = link.media;
20 |
21 | entry.href = link.href;
22 |
23 | if ("sizes" in link && link.sizes) entry.sizes = link.sizes;
24 |
25 | if ("type" in link && link.type) entry.type = link.type;
26 |
27 | return entry;
28 | }
29 |
--------------------------------------------------------------------------------
/src/context.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | QwikBuildTarget,
3 | QwikVitePlugin,
4 | } from "@builder.io/qwik/optimizer";
5 | import type { QwikCityPlugin } from "@builder.io/qwik-city/vite";
6 | import type { PWAAssetsGenerator, ResolvedPWAOptions } from "./assets/types";
7 | import type { ResolvedConfig } from "vite";
8 | import type { PWAOptions } from "./types";
9 | import { version } from "../package.json";
10 | import { join } from "node:path";
11 | import { resolveOptions } from "./assets/options";
12 |
13 | export interface QwikPWAContext {
14 | version: string;
15 | userOptions: PWAOptions;
16 | webManifestUrl: string;
17 | options: ResolvedPWAOptions;
18 | viteConfig: ResolvedConfig;
19 | qwikPlugin: QwikVitePlugin;
20 | qwikCityPlugin: QwikCityPlugin;
21 | publicDir: string;
22 | target: QwikBuildTarget;
23 | clientOutDir: string;
24 | basePathRelDir: string;
25 | clientOutBaseDir: string;
26 | swClientDistPath: string;
27 | assets: Promise;
28 | }
29 |
30 | export function createContext(options: PWAOptions): QwikPWAContext {
31 | return {
32 | version,
33 | userOptions: options,
34 | webManifestUrl: undefined!,
35 | options: undefined!,
36 | viteConfig: undefined!,
37 | publicDir: undefined!,
38 | qwikPlugin: undefined!,
39 | qwikCityPlugin: undefined!,
40 | target: undefined!,
41 | clientOutDir: undefined!,
42 | basePathRelDir: undefined!,
43 | clientOutBaseDir: undefined!,
44 | swClientDistPath: undefined!,
45 | assets: Promise.resolve(undefined),
46 | };
47 | }
48 |
49 | export function initializeContext(
50 | ctx: QwikPWAContext,
51 | viteConfig: ResolvedConfig,
52 | ) {
53 | ctx.viteConfig = viteConfig;
54 | ctx.qwikPlugin = viteConfig.plugins.find(
55 | (p) => p.name === "vite-plugin-qwik",
56 | ) as QwikVitePlugin;
57 | ctx.qwikCityPlugin = viteConfig.plugins.find(
58 | (p) => p.name === "vite-plugin-qwik-city",
59 | ) as QwikCityPlugin;
60 | ctx.target = ctx.qwikPlugin!.api.getOptions().target;
61 | ctx.publicDir = viteConfig.publicDir || "public";
62 | ctx.clientOutDir = ctx.qwikPlugin!.api.getClientOutDir()!;
63 | ctx.basePathRelDir = ctx
64 | .qwikCityPlugin!.api.getBasePathname()
65 | .replace(/^\/|\/$/, "");
66 | ctx.clientOutBaseDir = join(ctx.clientOutDir, ctx.basePathRelDir);
67 | ctx.swClientDistPath = join(ctx.clientOutBaseDir, "service-worker.js");
68 | if (ctx.userOptions.config || ctx.userOptions.preset) {
69 | resolveOptions(ctx);
70 | ctx.assets = import("./assets/generator")
71 | .then(({ loadInstructions }) => loadInstructions(ctx))
72 | .catch((e) => {
73 | console.error(["", `Qwik PWA v${ctx.version}`].join("\n"), e);
74 | return Promise.resolve(undefined);
75 | });
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/head.ts:
--------------------------------------------------------------------------------
1 | import type { DocumentMeta, DocumentLink } from "@builder.io/qwik-city";
2 | // @ts-ignore
3 | import * as head from "virtual:qwik-pwa/head";
4 |
5 | const meta = head.meta as DocumentMeta[];
6 | const links = head.links as DocumentLink[];
7 |
8 | export { meta, links };
9 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import type { Plugin } from "vite";
2 | import { createContext } from "./context";
3 | import type { PWAOptions } from "./types";
4 | import MainPlugin from "./plugins/main";
5 | import ClientPlugin from "./plugins/client";
6 | import SSRPlugin from "./plugins/ssr";
7 | import AssetsPlugin from "./plugins/assets";
8 |
9 | export * from "./types";
10 |
11 | export function qwikPwa(
12 | options: PWAOptions = { preset: "minimal-2023" },
13 | ): Plugin[] {
14 | const ctx = createContext(options);
15 | return [
16 | MainPlugin(ctx),
17 | ClientPlugin(ctx),
18 | SSRPlugin(ctx),
19 | AssetsPlugin(ctx),
20 | ];
21 | }
22 |
--------------------------------------------------------------------------------
/src/plugins/assets.ts:
--------------------------------------------------------------------------------
1 | import type { QwikPWAContext } from "../context";
2 | import type { Plugin } from "vite";
3 |
4 | const VIRTUAL = "virtual:qwik-pwa/head";
5 | const RESOLVED_VIRTUAL = `\0${VIRTUAL}`;
6 |
7 | export default function AssetsPlugin(ctx: QwikPWAContext): Plugin {
8 | return {
9 | name: "qwik-pwa:assets",
10 | enforce: "post",
11 | resolveId(id) {
12 | return id === VIRTUAL ? RESOLVED_VIRTUAL : undefined;
13 | },
14 | async load(id) {
15 | if (id === RESOLVED_VIRTUAL) {
16 | const assets = await ctx.assets;
17 | return (
18 | (await assets?.resolveHtmlLinks()) ??
19 | `export const links = [];
20 | export const meta = [];
21 | `
22 | );
23 | }
24 | },
25 | buildStart() {
26 | // add web manifest to watcher, and so we can reload the page when it changes
27 | this.addWatchFile(ctx.webManifestUrl);
28 | },
29 | async handleHotUpdate({ file, server }) {
30 | const assetsGenerator = await ctx.assets;
31 | // will handle web manifest and pwa assets generator config files changes
32 | const change = await assetsGenerator?.checkHotUpdate(file);
33 | if (change) {
34 | if (change === "webmanifest") {
35 | // send full page reload to load web manifest again
36 | server.ws.send({ type: "full-reload" });
37 | return [];
38 | }
39 |
40 | // pwa assets configuration change:
41 | // - invalidate resolved virtual module or
42 | // - send full page reload if resolved virtual module is not found
43 | const resolvedVirtual =
44 | server.moduleGraph.getModuleById(RESOLVED_VIRTUAL);
45 | if (resolvedVirtual) {
46 | return [resolvedVirtual];
47 | }
48 |
49 | server.ws.send({ type: "full-reload" });
50 | return [];
51 | }
52 | },
53 | configureServer(server) {
54 | server.middlewares.use(async (req, res, next) => {
55 | const url = req.url;
56 | if (!url) return next();
57 |
58 | // early return if not a pwa asset
59 | if (url !== ctx.webManifestUrl && !/\.(ico|png|svg|webp)$/.test(url))
60 | return next();
61 |
62 | const assetsGenerator = await ctx.assets;
63 | if (!assetsGenerator) return next();
64 |
65 | // will handle pwa icons and web manifest (only when pwa icons injection enabled)
66 | const asset = await assetsGenerator.findPWAAsset(url);
67 | if (!asset) return next();
68 |
69 | if (asset.age > 0) {
70 | const ifModifiedSince =
71 | req.headers["if-modified-since"] ??
72 | req.headers["If-Modified-Since"];
73 | const useIfModifiedSince = ifModifiedSince
74 | ? Array.isArray(ifModifiedSince)
75 | ? ifModifiedSince[0]
76 | : ifModifiedSince
77 | : undefined;
78 | if (
79 | useIfModifiedSince &&
80 | new Date(asset.lastModified).getTime() / 1000 >=
81 | new Date(useIfModifiedSince).getTime() / 1000
82 | ) {
83 | res.statusCode = 304;
84 | res.end();
85 | return;
86 | }
87 | }
88 |
89 | const buffer = await asset.buffer;
90 | res.setHeader("Age", asset.age / 1000);
91 | res.setHeader("Content-Type", asset.mimeType);
92 | res.setHeader("Content-Length", buffer.length);
93 | res.setHeader(
94 | "Last-Modified",
95 | new Date(asset.lastModified).toUTCString(),
96 | );
97 | res.statusCode = 200;
98 | res.end(buffer);
99 | });
100 | },
101 | closeBundle: {
102 | sequential: true,
103 | order: "post",
104 | async handler() {
105 | if (ctx.target !== "client") {
106 | return;
107 | }
108 | const assets = await ctx.assets;
109 | if (!assets) return;
110 |
111 | await assets.generate();
112 | },
113 | },
114 | };
115 | }
116 |
--------------------------------------------------------------------------------
/src/plugins/client.ts:
--------------------------------------------------------------------------------
1 | import fg from "fast-glob";
2 | import fs from "node:fs/promises";
3 | import type { QwikPWAContext } from "../context";
4 | import type { Plugin } from "vite";
5 |
6 | export default function ClientPlugin(ctx: QwikPWAContext): Plugin {
7 | return {
8 | name: "qwik-pwa:client",
9 | enforce: "post",
10 | writeBundle: {
11 | sequential: true,
12 | order: "post",
13 | async handler(_, bundle) {
14 | if (ctx.target !== "client") {
15 | return;
16 | }
17 | const publicDirAssets = await fg.glob("**/*", { cwd: ctx.publicDir });
18 | // the q-*.js files are going to be handled by qwik itself
19 | const emittedAssets = Object.keys(bundle).filter(
20 | (key) => !/.*q-.*\.js$/.test(key),
21 | );
22 | const assets = await ctx.assets;
23 | const generatedAssetsUrls = assets?.resolveSWPrecachingAssets() ?? [];
24 | const routes = ctx.qwikCityPlugin.api.getRoutes();
25 | const swCode = await fs.readFile(ctx.swClientDistPath, "utf-8");
26 | const swCodeUpdate = `
27 | const excludeAssets = ['_headers', '_redirects']
28 | const version = ${JSON.stringify(ctx.version)};
29 | const publicDirAssets = ${JSON.stringify(publicDirAssets)};
30 | const emittedAssets = ${JSON.stringify([
31 | ...generatedAssetsUrls,
32 | ...emittedAssets,
33 | ])};
34 | const routes = [${routes
35 | .map(
36 | (route) =>
37 | `{ pathname: ${JSON.stringify(
38 | route.pathname,
39 | )}, pattern: new RegExp(${JSON.stringify(route.pattern.source)}),
40 | hasParams: ${!!route.paramNames.length}
41 | }`,
42 | )
43 | .join(",\n")}];
44 |
45 | ${swCode}
46 | `;
47 | await fs.writeFile(ctx.swClientDistPath, swCodeUpdate);
48 | },
49 | },
50 | };
51 | }
52 |
--------------------------------------------------------------------------------
/src/plugins/main.ts:
--------------------------------------------------------------------------------
1 | import { initializeContext, type QwikPWAContext } from "../context";
2 | import type { Plugin } from "vite";
3 |
4 | export default function MainPlugin(ctx: QwikPWAContext): Plugin {
5 | return {
6 | name: "qwik-pwa:main",
7 | enforce: "pre",
8 | configResolved(config) {
9 | initializeContext(ctx, config);
10 | },
11 | };
12 | }
13 |
--------------------------------------------------------------------------------
/src/plugins/ssr.ts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs/promises";
2 | import type { QwikPWAContext } from "../context";
3 | import type { Plugin } from "vite";
4 |
5 | export default function SSRPlugin(ctx: QwikPWAContext): Plugin {
6 | return {
7 | name: "qwik-pwa:ssr",
8 | enforce: "post",
9 | closeBundle: {
10 | sequential: true,
11 | order: "post",
12 | async handler() {
13 | if (ctx.target !== "ssr") {
14 | return;
15 | }
16 | const swCode = await fs.readFile(ctx.swClientDistPath, "utf-8");
17 | const manifest = ctx.qwikPlugin.api.getManifest();
18 | const swCodeUpdate = `
19 | const manifestHash = ${JSON.stringify(manifest?.manifestHash)};
20 |
21 | ${swCode}
22 | `;
23 | await fs.writeFile(ctx.swClientDistPath, swCodeUpdate);
24 | },
25 | },
26 | };
27 | }
28 |
--------------------------------------------------------------------------------
/src/sw.ts:
--------------------------------------------------------------------------------
1 | import type { PrecacheEntry } from "workbox-precaching";
2 | import {
3 | cleanupOutdatedCaches,
4 | createHandlerBoundToURL,
5 | precacheAndRoute,
6 | } from "workbox-precaching";
7 | import { StaleWhileRevalidate, NetworkFirst } from "workbox-strategies";
8 |
9 | import {
10 | NavigationRoute,
11 | registerRoute,
12 | setDefaultHandler,
13 | } from "workbox-routing";
14 |
15 | export const assets = [...publicDirAssets, ...emittedAssets].filter((asset) => !excludeAssets.includes(asset));
16 | export { routes };
17 |
18 | function urlsToEntries(urls: string[], hash: string): PrecacheEntry[] {
19 | const matcher = /^build\/q-/;
20 | return urls.map((url) => {
21 | const match = url.match(matcher);
22 | // use null revision, removing the revision or using undefined will cause workbox warnings in runtime
23 | return match ? { url, revision: null } : { url, revision: hash };
24 | });
25 | }
26 |
27 | /**
28 | * Add PWA capabilities.
29 | *
30 | * **WARNING**: "prompt" mode not available yet.
31 | *
32 | * @param mode
33 | * @default "auto-update"
34 | */
35 | export function setupPwa(mode: "auto-update" | "prompt" = "auto-update") {
36 | if (import.meta.env.DEV) {
37 | console.info(`Qwik PWA v${version}, using ${mode} strategy`);
38 | }
39 |
40 | const noParamRoutes = routes.filter((r) => !r.hasParams);
41 | const paramRoutes = routes.filter((r) => r.hasParams);
42 | cleanupOutdatedCaches();
43 |
44 | precacheAndRoute(
45 | urlsToEntries(
46 | [...noParamRoutes.map((r) => r.pathname), ...assets],
47 | manifestHash,
48 | ),
49 | );
50 |
51 | // the rest of requests (like /api/) should be handled by network first (https://github.com/BuilderIO/qwik/issues/5148#issuecomment-1814692124)
52 | setDefaultHandler(new NetworkFirst());
53 |
54 | for (const route of noParamRoutes) {
55 | registerRoute(
56 | new NavigationRoute(createHandlerBoundToURL(route.pathname), {
57 | allowlist: [route.pattern],
58 | }),
59 | );
60 | }
61 | for (const route of paramRoutes) {
62 | registerRoute(route.pattern, new StaleWhileRevalidate());
63 | }
64 |
65 | if (mode === "prompt") {
66 | if (import.meta.env.DEV) {
67 | console.warn(
68 | `Qwik PWA v${version}\nWARNING: "prompt" mode not available yet`,
69 | );
70 | }
71 | /*
72 | self.addEventListener("message", (event) => {
73 | if (event.data.type === "SKIP_WAITING") {
74 | self.skipWaiting();
75 | }
76 | });
77 | */
78 | }
79 | // else {
80 | // Skip-Waiting Service Worker-based solution
81 | self.addEventListener("activate", async () => {
82 | // after we've taken over, iterate over all the current clients (windows)
83 | const clients = await self.clients.matchAll({ type: "window" });
84 | clients.forEach((client) => {
85 | // ...and refresh each one of them
86 | client.navigate(client.url);
87 | });
88 | });
89 | self.skipWaiting();
90 | // }
91 |
92 | const base = "/build/"; // TODO: it should be dynamic based on the build
93 | const qprefetchEvent = new MessageEvent("message", {
94 | data: {
95 | type: "qprefetch",
96 | base,
97 | links: routes.map((route) => route.pathname),
98 | bundles: appBundles.map((appBundle) => appBundle[0]),
99 | },
100 | });
101 |
102 | self.dispatchEvent(qprefetchEvent);
103 | }
104 |
105 | declare const version: string;
106 | declare const appBundles: AppBundle[];
107 |
108 | declare const excludeAssets: string[];
109 | declare const publicDirAssets: string[];
110 | declare const emittedAssets: string[];
111 | declare const routes: {
112 | pathname: string;
113 | pattern: RegExp;
114 | hasParams: boolean;
115 | }[];
116 | declare const manifestHash: string;
117 |
118 | declare const self: ServiceWorkerGlobalScope;
119 |
120 | export type AppSymbols = Map;
121 | export type AppBundle =
122 | | [bundleName: string, importedBundleIds: number[]]
123 | | [
124 | bundleName: string,
125 | importedBundleIds: number[],
126 | symbolHashesInBundle: string[],
127 | ];
128 |
129 | export type LinkBundle = [routePattern: RegExp, bundleIds: number[]];
130 |
131 | export interface QPrefetchData {
132 | links?: string[];
133 | bundles?: string[];
134 | symbols?: string[];
135 | }
136 |
137 | export interface QPrefetchMessage extends QPrefetchData {
138 | type: "qprefetch";
139 | base: string;
140 | }
141 |
142 | export type ServiceWorkerMessage = QPrefetchMessage;
143 |
144 | export interface ServiceWorkerMessageEvent {
145 | data: ServiceWorkerMessage;
146 | }
147 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { BuiltInPreset, Preset } from "@vite-pwa/assets-generator/config";
2 | import type { HtmlLinkPreset } from "@vite-pwa/assets-generator/api";
3 |
4 | /**
5 | * Qwik PWA options.
6 | */
7 | export interface PWAOptions {
8 | /**
9 | * PWA assets generation and injection.
10 | *
11 | * By default, the plugin will search for the pwa assets generator configuration file in the root directory of your project:
12 | * - pwa-assets.config.js
13 | * - pwa-assets.config.mjs
14 | * - pwa-assets.config.cjs
15 | * - pwa-assets.config.ts
16 | * - pwa-assets.config.cts
17 | * - pwa-assets.config.mts
18 | *
19 | * If using a string path, it should be relative to the root directory of your project.
20 | *
21 | * Setting to `false` will disable config resolving.
22 | *
23 | * @default false
24 | * @see https://vite-pwa-org.netlify.app/assets-generator/cli.html#configurations
25 | */
26 | config?: string | boolean;
27 | /**
28 | * Preset to use.
29 | *
30 | * If `config` option is enabled, this option will be ignored.
31 | *
32 | * Setting to `false` will disable PWA assets generation if `config` option disabled.
33 | *
34 | * @default 'minimal-2023'
35 | */
36 | preset?: false | BuiltInPreset | Preset;
37 | /**
38 | * Override assets?
39 | *
40 | * @default true
41 | */
42 | overrideAssets?: boolean;
43 | /**
44 | * Path relative to `root` folder where to find the image to use for generating PWA assets.
45 | *
46 | * @default `public/favicon.svg`
47 | */
48 | image?: string;
49 | /**
50 | * The preset to use for head links (favicon links).
51 | *
52 | * @see https://vite-pwa-org.netlify.app/assets-generator/#preset-minimal-2023
53 | * @see https://vite-pwa-org.netlify.app/assets-generator/#preset-minimal
54 | * @default '2023'
55 | */
56 | htmlPreset?: HtmlLinkPreset;
57 | /**
58 | * Should the plugin include html head links?
59 | *
60 | * @default true
61 | */
62 | includeHtmlHeadLinks?: boolean;
63 | /**
64 | * Should the PWA web manifest `theme_color` be injected in the html head when present?
65 | *
66 | * @default true
67 | */
68 | includeThemeColor?: boolean;
69 | /**
70 | * The name of the web manifest file located in the Vite's `publicDir` folder (defaults to `public` folder).
71 | *
72 | * @default 'manifest.json'
73 | */
74 | webManifestFilename?: string;
75 | /**
76 | * Should the plugin override the PWA web manifest icons' entry?
77 | *
78 | * If you rename the `public/manifest.json` file, remember to update `webManifestFilename` option.
79 | *
80 | * With this option enabled, the plugin will add the icons entry to your web manifest file.
81 | *
82 | * If your web manifest file already contains the icons entry, the plugin will ignore this option.
83 | *
84 | * @default true
85 | */
86 | overrideManifestIcons?: boolean;
87 | /**
88 | * Should the plugin include the PWA web manifest in the head links?
89 | *
90 | * If you enable this option, remember to remove ` ` from `src/root.tsx` component.
91 | *
92 | * @default false
93 | */
94 | includeWebManifest?: boolean;
95 | }
96 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "target": "ES2017",
5 | "module": "ES2020",
6 | "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"],
7 | "jsx": "react-jsx",
8 | "jsxImportSource": "@builder.io/qwik",
9 | "strict": true,
10 | "declaration": true,
11 | "declarationDir": "lib-types",
12 | "resolveJsonModule": true,
13 | "moduleResolution": "node",
14 | "esModuleInterop": true,
15 | "skipLibCheck": true,
16 | "incremental": true,
17 | "isolatedModules": true,
18 | "types": ["vite/client"]
19 | },
20 | "include": ["src"]
21 | }
22 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import pkg from "./package.json";
3 | // add this to the template of qwik lib because some imports are not node:stream and instead they're stream
4 | import { builtinModules } from "node:module";
5 | import { qwikVite } from "@builder.io/qwik/optimizer";
6 | import tsconfigPaths from "vite-tsconfig-paths";
7 |
8 | const { dependencies = {}, peerDependencies = {} } = pkg as any;
9 | const makeRegex = (dep: string) => new RegExp(`^${dep}(/.*)?$`);
10 | const excludeAll = (obj: any) => Object.keys(obj).map(makeRegex);
11 |
12 | export default defineConfig({
13 | build: {
14 | target: "es2020",
15 | lib: {
16 | entry: ["./src/index.ts", "./src/sw.ts", "./src/head.ts"],
17 | formats: ["es", "cjs"],
18 | fileName: (format, entryName) =>
19 | `${entryName}.qwik.${format === "es" ? "js" : "cjs"}`,
20 | },
21 | rollupOptions: {
22 | // externalize deps that shouldn't be bundled into the library
23 | external: [
24 | "fast-glob",
25 | "virtual:qwik-pwa/head",
26 | ...excludeAll(builtinModules),
27 | ...builtinModules,
28 | /^node:.*/,
29 | ...excludeAll(dependencies),
30 | ...excludeAll(peerDependencies),
31 | ],
32 | },
33 | },
34 | plugins: [qwikVite(), tsconfigPaths()],
35 | });
36 |
--------------------------------------------------------------------------------