@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,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
+ */