@caperjs/core 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Filesystem discovery: walks the conventional source directories and reads each
3
+ * file's AST to work out what it declares.
4
+ *
5
+ * Split from the list plugins so the plugins are only about emitting virtual
6
+ * modules, and the crawling can be reasoned about (and fixed) on its own.
7
+ *
8
+ * Every entry point takes an explicit `root` — vite's resolved `config.root`,
9
+ * passed down by the plugin that called it. That is the project root by
10
+ * definition; `process.cwd()` only happens to be the same one, and differs the
11
+ * moment anyone runs `vite --root elsewhere` or invokes vite from a parent
12
+ * directory in a monorepo. It is threaded as a parameter rather than kept in a
13
+ * module-level variable so nothing depends on load order or on who ran first.
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import {
18
+ AST_NODE_TYPES,
19
+ DEFINE_HELPER_NAMES,
20
+ findDefaultExportedClass,
21
+ findDefaultExportedScene,
22
+ findExportedConstants,
23
+ parse,
24
+ } from './ast.mjs';
25
+ import { SOURCE_DIRS } from './paths.mjs';
26
+ import { logger } from './util.mjs';
27
+
28
+
29
+ export async function findTypeScriptFiles(dir) {
30
+ const files = [];
31
+
32
+ async function scan(directory) {
33
+ const entries = await fs.promises.readdir(directory, { withFileTypes: true });
34
+
35
+ for (const entry of entries) {
36
+ const fullPath = path.join(directory, entry.name);
37
+
38
+ if (entry.isDirectory()) {
39
+ await scan(fullPath);
40
+ } else if (entry.isFile() && /\.ts?$/.test(entry.name)) {
41
+ files.push(fullPath);
42
+ }
43
+ }
44
+ }
45
+
46
+ await scan(dir);
47
+ return files;
48
+ }
49
+
50
+ // Callees whose single argument is treated as the entire config object and
51
+ // unwrapped into the exported constant's value. Keep in sync with the
52
+ // identity helpers in src/utils/define.ts.
53
+
54
+ export async function discoverScenes(server, root) {
55
+ const scenesDir = path.resolve(root, SOURCE_DIRS.scenes);
56
+ const scenes = [];
57
+
58
+ if (!fs.existsSync(scenesDir)) {
59
+ return [];
60
+ }
61
+
62
+ const files = await findTypeScriptFiles(scenesDir);
63
+
64
+ for (const file of files) {
65
+ try {
66
+ const content = await fs.promises.readFile(file, 'utf-8');
67
+ const ast = parse(content, {
68
+ jsx: false,
69
+ loc: true,
70
+ comment: false,
71
+ });
72
+
73
+ const sceneClass = findDefaultExportedScene(ast);
74
+ if (!sceneClass) continue;
75
+
76
+ const exports = findExportedConstants(ast);
77
+
78
+ const relativePath = file.replace(root, '').replace(/\\/g, '/').split('/src')[1];
79
+ // remove /src — the runtime import uses the raw alias (Vite resolves
80
+ // `.ts` automatically) while the typeof-import codegen needs the
81
+ // extension stripped so TypeScript's path-alias resolution finds it.
82
+ const importPath = `@${relativePath}`;
83
+ const importPathForTypes = importPath.replace(/\.ts$/, '');
84
+ const id = exports.id || sceneClass.id?.name || path.basename(file, '.ts');
85
+
86
+ scenes.push({
87
+ id,
88
+ importPath: importPathForTypes,
89
+ module:
90
+ exports.dynamic === false
91
+ ? importPath
92
+ : {
93
+ toString: () => `() => import('${importPath}')`,
94
+ isFunction: true, // Add a flag to identify dynamic imports
95
+ },
96
+ active: exports.active === false ? false : true,
97
+ debugLabel: exports.debug?.label || id,
98
+ debugGroup: exports.debug?.group || undefined,
99
+ debugOrder: exports.debug?.order >= 0 ? exports.debug.order : Number.MAX_SAFE_INTEGER,
100
+ assets: exports.assets ?? undefined,
101
+ plugins: exports.plugins ?? undefined,
102
+ autoUnloadAssets: exports.assets?.autoUnload ?? false,
103
+ });
104
+ } catch (e) {
105
+ const errorMessage = `Error parsing scene file ${file}: ${e.message}`;
106
+ logger.error(errorMessage);
107
+ if (e.stack) {
108
+ logger.error(e.stack);
109
+ }
110
+ if (server) {
111
+ server.ws.send({
112
+ type: 'error',
113
+ err: {
114
+ message: e.message,
115
+ stack: e.stack,
116
+ id: file,
117
+ plugin: 'vite-plugin-scenes',
118
+ },
119
+ });
120
+ }
121
+ }
122
+ }
123
+ return scenes;
124
+ }
125
+
126
+ export async function discoverLocalPlugins(server, root) {
127
+ const pluginsDir = path.resolve(root, SOURCE_DIRS.plugins);
128
+ const plugins = [];
129
+
130
+ if (!fs.existsSync(pluginsDir)) {
131
+ return [];
132
+ }
133
+
134
+ const files = await findTypeScriptFiles(pluginsDir);
135
+
136
+ for (const file of files) {
137
+ try {
138
+ const content = await fs.promises.readFile(file, 'utf-8');
139
+ const ast = parse(content, {
140
+ jsx: false,
141
+ loc: true,
142
+ comment: false,
143
+ });
144
+
145
+ const hasPluginWrapper = ast.body.some(
146
+ (n) =>
147
+ n.type === AST_NODE_TYPES.ExportNamedDeclaration &&
148
+ n.declaration?.type === AST_NODE_TYPES.VariableDeclaration &&
149
+ n.declaration.declarations.some(
150
+ (d) => d.init?.type === AST_NODE_TYPES.CallExpression && d.init.callee?.name === 'definePlugin',
151
+ ),
152
+ );
153
+ if (!hasPluginWrapper) continue;
154
+
155
+ const pluginClass = findDefaultExportedClass(ast);
156
+ if (!pluginClass) continue;
157
+
158
+ const exports = findExportedConstants(ast);
159
+
160
+ const relativePath = file.replace(root, '').replace(/\\/g, '/').split('/src')[1];
161
+ const importPath = `@${relativePath.replace(/\.ts$/, '')}`;
162
+ const id = exports.id || pluginClass.id?.name || path.basename(file, '.ts');
163
+ const name = pluginClass.id?.name || id;
164
+
165
+ plugins.push({
166
+ id,
167
+ name,
168
+ isLocal: true,
169
+ importPath,
170
+ module:
171
+ exports.dynamic === false
172
+ ? importPath
173
+ : {
174
+ toString: () => `() => import('${importPath}')`,
175
+ isFunction: true,
176
+ },
177
+ active: exports.active === false ? false : true,
178
+ requires: Array.isArray(exports.requires) ? exports.requires.filter((r) => typeof r === 'string') : [],
179
+ });
180
+ } catch (e) {
181
+ const errorMessage = `Error parsing plugin file ${file}: ${e.message}`;
182
+ logger.error(errorMessage);
183
+ if (e.stack) {
184
+ logger.error(e.stack);
185
+ }
186
+ if (server) {
187
+ server.ws.send({
188
+ type: 'error',
189
+ err: {
190
+ message: e.message,
191
+ stack: e.stack,
192
+ id: file,
193
+ plugin: 'vite-plugin-plugins',
194
+ },
195
+ });
196
+ }
197
+ }
198
+ }
199
+
200
+ return plugins;
201
+ }
202
+
203
+ /**
204
+ * Discovers npm packages prefixed with `@caperjs/plugin-` in the host
205
+ * project's `package.json`. Always emitted as dynamic imports.
206
+ */
207
+
208
+ export function discoverNpmPlugins(root) {
209
+ const packageJsonPath = path.resolve(root, 'package.json');
210
+ if (!fs.existsSync(packageJsonPath)) return [];
211
+
212
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
213
+ const allDependencies = {
214
+ ...(packageJson.dependencies || {}),
215
+ ...(packageJson.devDependencies || {}),
216
+ };
217
+
218
+ return Object.keys(allDependencies)
219
+ .filter((dep) => dep.startsWith('@caperjs/plugin-'))
220
+ .map((packageName) => {
221
+ const id = packageName.replace('@caperjs/plugin-', '');
222
+ return {
223
+ id,
224
+ name: packageName,
225
+ isLocal: false,
226
+ importPath: packageName,
227
+ module: {
228
+ toString: () => `() => import('${packageName}')`,
229
+ isFunction: true,
230
+ },
231
+ active: true,
232
+ // npm plugins can declare `requires` on the class itself
233
+ // (`public readonly requires = ['firebase']`); the runtime topo-sort
234
+ // reads from the live instance, so empty here is fine.
235
+ requires: [],
236
+ };
237
+ });
238
+ }
239
+
240
+ export async function discoverPlugins(server, root) {
241
+ const [local, npm] = [await discoverLocalPlugins(server, root), discoverNpmPlugins(root)];
242
+ return [...npm, ...local];
243
+ }
244
+
245
+ /**
246
+ * Generic recursive-class-file discoverer. Walks a directory, AST-parses
247
+ * each .ts file, requires a default-exported class, and reads `id` /
248
+ * `active` / `dynamic` metadata (either from individual exports or a
249
+ * `defineX({...})` wrapper). Used for popups and entities — same shape
250
+ * as scenes/plugins minus the npm-package discovery path.
251
+ *
252
+ * `defaultDynamic` sets the emit mode when a file doesn't declare it
253
+ * explicitly. Entities default to `false` (static import) because the
254
+ * typed `this.add.entity(id, props)` factory needs sync access to the
255
+ * constructor; popups default to `true` because `show()` is async.
256
+ */
257
+
258
+ export async function discoverLocalClassFiles({ dir, kind, server, root, defaultDynamic = true }) {
259
+ const rootDir = path.resolve(root, dir);
260
+ if (!fs.existsSync(rootDir)) return [];
261
+
262
+ const files = await findTypeScriptFiles(rootDir);
263
+ const results = [];
264
+
265
+ for (const file of files) {
266
+ try {
267
+ const content = await fs.promises.readFile(file, 'utf-8');
268
+ const ast = parse(content);
269
+
270
+ const cls = findDefaultExportedClass(ast);
271
+ if (!cls) continue;
272
+
273
+ const exports = findExportedConstants(ast);
274
+
275
+ const relativePath = file.replace(root, '').replace(/\\/g, '/').split('/src')[1];
276
+ const importPath = `@${relativePath.replace(/\.ts$/, '')}`;
277
+ const id = exports.id || cls.id?.name || path.basename(file, '.ts');
278
+ const name = cls.id?.name || id;
279
+ const isDynamic = exports.dynamic !== undefined ? exports.dynamic : defaultDynamic;
280
+
281
+ results.push({
282
+ id,
283
+ name,
284
+ importPath,
285
+ module: !isDynamic
286
+ ? importPath
287
+ : {
288
+ toString: () => `() => import('${importPath}')`,
289
+ isFunction: true,
290
+ },
291
+ active: exports.active === false ? false : true,
292
+ });
293
+ } catch (e) {
294
+ const errorMessage = `Error parsing ${kind} file ${file}: ${e.message}`;
295
+ logger.error(errorMessage);
296
+ if (e.stack) logger.error(e.stack);
297
+ if (server) {
298
+ server.ws.send({
299
+ type: 'error',
300
+ err: {
301
+ message: e.message,
302
+ stack: e.stack,
303
+ id: file,
304
+ plugin: `vite-plugin-${kind}s`,
305
+ },
306
+ });
307
+ }
308
+ }
309
+ }
310
+
311
+ return results;
312
+ }
313
+
314
+ export async function discoverPopups(server, root) {
315
+ return discoverLocalClassFiles({ dir: SOURCE_DIRS.popups, kind: 'popup', server, root });
316
+ }
317
+
318
+ export async function discoverEntities(server, root) {
319
+ // Entities default to static imports so `this.add.entity(id, props)` can
320
+ // synchronously construct without awaiting a dynamic import. Opt into
321
+ // code-splitting per-entity with `defineEntity({ dynamic: true })`.
322
+ return discoverLocalClassFiles({ dir: SOURCE_DIRS.entities, kind: 'entity', server, root, defaultDynamic: false });
323
+ }
324
+
325
+ export async function discoverUIs(server, root) {
326
+ // UI elements default to static imports so `this.add.ui(id, props)` can
327
+ // synchronously construct. Same rationale as entities.
328
+ return discoverLocalClassFiles({ dir: SOURCE_DIRS.ui, kind: 'ui', server, root, defaultDynamic: false });
329
+ }
330
+
331
+ /**
332
+ * Factory for `virtual:caper-popups` / `virtual:caper-entities`.
333
+ * Thin wrapper around a discover function that generates the static-import
334
+ * + dynamic-import `() => import(...)` mix, same shape as the scenes/plugins
335
+ * virtual modules.
336
+ */
337
+
338
+ export async function discoverLocaleKeys(server, root) {
339
+ const localesDir = path.resolve(root, SOURCE_DIRS.locales);
340
+ if (!fs.existsSync(localesDir)) return [];
341
+
342
+ const files = (await fs.promises.readdir(localesDir)).filter((f) => /\.ts$/.test(f)).sort();
343
+ if (files.length === 0) return [];
344
+
345
+ // Pick the reference file: en.ts if present, else the first alphabetically.
346
+ const referenceFile = files.find((f) => f === 'en.ts') || files[0];
347
+ const filePath = path.join(localesDir, referenceFile);
348
+
349
+ try {
350
+ const content = await fs.promises.readFile(filePath, 'utf-8');
351
+ if (!content.trim()) return [];
352
+
353
+ const ast = parse(content);
354
+ const defaultExport = ast.body.find((n) => n.type === AST_NODE_TYPES.ExportDefaultDeclaration);
355
+ if (!defaultExport) return [];
356
+
357
+ // Allow either `export default { ... }` or `export default satisfies ...`
358
+ let obj = defaultExport.declaration;
359
+ if (obj && obj.type === 'TSSatisfiesExpression') obj = obj.expression;
360
+ if (!obj || obj.type !== AST_NODE_TYPES.ObjectExpression) return [];
361
+
362
+ const keys = [];
363
+ const walk = (node, prefix) => {
364
+ if (node.type !== AST_NODE_TYPES.ObjectExpression) return;
365
+ for (const prop of node.properties) {
366
+ if (prop.type !== AST_NODE_TYPES.Property) continue;
367
+ let name;
368
+ if (prop.key.type === AST_NODE_TYPES.Identifier) name = prop.key.name;
369
+ else if (prop.key.type === AST_NODE_TYPES.Literal) name = String(prop.key.value);
370
+ else continue;
371
+ const dotPath = prefix ? `${prefix}.${name}` : name;
372
+ if (prop.value.type === AST_NODE_TYPES.ObjectExpression) {
373
+ walk(prop.value, dotPath);
374
+ } else if (prop.value.type === AST_NODE_TYPES.Literal) {
375
+ keys.push(dotPath);
376
+ }
377
+ }
378
+ };
379
+ walk(obj, '');
380
+ return keys.sort();
381
+ } catch (e) {
382
+ const errorMessage = `Error parsing locale file ${filePath}: ${e.message}`;
383
+ logger.error(errorMessage);
384
+ if (server) {
385
+ server.ws.send({
386
+ type: 'error',
387
+ err: {
388
+ message: e.message,
389
+ stack: e.stack,
390
+ id: filePath,
391
+ plugin: 'vite-plugin-locales',
392
+ },
393
+ });
394
+ }
395
+ return [];
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Virtual module plugin for `virtual:caper-plugins`. Mirrors
401
+ * `sceneListPlugin`: supports static + dynamic imports per entry, preserves
402
+ * the full metadata shape so consumers can filter by `active` / read IDs.
403
+ */
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Reads AssetPack's generated `assets.json`.
3
+ *
4
+ * Lives in `internal/` rather than beside the asset-types plugin because
5
+ * build-time validation needs it too, and `internal/` must not depend on
6
+ * `plugins/` — that dependency ran the wrong way and hid a missing import until
7
+ * a real build failed.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { cwd, logger } from './util.mjs';
12
+
13
+ export function loadManifestBundleNames(manifestUrl = 'assets.json') {
14
+ const manifestPath = path.join(process.cwd(), 'public', 'assets', manifestUrl);
15
+ if (!fs.existsSync(manifestPath)) return null;
16
+ try {
17
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
18
+ const names = new Set();
19
+ for (const bundle of manifest.bundles || []) {
20
+ if (bundle?.name) names.add(bundle.name);
21
+ }
22
+ return names;
23
+ } catch (e) {
24
+ logger.warn(`[caper] Could not parse manifest for build-time validation: ${e.message}`);
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Extract `defaultScene` string and the `plugins: [...]` id list from an
31
+ * already-parsed `defineConfig({...})` ObjectExpression AST node. Returns
32
+ * `{ defaultScene, pluginIds }` where either may be undefined if absent.
33
+ *
34
+ * Plugin entries can be either a string literal or a tuple literal whose
35
+ * first element is a string literal — anything else (dynamic expressions,
36
+ * spreads, non-literal identifiers) is skipped silently, since we can't
37
+ * statically know the ID.
38
+ */
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The conventional source directories, in one place.
3
+ *
4
+ * These are a contract, not a preference: `internal/discovery.mjs` only crawls
5
+ * these paths, so a project that puts its scenes elsewhere has no scenes as far
6
+ * as caper is concerned. `defaults.mjs` derives its `optimizeDeps.entries` from
7
+ * the same constant, so the dep scanner and the discovery crawl can never
8
+ * disagree about where an app's code lives.
9
+ *
10
+ * Root-relative and POSIX-separated — callers `path.resolve(root, dir)`, and
11
+ * vite's glob layer wants forward slashes on every platform.
12
+ */
13
+ export const SOURCE_DIRS = {
14
+ scenes: 'src/scenes',
15
+ plugins: 'src/plugins',
16
+ popups: 'src/popups',
17
+ entities: 'src/entities',
18
+ ui: 'src/ui',
19
+ locales: 'src/locales',
20
+ };
21
+
22
+ /** The project's caper config, resolved the one way every plugin resolves it. */
23
+ export const CAPER_CONFIG_FILE = 'caper.config.ts';
24
+
25
+ /** The runtime entry `plugins/runtime.mjs` globs for. */
26
+ export const APP_ENTRY_FILE = 'src/main.ts';
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Zod schema for `caper.config.ts`, strict by design: an unknown key is almost
3
+ * always a typo or a stale option, and failing loudly beats silently ignoring it.
4
+ */
5
+ import { z } from 'zod';
6
+
7
+ export const pluginConfigSchema = z.union([
8
+ z.string(),
9
+ z.tuple([
10
+ z.string(),
11
+ z
12
+ .object({
13
+ autoLoad: z.boolean().optional(),
14
+ options: z.any().optional(),
15
+ })
16
+ .loose(),
17
+ ]),
18
+ ]);
19
+
20
+ export const caperConfigSchema = z
21
+ .object({
22
+ id: z.string().min(1).optional(),
23
+ application: z.any().optional(),
24
+ defaultScene: z.string().min(1).optional(),
25
+ defaultSceneLoadMethod: z.string().optional(),
26
+ plugins: z.array(pluginConfigSchema).optional(),
27
+ scenes: z.any().optional(),
28
+ assets: z
29
+ .object({
30
+ manifest: z.any().optional(),
31
+ preload: z
32
+ .object({
33
+ bundles: z.array(z.string()).optional(),
34
+ })
35
+ .loose()
36
+ .optional(),
37
+ background: z
38
+ .object({
39
+ bundles: z.array(z.string()).optional(),
40
+ })
41
+ .loose()
42
+ .optional(),
43
+ })
44
+ .loose()
45
+ .optional(),
46
+ useStore: z.boolean().optional(),
47
+ useSpine: z.boolean().optional(),
48
+ useLayout: z.boolean().optional(),
49
+ useVoiceover: z.boolean().optional(),
50
+ useHash: z.boolean().optional(),
51
+ // Build-time only — read by readCaperBuildFlags(), no runtime effect.
52
+ useWasm: z.boolean().optional(),
53
+ showStats: z.boolean().optional(),
54
+ showSceneDebugMenu: z.boolean().optional(),
55
+ resizeToContainer: z.boolean().optional(),
56
+ logger: z.string().optional(),
57
+ sceneGroupOrder: z.array(z.string()).optional(),
58
+ })
59
+ .loose();
60
+
61
+ /**
62
+ * Evaluate the user's `caper.config.ts` through Vite's own module
63
+ * graph and validate the default export. Dev-only — requires a live
64
+ * `server` (i.e. `vite dev`). Returns `true` on success.
65
+ */
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Small shared pieces for caper's build plugins: the logger they all report
3
+ * through, and two timing helpers used by the file watchers.
4
+ *
5
+ * `cwd` is read once at module load, which is fine for what uses it (logging and
6
+ * relative-path trimming) but not for anything that resolves project files —
7
+ * those take vite's resolved `root` instead. See `build/defaults.mjs`.
8
+ */
9
+ import process from 'node:process';
10
+ import { createLogger } from 'vite';
11
+
12
+ export const env = process.env.NODE_ENV;
13
+ export const cwd = process.cwd();
14
+
15
+ export const logger = createLogger('caper-config');
16
+
17
+ export const DTS_FILE_NAME = 'caper-app.d.ts';
18
+ export const ASSET_DTS_FILE_NAME = 'caper-assets.d.ts';
19
+
20
+ export const debounce = (func, wait) => {
21
+ let timeout;
22
+ return (...args) => {
23
+ clearTimeout(timeout);
24
+ timeout = setTimeout(() => func.apply(this, args), wait);
25
+ };
26
+ };
27
+
28
+ export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
+
30
+ // Function to generate TypeScript types from the manifest