@caperjs/core 0.1.1 → 0.2.0

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.
Files changed (109) hide show
  1. package/README.md +1 -1
  2. package/{config → build}/assetpack.mjs +103 -45
  3. package/build/defaults.mjs +157 -0
  4. package/build/index.mjs +96 -0
  5. package/build/internal/ast.mjs +255 -0
  6. package/build/internal/buildFlags.mjs +50 -0
  7. package/build/internal/discovery.mjs +402 -0
  8. package/build/internal/manifest.mjs +38 -0
  9. package/build/internal/schema.mjs +65 -0
  10. package/build/internal/util.mjs +30 -0
  11. package/build/internal/validate.mjs +186 -0
  12. package/build/plugins/assetTypes.mjs +460 -0
  13. package/build/plugins/caperConfig.mjs +489 -0
  14. package/build/plugins/devHelper.mjs +31 -0
  15. package/build/plugins/lists.mjs +286 -0
  16. package/build/plugins/pruneFallbacks.mjs +150 -0
  17. package/build/plugins/pwa.mjs +240 -0
  18. package/build/plugins/runtime.mjs +129 -0
  19. package/cli.mjs +9 -6
  20. package/extras/llms.txt +23 -17
  21. package/lib/{CaptionsPlugin-Dc8fIrjy.js → CaptionsPlugin-DKhbmJvH.js} +15 -15
  22. package/lib/{CaptionsPlugin-Dc8fIrjy.js.map → CaptionsPlugin-DKhbmJvH.js.map} +1 -1
  23. package/lib/{DataAdapter-BY2114sp.js → DataAdapter-BZFTkFQ4.js} +4 -4
  24. package/lib/{DataAdapter-BY2114sp.js.map → DataAdapter-BZFTkFQ4.js.map} +1 -1
  25. package/lib/{DebugRenderer-BJMLILqI.js → DebugRenderer-C7X-nlXE.js} +2 -2
  26. package/lib/{DebugRenderer-BJMLILqI.js.map → DebugRenderer-C7X-nlXE.js.map} +1 -1
  27. package/lib/{DevToolsPlugin-wQ5rOkn0.js → DevToolsPlugin-P-atJ-5U.js} +2 -2
  28. package/lib/{DevToolsPlugin-wQ5rOkn0.js.map → DevToolsPlugin-P-atJ-5U.js.map} +1 -1
  29. package/lib/{GSAPPlugin-DHhSZKv8.js → GSAPPlugin-CeH9275z.js} +2 -2
  30. package/lib/{GSAPPlugin-DHhSZKv8.js.map → GSAPPlugin-CeH9275z.js.map} +1 -1
  31. package/lib/{LayoutPlugin-B-DF9SYM.js → LayoutPlugin-iZ3k6RsK.js} +4 -4
  32. package/lib/{LayoutPlugin-B-DF9SYM.js.map → LayoutPlugin-iZ3k6RsK.js.map} +1 -1
  33. package/lib/{SpinePlugin-Dz_3dljz.js → SpinePlugin-BWiavCpO.js} +2 -2
  34. package/lib/{SpinePlugin-Dz_3dljz.js.map → SpinePlugin-BWiavCpO.js.map} +1 -1
  35. package/lib/{StatsPlugin-Cg82JMBD.js → StatsPlugin-CBpzDJL0.js} +4 -4
  36. package/lib/{StatsPlugin-Cg82JMBD.js.map → StatsPlugin-CBpzDJL0.js.map} +1 -1
  37. package/lib/{VoiceOverPlugin-BR3ImT8C.js → VoiceOverPlugin-YaiKJtJ6.js} +26 -26
  38. package/lib/{VoiceOverPlugin-BR3ImT8C.js.map → VoiceOverPlugin-YaiKJtJ6.js.map} +1 -1
  39. package/lib/caper.mjs +95 -95
  40. package/lib/caper.mjs.map +1 -1
  41. package/lib/core/Application.d.ts +1 -1
  42. package/lib/core/Application.d.ts.map +1 -1
  43. package/lib/core/interfaces/IApplicationOptions.d.ts +2 -1
  44. package/lib/core/interfaces/IApplicationOptions.d.ts.map +1 -1
  45. package/lib/core/interfaces/ICoreSignals.d.ts +5 -1
  46. package/lib/core/interfaces/ICoreSignals.d.ts.map +1 -1
  47. package/lib/display/Container.d.ts +1 -1
  48. package/lib/display/Container.d.ts.map +1 -1
  49. package/lib/display/ParticleContainer.d.ts +1 -1
  50. package/lib/display/ParticleContainer.d.ts.map +1 -1
  51. package/lib/plugins/Plugin.d.ts +1 -1
  52. package/lib/plugins/Plugin.d.ts.map +1 -1
  53. package/lib/plugins/defaults.d.ts.map +1 -1
  54. package/lib/plugins/gesture/GesturePlugin.d.ts +58 -0
  55. package/lib/plugins/gesture/GesturePlugin.d.ts.map +1 -0
  56. package/lib/plugins/gesture/gestureMath.d.ts +32 -0
  57. package/lib/plugins/gesture/gestureMath.d.ts.map +1 -0
  58. package/lib/plugins/gesture/gestureMath.test.d.ts +2 -0
  59. package/lib/plugins/gesture/gestureMath.test.d.ts.map +1 -0
  60. package/lib/plugins/gesture/index.d.ts +3 -0
  61. package/lib/plugins/gesture/index.d.ts.map +1 -0
  62. package/lib/plugins/gesture/types.d.ts +45 -0
  63. package/lib/plugins/gesture/types.d.ts.map +1 -0
  64. package/lib/plugins/index.d.ts +1 -0
  65. package/lib/plugins/index.d.ts.map +1 -1
  66. package/lib/plugins/input/Controls.d.ts +5 -2
  67. package/lib/plugins/input/Controls.d.ts.map +1 -1
  68. package/lib/plugins/input/InputPlugin.d.ts +9 -2
  69. package/lib/plugins/input/InputPlugin.d.ts.map +1 -1
  70. package/lib/plugins/input/interfaces.d.ts +5 -3
  71. package/lib/plugins/input/interfaces.d.ts.map +1 -1
  72. package/lib/plugins/input/touch/{TouchControls.d.ts → VirtualControls.d.ts} +5 -4
  73. package/lib/plugins/input/touch/VirtualControls.d.ts.map +1 -0
  74. package/lib/plugins/input/touch/index.d.ts +1 -1
  75. package/lib/plugins/input/touch/index.d.ts.map +1 -1
  76. package/lib/{registries-CYCaYKZI.js → registries-qQmLeN0b.js} +505 -354
  77. package/lib/registries-qQmLeN0b.js.map +1 -0
  78. package/lib/types/caper-app.d.ts +1 -0
  79. package/lib/utils/bind.test.d.ts +2 -0
  80. package/lib/utils/bind.test.d.ts.map +1 -0
  81. package/package.json +33 -33
  82. package/src/core/Application.ts +1 -1
  83. package/src/core/interfaces/IApplicationOptions.ts +2 -0
  84. package/src/core/interfaces/ICoreSignals.ts +9 -0
  85. package/src/display/Container.ts +1 -1
  86. package/src/display/ParticleContainer.ts +1 -1
  87. package/src/plugins/Plugin.ts +1 -1
  88. package/src/plugins/defaults.ts +6 -0
  89. package/src/plugins/gesture/GesturePlugin.ts +215 -0
  90. package/src/plugins/gesture/gestureMath.test.ts +75 -0
  91. package/src/plugins/gesture/gestureMath.ts +59 -0
  92. package/src/plugins/gesture/index.ts +2 -0
  93. package/src/plugins/gesture/types.ts +54 -0
  94. package/src/plugins/index.ts +1 -0
  95. package/src/plugins/input/Controls.ts +9 -3
  96. package/src/plugins/input/InputPlugin.ts +61 -14
  97. package/src/plugins/input/interfaces.ts +5 -3
  98. package/src/plugins/input/touch/{TouchControls.ts → VirtualControls.ts} +4 -1
  99. package/src/plugins/input/touch/index.ts +1 -1
  100. package/src/types/caper-app.d.ts +1 -0
  101. package/src/utils/bind.test.ts +99 -0
  102. package/src/version.ts +1 -1
  103. package/templates/app/default/package.template.json +8 -4
  104. package/templates/app/default/vite.config.ts +15 -0
  105. package/LICENSE +0 -22
  106. package/cli/vite.mjs +0 -55
  107. package/config/vite.mjs +0 -2464
  108. package/lib/plugins/input/touch/TouchControls.d.ts.map +0 -1
  109. package/lib/registries-CYCaYKZI.js.map +0 -1
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Everything caper's build plugins learn by reading source rather than running
3
+ * it: an oxc parse wrapper, plus the extractors discovery uses to find a file's
4
+ * exported constants, its default-exported class, and the object passed to
5
+ * `defineConfig`.
6
+ *
7
+ * Source is parsed rather than imported because importing a project's modules
8
+ * pulls in @caperjs/core, whose @pixi/sound and GSAP dependencies run
9
+ * browser-only side effects at module top level and throw under plain Node.
10
+ */
11
+ import { parseSync } from 'oxc-parser';
12
+
13
+ export const AST_NODE_TYPES = {
14
+ ArrayExpression: 'ArrayExpression',
15
+ CallExpression: 'CallExpression',
16
+ ClassDeclaration: 'ClassDeclaration',
17
+ ExportDefaultDeclaration: 'ExportDefaultDeclaration',
18
+ ExportNamedDeclaration: 'ExportNamedDeclaration',
19
+ Identifier: 'Identifier',
20
+ ImportDeclaration: 'ImportDeclaration',
21
+ Literal: 'Literal',
22
+ ObjectExpression: 'ObjectExpression',
23
+ Property: 'Property',
24
+ VariableDeclaration: 'VariableDeclaration',
25
+ };
26
+
27
+ /**
28
+ * Thin wrapper around oxc-parser that mirrors the shape we previously got
29
+ * from `@typescript-eslint/typescript-estree`: it returns the `Program`
30
+ * node directly, so `ast.body` / `for (const node of ast.body)` keeps working.
31
+ *
32
+ * Swapping `typescript-estree` (JS-written TS parser, ~500KB, slow) out for
33
+ * oxc-parser (Rust, bundled with Vite 8) was the biggest remaining DX win
34
+ * from the Phase 1b rolldown `PLUGIN_TIMINGS` report: the config/discovery
35
+ * AST parses are the hottest paths in the dev-server startup and HMR.
36
+ */
37
+ export function parse(content, _options = {}) {
38
+ const result = parseSync('caper-discovery.ts', content, {
39
+ lang: 'ts',
40
+ sourceType: 'module',
41
+ });
42
+ if (result.errors && result.errors.length > 0) {
43
+ const first = result.errors[0];
44
+ const msg = first.message || JSON.stringify(first);
45
+ const err = new Error(`oxc-parser: ${msg}`);
46
+ throw err;
47
+ }
48
+ return result.program;
49
+ }
50
+
51
+ export function extractConfigReferences(configObject) {
52
+ const result = { defaultScene: undefined, pluginIds: [] };
53
+ if (!configObject || configObject.type !== AST_NODE_TYPES.ObjectExpression) return result;
54
+
55
+ for (const prop of configObject.properties) {
56
+ if (prop.type !== AST_NODE_TYPES.Property || prop.key?.type !== AST_NODE_TYPES.Identifier) continue;
57
+ if (prop.key.name === 'defaultScene' && prop.value?.type === AST_NODE_TYPES.Literal) {
58
+ result.defaultScene = prop.value.value;
59
+ }
60
+ if (prop.key.name === 'plugins' && prop.value?.type === AST_NODE_TYPES.ArrayExpression) {
61
+ for (const el of prop.value.elements) {
62
+ if (!el) continue;
63
+ if (el.type === AST_NODE_TYPES.Literal && typeof el.value === 'string') {
64
+ result.pluginIds.push(el.value);
65
+ } else if (el.type === AST_NODE_TYPES.ArrayExpression && el.elements[0]?.type === AST_NODE_TYPES.Literal) {
66
+ const first = el.elements[0];
67
+ if (typeof first.value === 'string') result.pluginIds.push(first.value);
68
+ }
69
+ }
70
+ }
71
+ }
72
+ return result;
73
+ }
74
+
75
+ /**
76
+ * Locate the `defineConfig({...})` ObjectExpression in a parsed
77
+ * `caper.config.ts` AST. Handles both the default-export form
78
+ * (`export default defineConfig({...})`) and the named-const form
79
+ * (`export const config = defineConfig({...})`).
80
+ */
81
+ export function findConfigObject(ast) {
82
+ let configObject;
83
+ for (const node of ast.body) {
84
+ if (
85
+ node.type === AST_NODE_TYPES.ExportDefaultDeclaration &&
86
+ node.declaration?.type === AST_NODE_TYPES.CallExpression &&
87
+ node.declaration.callee?.name === 'defineConfig'
88
+ ) {
89
+ configObject = node.declaration.arguments[0];
90
+ } else if (
91
+ node.type === AST_NODE_TYPES.ExportNamedDeclaration &&
92
+ node.declaration?.type === AST_NODE_TYPES.VariableDeclaration
93
+ ) {
94
+ const decl = node.declaration.declarations.find(
95
+ (d) => d.init?.type === AST_NODE_TYPES.CallExpression && d.init.callee?.name === 'defineConfig',
96
+ );
97
+ if (decl) configObject = decl.init.arguments[0];
98
+ }
99
+ }
100
+ return configObject;
101
+ }
102
+
103
+ /**
104
+ * Boolean build-time flags read straight out of `caper.config.ts`.
105
+ *
106
+ * Vite fixes a config's plugin list the moment the config object is created — no
107
+ * plugin hook can add one later, and `caper()` runs while the project's
108
+ * vite.config is still being evaluated, long before anything could execute
109
+ * caper.config.ts. So these are pulled with the same oxc AST parse discovery
110
+ * already uses rather than by importing the file: importing pulls in
111
+ * @caperjs/core, whose @pixi/sound + GSAP deps run
112
+ * browser-only top-level side effects that throw under Node (see
113
+ * `validateCaperConfig` for the gory details).
114
+ *
115
+ * Only boolean literals are honoured. A missing, empty, or unparseable
116
+ * config silently yields the defaults — this runs before the normal config
117
+ * error reporting, so it must never be the thing that fails the build.
118
+ */
119
+
120
+ export const DEFINE_HELPER_NAMES = new Set(['defineScene', 'definePlugin', 'definePopup', 'defineEntity', 'defineUI']);
121
+
122
+ export function findExportedConstants(ast) {
123
+ const exports = {};
124
+
125
+ function extractValue(node) {
126
+ switch (node.type) {
127
+ case AST_NODE_TYPES.Literal:
128
+ return node.value;
129
+ case AST_NODE_TYPES.ArrayExpression:
130
+ return node.elements.map((element) => element && extractValue(element)).filter((value) => value !== undefined);
131
+ case AST_NODE_TYPES.ObjectExpression: {
132
+ const obj = {};
133
+ for (const prop of node.properties) {
134
+ if (prop.type === AST_NODE_TYPES.Property && prop.key.type === AST_NODE_TYPES.Identifier) {
135
+ obj[prop.key.name] = extractValue(prop.value);
136
+ }
137
+ }
138
+ return obj;
139
+ }
140
+ case AST_NODE_TYPES.CallExpression: {
141
+ // Unwrap `defineScene({...})` / `definePlugin({...})` / etc.
142
+ // Other call expressions are opaque to discovery.
143
+ const calleeName = node.callee?.name;
144
+ if (calleeName && DEFINE_HELPER_NAMES.has(calleeName) && node.arguments[0]) {
145
+ return extractValue(node.arguments[0]);
146
+ }
147
+ return undefined;
148
+ }
149
+ default:
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ // Export names whose value came out of a `define*()` helper, in source order.
155
+ const wrapperKeys = [];
156
+
157
+ for (const node of ast.body) {
158
+ if (
159
+ node.type === AST_NODE_TYPES.ExportNamedDeclaration &&
160
+ node.declaration?.type === AST_NODE_TYPES.VariableDeclaration
161
+ ) {
162
+ for (const declarator of node.declaration.declarations) {
163
+ if (declarator.id.type === AST_NODE_TYPES.Identifier && declarator.init) {
164
+ exports[declarator.id.name] = extractValue(declarator.init);
165
+ if (
166
+ declarator.init.type === AST_NODE_TYPES.CallExpression &&
167
+ DEFINE_HELPER_NAMES.has(declarator.init.callee?.name)
168
+ ) {
169
+ wrapperKeys.push(declarator.id.name);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ // Flatten `export const scene = defineScene({...})` / `export const ui =
177
+ // defineUI({...})` wrappers onto the top level so discovery code can stay
178
+ // agnostic: `exports.id`, `exports.active`, `exports.assets` etc. work whether
179
+ // the file used individual exports or the helper form.
180
+ //
181
+ // Which exports get flattened is decided by *what they are* — a call to a
182
+ // define helper — rather than by matching a hardcoded list of export names.
183
+ // That list read ['scene', 'plugin', 'popup', 'entity'], so `defineUI` was
184
+ // silently ignored and every UI element registered under its class name
185
+ // instead of its declared id. Naming the export anything other than the kind
186
+ // (`export const ui_ = defineUI(...)`) failed the same way.
187
+ //
188
+ // Individual file-level exports still take precedence on conflict, and earlier
189
+ // wrappers win over later ones.
190
+ for (const wrapperKey of wrapperKeys) {
191
+ const wrapped = exports[wrapperKey];
192
+ if (wrapped && typeof wrapped === 'object' && !Array.isArray(wrapped)) {
193
+ for (const [k, v] of Object.entries(wrapped)) {
194
+ if (exports[k] === undefined) exports[k] = v;
195
+ }
196
+ }
197
+ }
198
+
199
+ return exports;
200
+ }
201
+
202
+ /**
203
+ * Finds the file's default-exported class. Handles both common forms:
204
+ *
205
+ * // inline
206
+ * export default class Foo extends Bar { ... }
207
+ *
208
+ * // named declaration + separate default export
209
+ * export class Foo extends Bar { ... }
210
+ * export default Foo;
211
+ *
212
+ * // or unexported named class
213
+ * class Foo extends Bar { ... }
214
+ * export default Foo;
215
+ *
216
+ * The separate-default form requires a second pass to resolve the
217
+ * identifier back to a matching `ClassDeclaration` in the same file.
218
+ */
219
+ export function findDefaultExportedClass(ast) {
220
+ let identifierName = null;
221
+
222
+ for (const node of ast.body) {
223
+ if (node.type !== AST_NODE_TYPES.ExportDefaultDeclaration) continue;
224
+ // Form 1: `export default class Foo { ... }`
225
+ if (node.declaration.type === AST_NODE_TYPES.ClassDeclaration) {
226
+ return node.declaration;
227
+ }
228
+ // Form 2/3: `export default Foo;` — remember the name to resolve below.
229
+ if (node.declaration.type === AST_NODE_TYPES.Identifier) {
230
+ identifierName = node.declaration.name;
231
+ break;
232
+ }
233
+ }
234
+ if (!identifierName) return null;
235
+
236
+ // Resolve the identifier to a class declaration in the same file. Accept
237
+ // either a bare `class Foo {}` or an `export class Foo {}` form.
238
+ for (const node of ast.body) {
239
+ if (node.type === AST_NODE_TYPES.ClassDeclaration && node.id?.name === identifierName) {
240
+ return node;
241
+ }
242
+ if (
243
+ node.type === AST_NODE_TYPES.ExportNamedDeclaration &&
244
+ node.declaration?.type === AST_NODE_TYPES.ClassDeclaration &&
245
+ node.declaration.id?.name === identifierName
246
+ ) {
247
+ return node.declaration;
248
+ }
249
+ }
250
+
251
+ return null;
252
+ }
253
+
254
+ // Back-compat alias — scene code historically called `findDefaultExportedScene`.
255
+ export const findDefaultExportedScene = findDefaultExportedClass;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Boolean build flags read out of `caper.config.ts` before anything can execute
3
+ * it. See the comment on `readCaperBuildFlags` for why this is an AST parse.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { AST_NODE_TYPES, findConfigObject, parse } from './ast.mjs';
8
+ import { cwd } from './util.mjs';
9
+
10
+ export function readCaperBuildFlags() {
11
+ const flags = { useWasm: false };
12
+ const configPath = path.resolve(cwd, 'caper.config.ts');
13
+ if (!fs.existsSync(configPath)) return flags;
14
+
15
+ let configObject;
16
+ try {
17
+ configObject = findConfigObject(parse(fs.readFileSync(configPath, 'utf-8')));
18
+ } catch {
19
+ return flags;
20
+ }
21
+ if (configObject?.type !== AST_NODE_TYPES.ObjectExpression) return flags;
22
+
23
+ for (const prop of configObject.properties) {
24
+ if (prop.type !== AST_NODE_TYPES.Property || prop.key?.type !== AST_NODE_TYPES.Identifier) continue;
25
+ if (!(prop.key.name in flags)) continue;
26
+ if (prop.value?.type === AST_NODE_TYPES.Literal && typeof prop.value.value === 'boolean') {
27
+ flags[prop.key.name] = prop.value.value;
28
+ }
29
+ }
30
+ return flags;
31
+ }
32
+
33
+ /**
34
+ * Cross-reference validation over discovered scenes/plugins/popups/entities
35
+ * + the parsed `caper.config.ts` AST + the assetpack manifest. Emits
36
+ * warnings via the project logger; in dev mode also forwards warnings to
37
+ * the browser error overlay as info so they're impossible to miss.
38
+ *
39
+ * Checks:
40
+ * 1. Scene `assets.preload.bundles` / `assets.background.bundles` entries
41
+ * exist in the assetpack manifest.
42
+ * 2. Plugin IDs in `caper.config.ts` match a discovered plugin
43
+ * (npm `@caperjs/plugin-*` OR local `src/plugins/*`).
44
+ * 3. `defaultScene` in `caper.config.ts` matches a discovered scene ID.
45
+ * 4. No duplicate scene / plugin / popup / entity IDs across discovery.
46
+ *
47
+ * All issues are warnings, not errors — they shouldn't fail the build
48
+ * (typos are a dev-time problem; the runtime will still start, just with
49
+ * broken references the user needs to see).
50
+ */
@@ -0,0 +1,402 @@
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 { logger } from './util.mjs';
26
+
27
+
28
+ export async function findTypeScriptFiles(dir) {
29
+ const files = [];
30
+
31
+ async function scan(directory) {
32
+ const entries = await fs.promises.readdir(directory, { withFileTypes: true });
33
+
34
+ for (const entry of entries) {
35
+ const fullPath = path.join(directory, entry.name);
36
+
37
+ if (entry.isDirectory()) {
38
+ await scan(fullPath);
39
+ } else if (entry.isFile() && /\.ts?$/.test(entry.name)) {
40
+ files.push(fullPath);
41
+ }
42
+ }
43
+ }
44
+
45
+ await scan(dir);
46
+ return files;
47
+ }
48
+
49
+ // Callees whose single argument is treated as the entire config object and
50
+ // unwrapped into the exported constant's value. Keep in sync with the
51
+ // identity helpers in src/utils/define.ts.
52
+
53
+ export async function discoverScenes(server, root) {
54
+ const scenesDir = path.resolve(root, 'src/scenes');
55
+ const scenes = [];
56
+
57
+ if (!fs.existsSync(scenesDir)) {
58
+ return [];
59
+ }
60
+
61
+ const files = await findTypeScriptFiles(scenesDir);
62
+
63
+ for (const file of files) {
64
+ try {
65
+ const content = await fs.promises.readFile(file, 'utf-8');
66
+ const ast = parse(content, {
67
+ jsx: false,
68
+ loc: true,
69
+ comment: false,
70
+ });
71
+
72
+ const sceneClass = findDefaultExportedScene(ast);
73
+ if (!sceneClass) continue;
74
+
75
+ const exports = findExportedConstants(ast);
76
+
77
+ const relativePath = file.replace(root, '').replace(/\\/g, '/').split('/src')[1];
78
+ // remove /src — the runtime import uses the raw alias (Vite resolves
79
+ // `.ts` automatically) while the typeof-import codegen needs the
80
+ // extension stripped so TypeScript's path-alias resolution finds it.
81
+ const importPath = `@${relativePath}`;
82
+ const importPathForTypes = importPath.replace(/\.ts$/, '');
83
+ const id = exports.id || sceneClass.id?.name || path.basename(file, '.ts');
84
+
85
+ scenes.push({
86
+ id,
87
+ importPath: importPathForTypes,
88
+ module:
89
+ exports.dynamic === false
90
+ ? importPath
91
+ : {
92
+ toString: () => `() => import('${importPath}')`,
93
+ isFunction: true, // Add a flag to identify dynamic imports
94
+ },
95
+ active: exports.active === false ? false : true,
96
+ debugLabel: exports.debug?.label || id,
97
+ debugGroup: exports.debug?.group || undefined,
98
+ debugOrder: exports.debug?.order >= 0 ? exports.debug.order : Number.MAX_SAFE_INTEGER,
99
+ assets: exports.assets ?? undefined,
100
+ plugins: exports.plugins ?? undefined,
101
+ autoUnloadAssets: exports.assets?.autoUnload ?? false,
102
+ });
103
+ } catch (e) {
104
+ const errorMessage = `Error parsing scene file ${file}: ${e.message}`;
105
+ logger.error(errorMessage);
106
+ if (e.stack) {
107
+ logger.error(e.stack);
108
+ }
109
+ if (server) {
110
+ server.ws.send({
111
+ type: 'error',
112
+ err: {
113
+ message: e.message,
114
+ stack: e.stack,
115
+ id: file,
116
+ plugin: 'vite-plugin-scenes',
117
+ },
118
+ });
119
+ }
120
+ }
121
+ }
122
+ return scenes;
123
+ }
124
+
125
+ export async function discoverLocalPlugins(server, root) {
126
+ const pluginsDir = path.resolve(root, 'src/plugins');
127
+ const plugins = [];
128
+
129
+ if (!fs.existsSync(pluginsDir)) {
130
+ return [];
131
+ }
132
+
133
+ const files = await findTypeScriptFiles(pluginsDir);
134
+
135
+ for (const file of files) {
136
+ try {
137
+ const content = await fs.promises.readFile(file, 'utf-8');
138
+ const ast = parse(content, {
139
+ jsx: false,
140
+ loc: true,
141
+ comment: false,
142
+ });
143
+
144
+ const hasPluginWrapper = ast.body.some(
145
+ (n) =>
146
+ n.type === AST_NODE_TYPES.ExportNamedDeclaration &&
147
+ n.declaration?.type === AST_NODE_TYPES.VariableDeclaration &&
148
+ n.declaration.declarations.some(
149
+ (d) => d.init?.type === AST_NODE_TYPES.CallExpression && d.init.callee?.name === 'definePlugin',
150
+ ),
151
+ );
152
+ if (!hasPluginWrapper) continue;
153
+
154
+ const pluginClass = findDefaultExportedClass(ast);
155
+ if (!pluginClass) continue;
156
+
157
+ const exports = findExportedConstants(ast);
158
+
159
+ const relativePath = file.replace(root, '').replace(/\\/g, '/').split('/src')[1];
160
+ const importPath = `@${relativePath.replace(/\.ts$/, '')}`;
161
+ const id = exports.id || pluginClass.id?.name || path.basename(file, '.ts');
162
+ const name = pluginClass.id?.name || id;
163
+
164
+ plugins.push({
165
+ id,
166
+ name,
167
+ isLocal: true,
168
+ importPath,
169
+ module:
170
+ exports.dynamic === false
171
+ ? importPath
172
+ : {
173
+ toString: () => `() => import('${importPath}')`,
174
+ isFunction: true,
175
+ },
176
+ active: exports.active === false ? false : true,
177
+ requires: Array.isArray(exports.requires) ? exports.requires.filter((r) => typeof r === 'string') : [],
178
+ });
179
+ } catch (e) {
180
+ const errorMessage = `Error parsing plugin file ${file}: ${e.message}`;
181
+ logger.error(errorMessage);
182
+ if (e.stack) {
183
+ logger.error(e.stack);
184
+ }
185
+ if (server) {
186
+ server.ws.send({
187
+ type: 'error',
188
+ err: {
189
+ message: e.message,
190
+ stack: e.stack,
191
+ id: file,
192
+ plugin: 'vite-plugin-plugins',
193
+ },
194
+ });
195
+ }
196
+ }
197
+ }
198
+
199
+ return plugins;
200
+ }
201
+
202
+ /**
203
+ * Discovers npm packages prefixed with `@caperjs/plugin-` in the host
204
+ * project's `package.json`. Always emitted as dynamic imports.
205
+ */
206
+
207
+ export function discoverNpmPlugins(root) {
208
+ const packageJsonPath = path.resolve(root, 'package.json');
209
+ if (!fs.existsSync(packageJsonPath)) return [];
210
+
211
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
212
+ const allDependencies = {
213
+ ...(packageJson.dependencies || {}),
214
+ ...(packageJson.devDependencies || {}),
215
+ };
216
+
217
+ return Object.keys(allDependencies)
218
+ .filter((dep) => dep.startsWith('@caperjs/plugin-'))
219
+ .map((packageName) => {
220
+ const id = packageName.replace('@caperjs/plugin-', '');
221
+ return {
222
+ id,
223
+ name: packageName,
224
+ isLocal: false,
225
+ importPath: packageName,
226
+ module: {
227
+ toString: () => `() => import('${packageName}')`,
228
+ isFunction: true,
229
+ },
230
+ active: true,
231
+ // npm plugins can declare `requires` on the class itself
232
+ // (`public readonly requires = ['firebase']`); the runtime topo-sort
233
+ // reads from the live instance, so empty here is fine.
234
+ requires: [],
235
+ };
236
+ });
237
+ }
238
+
239
+ export async function discoverPlugins(server, root) {
240
+ const [local, npm] = [await discoverLocalPlugins(server, root), discoverNpmPlugins(root)];
241
+ return [...npm, ...local];
242
+ }
243
+
244
+ /**
245
+ * Generic recursive-class-file discoverer. Walks a directory, AST-parses
246
+ * each .ts file, requires a default-exported class, and reads `id` /
247
+ * `active` / `dynamic` metadata (either from individual exports or a
248
+ * `defineX({...})` wrapper). Used for popups and entities — same shape
249
+ * as scenes/plugins minus the npm-package discovery path.
250
+ *
251
+ * `defaultDynamic` sets the emit mode when a file doesn't declare it
252
+ * explicitly. Entities default to `false` (static import) because the
253
+ * typed `this.add.entity(id, props)` factory needs sync access to the
254
+ * constructor; popups default to `true` because `show()` is async.
255
+ */
256
+
257
+ export async function discoverLocalClassFiles({ dir, kind, server, root, defaultDynamic = true }) {
258
+ const rootDir = path.resolve(root, dir);
259
+ if (!fs.existsSync(rootDir)) return [];
260
+
261
+ const files = await findTypeScriptFiles(rootDir);
262
+ const results = [];
263
+
264
+ for (const file of files) {
265
+ try {
266
+ const content = await fs.promises.readFile(file, 'utf-8');
267
+ const ast = parse(content);
268
+
269
+ const cls = findDefaultExportedClass(ast);
270
+ if (!cls) continue;
271
+
272
+ const exports = findExportedConstants(ast);
273
+
274
+ const relativePath = file.replace(root, '').replace(/\\/g, '/').split('/src')[1];
275
+ const importPath = `@${relativePath.replace(/\.ts$/, '')}`;
276
+ const id = exports.id || cls.id?.name || path.basename(file, '.ts');
277
+ const name = cls.id?.name || id;
278
+ const isDynamic = exports.dynamic !== undefined ? exports.dynamic : defaultDynamic;
279
+
280
+ results.push({
281
+ id,
282
+ name,
283
+ importPath,
284
+ module: !isDynamic
285
+ ? importPath
286
+ : {
287
+ toString: () => `() => import('${importPath}')`,
288
+ isFunction: true,
289
+ },
290
+ active: exports.active === false ? false : true,
291
+ });
292
+ } catch (e) {
293
+ const errorMessage = `Error parsing ${kind} file ${file}: ${e.message}`;
294
+ logger.error(errorMessage);
295
+ if (e.stack) logger.error(e.stack);
296
+ if (server) {
297
+ server.ws.send({
298
+ type: 'error',
299
+ err: {
300
+ message: e.message,
301
+ stack: e.stack,
302
+ id: file,
303
+ plugin: `vite-plugin-${kind}s`,
304
+ },
305
+ });
306
+ }
307
+ }
308
+ }
309
+
310
+ return results;
311
+ }
312
+
313
+ export async function discoverPopups(server, root) {
314
+ return discoverLocalClassFiles({ dir: 'src/popups', kind: 'popup', server, root });
315
+ }
316
+
317
+ export async function discoverEntities(server, root) {
318
+ // Entities default to static imports so `this.add.entity(id, props)` can
319
+ // synchronously construct without awaiting a dynamic import. Opt into
320
+ // code-splitting per-entity with `defineEntity({ dynamic: true })`.
321
+ return discoverLocalClassFiles({ dir: 'src/entities', kind: 'entity', server, root, defaultDynamic: false });
322
+ }
323
+
324
+ export async function discoverUIs(server, root) {
325
+ // UI elements default to static imports so `this.add.ui(id, props)` can
326
+ // synchronously construct. Same rationale as entities.
327
+ return discoverLocalClassFiles({ dir: 'src/ui', kind: 'ui', server, root, defaultDynamic: false });
328
+ }
329
+
330
+ /**
331
+ * Factory for `virtual:caper-popups` / `virtual:caper-entities`.
332
+ * Thin wrapper around a discover function that generates the static-import
333
+ * + dynamic-import `() => import(...)` mix, same shape as the scenes/plugins
334
+ * virtual modules.
335
+ */
336
+
337
+ export async function discoverLocaleKeys(server, root) {
338
+ const localesDir = path.resolve(root, 'src/locales');
339
+ if (!fs.existsSync(localesDir)) return [];
340
+
341
+ const files = (await fs.promises.readdir(localesDir)).filter((f) => /\.ts$/.test(f)).sort();
342
+ if (files.length === 0) return [];
343
+
344
+ // Pick the reference file: en.ts if present, else the first alphabetically.
345
+ const referenceFile = files.find((f) => f === 'en.ts') || files[0];
346
+ const filePath = path.join(localesDir, referenceFile);
347
+
348
+ try {
349
+ const content = await fs.promises.readFile(filePath, 'utf-8');
350
+ if (!content.trim()) return [];
351
+
352
+ const ast = parse(content);
353
+ const defaultExport = ast.body.find((n) => n.type === AST_NODE_TYPES.ExportDefaultDeclaration);
354
+ if (!defaultExport) return [];
355
+
356
+ // Allow either `export default { ... }` or `export default satisfies ...`
357
+ let obj = defaultExport.declaration;
358
+ if (obj && obj.type === 'TSSatisfiesExpression') obj = obj.expression;
359
+ if (!obj || obj.type !== AST_NODE_TYPES.ObjectExpression) return [];
360
+
361
+ const keys = [];
362
+ const walk = (node, prefix) => {
363
+ if (node.type !== AST_NODE_TYPES.ObjectExpression) return;
364
+ for (const prop of node.properties) {
365
+ if (prop.type !== AST_NODE_TYPES.Property) continue;
366
+ let name;
367
+ if (prop.key.type === AST_NODE_TYPES.Identifier) name = prop.key.name;
368
+ else if (prop.key.type === AST_NODE_TYPES.Literal) name = String(prop.key.value);
369
+ else continue;
370
+ const dotPath = prefix ? `${prefix}.${name}` : name;
371
+ if (prop.value.type === AST_NODE_TYPES.ObjectExpression) {
372
+ walk(prop.value, dotPath);
373
+ } else if (prop.value.type === AST_NODE_TYPES.Literal) {
374
+ keys.push(dotPath);
375
+ }
376
+ }
377
+ };
378
+ walk(obj, '');
379
+ return keys.sort();
380
+ } catch (e) {
381
+ const errorMessage = `Error parsing locale file ${filePath}: ${e.message}`;
382
+ logger.error(errorMessage);
383
+ if (server) {
384
+ server.ws.send({
385
+ type: 'error',
386
+ err: {
387
+ message: e.message,
388
+ stack: e.stack,
389
+ id: filePath,
390
+ plugin: 'vite-plugin-locales',
391
+ },
392
+ });
393
+ }
394
+ return [];
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Virtual module plugin for `virtual:caper-plugins`. Mirrors
400
+ * `sceneListPlugin`: supports static + dynamic imports per entry, preserves
401
+ * the full metadata shape so consumers can filter by `active` / read IDs.
402
+ */