@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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time checks over what discovery found: that every scene/plugin/popup id
|
|
3
|
+
* is unique, that referenced asset bundles exist in the manifest, and that
|
|
4
|
+
* plugin dependencies form no cycle.
|
|
5
|
+
*
|
|
6
|
+
* These run before the app ever boots, so a typo in a bundle name fails the
|
|
7
|
+
* build instead of throwing at runtime in front of a player.
|
|
8
|
+
*/
|
|
9
|
+
import { loadManifestBundleNames } from './manifest.mjs';
|
|
10
|
+
import { AST_NODE_TYPES, extractConfigReferences } from './ast.mjs';
|
|
11
|
+
import { logger } from './util.mjs';
|
|
12
|
+
|
|
13
|
+
export function runBuildTimeValidation({
|
|
14
|
+
server,
|
|
15
|
+
configPath,
|
|
16
|
+
configObject,
|
|
17
|
+
scenes,
|
|
18
|
+
plugins,
|
|
19
|
+
popups,
|
|
20
|
+
entities,
|
|
21
|
+
breakpointsName,
|
|
22
|
+
}) {
|
|
23
|
+
const warnings = [];
|
|
24
|
+
const bundleNames = loadManifestBundleNames();
|
|
25
|
+
|
|
26
|
+
// 1. Scene bundle references
|
|
27
|
+
if (bundleNames && bundleNames.size > 0) {
|
|
28
|
+
for (const scene of scenes) {
|
|
29
|
+
for (const kind of ['preload', 'background']) {
|
|
30
|
+
const bundlesField = scene.assets?.[kind]?.bundles;
|
|
31
|
+
if (!bundlesField) continue;
|
|
32
|
+
const list = Array.isArray(bundlesField) ? bundlesField : [bundlesField];
|
|
33
|
+
for (const bundle of list) {
|
|
34
|
+
if (typeof bundle !== 'string') continue;
|
|
35
|
+
if (!bundleNames.has(bundle)) {
|
|
36
|
+
warnings.push(
|
|
37
|
+
`Scene '${scene.id}' references ${kind} bundle '${bundle}' which is not in the assetpack manifest. ` +
|
|
38
|
+
`Known bundles: ${[...bundleNames].join(', ') || '(none)'}.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2 + 3. caper.config.ts cross-references
|
|
47
|
+
const { defaultScene, pluginIds: configPluginIds } = extractConfigReferences(configObject);
|
|
48
|
+
const discoveredPluginIds = new Set(plugins.map((p) => p.id));
|
|
49
|
+
const discoveredSceneIds = new Set(scenes.map((s) => s.id));
|
|
50
|
+
|
|
51
|
+
for (const id of configPluginIds) {
|
|
52
|
+
if (!discoveredPluginIds.has(id)) {
|
|
53
|
+
warnings.push(
|
|
54
|
+
`caper.config.ts references plugin '${id}' which was not discovered. ` +
|
|
55
|
+
`Known plugin IDs: ${[...discoveredPluginIds].join(', ') || '(none)'}.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (defaultScene && !discoveredSceneIds.has(defaultScene)) {
|
|
60
|
+
warnings.push(
|
|
61
|
+
`caper.config.ts defaultScene is '${defaultScene}' but no scene with that id was discovered. ` +
|
|
62
|
+
`Known scene IDs: ${[...discoveredSceneIds].join(', ') || '(none)'}.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 4. Plugin `requires` cross-references (local plugins only — npm
|
|
67
|
+
// plugins can declare requires on the class itself, which the runtime
|
|
68
|
+
// topo-sort handles, but the AST can't see).
|
|
69
|
+
for (const p of plugins) {
|
|
70
|
+
if (!p.isLocal || !Array.isArray(p.requires) || p.requires.length === 0) continue;
|
|
71
|
+
for (const req of p.requires) {
|
|
72
|
+
if (!discoveredPluginIds.has(req)) {
|
|
73
|
+
warnings.push(
|
|
74
|
+
`Plugin '${p.id}' requires '${req}' which is not a discovered plugin. ` +
|
|
75
|
+
`Known plugin IDs: ${[...discoveredPluginIds].join(', ') || '(none)'}.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 4b. Plugin `requires` cycle detection (build-time, so the dev sees the
|
|
82
|
+
// cycle in the terminal/overlay before bootstrap even tries to run).
|
|
83
|
+
const cycleEdges = new Map();
|
|
84
|
+
for (const p of plugins) {
|
|
85
|
+
cycleEdges.set(
|
|
86
|
+
p.id,
|
|
87
|
+
(p.requires ?? []).filter((r) => discoveredPluginIds.has(r)),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const cycle = detectCycle(cycleEdges);
|
|
91
|
+
if (cycle) {
|
|
92
|
+
warnings.push(`Plugin dependency cycle: ${cycle.join(' → ')}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 5. Duplicate-id detection
|
|
96
|
+
const checkDuplicates = (label, list) => {
|
|
97
|
+
const seen = new Map();
|
|
98
|
+
for (const item of list) {
|
|
99
|
+
if (seen.has(item.id)) {
|
|
100
|
+
warnings.push(`Duplicate ${label} id '${item.id}' — multiple files export the same id.`);
|
|
101
|
+
} else {
|
|
102
|
+
seen.set(item.id, true);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
checkDuplicates('scene', scenes);
|
|
107
|
+
checkDuplicates('plugin', plugins);
|
|
108
|
+
checkDuplicates('popup', popups);
|
|
109
|
+
checkDuplicates('entity', entities);
|
|
110
|
+
|
|
111
|
+
// 6. Breakpoints declared in config but not via defineBreakpoints() — the
|
|
112
|
+
// names will work at runtime but get no intellisense.
|
|
113
|
+
if (!breakpointsName && configObject?.type === AST_NODE_TYPES.ObjectExpression) {
|
|
114
|
+
const hasKey = configObject.properties.some(
|
|
115
|
+
(p) => p.type === AST_NODE_TYPES.Property && p.key?.name === 'breakpoints',
|
|
116
|
+
);
|
|
117
|
+
if (hasKey) {
|
|
118
|
+
warnings.push(
|
|
119
|
+
`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({ ... })\`.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (warnings.length === 0) return;
|
|
125
|
+
|
|
126
|
+
for (const msg of warnings) {
|
|
127
|
+
// Vite's createLogger is suppressed inside the dts plugin chain during
|
|
128
|
+
// builds, so go straight to console.warn — yellow ANSI so it stands out
|
|
129
|
+
// amid Vite's own output.
|
|
130
|
+
console.warn(`\x1b[33m[caper] ${msg}\x1b[0m`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (server?.ws) {
|
|
134
|
+
// Surface the first warning in the browser overlay so a dev in the
|
|
135
|
+
// tab notices. Subsequent ones are in the terminal — don't spam the
|
|
136
|
+
// overlay with a stack of them.
|
|
137
|
+
server.ws.send({
|
|
138
|
+
type: 'error',
|
|
139
|
+
err: {
|
|
140
|
+
message:
|
|
141
|
+
`caper build-time validation (${warnings.length} warning${warnings.length === 1 ? '' : 's'}):\n` +
|
|
142
|
+
warnings.map((w) => ' • ' + w).join('\n'),
|
|
143
|
+
id: configPath,
|
|
144
|
+
plugin: 'vite-plugin-caper-config',
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* DFS-based cycle detection over a `Map<id, dependencyIds[]>` graph.
|
|
152
|
+
* Returns the cycle path (e.g. `['A','B','A']`) on the first cycle found,
|
|
153
|
+
* or `null` if the graph is acyclic. Used by build-time validation so a
|
|
154
|
+
* plugin requires-cycle surfaces in the terminal before bootstrap runs.
|
|
155
|
+
*/
|
|
156
|
+
export function detectCycle(edges) {
|
|
157
|
+
const WHITE = 0;
|
|
158
|
+
const GRAY = 1;
|
|
159
|
+
const BLACK = 2;
|
|
160
|
+
const color = new Map();
|
|
161
|
+
for (const id of edges.keys()) color.set(id, WHITE);
|
|
162
|
+
const stack = [];
|
|
163
|
+
|
|
164
|
+
function visit(id) {
|
|
165
|
+
if (color.get(id) === GRAY) {
|
|
166
|
+
const startIdx = stack.indexOf(id);
|
|
167
|
+
return [...stack.slice(startIdx), id];
|
|
168
|
+
}
|
|
169
|
+
if (color.get(id) === BLACK) return null;
|
|
170
|
+
color.set(id, GRAY);
|
|
171
|
+
stack.push(id);
|
|
172
|
+
for (const dep of edges.get(id) ?? []) {
|
|
173
|
+
const found = visit(dep);
|
|
174
|
+
if (found) return found;
|
|
175
|
+
}
|
|
176
|
+
stack.pop();
|
|
177
|
+
color.set(id, BLACK);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const id of edges.keys()) {
|
|
182
|
+
const found = visit(id);
|
|
183
|
+
if (found) return found;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns AssetPack's `assets.json` into `caper-assets.d.ts`, so every bundle,
|
|
3
|
+
* texture, spritesheet, font, audio clip and JSON file in the project is a
|
|
4
|
+
* literal type rather than a string an app can typo.
|
|
5
|
+
*
|
|
6
|
+
* Regenerated whenever the manifest changes — the plugin watches it in dev and
|
|
7
|
+
* reloads the page, because vite does not reload for changes under `publicDir`.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { loadManifestBundleNames } from '../internal/manifest.mjs';
|
|
12
|
+
import { ASSET_DTS_FILE_NAME, debounce, delay, logger } from '../internal/util.mjs';
|
|
13
|
+
|
|
14
|
+
export { loadManifestBundleNames };
|
|
15
|
+
|
|
16
|
+
async function generateAssetTypes(manifest, assetsDir) {
|
|
17
|
+
// Flat totals per category (back-compat: AssetTextures, AssetAudio, etc.)
|
|
18
|
+
const assetsByType = {
|
|
19
|
+
textures: new Set(),
|
|
20
|
+
spritesheets: new Set(),
|
|
21
|
+
tpsFrames: new Set(),
|
|
22
|
+
fonts: new Set(),
|
|
23
|
+
bitmapFonts: new Set(),
|
|
24
|
+
fontFamilies: new Set(),
|
|
25
|
+
bitmapFontFamilies: new Set(),
|
|
26
|
+
audio: new Set(),
|
|
27
|
+
json: new Set(),
|
|
28
|
+
spine: new Set(),
|
|
29
|
+
rive: new Set(),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Per-bundle breakdown so we can narrow e.g. AssetTexturesIn<'menu'> → only
|
|
33
|
+
// textures that actually ship in the 'menu' bundle. Shape:
|
|
34
|
+
// byBundle.textures.menu = Set(['menu/logo'])
|
|
35
|
+
const byBundle = {
|
|
36
|
+
textures: {},
|
|
37
|
+
spritesheets: {},
|
|
38
|
+
tpsFrames: {},
|
|
39
|
+
fonts: {},
|
|
40
|
+
bitmapFonts: {},
|
|
41
|
+
audio: {},
|
|
42
|
+
json: {},
|
|
43
|
+
spine: {},
|
|
44
|
+
rive: {},
|
|
45
|
+
};
|
|
46
|
+
const addToBundle = (category, bundleName, alias) => {
|
|
47
|
+
if (!byBundle[category][bundleName]) byBundle[category][bundleName] = new Set();
|
|
48
|
+
byBundle[category][bundleName].add(alias);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const bundles = manifest.bundles || [];
|
|
52
|
+
const bundleNames = new Set();
|
|
53
|
+
|
|
54
|
+
// Process each bundle
|
|
55
|
+
for (const bundle of bundles) {
|
|
56
|
+
bundleNames.add(bundle.name);
|
|
57
|
+
|
|
58
|
+
// Process each asset in the bundle
|
|
59
|
+
for (const asset of bundle.assets) {
|
|
60
|
+
const aliases = asset.alias || [];
|
|
61
|
+
const srcs = Array.isArray(asset.src) ? asset.src : [asset.src];
|
|
62
|
+
const firstSrc = srcs[0];
|
|
63
|
+
const ext = path.extname(firstSrc).toLowerCase();
|
|
64
|
+
|
|
65
|
+
// Add to appropriate category based on extension and data tags
|
|
66
|
+
if (asset.data?.tags?.tps || (ext === '.json' && firstSrc.includes('sheet'))) {
|
|
67
|
+
aliases.forEach((alias) => {
|
|
68
|
+
assetsByType.spritesheets.add(alias);
|
|
69
|
+
addToBundle('spritesheets', bundle.name, alias);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Extract frame names from TPS JSON files
|
|
73
|
+
if (asset.data?.tags?.tps) {
|
|
74
|
+
try {
|
|
75
|
+
// Find the first .json file in the src array
|
|
76
|
+
const jsonSrc = srcs.find((src) => src.endsWith('.json'));
|
|
77
|
+
if (jsonSrc) {
|
|
78
|
+
// Construct the full path to the JSON file
|
|
79
|
+
const jsonPath = path.join(assetsDir, jsonSrc);
|
|
80
|
+
|
|
81
|
+
// Read and parse the JSON file
|
|
82
|
+
const jsonContent = await fs.promises.readFile(jsonPath, 'utf8');
|
|
83
|
+
const tpsData = JSON.parse(jsonContent);
|
|
84
|
+
|
|
85
|
+
// Extract frame names from the "frames" object
|
|
86
|
+
if (tpsData.frames) {
|
|
87
|
+
Object.keys(tpsData.frames).forEach((frameName) => {
|
|
88
|
+
assetsByType.tpsFrames.add(frameName);
|
|
89
|
+
addToBundle('tpsFrames', bundle.name, frameName);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logger.warn(`Failed to load TPS frames from ${firstSrc}:`, error.message);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} else if (ext === '.json' && !firstSrc.includes('atlas')) {
|
|
98
|
+
aliases.forEach((alias) => {
|
|
99
|
+
assetsByType.json.add(alias);
|
|
100
|
+
addToBundle('json', bundle.name, alias);
|
|
101
|
+
});
|
|
102
|
+
} else if (['.png', '.webp', '.jpg', '.jpeg', '.svg'].includes(ext)) {
|
|
103
|
+
aliases.forEach((alias) => {
|
|
104
|
+
assetsByType.textures.add(alias);
|
|
105
|
+
addToBundle('textures', bundle.name, alias);
|
|
106
|
+
});
|
|
107
|
+
} else if (['.mp3', '.ogg', '.wav'].includes(ext)) {
|
|
108
|
+
aliases.forEach((alias) => {
|
|
109
|
+
assetsByType.audio.add(alias);
|
|
110
|
+
addToBundle('audio', bundle.name, alias);
|
|
111
|
+
});
|
|
112
|
+
} else if (['.ttf', '.woff', '.woff2'].includes(ext) || asset.data?.tags?.wf) {
|
|
113
|
+
aliases.forEach((alias) => {
|
|
114
|
+
assetsByType.fonts.add(alias);
|
|
115
|
+
addToBundle('fonts', bundle.name, alias);
|
|
116
|
+
});
|
|
117
|
+
if (asset.data?.family) {
|
|
118
|
+
assetsByType.fontFamilies.add(asset.data.family);
|
|
119
|
+
}
|
|
120
|
+
} else if (['.fnt'].includes(ext)) {
|
|
121
|
+
aliases.forEach((alias) => {
|
|
122
|
+
assetsByType.bitmapFonts.add(alias);
|
|
123
|
+
assetsByType.bitmapFontFamilies.add(alias);
|
|
124
|
+
addToBundle('bitmapFonts', bundle.name, alias);
|
|
125
|
+
});
|
|
126
|
+
} else if (['.atlas', '.skel', '.json'].some((e) => firstSrc.includes(e)) && firstSrc.includes('spine')) {
|
|
127
|
+
aliases.forEach((alias) => {
|
|
128
|
+
assetsByType.spine.add(alias);
|
|
129
|
+
addToBundle('spine', bundle.name, alias);
|
|
130
|
+
});
|
|
131
|
+
} else if (ext === '.riv') {
|
|
132
|
+
aliases.forEach((alias) => {
|
|
133
|
+
assetsByType.rive.add(alias);
|
|
134
|
+
addToBundle('rive', bundle.name, alias);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Convert Sets to sorted arrays for consistent output
|
|
141
|
+
const types = Object.fromEntries(Object.entries(assetsByType).map(([key, value]) => [key, [...value].sort()]));
|
|
142
|
+
|
|
143
|
+
// Helper to emit a per-bundle mapped type body like:
|
|
144
|
+
// '\n menu: \'menu/logo\' | \'menu/bg\';\n game: \'game/hero\';\n '
|
|
145
|
+
// Returns `never` if no bundle contains anything in the category.
|
|
146
|
+
const emitByBundleMap = (category) => {
|
|
147
|
+
const entries = Object.entries(byBundle[category])
|
|
148
|
+
.map(([bundleName, set]) => [bundleName, [...set].sort()])
|
|
149
|
+
.filter(([, arr]) => arr.length > 0);
|
|
150
|
+
if (entries.length === 0) return '{}';
|
|
151
|
+
const lines = entries.map(([name, arr]) => ` ${name}: '${arr.join("' | '")}';`);
|
|
152
|
+
return `{\n${lines.join('\n')}\n }`;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return `// This file is auto-generated. Do not edit.
|
|
156
|
+
import type { ResolvedAsset, Texture, Spritesheet } from 'pixi.js';
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Available bundle names in the asset manifest
|
|
160
|
+
* @example
|
|
161
|
+
* const bundle: AssetBundles = 'game';
|
|
162
|
+
*/
|
|
163
|
+
export type AssetBundles = ${[...bundleNames].length ? `\n | '${[...bundleNames].sort().join("'\n | '")}'` : 'never'};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Available texture names in the asset manifest
|
|
167
|
+
* @example
|
|
168
|
+
* const texture: AssetTextures = 'game/wordmark';
|
|
169
|
+
*/
|
|
170
|
+
export type AssetTextures = ${types.textures.length ? `\n | '${types.textures.join("'\n | '")}'` : 'never'};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Per-bundle texture map. Use \`AssetTexturesIn<'menu'>\` to get the exact
|
|
174
|
+
* set of textures shipped in a single bundle, so a scene that only loads
|
|
175
|
+
* the 'menu' bundle can't accidentally reference a 'game/*' texture.
|
|
176
|
+
*/
|
|
177
|
+
export type AssetTexturesByBundle = ${emitByBundleMap('textures')};
|
|
178
|
+
export type AssetTexturesIn<B extends AssetBundles> = B extends keyof AssetTexturesByBundle ? AssetTexturesByBundle[B] : never;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Available spritesheet names in the asset manifest
|
|
182
|
+
* @example
|
|
183
|
+
* const spritesheet: AssetSpritesheets = 'game/sheet';
|
|
184
|
+
*/
|
|
185
|
+
export type AssetSpritesheets = ${types.spritesheets.length ? `\n | '${types.spritesheets.join("'\n | '")}'` : 'never'};
|
|
186
|
+
|
|
187
|
+
export type AssetSpritesheetsByBundle = ${emitByBundleMap('spritesheets')};
|
|
188
|
+
export type AssetSpritesheetsIn<B extends AssetBundles> = B extends keyof AssetSpritesheetsByBundle ? AssetSpritesheetsByBundle[B] : never;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Available TPS frame names from spritesheets
|
|
192
|
+
* @example
|
|
193
|
+
* const frame: AssetTPSFrames = 'btn/blue';
|
|
194
|
+
*/
|
|
195
|
+
export type AssetTPSFrames = ${types.tpsFrames.length ? `\n | '${types.tpsFrames.join("'\n | '")}'` : 'never'};
|
|
196
|
+
|
|
197
|
+
export type AssetTPSFramesByBundle = ${emitByBundleMap('tpsFrames')};
|
|
198
|
+
export type AssetTPSFramesIn<B extends AssetBundles> = B extends keyof AssetTPSFramesByBundle ? AssetTPSFramesByBundle[B] : never;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Available font names in the asset manifest
|
|
202
|
+
* @example
|
|
203
|
+
* const font: AssetFonts = 'SpaceGrotesk-Regular';
|
|
204
|
+
*/
|
|
205
|
+
export type AssetFonts = ${types.fonts.length ? `\n | '${types.fonts.join("'\n | '")}'` : 'never'};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Available font names in the asset manifest
|
|
209
|
+
* @example
|
|
210
|
+
* const font: AssetFonts = 'SpaceGrotesk-Regular';
|
|
211
|
+
*/
|
|
212
|
+
export type AssetBitmapFonts = ${types.bitmapFonts.length ? `\n | '${types.bitmapFonts.join("'\n | '")}'` : 'never'};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Available audio names in the asset manifest
|
|
216
|
+
* @example
|
|
217
|
+
* const audio: AssetAudio = 'click';
|
|
218
|
+
*/
|
|
219
|
+
export type AssetAudio = ${types.audio.length ? `\n | '${types.audio.join("'\n | '")}'` : 'never'};
|
|
220
|
+
|
|
221
|
+
export type AssetAudioByBundle = ${emitByBundleMap('audio')};
|
|
222
|
+
export type AssetAudioIn<B extends AssetBundles> = B extends keyof AssetAudioByBundle ? AssetAudioByBundle[B] : never;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Available JSON file names in the asset manifest
|
|
226
|
+
* @example
|
|
227
|
+
* const json: AssetJson = 'locales/en';
|
|
228
|
+
*/
|
|
229
|
+
export type AssetJson = ${types.json.length ? `\n | '${types.json.join("'\n | '")}'` : 'never'};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Available Spine animation names in the asset manifest
|
|
233
|
+
* @example
|
|
234
|
+
* const spine: AssetSpine = 'spine/hero';
|
|
235
|
+
*/
|
|
236
|
+
export type AssetSpine = ${types.spine.length ? `\n | '${types.spine.join("'\n | '")}'` : 'never'};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Available Rive animation names in the asset manifest
|
|
240
|
+
* @example
|
|
241
|
+
* const rive: AssetRive = 'static/marty';
|
|
242
|
+
*/
|
|
243
|
+
export type AssetRive = ${types.rive.length ? `\n | '${types.rive.join("'\n | '")}'` : 'never'};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Available font family names
|
|
247
|
+
* @example
|
|
248
|
+
* const fontFamily: AssetFontFamilies = 'Space Grotesk';
|
|
249
|
+
*/
|
|
250
|
+
export type AssetFontFamilies = ${types.fontFamilies.length ? `\n | '${types.fontFamilies.join("'\n | '")}'` : 'never'};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Available font family names
|
|
254
|
+
* @example
|
|
255
|
+
* const fontFamily: AssetFontFamilies = 'Space Grotesk';
|
|
256
|
+
*/
|
|
257
|
+
export type AssetBitmapFontFamilies = ${types.bitmapFontFamilies.length ? `\n | '${types.bitmapFontFamilies.join("'\n | '")}'` : 'never'};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Union type of all asset names
|
|
261
|
+
* @example
|
|
262
|
+
* const asset: AssetAlias = 'game/wordmark';
|
|
263
|
+
*/
|
|
264
|
+
export type AssetAlias =
|
|
265
|
+
| AssetTextures
|
|
266
|
+
| AssetSpritesheets
|
|
267
|
+
| AssetTPSFrames
|
|
268
|
+
| AssetFonts
|
|
269
|
+
| AssetBitmapFonts
|
|
270
|
+
| AssetAudio
|
|
271
|
+
| AssetJson
|
|
272
|
+
| AssetSpine
|
|
273
|
+
| AssetRive;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Type-safe manifest structure
|
|
277
|
+
*/
|
|
278
|
+
export interface AssetManifest {
|
|
279
|
+
bundles: {
|
|
280
|
+
[K in AssetBundles]: {
|
|
281
|
+
name: K;
|
|
282
|
+
assets: ResolvedAsset[];
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Type-safe asset types after loading
|
|
289
|
+
*/
|
|
290
|
+
export interface AssetTypes {
|
|
291
|
+
textures: Record<AssetTextures, Texture>;
|
|
292
|
+
spritesheets: Record<AssetSpritesheets, Spritesheet>;
|
|
293
|
+
tpsFrames: Record<AssetTPSFrames, Texture>;
|
|
294
|
+
fonts: Record<AssetFonts, any>;
|
|
295
|
+
audio: Record<AssetAudio, HTMLAudioElement>;
|
|
296
|
+
json: Record<AssetJson, any>;
|
|
297
|
+
spine: Record<AssetSpine, any>;
|
|
298
|
+
rive: Record<AssetRive, any>;
|
|
299
|
+
fontFamilies: Record<AssetFontFamilies, any>;
|
|
300
|
+
bitmapFonts: Record<AssetBitmapFonts, any>;
|
|
301
|
+
bitmapFontFamilies: Record<AssetBitmapFontFamilies, any>;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Helper type to get the asset type for a given alias
|
|
306
|
+
* @example
|
|
307
|
+
* type MyTextureType = AssetTypeOf<'game/wordmark'>; // Texture
|
|
308
|
+
*/
|
|
309
|
+
export type AssetTypeOf<T extends AssetAlias> =
|
|
310
|
+
T extends AssetTextures ? Texture :
|
|
311
|
+
T extends AssetSpritesheets ? Spritesheet :
|
|
312
|
+
T extends AssetTPSFrames ? Texture :
|
|
313
|
+
T extends AssetFonts ? any :
|
|
314
|
+
T extends AssetBitmapFonts ? any :
|
|
315
|
+
T extends AssetAudio ? HTMLAudioElement :
|
|
316
|
+
T extends AssetJson ? any :
|
|
317
|
+
T extends AssetSpine ? any :
|
|
318
|
+
T extends AssetRive ? any :
|
|
319
|
+
T extends AssetFontFamilies ? any :
|
|
320
|
+
T extends AssetBitmapFontFamilies ? any :
|
|
321
|
+
never;
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Get the bundle name for a given asset
|
|
326
|
+
* @example
|
|
327
|
+
* type MyBundle = AssetBundleOf<'game/wordmark'>; // 'game'
|
|
328
|
+
*/
|
|
329
|
+
export type AssetBundleOf<T extends AssetAlias> = Extract<AssetBundles, T extends \`\${infer B}/\${string}\` ? B : never>;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Add type overrides to the framework
|
|
333
|
+
*/
|
|
334
|
+
declare module '@caperjs/core' {
|
|
335
|
+
interface AssetTypeOverrides {
|
|
336
|
+
Texture: AssetTextures;
|
|
337
|
+
TPSFrames: AssetTPSFrames;
|
|
338
|
+
SpriteSheet: AssetSpritesheets;
|
|
339
|
+
SpineData: AssetSpine;
|
|
340
|
+
Audio: AssetAudio;
|
|
341
|
+
FontFamily: AssetFontFamilies;
|
|
342
|
+
BitmapFontFamily: AssetBitmapFontFamilies;
|
|
343
|
+
Bundles: AssetBundles;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Function to write types file
|
|
350
|
+
|
|
351
|
+
async function writeAssetTypes(manifest, outputDir, assetsDir, root) {
|
|
352
|
+
const types = await generateAssetTypes(manifest, assetsDir);
|
|
353
|
+
// Change output path to ./src/types/
|
|
354
|
+
const srcTypesDir = path.join(root, 'src', 'types');
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
// Ensure the directory exists
|
|
358
|
+
await fs.promises.mkdir(srcTypesDir, { recursive: true });
|
|
359
|
+
const typesPath = path.join(srcTypesDir, ASSET_DTS_FILE_NAME);
|
|
360
|
+
await fs.promises.writeFile(typesPath, types, 'utf8');
|
|
361
|
+
logger.info(`Caper asset types plugin:: Generated types at ${typesPath}`);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
logger.error('Caper asset types plugin:: Error writing types file:', error);
|
|
364
|
+
// Fallback to original location if src folder doesn't exist
|
|
365
|
+
const typesPath = path.join(outputDir, ASSET_DTS_FILE_NAME);
|
|
366
|
+
await fs.promises.writeFile(typesPath, types, 'utf8');
|
|
367
|
+
logger.info(`Caper asset types plugin:: Generated types at fallback location ${typesPath}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Asset types generation plugin
|
|
372
|
+
|
|
373
|
+
export function assetTypesPlugin(manifestUrl = 'assets.json') {
|
|
374
|
+
// vite's resolved publicDir/root rather than process.cwd() — same reasoning as
|
|
375
|
+
// internal/discovery.mjs.
|
|
376
|
+
let publicDir;
|
|
377
|
+
let root;
|
|
378
|
+
let viteServer;
|
|
379
|
+
let manifestWatcher;
|
|
380
|
+
let ispPwaEnabled = false;
|
|
381
|
+
|
|
382
|
+
async function generate(manifestUrl) {
|
|
383
|
+
try {
|
|
384
|
+
const manifestPath = path.join(publicDir, 'assets', manifestUrl);
|
|
385
|
+
if (!fs.existsSync(manifestPath)) {
|
|
386
|
+
logger.warn(`Caper asset types plugin:: manifest not found at ${manifestPath}, skipping type generation`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
|
|
390
|
+
await writeAssetTypes(manifest, path.dirname(manifestPath), path.join(publicDir, 'assets'), root);
|
|
391
|
+
logger.info('Caper asset types plugin:: manifest changed, reloading browser...');
|
|
392
|
+
viteServer?.ws?.send({ type: 'full-reload' });
|
|
393
|
+
} catch (error) {
|
|
394
|
+
logger.error('Caper asset types plugin:: Error handling manifest change:', error);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const debouncedHandleManifestChange = debounce(generate, 300);
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
name: 'vite-plugin-asset-types',
|
|
402
|
+
config(config) {
|
|
403
|
+
ispPwaEnabled = config.plugins.some((p) => p.name === 'vite-plugin-pwa');
|
|
404
|
+
},
|
|
405
|
+
configResolved(config) {
|
|
406
|
+
publicDir = config.publicDir;
|
|
407
|
+
root = config.root;
|
|
408
|
+
},
|
|
409
|
+
async buildStart() {
|
|
410
|
+
// a short delay to allow assetpack to generate the manifest
|
|
411
|
+
await delay(500);
|
|
412
|
+
await generate(manifestUrl);
|
|
413
|
+
await delay(500);
|
|
414
|
+
},
|
|
415
|
+
configureServer(server) {
|
|
416
|
+
viteServer = server;
|
|
417
|
+
|
|
418
|
+
// Watch for manifest changes in development
|
|
419
|
+
const manifestPath = path.join(publicDir, 'assets', manifestUrl);
|
|
420
|
+
server.watcher.add(manifestPath);
|
|
421
|
+
logger.info(`Caper asset types plugin:: watching manifest at ${manifestPath}`);
|
|
422
|
+
|
|
423
|
+
const handleChange = async (file) => {
|
|
424
|
+
if (file === manifestPath) {
|
|
425
|
+
await debouncedHandleManifestChange(manifestUrl);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
server.watcher.on('add', handleChange);
|
|
430
|
+
server.watcher.on('change', handleChange);
|
|
431
|
+
},
|
|
432
|
+
async buildEnd() {
|
|
433
|
+
manifestWatcher?.close();
|
|
434
|
+
|
|
435
|
+
// Generate types in build mode as well
|
|
436
|
+
try {
|
|
437
|
+
const manifestPath = path.join(publicDir, 'assets', manifestUrl);
|
|
438
|
+
if (fs.existsSync(manifestPath)) {
|
|
439
|
+
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
|
|
440
|
+
await writeAssetTypes(manifest, path.dirname(manifestPath), path.join(publicDir, 'assets'), root);
|
|
441
|
+
}
|
|
442
|
+
} catch (error) {
|
|
443
|
+
logger.error('Caper asset types plugin:: Error generating types during build:', error);
|
|
444
|
+
}
|
|
445
|
+
await delay(500);
|
|
446
|
+
},
|
|
447
|
+
async closeBundle() {
|
|
448
|
+
if (ispPwaEnabled && env !== 'development') {
|
|
449
|
+
logger.info('Caper asset types plugin:: PWA enabled, generating types one last time after bundle');
|
|
450
|
+
await generate(manifestUrl);
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Read the assetpack manifest from disk and return the set of bundle names.
|
|
458
|
+
* Returns `null` if the manifest doesn't exist yet — in that case the caller
|
|
459
|
+
* should skip bundle-name validation rather than spam false warnings.
|
|
460
|
+
*/
|