@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.
- package/README.md +1 -1
- package/{config → build}/assetpack.mjs +103 -45
- package/build/defaults.mjs +180 -0
- package/build/index.mjs +96 -0
- package/build/internal/ast.mjs +255 -0
- package/build/internal/buildFlags.mjs +50 -0
- package/build/internal/discovery.mjs +403 -0
- package/build/internal/manifest.mjs +38 -0
- package/build/internal/paths.mjs +26 -0
- package/build/internal/schema.mjs +65 -0
- package/build/internal/util.mjs +30 -0
- package/build/internal/validate.mjs +186 -0
- package/build/plugins/assetTypes.mjs +460 -0
- package/build/plugins/caperConfig.mjs +489 -0
- package/build/plugins/devHelper.mjs +31 -0
- package/build/plugins/lists.mjs +286 -0
- package/build/plugins/pruneFallbacks.mjs +150 -0
- package/build/plugins/pwa.mjs +240 -0
- package/build/plugins/runtime.mjs +129 -0
- package/cli.mjs +9 -6
- package/extras/llms.txt +23 -17
- package/lib/caper.mjs +1 -1
- package/lib/caper.mjs.map +1 -1
- package/lib/types/caper-app.d.ts +1 -0
- package/package.json +13 -14
- package/src/types/caper-app.d.ts +1 -0
- package/src/version.ts +1 -1
- package/templates/app/default/package.template.json +8 -4
- package/templates/app/default/vite.config.ts +15 -0
- package/cli/vite.mjs +0 -55
- package/config/vite.mjs +0 -2464
package/config/vite.mjs
DELETED
|
@@ -1,2464 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import process from 'node:process';
|
|
4
|
-
import { parseSync } from 'oxc-parser';
|
|
5
|
-
import { createLogger, mergeConfig } from 'vite';
|
|
6
|
-
import { VitePWA } from 'vite-plugin-pwa';
|
|
7
|
-
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
|
8
|
-
import wasm from 'vite-plugin-wasm';
|
|
9
|
-
import { z } from 'zod';
|
|
10
|
-
|
|
11
|
-
// oxc-parser emits an ESTree-compatible AST. We keep the `AST_NODE_TYPES`
|
|
12
|
-
// shape so existing call sites don't have to change — every value below is
|
|
13
|
-
// just the ESTree node-type string. If you add a new check, reference the
|
|
14
|
-
// ESTree spec at https://github.com/estree/estree.
|
|
15
|
-
const AST_NODE_TYPES = {
|
|
16
|
-
ArrayExpression: 'ArrayExpression',
|
|
17
|
-
CallExpression: 'CallExpression',
|
|
18
|
-
ClassDeclaration: 'ClassDeclaration',
|
|
19
|
-
ExportDefaultDeclaration: 'ExportDefaultDeclaration',
|
|
20
|
-
ExportNamedDeclaration: 'ExportNamedDeclaration',
|
|
21
|
-
Identifier: 'Identifier',
|
|
22
|
-
ImportDeclaration: 'ImportDeclaration',
|
|
23
|
-
Literal: 'Literal',
|
|
24
|
-
ObjectExpression: 'ObjectExpression',
|
|
25
|
-
Property: 'Property',
|
|
26
|
-
VariableDeclaration: 'VariableDeclaration',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Thin wrapper around oxc-parser that mirrors the shape we previously got
|
|
31
|
-
* from `@typescript-eslint/typescript-estree`: it returns the `Program`
|
|
32
|
-
* node directly, so `ast.body` / `for (const node of ast.body)` keeps working.
|
|
33
|
-
*
|
|
34
|
-
* Swapping `typescript-estree` (JS-written TS parser, ~500KB, slow) out for
|
|
35
|
-
* oxc-parser (Rust, bundled with Vite 8) was the biggest remaining DX win
|
|
36
|
-
* from the Phase 1b rolldown `PLUGIN_TIMINGS` report: the config/discovery
|
|
37
|
-
* AST parses are the hottest paths in the dev-server startup and HMR.
|
|
38
|
-
*/
|
|
39
|
-
function parse(content, _options = {}) {
|
|
40
|
-
const result = parseSync('caper-discovery.ts', content, {
|
|
41
|
-
lang: 'ts',
|
|
42
|
-
sourceType: 'module',
|
|
43
|
-
});
|
|
44
|
-
if (result.errors && result.errors.length > 0) {
|
|
45
|
-
const first = result.errors[0];
|
|
46
|
-
const msg = first.message || JSON.stringify(first);
|
|
47
|
-
const err = new Error(`oxc-parser: ${msg}`);
|
|
48
|
-
throw err;
|
|
49
|
-
}
|
|
50
|
-
return result.program;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const env = process.env.NODE_ENV;
|
|
54
|
-
const cwd = process.cwd();
|
|
55
|
-
|
|
56
|
-
const logger = createLogger('caper-config');
|
|
57
|
-
|
|
58
|
-
import { assetpackPlugin } from './assetpack.mjs';
|
|
59
|
-
|
|
60
|
-
const DTS_FILE_NAME = 'caper-app.d.ts';
|
|
61
|
-
const ASSET_DTS_FILE_NAME = 'caper-assets.d.ts';
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Strict-fields Zod schema for `caper.config.ts`.
|
|
65
|
-
*
|
|
66
|
-
* Only validates the fields that produce cryptic runtime errors today when
|
|
67
|
-
* they're malformed (wrong type, wrong shape). Everything else is passed
|
|
68
|
-
* through via `.loose()` so the schema doesn't need to stay 1:1 with
|
|
69
|
-
* `IApplicationOptions` — that would be a maintenance trap. Cross-reference
|
|
70
|
-
* validation (`defaultScene` must exist in discovered scenes, `plugins[]`
|
|
71
|
-
* IDs must exist) is deferred to Phase 5 #9 (build-time validation).
|
|
72
|
-
*
|
|
73
|
-
* Validation runs inside the Vite plugin in dev mode only, so Zod never
|
|
74
|
-
* ships to the client bundle.
|
|
75
|
-
*/
|
|
76
|
-
const pluginConfigSchema = z.union([
|
|
77
|
-
z.string(),
|
|
78
|
-
z.tuple([
|
|
79
|
-
z.string(),
|
|
80
|
-
z
|
|
81
|
-
.object({
|
|
82
|
-
autoLoad: z.boolean().optional(),
|
|
83
|
-
options: z.any().optional(),
|
|
84
|
-
})
|
|
85
|
-
.loose(),
|
|
86
|
-
]),
|
|
87
|
-
]);
|
|
88
|
-
|
|
89
|
-
const caperConfigSchema = z
|
|
90
|
-
.object({
|
|
91
|
-
id: z.string().min(1).optional(),
|
|
92
|
-
application: z.any().optional(),
|
|
93
|
-
defaultScene: z.string().min(1).optional(),
|
|
94
|
-
defaultSceneLoadMethod: z.string().optional(),
|
|
95
|
-
plugins: z.array(pluginConfigSchema).optional(),
|
|
96
|
-
scenes: z.any().optional(),
|
|
97
|
-
assets: z
|
|
98
|
-
.object({
|
|
99
|
-
manifest: z.any().optional(),
|
|
100
|
-
preload: z
|
|
101
|
-
.object({
|
|
102
|
-
bundles: z.array(z.string()).optional(),
|
|
103
|
-
})
|
|
104
|
-
.loose()
|
|
105
|
-
.optional(),
|
|
106
|
-
background: z
|
|
107
|
-
.object({
|
|
108
|
-
bundles: z.array(z.string()).optional(),
|
|
109
|
-
})
|
|
110
|
-
.loose()
|
|
111
|
-
.optional(),
|
|
112
|
-
})
|
|
113
|
-
.loose()
|
|
114
|
-
.optional(),
|
|
115
|
-
useStore: z.boolean().optional(),
|
|
116
|
-
useSpine: z.boolean().optional(),
|
|
117
|
-
useLayout: z.boolean().optional(),
|
|
118
|
-
useVoiceover: z.boolean().optional(),
|
|
119
|
-
useHash: z.boolean().optional(),
|
|
120
|
-
// Build-time only — read by readCaperBuildFlags(), no runtime effect.
|
|
121
|
-
useWasm: z.boolean().optional(),
|
|
122
|
-
showStats: z.boolean().optional(),
|
|
123
|
-
showSceneDebugMenu: z.boolean().optional(),
|
|
124
|
-
resizeToContainer: z.boolean().optional(),
|
|
125
|
-
logger: z.string().optional(),
|
|
126
|
-
sceneGroupOrder: z.array(z.string()).optional(),
|
|
127
|
-
})
|
|
128
|
-
.loose();
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Evaluate the user's `caper.config.ts` through Vite's own module
|
|
132
|
-
* graph and validate the default export. Dev-only — requires a live
|
|
133
|
-
* `server` (i.e. `vite dev`). Returns `true` on success.
|
|
134
|
-
*/
|
|
135
|
-
async function validateCaperConfig(server) {
|
|
136
|
-
if (!server || typeof server.ssrLoadModule !== 'function') return true;
|
|
137
|
-
const configPath = path.resolve(cwd, 'caper.config.ts');
|
|
138
|
-
if (!fs.existsSync(configPath)) return true;
|
|
139
|
-
|
|
140
|
-
// caper.config.ts pulls in @caperjs/core, which statically bundles
|
|
141
|
-
// @pixi/sound and GSAP. Both run browser-only top-level side effects
|
|
142
|
-
// during module init, which throw one after another under Vite SSR
|
|
143
|
-
// (Node, no DOM) and abort the whole ssrLoadModule — so config
|
|
144
|
-
// validation silently never runs. Install minimal stubs just for the
|
|
145
|
-
// duration of the load, and only for whichever globals aren't already
|
|
146
|
-
// defined (jsdom, a future Vite DOM environment, etc.).
|
|
147
|
-
//
|
|
148
|
-
// - document.createElement('audio').canPlayType: @pixi/sound's
|
|
149
|
-
// utils/supported.mjs probes playable formats at module top level.
|
|
150
|
-
// - createElement(...).style: GSAP's CSSPlugin auto-registers at
|
|
151
|
-
// import time and does `'transform' in tempDiv.style` on a div it
|
|
152
|
-
// creates via document.createElement — needs a `style` object (any
|
|
153
|
-
// object satisfies the `in` check; contents are never read here).
|
|
154
|
-
// - globalThis.window: @pixi/sound's WebAudioContext/SoundLibrary
|
|
155
|
-
// singleton construction reads `window` at module top level.
|
|
156
|
-
const hadDocument = 'document' in globalThis;
|
|
157
|
-
const hadWindow = 'window' in globalThis;
|
|
158
|
-
if (!hadDocument) {
|
|
159
|
-
globalThis.document = {
|
|
160
|
-
createElement: () => ({ canPlayType: () => '', style: {} }),
|
|
161
|
-
addEventListener: () => {},
|
|
162
|
-
removeEventListener: () => {},
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
if (!hadWindow) {
|
|
166
|
-
globalThis.window = globalThis;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
let mod;
|
|
170
|
-
try {
|
|
171
|
-
mod = await server.ssrLoadModule(configPath);
|
|
172
|
-
} catch {
|
|
173
|
-
// Config failed to load — the normal type-regen path already surfaces
|
|
174
|
-
// this error through the websocket overlay, so don't double-report.
|
|
175
|
-
return false;
|
|
176
|
-
} finally {
|
|
177
|
-
if (!hadDocument) delete globalThis.document;
|
|
178
|
-
if (!hadWindow) delete globalThis.window;
|
|
179
|
-
}
|
|
180
|
-
const cfg = mod.default;
|
|
181
|
-
if (!cfg) return true;
|
|
182
|
-
|
|
183
|
-
const result = caperConfigSchema.safeParse(cfg);
|
|
184
|
-
if (result.success) return true;
|
|
185
|
-
|
|
186
|
-
const issues = result.error.issues
|
|
187
|
-
.map((i) => ` - ${i.path.length ? i.path.join('.') : '(root)'}: ${i.message}`)
|
|
188
|
-
.join('\n');
|
|
189
|
-
const message = `Invalid caper.config.ts:\n${issues}`;
|
|
190
|
-
logger.error(`[caper] ${message}`);
|
|
191
|
-
|
|
192
|
-
server.ws.send({
|
|
193
|
-
type: 'error',
|
|
194
|
-
err: {
|
|
195
|
-
message,
|
|
196
|
-
id: configPath,
|
|
197
|
-
plugin: 'vite-plugin-caper-config',
|
|
198
|
-
},
|
|
199
|
-
});
|
|
200
|
-
return false;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// write a debounce function
|
|
204
|
-
const debounce = (func, wait) => {
|
|
205
|
-
let timeout;
|
|
206
|
-
return (...args) => {
|
|
207
|
-
clearTimeout(timeout);
|
|
208
|
-
timeout = setTimeout(() => func.apply(this, args), wait);
|
|
209
|
-
};
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
213
|
-
|
|
214
|
-
// Function to generate TypeScript types from the manifest
|
|
215
|
-
async function generateAssetTypes(manifest) {
|
|
216
|
-
// Flat totals per category (back-compat: AssetTextures, AssetAudio, etc.)
|
|
217
|
-
const assetsByType = {
|
|
218
|
-
textures: new Set(),
|
|
219
|
-
spritesheets: new Set(),
|
|
220
|
-
tpsFrames: new Set(),
|
|
221
|
-
fonts: new Set(),
|
|
222
|
-
bitmapFonts: new Set(),
|
|
223
|
-
fontFamilies: new Set(),
|
|
224
|
-
bitmapFontFamilies: new Set(),
|
|
225
|
-
audio: new Set(),
|
|
226
|
-
json: new Set(),
|
|
227
|
-
spine: new Set(),
|
|
228
|
-
rive: new Set(),
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
// Per-bundle breakdown so we can narrow e.g. AssetTexturesIn<'menu'> → only
|
|
232
|
-
// textures that actually ship in the 'menu' bundle. Shape:
|
|
233
|
-
// byBundle.textures.menu = Set(['menu/logo'])
|
|
234
|
-
const byBundle = {
|
|
235
|
-
textures: {},
|
|
236
|
-
spritesheets: {},
|
|
237
|
-
tpsFrames: {},
|
|
238
|
-
fonts: {},
|
|
239
|
-
bitmapFonts: {},
|
|
240
|
-
audio: {},
|
|
241
|
-
json: {},
|
|
242
|
-
spine: {},
|
|
243
|
-
rive: {},
|
|
244
|
-
};
|
|
245
|
-
const addToBundle = (category, bundleName, alias) => {
|
|
246
|
-
if (!byBundle[category][bundleName]) byBundle[category][bundleName] = new Set();
|
|
247
|
-
byBundle[category][bundleName].add(alias);
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
const bundles = manifest.bundles || [];
|
|
251
|
-
const bundleNames = new Set();
|
|
252
|
-
|
|
253
|
-
// Process each bundle
|
|
254
|
-
for (const bundle of bundles) {
|
|
255
|
-
bundleNames.add(bundle.name);
|
|
256
|
-
|
|
257
|
-
// Process each asset in the bundle
|
|
258
|
-
for (const asset of bundle.assets) {
|
|
259
|
-
const aliases = asset.alias || [];
|
|
260
|
-
const srcs = Array.isArray(asset.src) ? asset.src : [asset.src];
|
|
261
|
-
const firstSrc = srcs[0];
|
|
262
|
-
const ext = path.extname(firstSrc).toLowerCase();
|
|
263
|
-
|
|
264
|
-
// Add to appropriate category based on extension and data tags
|
|
265
|
-
if (asset.data?.tags?.tps || (ext === '.json' && firstSrc.includes('sheet'))) {
|
|
266
|
-
aliases.forEach((alias) => {
|
|
267
|
-
assetsByType.spritesheets.add(alias);
|
|
268
|
-
addToBundle('spritesheets', bundle.name, alias);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
// Extract frame names from TPS JSON files
|
|
272
|
-
if (asset.data?.tags?.tps) {
|
|
273
|
-
try {
|
|
274
|
-
// Find the first .json file in the src array
|
|
275
|
-
const jsonSrc = srcs.find((src) => src.endsWith('.json'));
|
|
276
|
-
if (jsonSrc) {
|
|
277
|
-
// Construct the full path to the JSON file
|
|
278
|
-
const jsonPath = path.join(process.cwd(), 'public', 'assets', jsonSrc);
|
|
279
|
-
|
|
280
|
-
// Read and parse the JSON file
|
|
281
|
-
const jsonContent = await fs.promises.readFile(jsonPath, 'utf8');
|
|
282
|
-
const tpsData = JSON.parse(jsonContent);
|
|
283
|
-
|
|
284
|
-
// Extract frame names from the "frames" object
|
|
285
|
-
if (tpsData.frames) {
|
|
286
|
-
Object.keys(tpsData.frames).forEach((frameName) => {
|
|
287
|
-
assetsByType.tpsFrames.add(frameName);
|
|
288
|
-
addToBundle('tpsFrames', bundle.name, frameName);
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
} catch (error) {
|
|
293
|
-
logger.warn(`Failed to load TPS frames from ${firstSrc}:`, error.message);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
} else if (ext === '.json' && !firstSrc.includes('atlas')) {
|
|
297
|
-
aliases.forEach((alias) => {
|
|
298
|
-
assetsByType.json.add(alias);
|
|
299
|
-
addToBundle('json', bundle.name, alias);
|
|
300
|
-
});
|
|
301
|
-
} else if (['.png', '.webp', '.jpg', '.jpeg', '.svg'].includes(ext)) {
|
|
302
|
-
aliases.forEach((alias) => {
|
|
303
|
-
assetsByType.textures.add(alias);
|
|
304
|
-
addToBundle('textures', bundle.name, alias);
|
|
305
|
-
});
|
|
306
|
-
} else if (['.mp3', '.ogg', '.wav'].includes(ext)) {
|
|
307
|
-
aliases.forEach((alias) => {
|
|
308
|
-
assetsByType.audio.add(alias);
|
|
309
|
-
addToBundle('audio', bundle.name, alias);
|
|
310
|
-
});
|
|
311
|
-
} else if (['.ttf', '.woff', '.woff2'].includes(ext) || asset.data?.tags?.wf) {
|
|
312
|
-
aliases.forEach((alias) => {
|
|
313
|
-
assetsByType.fonts.add(alias);
|
|
314
|
-
addToBundle('fonts', bundle.name, alias);
|
|
315
|
-
});
|
|
316
|
-
if (asset.data?.family) {
|
|
317
|
-
assetsByType.fontFamilies.add(asset.data.family);
|
|
318
|
-
}
|
|
319
|
-
} else if (['.fnt'].includes(ext)) {
|
|
320
|
-
aliases.forEach((alias) => {
|
|
321
|
-
assetsByType.bitmapFonts.add(alias);
|
|
322
|
-
assetsByType.bitmapFontFamilies.add(alias);
|
|
323
|
-
addToBundle('bitmapFonts', bundle.name, alias);
|
|
324
|
-
});
|
|
325
|
-
} else if (['.atlas', '.skel', '.json'].some((e) => firstSrc.includes(e)) && firstSrc.includes('spine')) {
|
|
326
|
-
aliases.forEach((alias) => {
|
|
327
|
-
assetsByType.spine.add(alias);
|
|
328
|
-
addToBundle('spine', bundle.name, alias);
|
|
329
|
-
});
|
|
330
|
-
} else if (ext === '.riv') {
|
|
331
|
-
aliases.forEach((alias) => {
|
|
332
|
-
assetsByType.rive.add(alias);
|
|
333
|
-
addToBundle('rive', bundle.name, alias);
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Convert Sets to sorted arrays for consistent output
|
|
340
|
-
const types = Object.fromEntries(Object.entries(assetsByType).map(([key, value]) => [key, [...value].sort()]));
|
|
341
|
-
|
|
342
|
-
// Helper to emit a per-bundle mapped type body like:
|
|
343
|
-
// '\n menu: \'menu/logo\' | \'menu/bg\';\n game: \'game/hero\';\n '
|
|
344
|
-
// Returns `never` if no bundle contains anything in the category.
|
|
345
|
-
const emitByBundleMap = (category) => {
|
|
346
|
-
const entries = Object.entries(byBundle[category])
|
|
347
|
-
.map(([bundleName, set]) => [bundleName, [...set].sort()])
|
|
348
|
-
.filter(([, arr]) => arr.length > 0);
|
|
349
|
-
if (entries.length === 0) return '{}';
|
|
350
|
-
const lines = entries.map(([name, arr]) => ` ${name}: '${arr.join("' | '")}';`);
|
|
351
|
-
return `{\n${lines.join('\n')}\n }`;
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
return `// This file is auto-generated. Do not edit.
|
|
355
|
-
import type { ResolvedAsset, Texture, Spritesheet } from 'pixi.js';
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Available bundle names in the asset manifest
|
|
359
|
-
* @example
|
|
360
|
-
* const bundle: AssetBundles = 'game';
|
|
361
|
-
*/
|
|
362
|
-
export type AssetBundles = ${[...bundleNames].length ? `\n | '${[...bundleNames].sort().join("'\n | '")}'` : 'never'};
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Available texture names in the asset manifest
|
|
366
|
-
* @example
|
|
367
|
-
* const texture: AssetTextures = 'game/wordmark';
|
|
368
|
-
*/
|
|
369
|
-
export type AssetTextures = ${types.textures.length ? `\n | '${types.textures.join("'\n | '")}'` : 'never'};
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Per-bundle texture map. Use \`AssetTexturesIn<'menu'>\` to get the exact
|
|
373
|
-
* set of textures shipped in a single bundle, so a scene that only loads
|
|
374
|
-
* the 'menu' bundle can't accidentally reference a 'game/*' texture.
|
|
375
|
-
*/
|
|
376
|
-
export type AssetTexturesByBundle = ${emitByBundleMap('textures')};
|
|
377
|
-
export type AssetTexturesIn<B extends AssetBundles> = B extends keyof AssetTexturesByBundle ? AssetTexturesByBundle[B] : never;
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Available spritesheet names in the asset manifest
|
|
381
|
-
* @example
|
|
382
|
-
* const spritesheet: AssetSpritesheets = 'game/sheet';
|
|
383
|
-
*/
|
|
384
|
-
export type AssetSpritesheets = ${types.spritesheets.length ? `\n | '${types.spritesheets.join("'\n | '")}'` : 'never'};
|
|
385
|
-
|
|
386
|
-
export type AssetSpritesheetsByBundle = ${emitByBundleMap('spritesheets')};
|
|
387
|
-
export type AssetSpritesheetsIn<B extends AssetBundles> = B extends keyof AssetSpritesheetsByBundle ? AssetSpritesheetsByBundle[B] : never;
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Available TPS frame names from spritesheets
|
|
391
|
-
* @example
|
|
392
|
-
* const frame: AssetTPSFrames = 'btn/blue';
|
|
393
|
-
*/
|
|
394
|
-
export type AssetTPSFrames = ${types.tpsFrames.length ? `\n | '${types.tpsFrames.join("'\n | '")}'` : 'never'};
|
|
395
|
-
|
|
396
|
-
export type AssetTPSFramesByBundle = ${emitByBundleMap('tpsFrames')};
|
|
397
|
-
export type AssetTPSFramesIn<B extends AssetBundles> = B extends keyof AssetTPSFramesByBundle ? AssetTPSFramesByBundle[B] : never;
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Available font names in the asset manifest
|
|
401
|
-
* @example
|
|
402
|
-
* const font: AssetFonts = 'SpaceGrotesk-Regular';
|
|
403
|
-
*/
|
|
404
|
-
export type AssetFonts = ${types.fonts.length ? `\n | '${types.fonts.join("'\n | '")}'` : 'never'};
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* Available font names in the asset manifest
|
|
408
|
-
* @example
|
|
409
|
-
* const font: AssetFonts = 'SpaceGrotesk-Regular';
|
|
410
|
-
*/
|
|
411
|
-
export type AssetBitmapFonts = ${types.bitmapFonts.length ? `\n | '${types.bitmapFonts.join("'\n | '")}'` : 'never'};
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Available audio names in the asset manifest
|
|
415
|
-
* @example
|
|
416
|
-
* const audio: AssetAudio = 'click';
|
|
417
|
-
*/
|
|
418
|
-
export type AssetAudio = ${types.audio.length ? `\n | '${types.audio.join("'\n | '")}'` : 'never'};
|
|
419
|
-
|
|
420
|
-
export type AssetAudioByBundle = ${emitByBundleMap('audio')};
|
|
421
|
-
export type AssetAudioIn<B extends AssetBundles> = B extends keyof AssetAudioByBundle ? AssetAudioByBundle[B] : never;
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Available JSON file names in the asset manifest
|
|
425
|
-
* @example
|
|
426
|
-
* const json: AssetJson = 'locales/en';
|
|
427
|
-
*/
|
|
428
|
-
export type AssetJson = ${types.json.length ? `\n | '${types.json.join("'\n | '")}'` : 'never'};
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Available Spine animation names in the asset manifest
|
|
432
|
-
* @example
|
|
433
|
-
* const spine: AssetSpine = 'spine/hero';
|
|
434
|
-
*/
|
|
435
|
-
export type AssetSpine = ${types.spine.length ? `\n | '${types.spine.join("'\n | '")}'` : 'never'};
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Available Rive animation names in the asset manifest
|
|
439
|
-
* @example
|
|
440
|
-
* const rive: AssetRive = 'static/marty';
|
|
441
|
-
*/
|
|
442
|
-
export type AssetRive = ${types.rive.length ? `\n | '${types.rive.join("'\n | '")}'` : 'never'};
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Available font family names
|
|
446
|
-
* @example
|
|
447
|
-
* const fontFamily: AssetFontFamilies = 'Space Grotesk';
|
|
448
|
-
*/
|
|
449
|
-
export type AssetFontFamilies = ${types.fontFamilies.length ? `\n | '${types.fontFamilies.join("'\n | '")}'` : 'never'};
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Available font family names
|
|
453
|
-
* @example
|
|
454
|
-
* const fontFamily: AssetFontFamilies = 'Space Grotesk';
|
|
455
|
-
*/
|
|
456
|
-
export type AssetBitmapFontFamilies = ${types.bitmapFontFamilies.length ? `\n | '${types.bitmapFontFamilies.join("'\n | '")}'` : 'never'};
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Union type of all asset names
|
|
460
|
-
* @example
|
|
461
|
-
* const asset: AssetAlias = 'game/wordmark';
|
|
462
|
-
*/
|
|
463
|
-
export type AssetAlias =
|
|
464
|
-
| AssetTextures
|
|
465
|
-
| AssetSpritesheets
|
|
466
|
-
| AssetTPSFrames
|
|
467
|
-
| AssetFonts
|
|
468
|
-
| AssetBitmapFonts
|
|
469
|
-
| AssetAudio
|
|
470
|
-
| AssetJson
|
|
471
|
-
| AssetSpine
|
|
472
|
-
| AssetRive;
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Type-safe manifest structure
|
|
476
|
-
*/
|
|
477
|
-
export interface AssetManifest {
|
|
478
|
-
bundles: {
|
|
479
|
-
[K in AssetBundles]: {
|
|
480
|
-
name: K;
|
|
481
|
-
assets: ResolvedAsset[];
|
|
482
|
-
}
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Type-safe asset types after loading
|
|
488
|
-
*/
|
|
489
|
-
export interface AssetTypes {
|
|
490
|
-
textures: Record<AssetTextures, Texture>;
|
|
491
|
-
spritesheets: Record<AssetSpritesheets, Spritesheet>;
|
|
492
|
-
tpsFrames: Record<AssetTPSFrames, Texture>;
|
|
493
|
-
fonts: Record<AssetFonts, any>;
|
|
494
|
-
audio: Record<AssetAudio, HTMLAudioElement>;
|
|
495
|
-
json: Record<AssetJson, any>;
|
|
496
|
-
spine: Record<AssetSpine, any>;
|
|
497
|
-
rive: Record<AssetRive, any>;
|
|
498
|
-
fontFamilies: Record<AssetFontFamilies, any>;
|
|
499
|
-
bitmapFonts: Record<AssetBitmapFonts, any>;
|
|
500
|
-
bitmapFontFamilies: Record<AssetBitmapFontFamilies, any>;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Helper type to get the asset type for a given alias
|
|
505
|
-
* @example
|
|
506
|
-
* type MyTextureType = AssetTypeOf<'game/wordmark'>; // Texture
|
|
507
|
-
*/
|
|
508
|
-
export type AssetTypeOf<T extends AssetAlias> =
|
|
509
|
-
T extends AssetTextures ? Texture :
|
|
510
|
-
T extends AssetSpritesheets ? Spritesheet :
|
|
511
|
-
T extends AssetTPSFrames ? Texture :
|
|
512
|
-
T extends AssetFonts ? any :
|
|
513
|
-
T extends AssetBitmapFonts ? any :
|
|
514
|
-
T extends AssetAudio ? HTMLAudioElement :
|
|
515
|
-
T extends AssetJson ? any :
|
|
516
|
-
T extends AssetSpine ? any :
|
|
517
|
-
T extends AssetRive ? any :
|
|
518
|
-
T extends AssetFontFamilies ? any :
|
|
519
|
-
T extends AssetBitmapFontFamilies ? any :
|
|
520
|
-
never;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* Get the bundle name for a given asset
|
|
525
|
-
* @example
|
|
526
|
-
* type MyBundle = AssetBundleOf<'game/wordmark'>; // 'game'
|
|
527
|
-
*/
|
|
528
|
-
export type AssetBundleOf<T extends AssetAlias> = Extract<AssetBundles, T extends \`\${infer B}/\${string}\` ? B : never>;
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* Add type overrides to the framework
|
|
532
|
-
*/
|
|
533
|
-
declare module '@caperjs/core' {
|
|
534
|
-
interface AssetTypeOverrides {
|
|
535
|
-
Texture: AssetTextures;
|
|
536
|
-
TPSFrames: AssetTPSFrames;
|
|
537
|
-
SpriteSheet: AssetSpritesheets;
|
|
538
|
-
SpineData: AssetSpine;
|
|
539
|
-
Audio: AssetAudio;
|
|
540
|
-
FontFamily: AssetFontFamilies;
|
|
541
|
-
BitmapFontFamily: AssetBitmapFontFamilies;
|
|
542
|
-
Bundles: AssetBundles;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
`;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// Function to write types file
|
|
549
|
-
async function writeAssetTypes(manifest, outputDir) {
|
|
550
|
-
const types = await generateAssetTypes(manifest);
|
|
551
|
-
// Change output path to ./src/types/
|
|
552
|
-
const srcTypesDir = path.join(process.cwd(), 'src', 'types');
|
|
553
|
-
|
|
554
|
-
try {
|
|
555
|
-
// Ensure the directory exists
|
|
556
|
-
await fs.promises.mkdir(srcTypesDir, { recursive: true });
|
|
557
|
-
const typesPath = path.join(srcTypesDir, ASSET_DTS_FILE_NAME);
|
|
558
|
-
await fs.promises.writeFile(typesPath, types, 'utf8');
|
|
559
|
-
logger.info(`Caper asset types plugin:: Generated types at ${typesPath}`);
|
|
560
|
-
} catch (error) {
|
|
561
|
-
logger.error('Caper asset types plugin:: Error writing types file:', error);
|
|
562
|
-
// Fallback to original location if src folder doesn't exist
|
|
563
|
-
const typesPath = path.join(outputDir, ASSET_DTS_FILE_NAME);
|
|
564
|
-
await fs.promises.writeFile(typesPath, types, 'utf8');
|
|
565
|
-
logger.info(`Caper asset types plugin:: Generated types at fallback location ${typesPath}`);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Asset types generation plugin
|
|
570
|
-
export function assetTypesPlugin(manifestUrl = 'assets.json') {
|
|
571
|
-
let viteServer;
|
|
572
|
-
let manifestWatcher;
|
|
573
|
-
let ispPwaEnabled = false;
|
|
574
|
-
|
|
575
|
-
async function generate(manifestUrl) {
|
|
576
|
-
try {
|
|
577
|
-
const manifestPath = path.join(process.cwd(), 'public', 'assets', manifestUrl);
|
|
578
|
-
if (!fs.existsSync(manifestPath)) {
|
|
579
|
-
logger.warn(`Caper asset types plugin:: manifest not found at ${manifestPath}, skipping type generation`);
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
|
|
583
|
-
await writeAssetTypes(manifest, path.dirname(manifestPath));
|
|
584
|
-
logger.info('Caper asset types plugin:: manifest changed, reloading browser...');
|
|
585
|
-
viteServer?.ws?.send({ type: 'full-reload' });
|
|
586
|
-
} catch (error) {
|
|
587
|
-
logger.error('Caper asset types plugin:: Error handling manifest change:', error);
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
const debouncedHandleManifestChange = debounce(generate, 300);
|
|
592
|
-
|
|
593
|
-
return {
|
|
594
|
-
name: 'vite-plugin-asset-types',
|
|
595
|
-
config(config) {
|
|
596
|
-
ispPwaEnabled = config.plugins.some((p) => p.name === 'vite-plugin-pwa');
|
|
597
|
-
},
|
|
598
|
-
async buildStart() {
|
|
599
|
-
// a short delay to allow assetpack to generate the manifest
|
|
600
|
-
await delay(500);
|
|
601
|
-
await generate(manifestUrl);
|
|
602
|
-
await delay(500);
|
|
603
|
-
},
|
|
604
|
-
configureServer(server) {
|
|
605
|
-
viteServer = server;
|
|
606
|
-
|
|
607
|
-
// Watch for manifest changes in development
|
|
608
|
-
const manifestPath = path.join(process.cwd(), 'public', 'assets', manifestUrl);
|
|
609
|
-
server.watcher.add(manifestPath);
|
|
610
|
-
logger.info(`Caper asset types plugin:: watching manifest at ${manifestPath}`);
|
|
611
|
-
|
|
612
|
-
const handleChange = async (file) => {
|
|
613
|
-
if (file === manifestPath) {
|
|
614
|
-
await debouncedHandleManifestChange(manifestUrl);
|
|
615
|
-
}
|
|
616
|
-
};
|
|
617
|
-
|
|
618
|
-
server.watcher.on('add', handleChange);
|
|
619
|
-
server.watcher.on('change', handleChange);
|
|
620
|
-
},
|
|
621
|
-
async buildEnd() {
|
|
622
|
-
manifestWatcher?.close();
|
|
623
|
-
|
|
624
|
-
// Generate types in build mode as well
|
|
625
|
-
try {
|
|
626
|
-
const manifestPath = path.join(process.cwd(), 'public', 'assets', manifestUrl);
|
|
627
|
-
if (fs.existsSync(manifestPath)) {
|
|
628
|
-
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
|
|
629
|
-
await writeAssetTypes(manifest, path.dirname(manifestPath));
|
|
630
|
-
}
|
|
631
|
-
} catch (error) {
|
|
632
|
-
logger.error('Caper asset types plugin:: Error generating types during build:', error);
|
|
633
|
-
}
|
|
634
|
-
await delay(500);
|
|
635
|
-
},
|
|
636
|
-
async closeBundle() {
|
|
637
|
-
if (ispPwaEnabled && env !== 'development') {
|
|
638
|
-
logger.info('Caper asset types plugin:: PWA enabled, generating types one last time after bundle');
|
|
639
|
-
await generate(manifestUrl);
|
|
640
|
-
}
|
|
641
|
-
},
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/**
|
|
646
|
-
* Read the assetpack manifest from disk and return the set of bundle names.
|
|
647
|
-
* Returns `null` if the manifest doesn't exist yet — in that case the caller
|
|
648
|
-
* should skip bundle-name validation rather than spam false warnings.
|
|
649
|
-
*/
|
|
650
|
-
function loadManifestBundleNames(manifestUrl = 'assets.json') {
|
|
651
|
-
const manifestPath = path.join(process.cwd(), 'public', 'assets', manifestUrl);
|
|
652
|
-
if (!fs.existsSync(manifestPath)) return null;
|
|
653
|
-
try {
|
|
654
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
655
|
-
const names = new Set();
|
|
656
|
-
for (const bundle of manifest.bundles || []) {
|
|
657
|
-
if (bundle?.name) names.add(bundle.name);
|
|
658
|
-
}
|
|
659
|
-
return names;
|
|
660
|
-
} catch (e) {
|
|
661
|
-
logger.warn(`[caper] Could not parse manifest for build-time validation: ${e.message}`);
|
|
662
|
-
return null;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
/**
|
|
667
|
-
* Extract `defaultScene` string and the `plugins: [...]` id list from an
|
|
668
|
-
* already-parsed `defineConfig({...})` ObjectExpression AST node. Returns
|
|
669
|
-
* `{ defaultScene, pluginIds }` where either may be undefined if absent.
|
|
670
|
-
*
|
|
671
|
-
* Plugin entries can be either a string literal or a tuple literal whose
|
|
672
|
-
* first element is a string literal — anything else (dynamic expressions,
|
|
673
|
-
* spreads, non-literal identifiers) is skipped silently, since we can't
|
|
674
|
-
* statically know the ID.
|
|
675
|
-
*/
|
|
676
|
-
function extractConfigReferences(configObject) {
|
|
677
|
-
const result = { defaultScene: undefined, pluginIds: [] };
|
|
678
|
-
if (!configObject || configObject.type !== AST_NODE_TYPES.ObjectExpression) return result;
|
|
679
|
-
|
|
680
|
-
for (const prop of configObject.properties) {
|
|
681
|
-
if (prop.type !== AST_NODE_TYPES.Property || prop.key?.type !== AST_NODE_TYPES.Identifier) continue;
|
|
682
|
-
if (prop.key.name === 'defaultScene' && prop.value?.type === AST_NODE_TYPES.Literal) {
|
|
683
|
-
result.defaultScene = prop.value.value;
|
|
684
|
-
}
|
|
685
|
-
if (prop.key.name === 'plugins' && prop.value?.type === AST_NODE_TYPES.ArrayExpression) {
|
|
686
|
-
for (const el of prop.value.elements) {
|
|
687
|
-
if (!el) continue;
|
|
688
|
-
if (el.type === AST_NODE_TYPES.Literal && typeof el.value === 'string') {
|
|
689
|
-
result.pluginIds.push(el.value);
|
|
690
|
-
} else if (el.type === AST_NODE_TYPES.ArrayExpression && el.elements[0]?.type === AST_NODE_TYPES.Literal) {
|
|
691
|
-
const first = el.elements[0];
|
|
692
|
-
if (typeof first.value === 'string') result.pluginIds.push(first.value);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
return result;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
/**
|
|
701
|
-
* Locate the `defineConfig({...})` ObjectExpression in a parsed
|
|
702
|
-
* `caper.config.ts` AST. Handles both the default-export form
|
|
703
|
-
* (`export default defineConfig({...})`) and the named-const form
|
|
704
|
-
* (`export const config = defineConfig({...})`).
|
|
705
|
-
*/
|
|
706
|
-
function findConfigObject(ast) {
|
|
707
|
-
let configObject;
|
|
708
|
-
for (const node of ast.body) {
|
|
709
|
-
if (
|
|
710
|
-
node.type === AST_NODE_TYPES.ExportDefaultDeclaration &&
|
|
711
|
-
node.declaration?.type === AST_NODE_TYPES.CallExpression &&
|
|
712
|
-
node.declaration.callee?.name === 'defineConfig'
|
|
713
|
-
) {
|
|
714
|
-
configObject = node.declaration.arguments[0];
|
|
715
|
-
} else if (
|
|
716
|
-
node.type === AST_NODE_TYPES.ExportNamedDeclaration &&
|
|
717
|
-
node.declaration?.type === AST_NODE_TYPES.VariableDeclaration
|
|
718
|
-
) {
|
|
719
|
-
const decl = node.declaration.declarations.find(
|
|
720
|
-
(d) => d.init?.type === AST_NODE_TYPES.CallExpression && d.init.callee?.name === 'defineConfig',
|
|
721
|
-
);
|
|
722
|
-
if (decl) configObject = decl.init.arguments[0];
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
return configObject;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
/**
|
|
729
|
-
* Boolean build-time flags read straight out of `caper.config.ts`.
|
|
730
|
-
*
|
|
731
|
-
* Vite fixes a config's plugin list the moment the config object is created —
|
|
732
|
-
* no plugin hook can add one later, and the CLI merges `defaultConfig` before
|
|
733
|
-
* anything has evaluated the user's config module. So these are pulled with
|
|
734
|
-
* the same oxc AST parse discovery already uses rather than by importing the
|
|
735
|
-
* file: importing pulls in @caperjs/core, whose @pixi/sound + GSAP deps run
|
|
736
|
-
* browser-only top-level side effects that throw under Node (see
|
|
737
|
-
* `validateCaperConfig` for the gory details).
|
|
738
|
-
*
|
|
739
|
-
* Only boolean literals are honoured. A missing, empty, or unparseable
|
|
740
|
-
* config silently yields the defaults — this runs before the normal config
|
|
741
|
-
* error reporting, so it must never be the thing that fails the build.
|
|
742
|
-
*/
|
|
743
|
-
function readCaperBuildFlags() {
|
|
744
|
-
const flags = { useWasm: false };
|
|
745
|
-
const configPath = path.resolve(cwd, 'caper.config.ts');
|
|
746
|
-
if (!fs.existsSync(configPath)) return flags;
|
|
747
|
-
|
|
748
|
-
let configObject;
|
|
749
|
-
try {
|
|
750
|
-
configObject = findConfigObject(parse(fs.readFileSync(configPath, 'utf-8')));
|
|
751
|
-
} catch {
|
|
752
|
-
return flags;
|
|
753
|
-
}
|
|
754
|
-
if (configObject?.type !== AST_NODE_TYPES.ObjectExpression) return flags;
|
|
755
|
-
|
|
756
|
-
for (const prop of configObject.properties) {
|
|
757
|
-
if (prop.type !== AST_NODE_TYPES.Property || prop.key?.type !== AST_NODE_TYPES.Identifier) continue;
|
|
758
|
-
if (!(prop.key.name in flags)) continue;
|
|
759
|
-
if (prop.value?.type === AST_NODE_TYPES.Literal && typeof prop.value.value === 'boolean') {
|
|
760
|
-
flags[prop.key.name] = prop.value.value;
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
return flags;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* Cross-reference validation over discovered scenes/plugins/popups/entities
|
|
768
|
-
* + the parsed `caper.config.ts` AST + the assetpack manifest. Emits
|
|
769
|
-
* warnings via the project logger; in dev mode also forwards warnings to
|
|
770
|
-
* the browser error overlay as info so they're impossible to miss.
|
|
771
|
-
*
|
|
772
|
-
* Checks:
|
|
773
|
-
* 1. Scene `assets.preload.bundles` / `assets.background.bundles` entries
|
|
774
|
-
* exist in the assetpack manifest.
|
|
775
|
-
* 2. Plugin IDs in `caper.config.ts` match a discovered plugin
|
|
776
|
-
* (npm `@caperjs/plugin-*` OR local `src/plugins/*`).
|
|
777
|
-
* 3. `defaultScene` in `caper.config.ts` matches a discovered scene ID.
|
|
778
|
-
* 4. No duplicate scene / plugin / popup / entity IDs across discovery.
|
|
779
|
-
*
|
|
780
|
-
* All issues are warnings, not errors — they shouldn't fail the build
|
|
781
|
-
* (typos are a dev-time problem; the runtime will still start, just with
|
|
782
|
-
* broken references the user needs to see).
|
|
783
|
-
*/
|
|
784
|
-
function runBuildTimeValidation({
|
|
785
|
-
server,
|
|
786
|
-
configPath,
|
|
787
|
-
configObject,
|
|
788
|
-
scenes,
|
|
789
|
-
plugins,
|
|
790
|
-
popups,
|
|
791
|
-
entities,
|
|
792
|
-
breakpointsName,
|
|
793
|
-
}) {
|
|
794
|
-
const warnings = [];
|
|
795
|
-
const bundleNames = loadManifestBundleNames();
|
|
796
|
-
|
|
797
|
-
// 1. Scene bundle references
|
|
798
|
-
if (bundleNames && bundleNames.size > 0) {
|
|
799
|
-
for (const scene of scenes) {
|
|
800
|
-
for (const kind of ['preload', 'background']) {
|
|
801
|
-
const bundlesField = scene.assets?.[kind]?.bundles;
|
|
802
|
-
if (!bundlesField) continue;
|
|
803
|
-
const list = Array.isArray(bundlesField) ? bundlesField : [bundlesField];
|
|
804
|
-
for (const bundle of list) {
|
|
805
|
-
if (typeof bundle !== 'string') continue;
|
|
806
|
-
if (!bundleNames.has(bundle)) {
|
|
807
|
-
warnings.push(
|
|
808
|
-
`Scene '${scene.id}' references ${kind} bundle '${bundle}' which is not in the assetpack manifest. ` +
|
|
809
|
-
`Known bundles: ${[...bundleNames].join(', ') || '(none)'}.`,
|
|
810
|
-
);
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
// 2 + 3. caper.config.ts cross-references
|
|
818
|
-
const { defaultScene, pluginIds: configPluginIds } = extractConfigReferences(configObject);
|
|
819
|
-
const discoveredPluginIds = new Set(plugins.map((p) => p.id));
|
|
820
|
-
const discoveredSceneIds = new Set(scenes.map((s) => s.id));
|
|
821
|
-
|
|
822
|
-
for (const id of configPluginIds) {
|
|
823
|
-
if (!discoveredPluginIds.has(id)) {
|
|
824
|
-
warnings.push(
|
|
825
|
-
`caper.config.ts references plugin '${id}' which was not discovered. ` +
|
|
826
|
-
`Known plugin IDs: ${[...discoveredPluginIds].join(', ') || '(none)'}.`,
|
|
827
|
-
);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
if (defaultScene && !discoveredSceneIds.has(defaultScene)) {
|
|
831
|
-
warnings.push(
|
|
832
|
-
`caper.config.ts defaultScene is '${defaultScene}' but no scene with that id was discovered. ` +
|
|
833
|
-
`Known scene IDs: ${[...discoveredSceneIds].join(', ') || '(none)'}.`,
|
|
834
|
-
);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// 4. Plugin `requires` cross-references (local plugins only — npm
|
|
838
|
-
// plugins can declare requires on the class itself, which the runtime
|
|
839
|
-
// topo-sort handles, but the AST can't see).
|
|
840
|
-
for (const p of plugins) {
|
|
841
|
-
if (!p.isLocal || !Array.isArray(p.requires) || p.requires.length === 0) continue;
|
|
842
|
-
for (const req of p.requires) {
|
|
843
|
-
if (!discoveredPluginIds.has(req)) {
|
|
844
|
-
warnings.push(
|
|
845
|
-
`Plugin '${p.id}' requires '${req}' which is not a discovered plugin. ` +
|
|
846
|
-
`Known plugin IDs: ${[...discoveredPluginIds].join(', ') || '(none)'}.`,
|
|
847
|
-
);
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
// 4b. Plugin `requires` cycle detection (build-time, so the dev sees the
|
|
853
|
-
// cycle in the terminal/overlay before bootstrap even tries to run).
|
|
854
|
-
const cycleEdges = new Map();
|
|
855
|
-
for (const p of plugins) {
|
|
856
|
-
cycleEdges.set(
|
|
857
|
-
p.id,
|
|
858
|
-
(p.requires ?? []).filter((r) => discoveredPluginIds.has(r)),
|
|
859
|
-
);
|
|
860
|
-
}
|
|
861
|
-
const cycle = detectCycle(cycleEdges);
|
|
862
|
-
if (cycle) {
|
|
863
|
-
warnings.push(`Plugin dependency cycle: ${cycle.join(' → ')}`);
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
// 5. Duplicate-id detection
|
|
867
|
-
const checkDuplicates = (label, list) => {
|
|
868
|
-
const seen = new Map();
|
|
869
|
-
for (const item of list) {
|
|
870
|
-
if (seen.has(item.id)) {
|
|
871
|
-
warnings.push(`Duplicate ${label} id '${item.id}' — multiple files export the same id.`);
|
|
872
|
-
} else {
|
|
873
|
-
seen.set(item.id, true);
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
};
|
|
877
|
-
checkDuplicates('scene', scenes);
|
|
878
|
-
checkDuplicates('plugin', plugins);
|
|
879
|
-
checkDuplicates('popup', popups);
|
|
880
|
-
checkDuplicates('entity', entities);
|
|
881
|
-
|
|
882
|
-
// 6. Breakpoints declared in config but not via defineBreakpoints() — the
|
|
883
|
-
// names will work at runtime but get no intellisense.
|
|
884
|
-
if (!breakpointsName && configObject?.type === AST_NODE_TYPES.ObjectExpression) {
|
|
885
|
-
const hasKey = configObject.properties.some(
|
|
886
|
-
(p) => p.type === AST_NODE_TYPES.Property && p.key?.name === 'breakpoints',
|
|
887
|
-
);
|
|
888
|
-
if (hasKey) {
|
|
889
|
-
warnings.push(
|
|
890
|
-
`caper.config.ts sets \`breakpoints\` but no \`defineBreakpoints()\` export was found — breakpoint names will not be type-checked. Wrap the object: \`export const breakpoints = defineBreakpoints({ ... })\`.`,
|
|
891
|
-
);
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
if (warnings.length === 0) return;
|
|
896
|
-
|
|
897
|
-
for (const msg of warnings) {
|
|
898
|
-
// Vite's createLogger is suppressed inside the dts plugin chain during
|
|
899
|
-
// builds, so go straight to console.warn — yellow ANSI so it stands out
|
|
900
|
-
// amid Vite's own output.
|
|
901
|
-
console.warn(`\x1b[33m[caper] ${msg}\x1b[0m`);
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
if (server?.ws) {
|
|
905
|
-
// Surface the first warning in the browser overlay so a dev in the
|
|
906
|
-
// tab notices. Subsequent ones are in the terminal — don't spam the
|
|
907
|
-
// overlay with a stack of them.
|
|
908
|
-
server.ws.send({
|
|
909
|
-
type: 'error',
|
|
910
|
-
err: {
|
|
911
|
-
message:
|
|
912
|
-
`caper build-time validation (${warnings.length} warning${warnings.length === 1 ? '' : 's'}):\n` +
|
|
913
|
-
warnings.map((w) => ' • ' + w).join('\n'),
|
|
914
|
-
id: configPath,
|
|
915
|
-
plugin: 'vite-plugin-caper-config',
|
|
916
|
-
},
|
|
917
|
-
});
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
/**
|
|
922
|
-
* DFS-based cycle detection over a `Map<id, dependencyIds[]>` graph.
|
|
923
|
-
* Returns the cycle path (e.g. `['A','B','A']`) on the first cycle found,
|
|
924
|
-
* or `null` if the graph is acyclic. Used by build-time validation so a
|
|
925
|
-
* plugin requires-cycle surfaces in the terminal before bootstrap runs.
|
|
926
|
-
*/
|
|
927
|
-
function detectCycle(edges) {
|
|
928
|
-
const WHITE = 0;
|
|
929
|
-
const GRAY = 1;
|
|
930
|
-
const BLACK = 2;
|
|
931
|
-
const color = new Map();
|
|
932
|
-
for (const id of edges.keys()) color.set(id, WHITE);
|
|
933
|
-
const stack = [];
|
|
934
|
-
|
|
935
|
-
function visit(id) {
|
|
936
|
-
if (color.get(id) === GRAY) {
|
|
937
|
-
const startIdx = stack.indexOf(id);
|
|
938
|
-
return [...stack.slice(startIdx), id];
|
|
939
|
-
}
|
|
940
|
-
if (color.get(id) === BLACK) return null;
|
|
941
|
-
color.set(id, GRAY);
|
|
942
|
-
stack.push(id);
|
|
943
|
-
for (const dep of edges.get(id) ?? []) {
|
|
944
|
-
const found = visit(dep);
|
|
945
|
-
if (found) return found;
|
|
946
|
-
}
|
|
947
|
-
stack.pop();
|
|
948
|
-
color.set(id, BLACK);
|
|
949
|
-
return null;
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
for (const id of edges.keys()) {
|
|
953
|
-
const found = visit(id);
|
|
954
|
-
if (found) return found;
|
|
955
|
-
}
|
|
956
|
-
return null;
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
function caperConfigPlugin(isProject = true) {
|
|
960
|
-
const virtualModuleId = 'virtual:caper-config';
|
|
961
|
-
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
|
962
|
-
|
|
963
|
-
async function generateTypes(server) {
|
|
964
|
-
if (!isProject) return { types: 'export {}' };
|
|
965
|
-
|
|
966
|
-
const configPath = path.resolve(cwd, 'caper.config.ts');
|
|
967
|
-
|
|
968
|
-
let ast;
|
|
969
|
-
try {
|
|
970
|
-
const content = await fs.promises.readFile(configPath, 'utf-8');
|
|
971
|
-
if (!content.trim()) {
|
|
972
|
-
// This can happen during file saves, where the file is temporarily empty.
|
|
973
|
-
return { types: '/* caper.config.ts is empty, skipping augmentation. */' };
|
|
974
|
-
}
|
|
975
|
-
ast = parse(content, { jsx: false });
|
|
976
|
-
} catch (e) {
|
|
977
|
-
if (e.code === 'ENOENT') {
|
|
978
|
-
// This can happen during file saves that do a delete/recreate.
|
|
979
|
-
return { types: '/* caper.config.ts not found, skipping augmentation. */' };
|
|
980
|
-
}
|
|
981
|
-
logger.error(`[caper] Error parsing caper.config.ts: ${e.message}`);
|
|
982
|
-
if (e.stack) {
|
|
983
|
-
logger.error(e.stack);
|
|
984
|
-
}
|
|
985
|
-
return {
|
|
986
|
-
types: '/* Error parsing caper.config.ts, skipping augmentation. See console for details. */',
|
|
987
|
-
error: e,
|
|
988
|
-
};
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
let appClassName = 'Application';
|
|
992
|
-
let appImportPath = '@caperjs/core';
|
|
993
|
-
let dataTypeName = 'Record<string, any>';
|
|
994
|
-
let dataSchemaName = '';
|
|
995
|
-
let hasActions = false;
|
|
996
|
-
let hasContexts = false;
|
|
997
|
-
let breakpointsName = '';
|
|
998
|
-
|
|
999
|
-
let configObject;
|
|
1000
|
-
|
|
1001
|
-
for (const node of ast.body) {
|
|
1002
|
-
if (
|
|
1003
|
-
node.type === AST_NODE_TYPES.ExportNamedDeclaration &&
|
|
1004
|
-
node.declaration?.type === AST_NODE_TYPES.VariableDeclaration
|
|
1005
|
-
) {
|
|
1006
|
-
// Find data schema
|
|
1007
|
-
const dataDecl = node.declaration.declarations.find(
|
|
1008
|
-
(d) => d.init?.type === AST_NODE_TYPES.CallExpression && d.init.callee.name === 'defineData',
|
|
1009
|
-
);
|
|
1010
|
-
if (dataDecl && dataDecl.id.type === AST_NODE_TYPES.Identifier) {
|
|
1011
|
-
dataSchemaName = dataDecl.id.name;
|
|
1012
|
-
dataTypeName = `typeof ${dataSchemaName}`;
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
// Find breakpoints (detect by callee, like defineData — more robust
|
|
1016
|
-
// than matching on the variable name).
|
|
1017
|
-
const bpDecl = node.declaration.declarations.find(
|
|
1018
|
-
(d) => d.init?.type === AST_NODE_TYPES.CallExpression && d.init.callee.name === 'defineBreakpoints',
|
|
1019
|
-
);
|
|
1020
|
-
if (bpDecl && bpDecl.id.type === AST_NODE_TYPES.Identifier) {
|
|
1021
|
-
breakpointsName = bpDecl.id.name;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
// Find actions
|
|
1025
|
-
if (node.declaration.declarations.some((d) => d.id.name === 'actions')) {
|
|
1026
|
-
hasActions = true;
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
// Find contexts
|
|
1030
|
-
if (node.declaration.declarations.some((d) => d.id.name === 'contexts')) {
|
|
1031
|
-
hasContexts = true;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
// Find defineConfig to get application class
|
|
1035
|
-
const configDecl = node.declaration.declarations.find(
|
|
1036
|
-
(d) => d.init?.type === AST_NODE_TYPES.CallExpression && d.init.callee.name === 'defineConfig',
|
|
1037
|
-
);
|
|
1038
|
-
if (configDecl?.init.type === AST_NODE_TYPES.CallExpression) {
|
|
1039
|
-
configObject = configDecl.init.arguments[0];
|
|
1040
|
-
}
|
|
1041
|
-
} else if (
|
|
1042
|
-
node.type === AST_NODE_TYPES.ExportDefaultDeclaration &&
|
|
1043
|
-
node.declaration.type === AST_NODE_TYPES.CallExpression &&
|
|
1044
|
-
node.declaration.callee.name === 'defineConfig'
|
|
1045
|
-
) {
|
|
1046
|
-
configObject = node.declaration.arguments[0];
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
if (configObject?.type === AST_NODE_TYPES.ObjectExpression) {
|
|
1051
|
-
const appProperty = configObject.properties.find(
|
|
1052
|
-
(p) =>
|
|
1053
|
-
p.type === AST_NODE_TYPES.Property &&
|
|
1054
|
-
p.key.name === 'application' &&
|
|
1055
|
-
p.value.type === AST_NODE_TYPES.Identifier,
|
|
1056
|
-
);
|
|
1057
|
-
|
|
1058
|
-
if (appProperty) {
|
|
1059
|
-
const importedAppName = appProperty.value.name;
|
|
1060
|
-
const importDecl = ast.body.find(
|
|
1061
|
-
(n) =>
|
|
1062
|
-
n.type === AST_NODE_TYPES.ImportDeclaration && n.specifiers.some((s) => s.local.name === importedAppName),
|
|
1063
|
-
);
|
|
1064
|
-
if (importDecl) {
|
|
1065
|
-
appClassName = importedAppName;
|
|
1066
|
-
appImportPath = importDecl.source.value;
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
// Discover scenes, plugins, popups, entities, and locale keys.
|
|
1072
|
-
const scenes = await discoverScenes(server);
|
|
1073
|
-
const plugins = await discoverPlugins(server);
|
|
1074
|
-
const popups = await discoverPopups(server);
|
|
1075
|
-
const entities = await discoverEntities(server);
|
|
1076
|
-
const uis = await discoverUIs(server);
|
|
1077
|
-
const localeKeys = await discoverLocaleKeys(server);
|
|
1078
|
-
|
|
1079
|
-
const sceneIds = scenes.filter((s) => s.active !== false).map((s) => s.id);
|
|
1080
|
-
const pluginIds = plugins.filter((p) => p.active !== false).map((p) => p.id);
|
|
1081
|
-
const popupIds = popups.filter((p) => p.active !== false).map((p) => p.id);
|
|
1082
|
-
const entityIds = entities.filter((e) => e.active !== false).map((e) => e.id);
|
|
1083
|
-
const uiIds = uis.filter((u) => u.active !== false).map((u) => u.id);
|
|
1084
|
-
|
|
1085
|
-
// Build-time cross-reference validation. Warnings for typos that would
|
|
1086
|
-
// silently fail at runtime (missing bundles, unknown plugin IDs,
|
|
1087
|
-
// defaultScene pointing at a non-existent scene, duplicate IDs).
|
|
1088
|
-
runBuildTimeValidation({
|
|
1089
|
-
server,
|
|
1090
|
-
configPath,
|
|
1091
|
-
configObject,
|
|
1092
|
-
scenes,
|
|
1093
|
-
plugins,
|
|
1094
|
-
popups,
|
|
1095
|
-
entities,
|
|
1096
|
-
breakpointsName,
|
|
1097
|
-
});
|
|
1098
|
-
|
|
1099
|
-
const sceneIdType = sceneIds.length > 0 ? `\n | '${sceneIds.join("'\n | '")}'` : 'string';
|
|
1100
|
-
const pluginIdType = pluginIds.length > 0 ? `\n | '${pluginIds.join("'\n | '")}'` : 'string';
|
|
1101
|
-
const popupIdType = popupIds.length > 0 ? `\n | '${popupIds.join("'\n | '")}'` : 'string';
|
|
1102
|
-
const entityIdType = entityIds.length > 0 ? `\n | '${entityIds.join("'\n | '")}'` : 'string';
|
|
1103
|
-
const uiIdType = uiIds.length > 0 ? `\n | '${uiIds.join("'\n | '")}'` : 'string';
|
|
1104
|
-
const localeKeyType = localeKeys.length > 0 ? `\n | '${localeKeys.join("'\n | '")}'` : 'string';
|
|
1105
|
-
|
|
1106
|
-
// Emit a keyed `{ id: typeof import('...').default }` map for each of
|
|
1107
|
-
// scenes/popups/entities so the framework can derive typed constructor
|
|
1108
|
-
// props via `ConstructorParameters<...>[0]` / `InstanceType<...>` at call
|
|
1109
|
-
// sites (`this.add.entity(id, props)` etc). Uses the `@/` alias path the
|
|
1110
|
-
// discovery already emits — the generated .d.ts lives inside the user's
|
|
1111
|
-
// project so tsconfig `paths` applies.
|
|
1112
|
-
const buildClassMap = (items) => {
|
|
1113
|
-
const active = items.filter((i) => i.active !== false && i.importPath);
|
|
1114
|
-
if (active.length === 0) return ' [id: string]: never;';
|
|
1115
|
-
return active
|
|
1116
|
-
.map((item) => ` ${JSON.stringify(item.id)}: typeof import('${item.importPath}').default;`)
|
|
1117
|
-
.join('\n');
|
|
1118
|
-
};
|
|
1119
|
-
|
|
1120
|
-
const sceneClassMap = buildClassMap(scenes);
|
|
1121
|
-
const popupClassMap = buildClassMap(popups);
|
|
1122
|
-
const entityClassMap = buildClassMap(entities);
|
|
1123
|
-
const uiClassMap = buildClassMap(uis);
|
|
1124
|
-
|
|
1125
|
-
const configDir = path.dirname(configPath);
|
|
1126
|
-
const dtsDir = path.resolve(configDir, 'src/types');
|
|
1127
|
-
|
|
1128
|
-
let relativeAppPath = appImportPath;
|
|
1129
|
-
if (appImportPath.startsWith('.')) {
|
|
1130
|
-
relativeAppPath = path.relative(dtsDir, path.resolve(configDir, appImportPath)).replace(/\\/g, '/');
|
|
1131
|
-
if (relativeAppPath.endsWith('.ts')) {
|
|
1132
|
-
relativeAppPath = relativeAppPath.slice(0, -3);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
const relativeConfigPath = path.relative(dtsDir, configPath).replace(/\\/g, '/').replace(/\.ts$/, '');
|
|
1137
|
-
|
|
1138
|
-
const imports = [];
|
|
1139
|
-
if (appClassName === 'Application') {
|
|
1140
|
-
imports.push(`import type { Application } from '@caperjs/core';`);
|
|
1141
|
-
}
|
|
1142
|
-
if (fs.existsSync(configPath)) {
|
|
1143
|
-
const configParts = [];
|
|
1144
|
-
if (dataSchemaName) {
|
|
1145
|
-
configParts.push(dataSchemaName);
|
|
1146
|
-
}
|
|
1147
|
-
if (hasActions) {
|
|
1148
|
-
configParts.push('actions');
|
|
1149
|
-
}
|
|
1150
|
-
if (hasContexts) {
|
|
1151
|
-
configParts.push('contexts');
|
|
1152
|
-
}
|
|
1153
|
-
if (breakpointsName) {
|
|
1154
|
-
configParts.push(breakpointsName);
|
|
1155
|
-
}
|
|
1156
|
-
if (configParts.length > 0) {
|
|
1157
|
-
imports.push(`import type { ${configParts.join(', ')} } from '${relativeConfigPath}';`);
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
if (appClassName !== 'Application') {
|
|
1161
|
-
imports.push(`import type { ${appClassName} } from '${relativeAppPath}';`);
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
return {
|
|
1165
|
-
types: `
|
|
1166
|
-
// This file is auto-generated by caper. Do not edit.
|
|
1167
|
-
${imports.join('\n')}
|
|
1168
|
-
// Data
|
|
1169
|
-
type AppData = ${dataTypeName};
|
|
1170
|
-
|
|
1171
|
-
// Action Contexts
|
|
1172
|
-
${hasContexts ? 'type AppContexts = (typeof contexts)[number];' : 'type AppContexts = string;'}
|
|
1173
|
-
|
|
1174
|
-
// Actions
|
|
1175
|
-
${hasActions ? 'type AppActionMap = typeof actions;' : 'type AppActionMap = Record<string, any>;'}
|
|
1176
|
-
${hasActions ? 'type AppActions = keyof AppActionMap;' : 'type AppActions = string;'}
|
|
1177
|
-
|
|
1178
|
-
// Scenes
|
|
1179
|
-
type AppScenes = ${sceneIdType};
|
|
1180
|
-
|
|
1181
|
-
// Plugins
|
|
1182
|
-
type AppPlugins = ${pluginIdType};
|
|
1183
|
-
|
|
1184
|
-
// Popups
|
|
1185
|
-
type AppPopups = ${popupIdType};
|
|
1186
|
-
|
|
1187
|
-
// Entities
|
|
1188
|
-
type AppEntities = ${entityIdType};
|
|
1189
|
-
|
|
1190
|
-
// UI Elements
|
|
1191
|
-
type AppUIs = ${uiIdType};
|
|
1192
|
-
|
|
1193
|
-
// Class maps — typeof-import pointers to the real discovered classes. The
|
|
1194
|
-
// framework uses these with ConstructorParameters<> + InstanceType<> + infer
|
|
1195
|
-
// to derive typed props and return types for this.add.entity / popups.show /
|
|
1196
|
-
// scenes.load without any AST type extraction.
|
|
1197
|
-
type AppSceneClasses = {
|
|
1198
|
-
${sceneClassMap}
|
|
1199
|
-
};
|
|
1200
|
-
|
|
1201
|
-
type AppPopupClasses = {
|
|
1202
|
-
${popupClassMap}
|
|
1203
|
-
};
|
|
1204
|
-
|
|
1205
|
-
type AppEntityClasses = {
|
|
1206
|
-
${entityClassMap}
|
|
1207
|
-
};
|
|
1208
|
-
|
|
1209
|
-
type AppUIClasses = {
|
|
1210
|
-
${uiClassMap}
|
|
1211
|
-
};
|
|
1212
|
-
|
|
1213
|
-
// Locale keys (flattened dot-paths from src/locales/<reference>.ts)
|
|
1214
|
-
type AppLocaleKeys = ${localeKeyType};${breakpointsName ? `\n\n// Breakpoints\ntype AppBreakpoints = keyof (typeof ${breakpointsName})['tiers'] & string;\ntype AppBreakpointModes = keyof (typeof ${breakpointsName})['modes'] & string;` : ''}
|
|
1215
|
-
|
|
1216
|
-
/**
|
|
1217
|
-
* Add type overrides to the framework
|
|
1218
|
-
*/
|
|
1219
|
-
declare module '@caperjs/core' {
|
|
1220
|
-
interface AppTypeOverrides {
|
|
1221
|
-
App: ${appClassName};
|
|
1222
|
-
Data: AppData;
|
|
1223
|
-
Contexts: AppContexts;
|
|
1224
|
-
Actions: AppActions;
|
|
1225
|
-
ActionMap: AppActionMap;
|
|
1226
|
-
Scenes: AppScenes;
|
|
1227
|
-
Plugins: AppPlugins;
|
|
1228
|
-
Popups: AppPopups;
|
|
1229
|
-
Entities: AppEntities;
|
|
1230
|
-
SceneClasses: AppSceneClasses;
|
|
1231
|
-
PopupClasses: AppPopupClasses;
|
|
1232
|
-
EntityClasses: AppEntityClasses;
|
|
1233
|
-
UIs: AppUIs;
|
|
1234
|
-
UIClasses: AppUIClasses;
|
|
1235
|
-
LocaleKeys: AppLocaleKeys;${breakpointsName ? `\n Breakpoints: AppBreakpoints;\n BreakpointModes: AppBreakpointModes;` : ''}
|
|
1236
|
-
Eases: Eases;
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
`,
|
|
1240
|
-
};
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
async function build(msg = `Building ${DTS_FILE_NAME}`, server) {
|
|
1244
|
-
logger.info(msg);
|
|
1245
|
-
const { types, error } = await generateTypes(server);
|
|
1246
|
-
|
|
1247
|
-
if (error) {
|
|
1248
|
-
if (server) {
|
|
1249
|
-
server.ws.send({
|
|
1250
|
-
type: 'error',
|
|
1251
|
-
err: {
|
|
1252
|
-
message: error.message,
|
|
1253
|
-
stack: error.stack,
|
|
1254
|
-
id: path.resolve(cwd, 'caper.config.ts'),
|
|
1255
|
-
plugin: 'vite-plugin-caper-config',
|
|
1256
|
-
},
|
|
1257
|
-
});
|
|
1258
|
-
}
|
|
1259
|
-
return;
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
const typesDir = path.resolve(cwd, 'src/types');
|
|
1263
|
-
await fs.promises.mkdir(typesDir, { recursive: true });
|
|
1264
|
-
await fs.promises.writeFile(path.join(typesDir, DTS_FILE_NAME), types, 'utf-8');
|
|
1265
|
-
|
|
1266
|
-
// Dev-only: evaluate + validate the user's config through Vite's SSR
|
|
1267
|
-
// module graph. Failures surface in the overlay with file:line detail.
|
|
1268
|
-
// Prod builds skip this (no server).
|
|
1269
|
-
if (server) {
|
|
1270
|
-
await validateCaperConfig(server);
|
|
1271
|
-
server.ws.send({ type: 'full-reload' });
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
return {
|
|
1276
|
-
name: 'vite-plugin-caper-config',
|
|
1277
|
-
enforce: 'pre',
|
|
1278
|
-
resolveId(id) {
|
|
1279
|
-
if (id === virtualModuleId) {
|
|
1280
|
-
return resolvedVirtualModuleId;
|
|
1281
|
-
}
|
|
1282
|
-
},
|
|
1283
|
-
async load(id) {
|
|
1284
|
-
if (id === resolvedVirtualModuleId) {
|
|
1285
|
-
return `
|
|
1286
|
-
const configModule = await import('/caper.config.ts');
|
|
1287
|
-
export default configModule.default;
|
|
1288
|
-
`;
|
|
1289
|
-
}
|
|
1290
|
-
},
|
|
1291
|
-
async buildStart() {
|
|
1292
|
-
if (!isProject) return;
|
|
1293
|
-
await build('Generating types from caper.config.ts');
|
|
1294
|
-
},
|
|
1295
|
-
configureServer(server) {
|
|
1296
|
-
if (!isProject) return;
|
|
1297
|
-
|
|
1298
|
-
const configPath = path.resolve(cwd, 'caper.config.ts');
|
|
1299
|
-
const scenesDir = path.resolve(cwd, 'src/scenes');
|
|
1300
|
-
const pluginsDir = path.resolve(cwd, 'src/plugins');
|
|
1301
|
-
const popupsDir = path.resolve(cwd, 'src/popups');
|
|
1302
|
-
const entitiesDir = path.resolve(cwd, 'src/entities');
|
|
1303
|
-
const localesDir = path.resolve(cwd, 'src/locales');
|
|
1304
|
-
|
|
1305
|
-
const handleFileChange = async (file) => {
|
|
1306
|
-
const isScene = file.startsWith(scenesDir);
|
|
1307
|
-
const isPlugin = file.startsWith(pluginsDir);
|
|
1308
|
-
const isPopup = file.startsWith(popupsDir);
|
|
1309
|
-
const isEntity = file.startsWith(entitiesDir);
|
|
1310
|
-
const isLocale = file.startsWith(localesDir);
|
|
1311
|
-
const isConfig = file === configPath;
|
|
1312
|
-
|
|
1313
|
-
if (!isScene && !isPlugin && !isPopup && !isEntity && !isLocale && !isConfig) return;
|
|
1314
|
-
|
|
1315
|
-
const msg = isScene
|
|
1316
|
-
? 'Scene file changed'
|
|
1317
|
-
: isPlugin
|
|
1318
|
-
? 'Plugin file changed'
|
|
1319
|
-
: isPopup
|
|
1320
|
-
? 'Popup file changed'
|
|
1321
|
-
: isEntity
|
|
1322
|
-
? 'Entity file changed'
|
|
1323
|
-
: isLocale
|
|
1324
|
-
? 'Locale file changed'
|
|
1325
|
-
: 'Config file changed';
|
|
1326
|
-
await build(`${msg}, regenerating types...`, server);
|
|
1327
|
-
};
|
|
1328
|
-
|
|
1329
|
-
server.watcher.add(configPath);
|
|
1330
|
-
server.watcher.add(scenesDir);
|
|
1331
|
-
server.watcher.add(pluginsDir);
|
|
1332
|
-
server.watcher.add(popupsDir);
|
|
1333
|
-
server.watcher.add(entitiesDir);
|
|
1334
|
-
server.watcher.add(localesDir);
|
|
1335
|
-
|
|
1336
|
-
server.watcher.on('change', handleFileChange);
|
|
1337
|
-
server.watcher.on('add', handleFileChange);
|
|
1338
|
-
server.watcher.on('unlink', handleFileChange);
|
|
1339
|
-
},
|
|
1340
|
-
};
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
/** PLUGINS */
|
|
1344
|
-
/**
|
|
1345
|
-
* Walks `src/plugins/` recursively, AST-parses each TypeScript file, and
|
|
1346
|
-
* returns a list of local plugin metadata objects.
|
|
1347
|
-
*
|
|
1348
|
-
* - requires `export const <name> = definePlugin({...})` — files without
|
|
1349
|
-
* this marker are skipped (so sibling helper modules that incidentally
|
|
1350
|
-
* default-export a class don't get phantom-registered as plugins)
|
|
1351
|
-
* - requires a default-exported class in the file
|
|
1352
|
-
* - honours `export const id`, `export const active`, `export const dynamic`
|
|
1353
|
-
* (also flattened from inside `definePlugin({...})` by findExportedConstants)
|
|
1354
|
-
* - default is dynamic import (code-split), opt out with `dynamic = false`
|
|
1355
|
-
* - plugin ID defaults to exported `id` → class name → filename
|
|
1356
|
-
*/
|
|
1357
|
-
async function discoverLocalPlugins(server) {
|
|
1358
|
-
const pluginsDir = path.resolve(process.cwd(), 'src/plugins');
|
|
1359
|
-
const plugins = [];
|
|
1360
|
-
|
|
1361
|
-
if (!fs.existsSync(pluginsDir)) {
|
|
1362
|
-
return [];
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
const files = await findTypeScriptFiles(pluginsDir);
|
|
1366
|
-
|
|
1367
|
-
for (const file of files) {
|
|
1368
|
-
try {
|
|
1369
|
-
const content = await fs.promises.readFile(file, 'utf-8');
|
|
1370
|
-
const ast = parse(content, {
|
|
1371
|
-
jsx: false,
|
|
1372
|
-
loc: true,
|
|
1373
|
-
comment: false,
|
|
1374
|
-
});
|
|
1375
|
-
|
|
1376
|
-
const hasPluginWrapper = ast.body.some(
|
|
1377
|
-
(n) =>
|
|
1378
|
-
n.type === AST_NODE_TYPES.ExportNamedDeclaration &&
|
|
1379
|
-
n.declaration?.type === AST_NODE_TYPES.VariableDeclaration &&
|
|
1380
|
-
n.declaration.declarations.some(
|
|
1381
|
-
(d) => d.init?.type === AST_NODE_TYPES.CallExpression && d.init.callee?.name === 'definePlugin',
|
|
1382
|
-
),
|
|
1383
|
-
);
|
|
1384
|
-
if (!hasPluginWrapper) continue;
|
|
1385
|
-
|
|
1386
|
-
const pluginClass = findDefaultExportedClass(ast);
|
|
1387
|
-
if (!pluginClass) continue;
|
|
1388
|
-
|
|
1389
|
-
const exports = findExportedConstants(ast);
|
|
1390
|
-
|
|
1391
|
-
const relativePath = file.replace(process.cwd(), '').replace(/\\/g, '/').split('/src')[1];
|
|
1392
|
-
const importPath = `@${relativePath.replace(/\.ts$/, '')}`;
|
|
1393
|
-
const id = exports.id || pluginClass.id?.name || path.basename(file, '.ts');
|
|
1394
|
-
const name = pluginClass.id?.name || id;
|
|
1395
|
-
|
|
1396
|
-
plugins.push({
|
|
1397
|
-
id,
|
|
1398
|
-
name,
|
|
1399
|
-
isLocal: true,
|
|
1400
|
-
importPath,
|
|
1401
|
-
module:
|
|
1402
|
-
exports.dynamic === false
|
|
1403
|
-
? importPath
|
|
1404
|
-
: {
|
|
1405
|
-
toString: () => `() => import('${importPath}')`,
|
|
1406
|
-
isFunction: true,
|
|
1407
|
-
},
|
|
1408
|
-
active: exports.active === false ? false : true,
|
|
1409
|
-
requires: Array.isArray(exports.requires) ? exports.requires.filter((r) => typeof r === 'string') : [],
|
|
1410
|
-
});
|
|
1411
|
-
} catch (e) {
|
|
1412
|
-
const errorMessage = `Error parsing plugin file ${file}: ${e.message}`;
|
|
1413
|
-
logger.error(errorMessage);
|
|
1414
|
-
if (e.stack) {
|
|
1415
|
-
logger.error(e.stack);
|
|
1416
|
-
}
|
|
1417
|
-
if (server) {
|
|
1418
|
-
server.ws.send({
|
|
1419
|
-
type: 'error',
|
|
1420
|
-
err: {
|
|
1421
|
-
message: e.message,
|
|
1422
|
-
stack: e.stack,
|
|
1423
|
-
id: file,
|
|
1424
|
-
plugin: 'vite-plugin-plugins',
|
|
1425
|
-
},
|
|
1426
|
-
});
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
return plugins;
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
/**
|
|
1435
|
-
* Discovers npm packages prefixed with `@caperjs/plugin-` in the host
|
|
1436
|
-
* project's `package.json`. Always emitted as dynamic imports.
|
|
1437
|
-
*/
|
|
1438
|
-
function discoverNpmPlugins() {
|
|
1439
|
-
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
|
|
1440
|
-
if (!fs.existsSync(packageJsonPath)) return [];
|
|
1441
|
-
|
|
1442
|
-
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
|
1443
|
-
const allDependencies = {
|
|
1444
|
-
...(packageJson.dependencies || {}),
|
|
1445
|
-
...(packageJson.devDependencies || {}),
|
|
1446
|
-
};
|
|
1447
|
-
|
|
1448
|
-
return Object.keys(allDependencies)
|
|
1449
|
-
.filter((dep) => dep.startsWith('@caperjs/plugin-'))
|
|
1450
|
-
.map((packageName) => {
|
|
1451
|
-
const id = packageName.replace('@caperjs/plugin-', '');
|
|
1452
|
-
return {
|
|
1453
|
-
id,
|
|
1454
|
-
name: packageName,
|
|
1455
|
-
isLocal: false,
|
|
1456
|
-
importPath: packageName,
|
|
1457
|
-
module: {
|
|
1458
|
-
toString: () => `() => import('${packageName}')`,
|
|
1459
|
-
isFunction: true,
|
|
1460
|
-
},
|
|
1461
|
-
active: true,
|
|
1462
|
-
// npm plugins can declare `requires` on the class itself
|
|
1463
|
-
// (`public readonly requires = ['firebase']`); the runtime topo-sort
|
|
1464
|
-
// reads from the live instance, so empty here is fine.
|
|
1465
|
-
requires: [],
|
|
1466
|
-
};
|
|
1467
|
-
});
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
async function discoverPlugins(server) {
|
|
1471
|
-
const [local, npm] = [await discoverLocalPlugins(server), discoverNpmPlugins()];
|
|
1472
|
-
return [...npm, ...local];
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
/**
|
|
1476
|
-
* Generic recursive-class-file discoverer. Walks a directory, AST-parses
|
|
1477
|
-
* each .ts file, requires a default-exported class, and reads `id` /
|
|
1478
|
-
* `active` / `dynamic` metadata (either from individual exports or a
|
|
1479
|
-
* `defineX({...})` wrapper). Used for popups and entities — same shape
|
|
1480
|
-
* as scenes/plugins minus the npm-package discovery path.
|
|
1481
|
-
*
|
|
1482
|
-
* `defaultDynamic` sets the emit mode when a file doesn't declare it
|
|
1483
|
-
* explicitly. Entities default to `false` (static import) because the
|
|
1484
|
-
* typed `this.add.entity(id, props)` factory needs sync access to the
|
|
1485
|
-
* constructor; popups default to `true` because `show()` is async.
|
|
1486
|
-
*/
|
|
1487
|
-
async function discoverLocalClassFiles({ dir, kind, server, defaultDynamic = true }) {
|
|
1488
|
-
const rootDir = path.resolve(process.cwd(), dir);
|
|
1489
|
-
if (!fs.existsSync(rootDir)) return [];
|
|
1490
|
-
|
|
1491
|
-
const files = await findTypeScriptFiles(rootDir);
|
|
1492
|
-
const results = [];
|
|
1493
|
-
|
|
1494
|
-
for (const file of files) {
|
|
1495
|
-
try {
|
|
1496
|
-
const content = await fs.promises.readFile(file, 'utf-8');
|
|
1497
|
-
const ast = parse(content);
|
|
1498
|
-
|
|
1499
|
-
const cls = findDefaultExportedClass(ast);
|
|
1500
|
-
if (!cls) continue;
|
|
1501
|
-
|
|
1502
|
-
const exports = findExportedConstants(ast);
|
|
1503
|
-
|
|
1504
|
-
const relativePath = file.replace(process.cwd(), '').replace(/\\/g, '/').split('/src')[1];
|
|
1505
|
-
const importPath = `@${relativePath.replace(/\.ts$/, '')}`;
|
|
1506
|
-
const id = exports.id || cls.id?.name || path.basename(file, '.ts');
|
|
1507
|
-
const name = cls.id?.name || id;
|
|
1508
|
-
const isDynamic = exports.dynamic !== undefined ? exports.dynamic : defaultDynamic;
|
|
1509
|
-
|
|
1510
|
-
results.push({
|
|
1511
|
-
id,
|
|
1512
|
-
name,
|
|
1513
|
-
importPath,
|
|
1514
|
-
module: !isDynamic
|
|
1515
|
-
? importPath
|
|
1516
|
-
: {
|
|
1517
|
-
toString: () => `() => import('${importPath}')`,
|
|
1518
|
-
isFunction: true,
|
|
1519
|
-
},
|
|
1520
|
-
active: exports.active === false ? false : true,
|
|
1521
|
-
});
|
|
1522
|
-
} catch (e) {
|
|
1523
|
-
const errorMessage = `Error parsing ${kind} file ${file}: ${e.message}`;
|
|
1524
|
-
logger.error(errorMessage);
|
|
1525
|
-
if (e.stack) logger.error(e.stack);
|
|
1526
|
-
if (server) {
|
|
1527
|
-
server.ws.send({
|
|
1528
|
-
type: 'error',
|
|
1529
|
-
err: {
|
|
1530
|
-
message: e.message,
|
|
1531
|
-
stack: e.stack,
|
|
1532
|
-
id: file,
|
|
1533
|
-
plugin: `vite-plugin-${kind}s`,
|
|
1534
|
-
},
|
|
1535
|
-
});
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
return results;
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
async function discoverPopups(server) {
|
|
1544
|
-
return discoverLocalClassFiles({ dir: 'src/popups', kind: 'popup', server });
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
async function discoverEntities(server) {
|
|
1548
|
-
// Entities default to static imports so `this.add.entity(id, props)` can
|
|
1549
|
-
// synchronously construct without awaiting a dynamic import. Opt into
|
|
1550
|
-
// code-splitting per-entity with `defineEntity({ dynamic: true })`.
|
|
1551
|
-
return discoverLocalClassFiles({ dir: 'src/entities', kind: 'entity', server, defaultDynamic: false });
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
async function discoverUIs(server) {
|
|
1555
|
-
// UI elements default to static imports so `this.add.ui(id, props)` can
|
|
1556
|
-
// synchronously construct. Same rationale as entities.
|
|
1557
|
-
return discoverLocalClassFiles({ dir: 'src/ui', kind: 'ui', server, defaultDynamic: false });
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
/**
|
|
1561
|
-
* Factory for `virtual:caper-popups` / `virtual:caper-entities`.
|
|
1562
|
-
* Thin wrapper around a discover function that generates the static-import
|
|
1563
|
-
* + dynamic-import `() => import(...)` mix, same shape as the scenes/plugins
|
|
1564
|
-
* virtual modules.
|
|
1565
|
-
*/
|
|
1566
|
-
function createClassListPlugin({ virtualModuleId, discoverFn, exportName, pluginName }) {
|
|
1567
|
-
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
|
1568
|
-
let server;
|
|
1569
|
-
|
|
1570
|
-
const extractClassName = (item) => {
|
|
1571
|
-
const basename = path.basename(item.module);
|
|
1572
|
-
return basename.replace(/\.ts$/, '');
|
|
1573
|
-
};
|
|
1574
|
-
|
|
1575
|
-
const generate = (items) => {
|
|
1576
|
-
const staticItems = items.filter((i) => i.module && !i.module.isFunction);
|
|
1577
|
-
const imports = staticItems.map((i) => `import ${extractClassName(i)} from '${i.module}';`);
|
|
1578
|
-
|
|
1579
|
-
return `
|
|
1580
|
-
${imports.join('\n')}
|
|
1581
|
-
export const ${exportName} = [
|
|
1582
|
-
${items
|
|
1583
|
-
.map((item) => {
|
|
1584
|
-
const moduleExpr =
|
|
1585
|
-
item.module && !item.module.isFunction
|
|
1586
|
-
? extractClassName(item)
|
|
1587
|
-
: (item.module?.toString?.() ?? `() => import('${item.importPath}')`);
|
|
1588
|
-
return `{
|
|
1589
|
-
id: '${item.id}',
|
|
1590
|
-
name: '${item.name}',
|
|
1591
|
-
active: ${item.active},
|
|
1592
|
-
module: ${moduleExpr}
|
|
1593
|
-
}`;
|
|
1594
|
-
})
|
|
1595
|
-
.join(',\n')}
|
|
1596
|
-
];
|
|
1597
|
-
`;
|
|
1598
|
-
};
|
|
1599
|
-
|
|
1600
|
-
return {
|
|
1601
|
-
name: pluginName,
|
|
1602
|
-
configureServer(s) {
|
|
1603
|
-
server = s;
|
|
1604
|
-
},
|
|
1605
|
-
resolveId(id) {
|
|
1606
|
-
if (id === virtualModuleId) return resolvedVirtualModuleId;
|
|
1607
|
-
},
|
|
1608
|
-
async load(id) {
|
|
1609
|
-
if (id === resolvedVirtualModuleId) {
|
|
1610
|
-
const items = await discoverFn(server);
|
|
1611
|
-
return generate(items);
|
|
1612
|
-
}
|
|
1613
|
-
},
|
|
1614
|
-
};
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
export function popupListPlugin(isProject = true) {
|
|
1618
|
-
if (!isProject) {
|
|
1619
|
-
const virtualModuleId = 'virtual:caper-popups';
|
|
1620
|
-
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
|
1621
|
-
return {
|
|
1622
|
-
name: 'vite-plugin-popups',
|
|
1623
|
-
resolveId: (id) => (id === virtualModuleId ? resolvedVirtualModuleId : undefined),
|
|
1624
|
-
load: (id) => (id === resolvedVirtualModuleId ? 'export const popupList = [];' : undefined),
|
|
1625
|
-
};
|
|
1626
|
-
}
|
|
1627
|
-
return createClassListPlugin({
|
|
1628
|
-
virtualModuleId: 'virtual:caper-popups',
|
|
1629
|
-
discoverFn: discoverPopups,
|
|
1630
|
-
exportName: 'popupList',
|
|
1631
|
-
pluginName: 'vite-plugin-popups',
|
|
1632
|
-
});
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
export function entityListPlugin(isProject = true) {
|
|
1636
|
-
if (!isProject) {
|
|
1637
|
-
const virtualModuleId = 'virtual:caper-entities';
|
|
1638
|
-
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
|
1639
|
-
return {
|
|
1640
|
-
name: 'vite-plugin-entities',
|
|
1641
|
-
resolveId: (id) => (id === virtualModuleId ? resolvedVirtualModuleId : undefined),
|
|
1642
|
-
load: (id) => (id === resolvedVirtualModuleId ? 'export const entityList = [];' : undefined),
|
|
1643
|
-
};
|
|
1644
|
-
}
|
|
1645
|
-
return createClassListPlugin({
|
|
1646
|
-
virtualModuleId: 'virtual:caper-entities',
|
|
1647
|
-
discoverFn: discoverEntities,
|
|
1648
|
-
exportName: 'entityList',
|
|
1649
|
-
pluginName: 'vite-plugin-entities',
|
|
1650
|
-
});
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
export function uiListPlugin(isProject = true) {
|
|
1654
|
-
if (!isProject) {
|
|
1655
|
-
const virtualModuleId = 'virtual:caper-uis';
|
|
1656
|
-
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
|
1657
|
-
return {
|
|
1658
|
-
name: 'vite-plugin-uis',
|
|
1659
|
-
resolveId: (id) => (id === virtualModuleId ? resolvedVirtualModuleId : undefined),
|
|
1660
|
-
load: (id) => (id === resolvedVirtualModuleId ? 'export const uiList = [];' : undefined),
|
|
1661
|
-
};
|
|
1662
|
-
}
|
|
1663
|
-
return createClassListPlugin({
|
|
1664
|
-
virtualModuleId: 'virtual:caper-uis',
|
|
1665
|
-
discoverFn: discoverUIs,
|
|
1666
|
-
exportName: 'uiList',
|
|
1667
|
-
pluginName: 'vite-plugin-uis',
|
|
1668
|
-
});
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
/**
|
|
1672
|
-
* Walks `src/locales/`, picks a reference locale file (prefers `en.ts`,
|
|
1673
|
-
* falls back alphabetically), AST-parses its default-exported object, and
|
|
1674
|
-
* flattens every leaf path into a dot-notation key.
|
|
1675
|
-
*
|
|
1676
|
-
* Given:
|
|
1677
|
-
* export default { foo: 'bar', obj: { nested: 'baz' }, replace: { x: '...' } };
|
|
1678
|
-
* Returns:
|
|
1679
|
-
* ['foo', 'obj.nested', 'replace.x']
|
|
1680
|
-
*
|
|
1681
|
-
* Only string literal leaves are emitted. Arrays, function calls, and other
|
|
1682
|
-
* non-object/non-literal expressions are ignored (they can't be typed
|
|
1683
|
-
* meaningfully at this layer).
|
|
1684
|
-
*/
|
|
1685
|
-
async function discoverLocaleKeys(server) {
|
|
1686
|
-
const localesDir = path.resolve(process.cwd(), 'src/locales');
|
|
1687
|
-
if (!fs.existsSync(localesDir)) return [];
|
|
1688
|
-
|
|
1689
|
-
const files = (await fs.promises.readdir(localesDir)).filter((f) => /\.ts$/.test(f)).sort();
|
|
1690
|
-
if (files.length === 0) return [];
|
|
1691
|
-
|
|
1692
|
-
// Pick the reference file: en.ts if present, else the first alphabetically.
|
|
1693
|
-
const referenceFile = files.find((f) => f === 'en.ts') || files[0];
|
|
1694
|
-
const filePath = path.join(localesDir, referenceFile);
|
|
1695
|
-
|
|
1696
|
-
try {
|
|
1697
|
-
const content = await fs.promises.readFile(filePath, 'utf-8');
|
|
1698
|
-
if (!content.trim()) return [];
|
|
1699
|
-
|
|
1700
|
-
const ast = parse(content);
|
|
1701
|
-
const defaultExport = ast.body.find((n) => n.type === AST_NODE_TYPES.ExportDefaultDeclaration);
|
|
1702
|
-
if (!defaultExport) return [];
|
|
1703
|
-
|
|
1704
|
-
// Allow either `export default { ... }` or `export default satisfies ...`
|
|
1705
|
-
let obj = defaultExport.declaration;
|
|
1706
|
-
if (obj && obj.type === 'TSSatisfiesExpression') obj = obj.expression;
|
|
1707
|
-
if (!obj || obj.type !== AST_NODE_TYPES.ObjectExpression) return [];
|
|
1708
|
-
|
|
1709
|
-
const keys = [];
|
|
1710
|
-
const walk = (node, prefix) => {
|
|
1711
|
-
if (node.type !== AST_NODE_TYPES.ObjectExpression) return;
|
|
1712
|
-
for (const prop of node.properties) {
|
|
1713
|
-
if (prop.type !== AST_NODE_TYPES.Property) continue;
|
|
1714
|
-
let name;
|
|
1715
|
-
if (prop.key.type === AST_NODE_TYPES.Identifier) name = prop.key.name;
|
|
1716
|
-
else if (prop.key.type === AST_NODE_TYPES.Literal) name = String(prop.key.value);
|
|
1717
|
-
else continue;
|
|
1718
|
-
const dotPath = prefix ? `${prefix}.${name}` : name;
|
|
1719
|
-
if (prop.value.type === AST_NODE_TYPES.ObjectExpression) {
|
|
1720
|
-
walk(prop.value, dotPath);
|
|
1721
|
-
} else if (prop.value.type === AST_NODE_TYPES.Literal) {
|
|
1722
|
-
keys.push(dotPath);
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
};
|
|
1726
|
-
walk(obj, '');
|
|
1727
|
-
return keys.sort();
|
|
1728
|
-
} catch (e) {
|
|
1729
|
-
const errorMessage = `Error parsing locale file ${filePath}: ${e.message}`;
|
|
1730
|
-
logger.error(errorMessage);
|
|
1731
|
-
if (server) {
|
|
1732
|
-
server.ws.send({
|
|
1733
|
-
type: 'error',
|
|
1734
|
-
err: {
|
|
1735
|
-
message: e.message,
|
|
1736
|
-
stack: e.stack,
|
|
1737
|
-
id: filePath,
|
|
1738
|
-
plugin: 'vite-plugin-locales',
|
|
1739
|
-
},
|
|
1740
|
-
});
|
|
1741
|
-
}
|
|
1742
|
-
return [];
|
|
1743
|
-
}
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
/**
|
|
1747
|
-
* Virtual module plugin for `virtual:caper-plugins`. Mirrors
|
|
1748
|
-
* `sceneListPlugin`: supports static + dynamic imports per entry, preserves
|
|
1749
|
-
* the full metadata shape so consumers can filter by `active` / read IDs.
|
|
1750
|
-
*/
|
|
1751
|
-
export function pluginListPlugin(isProject = true) {
|
|
1752
|
-
function extractClassName(plugin) {
|
|
1753
|
-
// For static (non-function) imports, the module value is the raw import path.
|
|
1754
|
-
const basename = path.basename(plugin.module);
|
|
1755
|
-
return basename.replace(/\.ts$/, '');
|
|
1756
|
-
}
|
|
1757
|
-
|
|
1758
|
-
function generatePluginListModule(plugins) {
|
|
1759
|
-
const staticPlugins = plugins.filter((p) => p.module && !p.module.isFunction && p.isLocal);
|
|
1760
|
-
|
|
1761
|
-
// npm static imports would collide on class-name derivation from the pkg
|
|
1762
|
-
// path, so only local plugins support `dynamic = false`. npm packages are
|
|
1763
|
-
// always dynamic.
|
|
1764
|
-
const imports = staticPlugins.map((p) => `import ${extractClassName(p)} from '${p.module}';`);
|
|
1765
|
-
|
|
1766
|
-
return `
|
|
1767
|
-
${imports.join('\n')}
|
|
1768
|
-
export const pluginsList = [
|
|
1769
|
-
${plugins
|
|
1770
|
-
.map((plugin) => {
|
|
1771
|
-
const moduleExpr =
|
|
1772
|
-
plugin.module && !plugin.module.isFunction && plugin.isLocal
|
|
1773
|
-
? extractClassName(plugin)
|
|
1774
|
-
: (plugin.module?.toString?.() ?? `() => import('${plugin.importPath}')`);
|
|
1775
|
-
return `{
|
|
1776
|
-
name: '${plugin.name}',
|
|
1777
|
-
id: '${plugin.id}',
|
|
1778
|
-
isLocal: ${plugin.isLocal},
|
|
1779
|
-
active: ${plugin.active},
|
|
1780
|
-
requires: ${JSON.stringify(plugin.requires || [])},
|
|
1781
|
-
module: ${moduleExpr}
|
|
1782
|
-
}`;
|
|
1783
|
-
})
|
|
1784
|
-
.join(',\n')}
|
|
1785
|
-
];
|
|
1786
|
-
`;
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
const virtualModuleId = 'virtual:caper-plugins';
|
|
1790
|
-
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
|
1791
|
-
|
|
1792
|
-
let server;
|
|
1793
|
-
|
|
1794
|
-
return {
|
|
1795
|
-
name: 'vite-plugin-plugins',
|
|
1796
|
-
configureServer(s) {
|
|
1797
|
-
server = s;
|
|
1798
|
-
},
|
|
1799
|
-
resolveId(id) {
|
|
1800
|
-
if (id === virtualModuleId) {
|
|
1801
|
-
return resolvedVirtualModuleId;
|
|
1802
|
-
}
|
|
1803
|
-
},
|
|
1804
|
-
async load(id) {
|
|
1805
|
-
if (id === resolvedVirtualModuleId) {
|
|
1806
|
-
const plugins = isProject ? await discoverPlugins(server) : [];
|
|
1807
|
-
return generatePluginListModule(plugins);
|
|
1808
|
-
}
|
|
1809
|
-
},
|
|
1810
|
-
};
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
// scene list plugin
|
|
1814
|
-
async function findTypeScriptFiles(dir) {
|
|
1815
|
-
const files = [];
|
|
1816
|
-
|
|
1817
|
-
async function scan(directory) {
|
|
1818
|
-
const entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
|
1819
|
-
|
|
1820
|
-
for (const entry of entries) {
|
|
1821
|
-
const fullPath = path.join(directory, entry.name);
|
|
1822
|
-
|
|
1823
|
-
if (entry.isDirectory()) {
|
|
1824
|
-
await scan(fullPath);
|
|
1825
|
-
} else if (entry.isFile() && /\.ts?$/.test(entry.name)) {
|
|
1826
|
-
files.push(fullPath);
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
|
|
1831
|
-
await scan(dir);
|
|
1832
|
-
return files;
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
// Callees whose single argument is treated as the entire config object and
|
|
1836
|
-
// unwrapped into the exported constant's value. Keep in sync with the
|
|
1837
|
-
// identity helpers in src/utils/define.ts.
|
|
1838
|
-
const DEFINE_HELPER_NAMES = new Set(['defineScene', 'definePlugin', 'definePopup', 'defineEntity', 'defineUI']);
|
|
1839
|
-
|
|
1840
|
-
function findExportedConstants(ast) {
|
|
1841
|
-
const exports = {};
|
|
1842
|
-
|
|
1843
|
-
function extractValue(node) {
|
|
1844
|
-
switch (node.type) {
|
|
1845
|
-
case AST_NODE_TYPES.Literal:
|
|
1846
|
-
return node.value;
|
|
1847
|
-
case AST_NODE_TYPES.ArrayExpression:
|
|
1848
|
-
return node.elements.map((element) => element && extractValue(element)).filter((value) => value !== undefined);
|
|
1849
|
-
case AST_NODE_TYPES.ObjectExpression: {
|
|
1850
|
-
const obj = {};
|
|
1851
|
-
for (const prop of node.properties) {
|
|
1852
|
-
if (prop.type === AST_NODE_TYPES.Property && prop.key.type === AST_NODE_TYPES.Identifier) {
|
|
1853
|
-
obj[prop.key.name] = extractValue(prop.value);
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
return obj;
|
|
1857
|
-
}
|
|
1858
|
-
case AST_NODE_TYPES.CallExpression: {
|
|
1859
|
-
// Unwrap `defineScene({...})` / `definePlugin({...})` / etc.
|
|
1860
|
-
// Other call expressions are opaque to discovery.
|
|
1861
|
-
const calleeName = node.callee?.name;
|
|
1862
|
-
if (calleeName && DEFINE_HELPER_NAMES.has(calleeName) && node.arguments[0]) {
|
|
1863
|
-
return extractValue(node.arguments[0]);
|
|
1864
|
-
}
|
|
1865
|
-
return undefined;
|
|
1866
|
-
}
|
|
1867
|
-
default:
|
|
1868
|
-
return undefined;
|
|
1869
|
-
}
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
for (const node of ast.body) {
|
|
1873
|
-
if (
|
|
1874
|
-
node.type === AST_NODE_TYPES.ExportNamedDeclaration &&
|
|
1875
|
-
node.declaration?.type === AST_NODE_TYPES.VariableDeclaration
|
|
1876
|
-
) {
|
|
1877
|
-
for (const declarator of node.declaration.declarations) {
|
|
1878
|
-
if (declarator.id.type === AST_NODE_TYPES.Identifier && declarator.init) {
|
|
1879
|
-
exports[declarator.id.name] = extractValue(declarator.init);
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
// Flatten `export const scene = defineScene({...})` / `export const plugin
|
|
1886
|
-
// = definePlugin({...})` wrappers onto the top level so discovery code can
|
|
1887
|
-
// stay agnostic: `exports.id`, `exports.active`, `exports.assets`, etc.
|
|
1888
|
-
// work whether the user wrote individual exports or the helper form.
|
|
1889
|
-
// Individual file-level exports take precedence on conflict.
|
|
1890
|
-
for (const wrapperKey of ['scene', 'plugin', 'popup', 'entity']) {
|
|
1891
|
-
const wrapped = exports[wrapperKey];
|
|
1892
|
-
if (wrapped && typeof wrapped === 'object' && !Array.isArray(wrapped)) {
|
|
1893
|
-
for (const [k, v] of Object.entries(wrapped)) {
|
|
1894
|
-
if (exports[k] === undefined) exports[k] = v;
|
|
1895
|
-
}
|
|
1896
|
-
}
|
|
1897
|
-
}
|
|
1898
|
-
|
|
1899
|
-
return exports;
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
/**
|
|
1903
|
-
* Finds the file's default-exported class. Handles both common forms:
|
|
1904
|
-
*
|
|
1905
|
-
* // inline
|
|
1906
|
-
* export default class Foo extends Bar { ... }
|
|
1907
|
-
*
|
|
1908
|
-
* // named declaration + separate default export
|
|
1909
|
-
* export class Foo extends Bar { ... }
|
|
1910
|
-
* export default Foo;
|
|
1911
|
-
*
|
|
1912
|
-
* // or unexported named class
|
|
1913
|
-
* class Foo extends Bar { ... }
|
|
1914
|
-
* export default Foo;
|
|
1915
|
-
*
|
|
1916
|
-
* The separate-default form requires a second pass to resolve the
|
|
1917
|
-
* identifier back to a matching `ClassDeclaration` in the same file.
|
|
1918
|
-
*/
|
|
1919
|
-
function findDefaultExportedClass(ast) {
|
|
1920
|
-
let identifierName = null;
|
|
1921
|
-
|
|
1922
|
-
for (const node of ast.body) {
|
|
1923
|
-
if (node.type !== AST_NODE_TYPES.ExportDefaultDeclaration) continue;
|
|
1924
|
-
// Form 1: `export default class Foo { ... }`
|
|
1925
|
-
if (node.declaration.type === AST_NODE_TYPES.ClassDeclaration) {
|
|
1926
|
-
return node.declaration;
|
|
1927
|
-
}
|
|
1928
|
-
// Form 2/3: `export default Foo;` — remember the name to resolve below.
|
|
1929
|
-
if (node.declaration.type === AST_NODE_TYPES.Identifier) {
|
|
1930
|
-
identifierName = node.declaration.name;
|
|
1931
|
-
break;
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
if (!identifierName) return null;
|
|
1935
|
-
|
|
1936
|
-
// Resolve the identifier to a class declaration in the same file. Accept
|
|
1937
|
-
// either a bare `class Foo {}` or an `export class Foo {}` form.
|
|
1938
|
-
for (const node of ast.body) {
|
|
1939
|
-
if (node.type === AST_NODE_TYPES.ClassDeclaration && node.id?.name === identifierName) {
|
|
1940
|
-
return node;
|
|
1941
|
-
}
|
|
1942
|
-
if (
|
|
1943
|
-
node.type === AST_NODE_TYPES.ExportNamedDeclaration &&
|
|
1944
|
-
node.declaration?.type === AST_NODE_TYPES.ClassDeclaration &&
|
|
1945
|
-
node.declaration.id?.name === identifierName
|
|
1946
|
-
) {
|
|
1947
|
-
return node.declaration;
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
|
-
|
|
1951
|
-
return null;
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
// Back-compat alias — scene code historically called `findDefaultExportedScene`.
|
|
1955
|
-
const findDefaultExportedScene = findDefaultExportedClass;
|
|
1956
|
-
|
|
1957
|
-
async function discoverScenes(server) {
|
|
1958
|
-
const scenesDir = path.resolve(process.cwd(), 'src/scenes');
|
|
1959
|
-
const scenes = [];
|
|
1960
|
-
|
|
1961
|
-
if (!fs.existsSync(scenesDir)) {
|
|
1962
|
-
return [];
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
const files = await findTypeScriptFiles(scenesDir);
|
|
1966
|
-
|
|
1967
|
-
for (const file of files) {
|
|
1968
|
-
try {
|
|
1969
|
-
const content = await fs.promises.readFile(file, 'utf-8');
|
|
1970
|
-
const ast = parse(content, {
|
|
1971
|
-
jsx: false,
|
|
1972
|
-
loc: true,
|
|
1973
|
-
comment: false,
|
|
1974
|
-
});
|
|
1975
|
-
|
|
1976
|
-
const sceneClass = findDefaultExportedScene(ast);
|
|
1977
|
-
if (!sceneClass) continue;
|
|
1978
|
-
|
|
1979
|
-
const exports = findExportedConstants(ast);
|
|
1980
|
-
|
|
1981
|
-
const relativePath = file.replace(process.cwd(), '').replace(/\\/g, '/').split('/src')[1];
|
|
1982
|
-
// remove /src — the runtime import uses the raw alias (Vite resolves
|
|
1983
|
-
// `.ts` automatically) while the typeof-import codegen needs the
|
|
1984
|
-
// extension stripped so TypeScript's path-alias resolution finds it.
|
|
1985
|
-
const importPath = `@${relativePath}`;
|
|
1986
|
-
const importPathForTypes = importPath.replace(/\.ts$/, '');
|
|
1987
|
-
const id = exports.id || sceneClass.id?.name || path.basename(file, '.ts');
|
|
1988
|
-
|
|
1989
|
-
scenes.push({
|
|
1990
|
-
id,
|
|
1991
|
-
importPath: importPathForTypes,
|
|
1992
|
-
module:
|
|
1993
|
-
exports.dynamic === false
|
|
1994
|
-
? importPath
|
|
1995
|
-
: {
|
|
1996
|
-
toString: () => `() => import('${importPath}')`,
|
|
1997
|
-
isFunction: true, // Add a flag to identify dynamic imports
|
|
1998
|
-
},
|
|
1999
|
-
active: exports.active === false ? false : true,
|
|
2000
|
-
debugLabel: exports.debug?.label || id,
|
|
2001
|
-
debugGroup: exports.debug?.group || undefined,
|
|
2002
|
-
debugOrder: exports.debug?.order >= 0 ? exports.debug.order : Number.MAX_SAFE_INTEGER,
|
|
2003
|
-
assets: exports.assets ?? undefined,
|
|
2004
|
-
plugins: exports.plugins ?? undefined,
|
|
2005
|
-
autoUnloadAssets: exports.assets?.autoUnload ?? false,
|
|
2006
|
-
});
|
|
2007
|
-
} catch (e) {
|
|
2008
|
-
const errorMessage = `Error parsing scene file ${file}: ${e.message}`;
|
|
2009
|
-
logger.error(errorMessage);
|
|
2010
|
-
if (e.stack) {
|
|
2011
|
-
logger.error(e.stack);
|
|
2012
|
-
}
|
|
2013
|
-
if (server) {
|
|
2014
|
-
server.ws.send({
|
|
2015
|
-
type: 'error',
|
|
2016
|
-
err: {
|
|
2017
|
-
message: e.message,
|
|
2018
|
-
stack: e.stack,
|
|
2019
|
-
id: file,
|
|
2020
|
-
plugin: 'vite-plugin-scenes',
|
|
2021
|
-
},
|
|
2022
|
-
});
|
|
2023
|
-
}
|
|
2024
|
-
}
|
|
2025
|
-
}
|
|
2026
|
-
return scenes;
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
export function sceneListPlugin(isProject = true) {
|
|
2030
|
-
function extractClassName(scene) {
|
|
2031
|
-
const basename = path.basename(scene.module);
|
|
2032
|
-
// remove .ts
|
|
2033
|
-
return basename.replace('.ts', '');
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
function generateSceneListModule(scenes) {
|
|
2037
|
-
// extract non function scenes from the list
|
|
2038
|
-
const nonFunctionScenes = scenes.filter((scene) => !scene.module.isFunction);
|
|
2039
|
-
|
|
2040
|
-
const imports = nonFunctionScenes.map((scene) => `import ${extractClassName(scene)} from '${scene.module}';`);
|
|
2041
|
-
|
|
2042
|
-
const result = `
|
|
2043
|
-
${imports.join('\n')}
|
|
2044
|
-
export const sceneList = [
|
|
2045
|
-
${scenes
|
|
2046
|
-
.map(
|
|
2047
|
-
(scene) => `{
|
|
2048
|
-
id: '${scene.id}',
|
|
2049
|
-
active: ${scene.active},
|
|
2050
|
-
module: ${scene.module.isFunction ? scene.module.toString() : extractClassName(scene)},
|
|
2051
|
-
debugLabel: ${JSON.stringify(scene.debugLabel)},
|
|
2052
|
-
debugGroup: ${JSON.stringify(scene.debugGroup)},
|
|
2053
|
-
debugOrder: ${scene.debugOrder},
|
|
2054
|
-
assets: ${JSON.stringify(scene.assets)},
|
|
2055
|
-
plugins: ${JSON.stringify(scene.plugins)},
|
|
2056
|
-
autoUnloadAssets: ${scene.autoUnloadAssets}
|
|
2057
|
-
}`,
|
|
2058
|
-
)
|
|
2059
|
-
.join(',\n')}
|
|
2060
|
-
];
|
|
2061
|
-
`;
|
|
2062
|
-
return result;
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2065
|
-
const virtualModuleId = 'virtual:caper-scenes';
|
|
2066
|
-
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
|
2067
|
-
|
|
2068
|
-
let server;
|
|
2069
|
-
|
|
2070
|
-
return {
|
|
2071
|
-
name: 'vite-plugin-scenes',
|
|
2072
|
-
configureServer(s) {
|
|
2073
|
-
server = s;
|
|
2074
|
-
},
|
|
2075
|
-
resolveId(id) {
|
|
2076
|
-
if (id === virtualModuleId) {
|
|
2077
|
-
return resolvedVirtualModuleId;
|
|
2078
|
-
}
|
|
2079
|
-
},
|
|
2080
|
-
async load(id) {
|
|
2081
|
-
if (id === resolvedVirtualModuleId) {
|
|
2082
|
-
let scenes = [];
|
|
2083
|
-
if (isProject) {
|
|
2084
|
-
scenes = await discoverScenes(server);
|
|
2085
|
-
}
|
|
2086
|
-
return generateSceneListModule(scenes);
|
|
2087
|
-
}
|
|
2088
|
-
},
|
|
2089
|
-
};
|
|
2090
|
-
}
|
|
2091
|
-
|
|
2092
|
-
function createCaperRuntimePlugin() {
|
|
2093
|
-
const virtualModuleId = 'caper-runtime';
|
|
2094
|
-
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
|
2095
|
-
|
|
2096
|
-
return {
|
|
2097
|
-
name: 'vite-plugin-caper-runtime',
|
|
2098
|
-
enforce: 'pre',
|
|
2099
|
-
// Auto-inject the runtime entry so apps don't need a hand-written
|
|
2100
|
-
// `src/index.ts` that just does `import('caper-runtime')`. Legacy HTML that
|
|
2101
|
-
// already references the runtime (or a src/index.(ts|js) entry) is left
|
|
2102
|
-
// untouched so existing apps keep working.
|
|
2103
|
-
transformIndexHtml: {
|
|
2104
|
-
order: 'pre',
|
|
2105
|
-
handler(html) {
|
|
2106
|
-
const referencesRuntime = html.includes('caper-runtime');
|
|
2107
|
-
const referencesLegacyEntry = /<script[^>]*\bsrc=["'][^"']*src\/index\.(ts|js)["']/.test(html);
|
|
2108
|
-
if (referencesRuntime || referencesLegacyEntry) {
|
|
2109
|
-
return html;
|
|
2110
|
-
}
|
|
2111
|
-
return {
|
|
2112
|
-
html,
|
|
2113
|
-
tags: [
|
|
2114
|
-
{
|
|
2115
|
-
tag: 'script',
|
|
2116
|
-
attrs: { type: 'module' },
|
|
2117
|
-
children: 'import("caper-runtime");',
|
|
2118
|
-
injectTo: 'body',
|
|
2119
|
-
},
|
|
2120
|
-
],
|
|
2121
|
-
};
|
|
2122
|
-
},
|
|
2123
|
-
},
|
|
2124
|
-
resolveId(id) {
|
|
2125
|
-
if (id === virtualModuleId) {
|
|
2126
|
-
return resolvedVirtualModuleId;
|
|
2127
|
-
}
|
|
2128
|
-
},
|
|
2129
|
-
load(id) {
|
|
2130
|
-
if (id === resolvedVirtualModuleId) {
|
|
2131
|
-
return `
|
|
2132
|
-
import { pluginsList } from 'virtual:caper-plugins';
|
|
2133
|
-
import { sceneList } from 'virtual:caper-scenes';
|
|
2134
|
-
import { popupList } from 'virtual:caper-popups';
|
|
2135
|
-
import { entityList } from 'virtual:caper-entities';
|
|
2136
|
-
import { uiList } from 'virtual:caper-uis';
|
|
2137
|
-
import { create, signalCaperReady, installCaperGlobal } from '@caperjs/core';
|
|
2138
|
-
|
|
2139
|
-
(globalThis).Caper = (globalThis).Caper || {};
|
|
2140
|
-
|
|
2141
|
-
// Install Caper.apps / Caper.ready() / Caper.automation immediately
|
|
2142
|
-
// so automation drivers can await Caper.ready() before boot finishes.
|
|
2143
|
-
installCaperGlobal();
|
|
2144
|
-
|
|
2145
|
-
try {
|
|
2146
|
-
(globalThis).Caper.APP_NAME = __CAPER_APP_NAME;
|
|
2147
|
-
(globalThis).Caper.APP_VERSION = __CAPER_APP_VERSION;
|
|
2148
|
-
} catch (e) {
|
|
2149
|
-
console.error('Failed to set app name and version', e);
|
|
2150
|
-
}
|
|
2151
|
-
|
|
2152
|
-
(globalThis).Caper.sceneList = sceneList;
|
|
2153
|
-
(globalThis).Caper.pluginsList = pluginsList;
|
|
2154
|
-
(globalThis).Caper.popupList = popupList;
|
|
2155
|
-
(globalThis).Caper.entityList = entityList;
|
|
2156
|
-
(globalThis).Caper.uiList = uiList;
|
|
2157
|
-
|
|
2158
|
-
(globalThis).Caper.sceneIds = sceneList.map((scene) => scene.id);
|
|
2159
|
-
(globalThis).Caper.pluginIds = pluginsList.map((plugin) => plugin.id);
|
|
2160
|
-
(globalThis).Caper.popupIds = popupList.map((popup) => popup.id);
|
|
2161
|
-
(globalThis).Caper.entityIds = entityList.map((entity) => entity.id);
|
|
2162
|
-
(globalThis).Caper.uiIds = uiList.map((ui) => ui.id);
|
|
2163
|
-
|
|
2164
|
-
(globalThis).Caper.get = function (key) {
|
|
2165
|
-
(globalThis).Caper = (globalThis).Caper || {};
|
|
2166
|
-
return key ? (globalThis).Caper[key] : (globalThis).Caper;
|
|
2167
|
-
};
|
|
2168
|
-
|
|
2169
|
-
async function bootstrap() {
|
|
2170
|
-
const configModule = await import('virtual:caper-config');
|
|
2171
|
-
const config = configModule.default;
|
|
2172
|
-
(globalThis).Caper.config = config;
|
|
2173
|
-
const app = await create(config);
|
|
2174
|
-
const mains = import.meta.glob('/src/main.ts', { eager: true });
|
|
2175
|
-
const mainPath = Object.keys(mains)[0];
|
|
2176
|
-
|
|
2177
|
-
if (mainPath) {
|
|
2178
|
-
const mainModule = mains[mainPath];
|
|
2179
|
-
if (mainModule && typeof mainModule.default === 'function') {
|
|
2180
|
-
await mainModule.default(app);
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
signalCaperReady(app);
|
|
2185
|
-
}
|
|
2186
|
-
|
|
2187
|
-
// Pass dev-ness through to the framework. This virtual module is
|
|
2188
|
-
// transformed in the CONSUMER app's vite context, so import.meta.env
|
|
2189
|
-
// is real here — inside the pre-built framework lib it has already
|
|
2190
|
-
// been compiled away, so globals.ts reads Caper.__dev instead.
|
|
2191
|
-
(globalThis).Caper.__dev = !!import.meta.env.DEV;
|
|
2192
|
-
|
|
2193
|
-
// Mark this app as managed by the vite runtime so create() does not
|
|
2194
|
-
// double-signal readiness, then guard against a double bootstrap.
|
|
2195
|
-
// (ES module caching already prevents re-evaluating this id; this is
|
|
2196
|
-
// belt-and-braces.)
|
|
2197
|
-
(globalThis).Caper.__runtimeManaged = true;
|
|
2198
|
-
if (!(globalThis).__CAPER_BOOTSTRAPPED__) {
|
|
2199
|
-
(globalThis).__CAPER_BOOTSTRAPPED__ = true;
|
|
2200
|
-
bootstrap();
|
|
2201
|
-
}
|
|
2202
|
-
`;
|
|
2203
|
-
}
|
|
2204
|
-
},
|
|
2205
|
-
};
|
|
2206
|
-
}
|
|
2207
|
-
|
|
2208
|
-
function createCaperPWAPlugin() {
|
|
2209
|
-
const entryId = 'caper-pwa';
|
|
2210
|
-
const resolvedVirtualModuleId = '\0' + entryId;
|
|
2211
|
-
|
|
2212
|
-
return {
|
|
2213
|
-
name: 'vite-virtual-entry',
|
|
2214
|
-
enforce: 'pre',
|
|
2215
|
-
resolveId(id) {
|
|
2216
|
-
if (id === entryId) {
|
|
2217
|
-
return resolvedVirtualModuleId;
|
|
2218
|
-
}
|
|
2219
|
-
},
|
|
2220
|
-
load(id) {
|
|
2221
|
-
if (id === resolvedVirtualModuleId) {
|
|
2222
|
-
return `
|
|
2223
|
-
import {pwaInfo} from 'virtual:pwa-info';
|
|
2224
|
-
import {registerSW} from 'virtual:pwa-register';
|
|
2225
|
-
|
|
2226
|
-
window.Caper = window.Caper || {};
|
|
2227
|
-
window.Caper.pwa = window.Caper.pwa || {};
|
|
2228
|
-
|
|
2229
|
-
window.Caper.pwa.info = pwaInfo;
|
|
2230
|
-
|
|
2231
|
-
window.Caper.pwa.onRegisteredSW = function(swScriptUrl){
|
|
2232
|
-
console.log('Caper PWA: SW registered: ', swScriptUrl)
|
|
2233
|
-
}
|
|
2234
|
-
|
|
2235
|
-
window.Caper.pwa.offlineReady = function(){
|
|
2236
|
-
console.log('Caper PWA: ready to work offline')
|
|
2237
|
-
}
|
|
2238
|
-
|
|
2239
|
-
window.Caper.pwa.register = function(){
|
|
2240
|
-
registerSW({
|
|
2241
|
-
immediate: true,
|
|
2242
|
-
onRegisteredSW(swScriptUrl) {
|
|
2243
|
-
window.Caper.pwa.onRegisteredSW(swScriptUrl)
|
|
2244
|
-
},
|
|
2245
|
-
onOfflineReady() {
|
|
2246
|
-
window.Caper.pwa.offlineReady()
|
|
2247
|
-
},
|
|
2248
|
-
});
|
|
2249
|
-
}
|
|
2250
|
-
`;
|
|
2251
|
-
}
|
|
2252
|
-
},
|
|
2253
|
-
config(config) {
|
|
2254
|
-
const input = config.build?.rolldownOptions?.input || config.build?.rollupOptions?.input || 'index.html';
|
|
2255
|
-
const inputs = Array.isArray(input) ? input : [input];
|
|
2256
|
-
|
|
2257
|
-
return {
|
|
2258
|
-
build: {
|
|
2259
|
-
rolldownOptions: {
|
|
2260
|
-
input: [entryId, ...inputs],
|
|
2261
|
-
output: {
|
|
2262
|
-
entryFileNames: (chunkInfo) => {
|
|
2263
|
-
if (chunkInfo.facadeModuleId?.includes('caper-pqa')) {
|
|
2264
|
-
return 'assets/caper-[hash].js';
|
|
2265
|
-
}
|
|
2266
|
-
return 'assets/[name]-[hash].js';
|
|
2267
|
-
},
|
|
2268
|
-
},
|
|
2269
|
-
},
|
|
2270
|
-
},
|
|
2271
|
-
};
|
|
2272
|
-
},
|
|
2273
|
-
};
|
|
2274
|
-
}
|
|
2275
|
-
|
|
2276
|
-
function caperDevHelperPlugin() {
|
|
2277
|
-
return {
|
|
2278
|
-
name: 'vite-plugin-caper-dev-helper',
|
|
2279
|
-
configureServer(server) {
|
|
2280
|
-
server.ws.on('caper:show-error', (data) => {
|
|
2281
|
-
const { error } = data;
|
|
2282
|
-
// Send the 'error' event back to the client
|
|
2283
|
-
server.ws.send({
|
|
2284
|
-
type: 'error',
|
|
2285
|
-
err: {
|
|
2286
|
-
message: error.message || 'An unknown error occurred.',
|
|
2287
|
-
stack: error.stack || new Error(error.message).stack,
|
|
2288
|
-
id: error.id,
|
|
2289
|
-
loc: {
|
|
2290
|
-
file: error.id,
|
|
2291
|
-
line: error.line,
|
|
2292
|
-
column: error.column,
|
|
2293
|
-
},
|
|
2294
|
-
plugin: 'caper-dev-helper',
|
|
2295
|
-
},
|
|
2296
|
-
});
|
|
2297
|
-
});
|
|
2298
|
-
},
|
|
2299
|
-
};
|
|
2300
|
-
}
|
|
2301
|
-
|
|
2302
|
-
/** END PLUGINS */
|
|
2303
|
-
|
|
2304
|
-
/** CONFIG */
|
|
2305
|
-
const buildFlags = readCaperBuildFlags();
|
|
2306
|
-
|
|
2307
|
-
/**
|
|
2308
|
-
* @type {Partial<import('vite').UserConfig>}
|
|
2309
|
-
*/
|
|
2310
|
-
const defaultConfig = {
|
|
2311
|
-
cacheDir: '.cache',
|
|
2312
|
-
logLevel: 'info',
|
|
2313
|
-
publicDir: './public',
|
|
2314
|
-
base: env === 'development' ? '/' : './',
|
|
2315
|
-
server: {
|
|
2316
|
-
port: 3000,
|
|
2317
|
-
host: true,
|
|
2318
|
-
open: true,
|
|
2319
|
-
},
|
|
2320
|
-
preview: {
|
|
2321
|
-
host: true,
|
|
2322
|
-
port: 8080,
|
|
2323
|
-
},
|
|
2324
|
-
build: {
|
|
2325
|
-
sourcemap: env === 'development',
|
|
2326
|
-
rolldownOptions: {
|
|
2327
|
-
external: ['caper-globals'],
|
|
2328
|
-
output: {
|
|
2329
|
-
// Rolldown requires manualChunks as a function (Rollup allowed an object).
|
|
2330
|
-
manualChunks(id) {
|
|
2331
|
-
if (id.includes('node_modules/gsap/')) return 'gsap';
|
|
2332
|
-
},
|
|
2333
|
-
chunkFileNames: 'assets/[name]-[hash].js',
|
|
2334
|
-
entryFileNames: 'assets/[name]-[hash].js',
|
|
2335
|
-
assetFileNames: 'assets/[name]-[hash][extname]',
|
|
2336
|
-
},
|
|
2337
|
-
},
|
|
2338
|
-
},
|
|
2339
|
-
plugins: [
|
|
2340
|
-
// wasm() only matters for ESM-integration `.wasm` imports (a bare
|
|
2341
|
-
// `import init from './foo.wasm'`), and it costs an enforce:'pre'
|
|
2342
|
-
// resolveId+load hook on every module in the graph. Nothing in caper
|
|
2343
|
-
// needs it: Rive fetches its wasm at runtime via `locateFile`, pixi's
|
|
2344
|
-
// KTX/basis transcoders fetch theirs from a CDN, spine-core is pure JS,
|
|
2345
|
-
// and Vite 8 handles `?init` / `?url` wasm natively. Off by default —
|
|
2346
|
-
// set `useWasm: true` in caper.config.ts if your project imports a
|
|
2347
|
-
// .wasm module directly.
|
|
2348
|
-
...(buildFlags.useWasm ? [wasm()] : []),
|
|
2349
|
-
// vite-plugin-top-level-await dropped in Vite 8 upgrade: Chrome 111 /
|
|
2350
|
-
// Safari 16.4 default targets support TLA natively.
|
|
2351
|
-
// vite-plugin-html dropped: was called with no args, Vite handles
|
|
2352
|
-
// HTML transforms natively.
|
|
2353
|
-
createCaperRuntimePlugin(),
|
|
2354
|
-
viteStaticCopy({
|
|
2355
|
-
targets: [
|
|
2356
|
-
{
|
|
2357
|
-
src: './node_modules/@caperjs/core/src/plugins/captions/font/*.*',
|
|
2358
|
-
dest: './assets/caper/font',
|
|
2359
|
-
},
|
|
2360
|
-
],
|
|
2361
|
-
}),
|
|
2362
|
-
pluginListPlugin(),
|
|
2363
|
-
sceneListPlugin(),
|
|
2364
|
-
popupListPlugin(),
|
|
2365
|
-
entityListPlugin(),
|
|
2366
|
-
uiListPlugin(),
|
|
2367
|
-
assetpackPlugin(),
|
|
2368
|
-
assetTypesPlugin(),
|
|
2369
|
-
caperConfigPlugin(),
|
|
2370
|
-
caperDevHelperPlugin(),
|
|
2371
|
-
],
|
|
2372
|
-
resolve: {
|
|
2373
|
-
alias: {
|
|
2374
|
-
'@': path.resolve(cwd, './src'),
|
|
2375
|
-
},
|
|
2376
|
-
// Force a single instance of each singleton lib. pixi keeps global
|
|
2377
|
-
// registries (extensions, TextureSource cache, Ticker.shared) and relies on
|
|
2378
|
-
// instanceof; two copies (via linked/transitive installs) split its state.
|
|
2379
|
-
dedupe: ['pixi.js', 'gsap', '@pixi/sound'],
|
|
2380
|
-
},
|
|
2381
|
-
// Don't prebundle @pixi/ui: esbuild inlines its own copy of pixi.js into the
|
|
2382
|
-
// dep chunk, giving two pixi instances — every cross-boundary
|
|
2383
|
-
// `instanceof Texture/Sprite` then fails. Served as source, its
|
|
2384
|
-
// `import "pixi.js"` resolves to the same optimized pixi as the app. Its
|
|
2385
|
-
// nested typed-signals dep (CJS) still needs prebundling for interop.
|
|
2386
|
-
optimizeDeps: {
|
|
2387
|
-
exclude: ['@pixi/ui'],
|
|
2388
|
-
include: ['@pixi/ui > typed-signals'],
|
|
2389
|
-
},
|
|
2390
|
-
define: {
|
|
2391
|
-
__CAPER_APP_NAME: JSON.stringify(process.env.npm_package_name),
|
|
2392
|
-
__CAPER_APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
|
2393
|
-
},
|
|
2394
|
-
};
|
|
2395
|
-
|
|
2396
|
-
// config without assetpack plugin
|
|
2397
|
-
const noAssetpackConfig = { ...defaultConfig };
|
|
2398
|
-
// remove assetpack plugin and asset types plugin
|
|
2399
|
-
noAssetpackConfig.plugins = noAssetpackConfig.plugins.filter(
|
|
2400
|
-
(plugin) => plugin.name !== 'vite-plugin-assetpack' && plugin.name !== 'vite-plugin-asset-types',
|
|
2401
|
-
);
|
|
2402
|
-
|
|
2403
|
-
// withPWA
|
|
2404
|
-
const injectRegister = process.env.SW_INLINE ?? 'auto';
|
|
2405
|
-
const selfDestroying = process.env.SW_DESTROY === 'true';
|
|
2406
|
-
/**
|
|
2407
|
-
* @type {Partial<import('vite-plugin-pwa').VitePWAOptions>}
|
|
2408
|
-
*/
|
|
2409
|
-
const defaultPWAConfig = {
|
|
2410
|
-
base: '/',
|
|
2411
|
-
buildBase: './',
|
|
2412
|
-
registerType: 'autoUpdate',
|
|
2413
|
-
injectRegister,
|
|
2414
|
-
selfDestroying,
|
|
2415
|
-
devOptions: {
|
|
2416
|
-
enabled: process.env.SW_DEV === 'true',
|
|
2417
|
-
navigateFallback: 'index.html',
|
|
2418
|
-
suppressWarnings: true,
|
|
2419
|
-
},
|
|
2420
|
-
manifest: {
|
|
2421
|
-
name: process.env.npm_package_name,
|
|
2422
|
-
short_name: process.env.npm_package_name,
|
|
2423
|
-
description: process.env.npm_package_description,
|
|
2424
|
-
theme_color: '#ffffff',
|
|
2425
|
-
background_color: '#000000',
|
|
2426
|
-
display: 'fullscreen',
|
|
2427
|
-
orientation: 'portrait',
|
|
2428
|
-
categories: ['game', 'application'],
|
|
2429
|
-
},
|
|
2430
|
-
};
|
|
2431
|
-
|
|
2432
|
-
/**
|
|
2433
|
-
* @param {Partial<import('vite-plugin-pwa').VitePWAOptions>} userPWAConfig
|
|
2434
|
-
* @param {Partial<import('vite').UserConfig>} userConfig
|
|
2435
|
-
* @returns {import('vite').UserConfig}
|
|
2436
|
-
*/
|
|
2437
|
-
function withPWA(userPWAConfig = {}, userConfig = {}) {
|
|
2438
|
-
const pwaConfig = {
|
|
2439
|
-
...defaultPWAConfig,
|
|
2440
|
-
...userPWAConfig,
|
|
2441
|
-
devOptions: { ...defaultPWAConfig.devOptions, ...userPWAConfig.devOptions },
|
|
2442
|
-
manifest: {
|
|
2443
|
-
...defaultPWAConfig.manifest,
|
|
2444
|
-
...(userPWAConfig?.manifest ?? {}),
|
|
2445
|
-
icons: [...(userPWAConfig?.manifest?.icons ?? [])],
|
|
2446
|
-
},
|
|
2447
|
-
};
|
|
2448
|
-
const config = mergeConfig(defaultConfig, userConfig);
|
|
2449
|
-
config.plugins.push(VitePWA(pwaConfig));
|
|
2450
|
-
config.plugins.push(createCaperPWAPlugin());
|
|
2451
|
-
return config;
|
|
2452
|
-
}
|
|
2453
|
-
|
|
2454
|
-
/**
|
|
2455
|
-
* @param {Partial<import('vite').UserConfig>} userConfig
|
|
2456
|
-
* @returns {import('vite').UserConfig}
|
|
2457
|
-
*/
|
|
2458
|
-
function extendConfig(userConfig = {}) {
|
|
2459
|
-
return mergeConfig(defaultConfig, userConfig);
|
|
2460
|
-
}
|
|
2461
|
-
|
|
2462
|
-
export { defaultConfig, extendConfig, noAssetpackConfig, withPWA };
|
|
2463
|
-
|
|
2464
|
-
export default defaultConfig;
|