@caperjs/core 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * The five list plugins and the virtual modules they feed the runtime:
3
+ * `virtual:caper-scenes`, `-plugins`, `-popups`, `-entities`, `-uis`.
4
+ *
5
+ * Each asks `internal/discovery.mjs` what exists, then emits a module the runtime
6
+ * imports — statically for entities and UI (so `this.add.entity(id)` can construct
7
+ * synchronously) and dynamically for scenes and popups (so they code-split).
8
+ */
9
+ import path from 'node:path';
10
+ import {
11
+ discoverEntities,
12
+ discoverLocaleKeys,
13
+ discoverPlugins,
14
+ discoverPopups,
15
+ discoverScenes,
16
+ discoverUIs,
17
+ } from '../internal/discovery.mjs';
18
+ import { logger } from '../internal/util.mjs';
19
+ import { loadManifestBundleNames } from '../internal/manifest.mjs';
20
+
21
+ export function createClassListPlugin({ virtualModuleId, discoverFn, exportName, pluginName }) {
22
+ // vite's resolved project root, captured for the discovery pass. See
23
+ // internal/discovery.mjs for why this is not process.cwd().
24
+ let root;
25
+ const resolvedVirtualModuleId = '\0' + virtualModuleId;
26
+ let server;
27
+
28
+ const extractClassName = (item) => {
29
+ const basename = path.basename(item.module);
30
+ return basename.replace(/\.ts$/, '');
31
+ };
32
+
33
+ const generate = (items) => {
34
+ const staticItems = items.filter((i) => i.module && !i.module.isFunction);
35
+ const imports = staticItems.map((i) => `import ${extractClassName(i)} from '${i.module}';`);
36
+
37
+ return `
38
+ ${imports.join('\n')}
39
+ export const ${exportName} = [
40
+ ${items
41
+ .map((item) => {
42
+ const moduleExpr =
43
+ item.module && !item.module.isFunction
44
+ ? extractClassName(item)
45
+ : (item.module?.toString?.() ?? `() => import('${item.importPath}')`);
46
+ return `{
47
+ id: '${item.id}',
48
+ name: '${item.name}',
49
+ active: ${item.active},
50
+ module: ${moduleExpr}
51
+ }`;
52
+ })
53
+ .join(',\n')}
54
+ ];
55
+ `;
56
+ };
57
+
58
+ return {
59
+ name: pluginName,
60
+ configResolved(config) {
61
+ root = config.root;
62
+ },
63
+ configureServer(s) {
64
+ server = s;
65
+ },
66
+ resolveId(id) {
67
+ if (id === virtualModuleId) return resolvedVirtualModuleId;
68
+ },
69
+ async load(id) {
70
+ if (id === resolvedVirtualModuleId) {
71
+ const items = await discoverFn(server, root);
72
+ return generate(items);
73
+ }
74
+ },
75
+ };
76
+ }
77
+
78
+ export function popupListPlugin(isProject = true) {
79
+ if (!isProject) {
80
+ const virtualModuleId = 'virtual:caper-popups';
81
+ const resolvedVirtualModuleId = '\0' + virtualModuleId;
82
+ return {
83
+ name: 'vite-plugin-popups',
84
+ resolveId: (id) => (id === virtualModuleId ? resolvedVirtualModuleId : undefined),
85
+ load: (id) => (id === resolvedVirtualModuleId ? 'export const popupList = [];' : undefined),
86
+ };
87
+ }
88
+ return createClassListPlugin({
89
+ virtualModuleId: 'virtual:caper-popups',
90
+ discoverFn: discoverPopups,
91
+ exportName: 'popupList',
92
+ pluginName: 'vite-plugin-popups',
93
+ });
94
+ }
95
+
96
+ export function entityListPlugin(isProject = true) {
97
+ if (!isProject) {
98
+ const virtualModuleId = 'virtual:caper-entities';
99
+ const resolvedVirtualModuleId = '\0' + virtualModuleId;
100
+ return {
101
+ name: 'vite-plugin-entities',
102
+ resolveId: (id) => (id === virtualModuleId ? resolvedVirtualModuleId : undefined),
103
+ load: (id) => (id === resolvedVirtualModuleId ? 'export const entityList = [];' : undefined),
104
+ };
105
+ }
106
+ return createClassListPlugin({
107
+ virtualModuleId: 'virtual:caper-entities',
108
+ discoverFn: discoverEntities,
109
+ exportName: 'entityList',
110
+ pluginName: 'vite-plugin-entities',
111
+ });
112
+ }
113
+
114
+ export function uiListPlugin(isProject = true) {
115
+ if (!isProject) {
116
+ const virtualModuleId = 'virtual:caper-uis';
117
+ const resolvedVirtualModuleId = '\0' + virtualModuleId;
118
+ return {
119
+ name: 'vite-plugin-uis',
120
+ resolveId: (id) => (id === virtualModuleId ? resolvedVirtualModuleId : undefined),
121
+ load: (id) => (id === resolvedVirtualModuleId ? 'export const uiList = [];' : undefined),
122
+ };
123
+ }
124
+ return createClassListPlugin({
125
+ virtualModuleId: 'virtual:caper-uis',
126
+ discoverFn: discoverUIs,
127
+ exportName: 'uiList',
128
+ pluginName: 'vite-plugin-uis',
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Walks `src/locales/`, picks a reference locale file (prefers `en.ts`,
134
+ * falls back alphabetically), AST-parses its default-exported object, and
135
+ * flattens every leaf path into a dot-notation key.
136
+ *
137
+ * Given:
138
+ * export default { foo: 'bar', obj: { nested: 'baz' }, replace: { x: '...' } };
139
+ * Returns:
140
+ * ['foo', 'obj.nested', 'replace.x']
141
+ *
142
+ * Only string literal leaves are emitted. Arrays, function calls, and other
143
+ * non-object/non-literal expressions are ignored (they can't be typed
144
+ * meaningfully at this layer).
145
+ */
146
+
147
+ export function pluginListPlugin(isProject = true) {
148
+ function extractClassName(plugin) {
149
+ // For static (non-function) imports, the module value is the raw import path.
150
+ const basename = path.basename(plugin.module);
151
+ return basename.replace(/\.ts$/, '');
152
+ }
153
+
154
+ function generatePluginListModule(plugins) {
155
+ const staticPlugins = plugins.filter((p) => p.module && !p.module.isFunction && p.isLocal);
156
+
157
+ // npm static imports would collide on class-name derivation from the pkg
158
+ // path, so only local plugins support `dynamic = false`. npm packages are
159
+ // always dynamic.
160
+ const imports = staticPlugins.map((p) => `import ${extractClassName(p)} from '${p.module}';`);
161
+
162
+ return `
163
+ ${imports.join('\n')}
164
+ export const pluginsList = [
165
+ ${plugins
166
+ .map((plugin) => {
167
+ const moduleExpr =
168
+ plugin.module && !plugin.module.isFunction && plugin.isLocal
169
+ ? extractClassName(plugin)
170
+ : (plugin.module?.toString?.() ?? `() => import('${plugin.importPath}')`);
171
+ return `{
172
+ name: '${plugin.name}',
173
+ id: '${plugin.id}',
174
+ isLocal: ${plugin.isLocal},
175
+ active: ${plugin.active},
176
+ requires: ${JSON.stringify(plugin.requires || [])},
177
+ module: ${moduleExpr}
178
+ }`;
179
+ })
180
+ .join(',\n')}
181
+ ];
182
+ `;
183
+ }
184
+
185
+ const virtualModuleId = 'virtual:caper-plugins';
186
+ const resolvedVirtualModuleId = '\0' + virtualModuleId;
187
+
188
+ let root;
189
+ let server;
190
+
191
+ return {
192
+ name: 'vite-plugin-plugins',
193
+ configResolved(config) {
194
+ root = config.root;
195
+ },
196
+ configureServer(s) {
197
+ server = s;
198
+ },
199
+ resolveId(id) {
200
+ if (id === virtualModuleId) {
201
+ return resolvedVirtualModuleId;
202
+ }
203
+ },
204
+ async load(id) {
205
+ if (id === resolvedVirtualModuleId) {
206
+ const plugins = isProject ? await discoverPlugins(server, root) : [];
207
+ return generatePluginListModule(plugins);
208
+ }
209
+ },
210
+ };
211
+ }
212
+
213
+ // scene list plugin
214
+
215
+ export function sceneListPlugin(isProject = true) {
216
+ function extractClassName(scene) {
217
+ const basename = path.basename(scene.module);
218
+ // remove .ts
219
+ return basename.replace('.ts', '');
220
+ }
221
+
222
+ function generateSceneListModule(scenes) {
223
+ // extract non function scenes from the list
224
+ const nonFunctionScenes = scenes.filter((scene) => !scene.module.isFunction);
225
+
226
+ const imports = nonFunctionScenes.map((scene) => `import ${extractClassName(scene)} from '${scene.module}';`);
227
+
228
+ const result = `
229
+ ${imports.join('\n')}
230
+ export const sceneList = [
231
+ ${scenes
232
+ .map(
233
+ (scene) => `{
234
+ id: '${scene.id}',
235
+ active: ${scene.active},
236
+ module: ${scene.module.isFunction ? scene.module.toString() : extractClassName(scene)},
237
+ debugLabel: ${JSON.stringify(scene.debugLabel)},
238
+ debugGroup: ${JSON.stringify(scene.debugGroup)},
239
+ debugOrder: ${scene.debugOrder},
240
+ assets: ${JSON.stringify(scene.assets)},
241
+ plugins: ${JSON.stringify(scene.plugins)},
242
+ autoUnloadAssets: ${scene.autoUnloadAssets}
243
+ }`,
244
+ )
245
+ .join(',\n')}
246
+ ];
247
+ `;
248
+ return result;
249
+ }
250
+
251
+ const virtualModuleId = 'virtual:caper-scenes';
252
+ const resolvedVirtualModuleId = '\0' + virtualModuleId;
253
+
254
+ let root;
255
+ let server;
256
+
257
+ return {
258
+ name: 'vite-plugin-scenes',
259
+ configResolved(config) {
260
+ root = config.root;
261
+ },
262
+ configureServer(s) {
263
+ server = s;
264
+ },
265
+ resolveId(id) {
266
+ if (id === virtualModuleId) {
267
+ return resolvedVirtualModuleId;
268
+ }
269
+ },
270
+ async load(id) {
271
+ if (id === resolvedVirtualModuleId) {
272
+ let scenes = [];
273
+ if (isProject) {
274
+ scenes = await discoverScenes(server, root);
275
+ }
276
+ return generateSceneListModule(scenes);
277
+ }
278
+ },
279
+ };
280
+ }
281
+
282
+ /**
283
+ * @param {{ pwa?: object }} [options] When `pwa` is set, the runtime module also
284
+ * installs `Caper.pwa` and (unless `autoRegister: false`) registers the
285
+ * service worker. See `../build/plugins/pwa.mjs`.
286
+ */
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Webp-only production builds.
3
+ *
4
+ * AssetPack emits every image twice, `.webp` and `.png`, and lists webp first in
5
+ * `assets.json` — so pixi's resolver takes webp on every browser shipped since
6
+ * 2020 and the png half is dead weight. In a caper game that's megabytes: 5.8MB
7
+ * of a 43MB build, in bankshot's case.
8
+ *
9
+ * Dev keeps both on purpose: the png is passed through uncompressed there, which
10
+ * is the cheapest thing the pipeline can do, and nothing downloads it anyway.
11
+ * Production drops it. Opt back in with `caper({ assets: { pngFallback: true } })`
12
+ * if you need to serve a browser without webp support.
13
+ *
14
+ * Why prune rather than not emit: AssetPack's `compression.png: false` doesn't
15
+ * drop the file, it passes the *uncompressed original* through — bigger than the
16
+ * compressed one — and that combination crashes the texture-packer cache-buster
17
+ * before the manifest is written.
18
+ *
19
+ * `assets.json` drives the prune rather than filenames, because production
20
+ * cache-busts each format with its own hash (`sheet-u_QUSQ@2x.png` next to
21
+ * `sheet-Ui99Xw@2x.webp`), so pairing them on disk is guesswork. A png `src` is
22
+ * only dropped when a non-png `src` survives it, so a `{nc}`-tagged (uncompressed
23
+ * on purpose) image is never deleted out from under the game.
24
+ */
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+
28
+ /** A png image, a texture-packer descriptor, or a spine atlas for one. */
29
+ const isPngSrc = (src) => /\.png(\.json|\.atlas)?$/.test(src);
30
+
31
+ /**
32
+ * Works out what to delete without touching the filesystem, so the decision is
33
+ * testable on its own.
34
+ *
35
+ * @param {{ bundles: { assets: { src: string[] }[] }[] }} manifest Parsed asset manifest; rewritten in place.
36
+ * @param {(rel: string) => string | null} readText Returns a descriptor's contents, or null if absent.
37
+ * @returns {string[]} Manifest-relative paths to delete.
38
+ */
39
+ export function planPngPrune(manifest, readText) {
40
+ const doomed = [];
41
+ const seen = new Set();
42
+
43
+ /**
44
+ * A descriptor, the image(s) it names, and any further pages it chains to.
45
+ *
46
+ * Two formats reference images by name rather than being manifest entries
47
+ * themselves: a texture-packer `*.png.json` (`meta.image`, plus
48
+ * `related_multi_packs` for pages past the first, which are never manifest
49
+ * entries) and a spine `*.png.atlas` (page names on their own lines). `seen` is
50
+ * load-bearing for the first: sibling pages list each other, so following the
51
+ * chain without it never terminates.
52
+ */
53
+ const addDescriptor = (rel) => {
54
+ if (seen.has(rel)) return;
55
+ seen.add(rel);
56
+
57
+ const text = readText(rel);
58
+ if (text) {
59
+ if (rel.endsWith('.atlas')) {
60
+ for (const line of text.split(/\r?\n/)) {
61
+ const name = line.trim();
62
+ if (name.endsWith('.png')) doomed.push(path.join(path.dirname(rel), name));
63
+ }
64
+ } else {
65
+ let sheet;
66
+ try {
67
+ sheet = JSON.parse(text);
68
+ } catch {
69
+ sheet = null;
70
+ }
71
+ if (sheet?.meta?.image) doomed.push(path.join(path.dirname(rel), sheet.meta.image));
72
+ for (const pack of sheet?.meta?.related_multi_packs ?? []) {
73
+ addDescriptor(path.join(path.dirname(rel), pack));
74
+ }
75
+ }
76
+ }
77
+ doomed.push(rel);
78
+ };
79
+
80
+ for (const bundle of manifest.bundles ?? []) {
81
+ for (const asset of bundle.assets ?? []) {
82
+ const png = (asset.src ?? []).filter(isPngSrc);
83
+ const kept = (asset.src ?? []).filter((src) => !isPngSrc(src));
84
+ if (!png.length || !kept.length) continue;
85
+
86
+ for (const rel of png) {
87
+ if (rel.endsWith('.png.json') || rel.endsWith('.png.atlas')) addDescriptor(rel);
88
+ else doomed.push(rel);
89
+ }
90
+ // Keep the manifest honest: a src the resolver could still pick but the
91
+ // build no longer ships would 404 on whoever got that far.
92
+ asset.src = kept;
93
+ }
94
+ }
95
+
96
+ return doomed;
97
+ }
98
+
99
+ /**
100
+ * @param {{ manifestUrl?: string }} [options]
101
+ * @returns {import('vite').Plugin}
102
+ */
103
+ export function pngFallbackPrunePlugin({ manifestUrl = 'assets.json' } = {}) {
104
+ let assetsDir;
105
+ let enabled = false;
106
+
107
+ return {
108
+ name: 'caper:prune-png-fallbacks',
109
+ apply: 'build',
110
+ configResolved(config) {
111
+ // Dev builds keep the fallback; so does a `vite build --mode development`.
112
+ enabled = config.isProduction;
113
+ assetsDir = path.join(config.build.outDir, 'assets');
114
+ if (!path.isAbsolute(assetsDir)) assetsDir = path.resolve(config.root, assetsDir);
115
+ },
116
+ closeBundle: {
117
+ // Ahead of vite-plugin-pwa, which globs `dist` to build its precache list.
118
+ order: 'pre',
119
+ sequential: true,
120
+ handler() {
121
+ if (!enabled) return;
122
+
123
+ const manifestPath = path.join(assetsDir, manifestUrl);
124
+ if (!fs.existsSync(manifestPath)) return;
125
+
126
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
127
+ const doomed = planPngPrune(manifest, (rel) => {
128
+ const file = path.join(assetsDir, rel);
129
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
130
+ });
131
+
132
+ let removed = 0;
133
+ let bytes = 0;
134
+ for (const rel of doomed) {
135
+ const file = path.join(assetsDir, rel);
136
+ if (!fs.existsSync(file)) continue;
137
+ bytes += fs.statSync(file).size;
138
+ fs.rmSync(file);
139
+ removed += 1;
140
+ }
141
+
142
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest));
143
+
144
+ if (removed) {
145
+ this.info?.(`pruned ${removed} png fallbacks (${(bytes / 1e6).toFixed(1)}MB) — set assets.pngFallback to keep them`);
146
+ }
147
+ },
148
+ },
149
+ };
150
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * PWA support for the `caper()` preset: `caper({ pwa: {} })` should be enough to
3
+ * ship an installable game. Anything a project does set wins over the defaults
4
+ * below.
5
+ *
6
+ * Three halves, really. `caperPwaPlugins()` adds vite-plugin-pwa with caper's
7
+ * defaults merged under the project's options, plus a small companion plugin that
8
+ * fills in the parts only knowable once vite has resolved its config (icons found
9
+ * in `publicDir`, and the precache globs). `pwaRuntimeSnippet()` returns the code
10
+ * that installs `Caper.pwa` — appended to the `caper-runtime` virtual module,
11
+ * which `index.html` already loads.
12
+ *
13
+ * That last part is the fix for the old arrangement: `withPWA()` added a virtual
14
+ * `caper-pwa` rollup input that defined `Caper.pwa.register()`, but nothing ever
15
+ * imported it, so registration never ran and the API was dead. Registering from
16
+ * the runtime also means `injectRegister` defaults to `false` — exactly one thing
17
+ * registers the worker.
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { VitePWA } from 'vite-plugin-pwa';
22
+ import { readAppIdentity } from '../defaults.mjs';
23
+
24
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
25
+
26
+ /** Deep-merge `overrides` over `base`; arrays and scalars replace. */
27
+ function deepMerge(base, overrides) {
28
+ if (!isPlainObject(overrides)) return overrides === undefined ? base : overrides;
29
+ const out = { ...base };
30
+ for (const [key, value] of Object.entries(overrides)) {
31
+ out[key] = isPlainObject(value) && isPlainObject(base?.[key]) ? deepMerge(base[key], value) : value;
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * Icon files a project is likely to already have, in the names the usual
38
+ * favicon generators produce. A web manifest needs a 192 and a 512 to be
39
+ * installable, so finding them automatically is the difference between
40
+ * `pwa: {}` working and quietly producing an uninstallable app.
41
+ */
42
+ const ICON_CANDIDATES = [
43
+ { file: 'android-chrome-192x192.png', sizes: '192x192' },
44
+ { file: 'pwa-192x192.png', sizes: '192x192' },
45
+ { file: 'icon-192.png', sizes: '192x192' },
46
+ { file: 'android-chrome-512x512.png', sizes: '512x512' },
47
+ { file: 'pwa-512x512.png', sizes: '512x512' },
48
+ { file: 'icon-512.png', sizes: '512x512' },
49
+ ];
50
+
51
+ /**
52
+ * Manifest icon entries for whatever conventional icons exist in `publicDir`.
53
+ * The largest one is repeated as `maskable` so Android doesn't letterbox it.
54
+ */
55
+ export function discoverIcons(publicDir) {
56
+ if (!publicDir || !fs.existsSync(publicDir)) return [];
57
+
58
+ const icons = [];
59
+ const seen = new Set();
60
+ for (const { file, sizes } of ICON_CANDIDATES) {
61
+ if (seen.has(sizes)) continue;
62
+ if (!fs.existsSync(path.join(publicDir, file))) continue;
63
+ seen.add(sizes);
64
+ icons.push({ src: `/${file}`, sizes, type: 'image/png' });
65
+ }
66
+
67
+ const largest = icons.find((icon) => icon.sizes === '512x512');
68
+ if (largest) icons.push({ ...largest, purpose: 'maskable' });
69
+
70
+ return icons;
71
+ }
72
+
73
+ /**
74
+ * Precache the shell, runtime-cache the art.
75
+ *
76
+ * A caper game's `public/assets` is usually tens of megabytes, so precaching it
77
+ * would turn "install" into a full download — and workbox would silently skip
78
+ * whatever exceeded its 2MB default anyway. So: precache what's needed to boot
79
+ * (code, icons, fonts, the asset manifest, the `required` bundle, level data) and
80
+ * let everything else land in the cache the first time it's fetched.
81
+ */
82
+ function defaultWorkbox() {
83
+ return {
84
+ globPatterns: [
85
+ 'index.html',
86
+ 'manifest.webmanifest',
87
+ 'assets/*.{js,css}',
88
+ 'assets/assets.json',
89
+ 'assets/caper/**/*',
90
+ 'assets/required/**/*.{webp,png,json,woff2,ttf}',
91
+ 'assets/splash/**/*.{webp,png}',
92
+ 'levels/**/*.json',
93
+ '*.{ico,svg,png}',
94
+ ],
95
+ // A pixi + game bundle clears the 2MiB default on its own.
96
+ maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
97
+ cleanupOutdatedCaches: true,
98
+ navigateFallback: 'index.html',
99
+ runtimeCaching: [
100
+ {
101
+ // Cache-busted filenames make these immutable: first fetch wins, and a
102
+ // new build simply asks for new names.
103
+ urlPattern: /^\/assets\/.*\.(?:webp|png|jpg|json|mp3|ogg|woff2|ttf|atlas|skel|fnt)$/,
104
+ handler: 'CacheFirst',
105
+ options: {
106
+ cacheName: 'caper-assets',
107
+ expiration: { maxEntries: 500, maxAgeSeconds: 60 * 60 * 24 * 60 },
108
+ cacheableResponse: { statuses: [0, 200] },
109
+ // Safari streams audio with Range requests, which a plain CacheFirst
110
+ // match would answer with a 200 and break playback.
111
+ rangeRequests: true,
112
+ },
113
+ },
114
+ ],
115
+ };
116
+ }
117
+
118
+ /** Caper's vite-plugin-pwa defaults. */
119
+ export function defaultPwaOptions() {
120
+ const app = readAppIdentity();
121
+
122
+ return {
123
+ // Root-absolute so the worker's scope and the manifest link don't depend on
124
+ // vite's relative production `base`.
125
+ base: '/',
126
+ registerType: 'autoUpdate',
127
+ // The caper runtime registers the worker (see pwaRuntimeSnippet), so the
128
+ // plugin must not also inject a script for it.
129
+ injectRegister: false,
130
+ selfDestroying: process.env.SW_DESTROY === 'true',
131
+ devOptions: {
132
+ // A service worker in dev serves stale assets from cache, which is a bad
133
+ // time in a project whose assets rebuild on save. Opt in with SW_DEV=true.
134
+ enabled: process.env.SW_DEV === 'true',
135
+ navigateFallback: 'index.html',
136
+ suppressWarnings: true,
137
+ },
138
+ manifest: {
139
+ id: '/',
140
+ name: app.name,
141
+ short_name: app.name,
142
+ description: app.description,
143
+ start_url: '/',
144
+ scope: '/',
145
+ theme_color: '#000000',
146
+ background_color: '#000000',
147
+ display: 'fullscreen',
148
+ display_override: ['fullscreen', 'standalone', 'minimal-ui'],
149
+ orientation: 'any',
150
+ categories: ['games', 'entertainment'],
151
+ },
152
+ workbox: defaultWorkbox(),
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Fills the defaults that need vite's resolved config. Mutates the same options
158
+ * object handed to `VitePWA` above, which reads it later (at build time) — doing
159
+ * this here rather than at construction means it follows `publicDir` and
160
+ * `--root`, instead of guessing from `process.cwd()`.
161
+ */
162
+ function caperPwaDefaultsPlugin(options, projectSetIcons) {
163
+ return {
164
+ name: 'caper:pwa-defaults',
165
+ configResolved: {
166
+ // Must beat vite-plugin-pwa, which snapshots its options in its own
167
+ // configResolved — mutating after that has no effect on the manifest.
168
+ order: 'pre',
169
+ handler(config) {
170
+ if (projectSetIcons) return;
171
+
172
+ const icons = discoverIcons(config.publicDir);
173
+ if (icons.length) {
174
+ options.manifest.icons = icons;
175
+ } else {
176
+ config.logger.warn(
177
+ '[caper] pwa: no icons found in publicDir — the app will not be installable. ' +
178
+ 'Add android-chrome-192x192.png and android-chrome-512x512.png, or set pwa.manifest.icons.',
179
+ );
180
+ }
181
+ },
182
+ },
183
+ };
184
+ }
185
+
186
+ /**
187
+ * @param {object} pwa Project options, merged over `defaultPwaOptions()`.
188
+ * @returns {import('vite').PluginOption[]}
189
+ */
190
+ export function caperPwaPlugins(pwa) {
191
+ const { autoRegister: _autoRegister, ...projectOptions } = pwa;
192
+ const options = deepMerge(defaultPwaOptions(), projectOptions);
193
+ const projectSetIcons = Boolean(projectOptions.manifest?.icons?.length);
194
+
195
+ return [caperPwaDefaultsPlugin(options, projectSetIcons), VitePWA(options)];
196
+ }
197
+
198
+ /**
199
+ * Code appended to the `caper-runtime` virtual module when PWA is enabled.
200
+ * Installs `Caper.pwa` (the interface declared in `src/core/create.ts`) and, by
201
+ * default, registers the worker.
202
+ *
203
+ * The handlers are read off `Caper.pwa` at call time rather than captured, so an
204
+ * app can assign `Caper.pwa.onNeedRefresh` after boot and still have it fire.
205
+ */
206
+ export function pwaRuntimeSnippet({ autoRegister = true } = {}) {
207
+ return `
208
+ import { pwaInfo } from 'virtual:pwa-info';
209
+ import { registerSW } from 'virtual:pwa-register';
210
+
211
+ (globalThis).Caper.pwa = {
212
+ info: pwaInfo,
213
+ onRegisteredSW(swScriptUrl) {
214
+ console.log('Caper PWA: service worker registered:', swScriptUrl);
215
+ },
216
+ offlineReady() {
217
+ console.log('Caper PWA: ready to work offline');
218
+ },
219
+ register() {
220
+ registerSW({
221
+ immediate: true,
222
+ onRegisteredSW(swScriptUrl) {
223
+ (globalThis).Caper.pwa.onRegisteredSW?.(swScriptUrl);
224
+ },
225
+ onOfflineReady() {
226
+ (globalThis).Caper.pwa.offlineReady?.();
227
+ },
228
+ onNeedRefresh() {
229
+ (globalThis).Caper.pwa.onNeedRefresh?.();
230
+ },
231
+ onRegisterError(error) {
232
+ (globalThis).Caper.pwa.onRegisterError?.(error);
233
+ },
234
+ });
235
+ },
236
+ };
237
+
238
+ ${autoRegister ? '(globalThis).Caper.pwa.register();' : '// autoRegister: false — the app calls Caper.pwa.register()'}
239
+ `;
240
+ }