@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 CHANGED
@@ -18,7 +18,7 @@ This package is **not yet published to npm**. The recommended way to use it is t
18
18
  - **Per-bundle asset types** generated from your AssetPack manifest
19
19
  - **Plugin `requires: [...]`** declarations + topological sort at bootstrap
20
20
  - **Build-time validation** for asset bundles, plugin IDs, dependency cycles, duplicate IDs
21
- - **Exported Vite + AssetPack config** (`@caperjs/core/config/vite`, `@caperjs/core/config/assetpack`)
21
+ - **A Vite preset** (`@caperjs/core/vite`) — `plugins: [caper()]`, then plain `vite` / `vite build`
22
22
  - **`caper add` CLI** for scaffolding scenes, plugins, entities, popups
23
23
  - **Signals** via `typed-signals`
24
24
  - **Tested** — Vitest covers `Plugin`, `Store`, `Scene`, `SignalRegistry`
@@ -1,36 +1,8 @@
1
1
  import { AssetPack, Logger } from '@assetpack/core';
2
2
  import { pixiPipes } from '@assetpack/core/pixi';
3
- import fs from 'node:fs';
4
3
  import process from 'node:process';
5
4
  import path from 'path';
6
5
 
7
- // Check if user config exists (synchronously)
8
- function hasUserAssetpackConfig() {
9
- const configPath = path.join(process.cwd(), '.assetpack.mjs');
10
- try {
11
- return fs.existsSync(configPath);
12
- } catch {
13
- return false;
14
- }
15
- }
16
-
17
- // Async function to load user config
18
- async function loadUserAssetpackConfig() {
19
- if (!hasUserAssetpackConfig()) {
20
- return null;
21
- }
22
-
23
- try {
24
- const configPath = path.join(process.cwd(), '.assetpack.mjs');
25
- const userConfig = await import(configPath);
26
- Logger.info('Caper assetpack plugin:: Using user assetpack config from .assetpack.mjs');
27
- return userConfig.default || userConfig;
28
- } catch (error) {
29
- Logger.error('Caper assetpack plugin:: Error loading user assetpack config:', error);
30
- return null;
31
- }
32
- }
33
-
34
6
  const cwd = process.cwd();
35
7
 
36
8
  /**
@@ -67,7 +39,8 @@ const defaultPixiPipesConfig = {
67
39
  // Retina-first: raw art is authored at 2x native game pixels. AssetPack
68
40
  // treats the source as the largest resolution listed, so this emits @2x
69
41
  // plus downscaled 1x and 0.5x. Apps whose art is authored at 1x override
70
- // `resolutions` in their .assetpack.mjs (or tag individual assets {fix}).
42
+ // `resolutions` via `caper({ assets: { resolutions } })` (or tag individual
43
+ // assets {fix}).
71
44
  resolutions: { high: 2, default: 1, low: 0.5 },
72
45
  // Quality-first compression: the upstream webp default (quality 80, lossy
73
46
  // alpha) rings on anti-aliased UI edges, and browsers load .webp over .png.
@@ -85,7 +58,27 @@ const defaultPixiPipesConfig = {
85
58
  removeFileExtension: true,
86
59
  texturePacker: { nameStyle: 'relative', removeFileExtension: true },
87
60
  },
88
- audio: {},
61
+ // AssetPack's audio defaults are a trap: the mp3 output has
62
+ // `recompress: false`, so an mp3 source is *copied* — a 320kbps master ships
63
+ // untouched, cover art included — while the ogg twin is re-encoded down to
64
+ // 32kbps mono. Since pixi's resolver prefers ogg, desktop ends up playing the
65
+ // worst copy of a file the project also ships at ten times the size. Both
66
+ // outputs get the same music-grade target instead, and `noVideo` drops the
67
+ // embedded cover art ffmpeg would otherwise copy into every output.
68
+ audio: {
69
+ outputs: [
70
+ {
71
+ formats: ['.mp3'],
72
+ recompress: true,
73
+ options: { audioBitrate: 128, audioChannels: 2, audioFrequency: 44100, noVideo: true },
74
+ },
75
+ {
76
+ formats: ['.ogg'],
77
+ recompress: true,
78
+ options: { audioBitrate: 128, audioChannels: 2, audioFrequency: 44100, noVideo: true },
79
+ },
80
+ ],
81
+ },
89
82
  webfont: {},
90
83
  manifest: { trimExtensions: true, createShortcuts: true, output: defaultManifestUrl },
91
84
  };
@@ -108,22 +101,84 @@ function devCompression(compression) {
108
101
  return result;
109
102
  }
110
103
 
111
- export const assetpackConfig = (manifestUrl = defaultManifestUrl, pixiPipesConfig = {}, cacheBust) => {
112
- pixiPipesConfig = { ...defaultPixiPipesConfig, ...pixiPipesConfig };
104
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
105
+
106
+ /**
107
+ * Keys whose value is one whole decision rather than a bag of options, so an
108
+ * override replaces it outright instead of merging into it.
109
+ *
110
+ * `resolutions` is the important one, and AssetPack's own `pixiPipes()` makes the
111
+ * same exception ("don't merge the resolutions, just overwrite them"): the object
112
+ * is a *set of tiers*, so merging `{ default: 1, low: 0.5 }` into caper's
113
+ * retina-first default would silently put `high: 2` back and render 1x art at
114
+ * half size.
115
+ */
116
+ const REPLACE_WHOLE = new Set(['resolutions']);
117
+
118
+ /**
119
+ * Deep-merge `overrides` over `base`: plain objects recurse, everything else
120
+ * (arrays, scalars, `false`) replaces. Deep so that overriding one knob keeps
121
+ * its siblings — `{ compression: { png: false } }` must not silently discard
122
+ * caper's webp settings. Arrays replace rather than merge because the arrays
123
+ * here are whole specifications (an audio `outputs` list), not sets to extend.
124
+ */
125
+ function deepMerge(base, overrides) {
126
+ if (!isPlainObject(overrides)) return overrides === undefined ? base : overrides;
127
+ const out = { ...base };
128
+ for (const [key, value] of Object.entries(overrides)) {
129
+ const mergeable = isPlainObject(value) && isPlainObject(base?.[key]) && !REPLACE_WHOLE.has(key);
130
+ out[key] = mergeable ? deepMerge(base[key], value) : value;
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Whether this is a production build.
137
+ *
138
+ * Vite is the authority here, not `process.env.NODE_ENV`: nothing guarantees
139
+ * vite sets that variable, and depending on it silently shipped a kitchen-sink
140
+ * build with no cache-busting hashes and dev-effort compression. Callers pass
141
+ * `resolvedConfig.isProduction` from `configResolved`; the NODE_ENV read is only
142
+ * a fallback for direct callers outside a vite build.
143
+ */
144
+ function resolveIsProduction(isProduction) {
145
+ return isProduction ?? process.env.NODE_ENV === 'production';
146
+ }
147
+
148
+ /**
149
+ * Caper's pixi-pipes defaults with a project's overrides merged in, plus the two
150
+ * environment-dependent adjustments: cache-busting in production, and cheaper
151
+ * compression effort in dev.
152
+ *
153
+ * Exported for tests — the merged pipes themselves are opaque, so this plain
154
+ * object is the only place the merge is observable.
155
+ */
156
+ export function resolvePixiPipesConfig(overrides = {}, { cacheBust, isProduction } = {}) {
157
+ const config = deepMerge(defaultPixiPipesConfig, overrides);
158
+ const production = resolveIsProduction(isProduction);
113
159
 
114
160
  if (cacheBust !== undefined) {
115
- pixiPipesConfig.cacheBust = cacheBust;
161
+ config.cacheBust = cacheBust;
116
162
  }
117
- // NODE_ENV is read at call time, not module load: under the caper CLI this
118
- // module is imported before vite's resolveConfig sets NODE_ENV, whereas
119
- // assetpackConfig runs after (configResolved / user .assetpack.mjs import).
120
- if (pixiPipesConfig.cacheBust === undefined) {
121
- pixiPipesConfig.cacheBust = process.env.NODE_ENV === 'production';
163
+ if (config.cacheBust === undefined) {
164
+ config.cacheBust = production;
122
165
  }
123
- if (process.env.NODE_ENV !== 'production') {
124
- pixiPipesConfig.compression = devCompression(pixiPipesConfig.compression);
166
+ if (!production) {
167
+ config.compression = devCompression(config.compression);
168
+ }
169
+ return config;
170
+ }
171
+
172
+ export const assetpackConfig = (manifestUrl = defaultManifestUrl, pixiPipesConfig = {}, env = {}) => {
173
+ const resolved = resolvePixiPipesConfig(pixiPipesConfig, env);
174
+
175
+ // A custom manifest filename has to reach the manifest pipe too, or the file
176
+ // gets written as assets.json while everything else looks for the new name.
177
+ if (!pixiPipesConfig?.manifest?.output) {
178
+ resolved.manifest = { ...resolved.manifest, output: manifestUrl };
125
179
  }
126
- const pipes = pixiPipes({ ...pixiPipesConfig });
180
+
181
+ const pipes = pixiPipes({ ...resolved });
127
182
 
128
183
  // Insert fontWeights before the webfont pipe so that the {weight} tag
129
184
  // is converted to a weights[] array before webfont processes the asset.
@@ -140,17 +195,18 @@ export const assetpackConfig = (manifestUrl = defaultManifestUrl, pixiPipesConfi
140
195
 
141
196
  export default assetpackConfig;
142
197
 
143
- export function assetpackPlugin(manifestUrl = defaultManifestUrl, pixiPipesConfig = defaultPixiPipesConfig) {
198
+ export function assetpackPlugin(manifestUrl = defaultManifestUrl, pixiPipesConfig = {}) {
144
199
  let mode;
145
200
  let ap;
146
201
  let apConfig;
147
202
  let isBuilding = false;
148
203
  let server;
149
204
 
205
+ let isProduction;
206
+
150
207
  async function getConfig() {
151
208
  if (!apConfig) {
152
- const userConfig = await loadUserAssetpackConfig();
153
- apConfig = userConfig || assetpackConfig(manifestUrl, pixiPipesConfig);
209
+ apConfig = assetpackConfig(manifestUrl, pixiPipesConfig, { isProduction });
154
210
  }
155
211
  }
156
212
 
@@ -158,10 +214,12 @@ export function assetpackPlugin(manifestUrl = defaultManifestUrl, pixiPipesConfi
158
214
  name: 'vite-plugin-assetpack',
159
215
  async configResolved(resolvedConfig) {
160
216
  mode = resolvedConfig.command;
217
+ // Captured before getConfig() below, which is what consumes it.
218
+ isProduction = resolvedConfig.isProduction;
161
219
  if (!resolvedConfig.publicDir) return;
162
220
  await getConfig();
163
221
  if (apConfig.output) return;
164
- const publicDir = resolvedConfig.publicDir.replace(cwd, '');
222
+ const publicDir = resolvedConfig.publicDir.replace(resolvedConfig.root, '');
165
223
  const outputPath = path.join(publicDir, 'assets');
166
224
  apConfig.output = path.isAbsolute(publicDir)
167
225
  ? path.join('.', outputPath).replace(/\\/g, '/')
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The Vite settings caper contributes, expressed as *defaults* rather than
3
+ * overrides.
4
+ *
5
+ * `caperDefaults(userConfig, env)` returns a partial config containing only what
6
+ * the project left unset. That shape matters: Vite deep-merges a plugin's
7
+ * returned partial *over* the existing config, so returning values
8
+ * unconditionally would silently beat the project's own. Filling only the gaps
9
+ * means the project always wins, which is the whole point of the preset.
10
+ *
11
+ * Three merge rules, applied by `fillMissing`:
12
+ *
13
+ * - **Plain objects recurse.** A project that sets `server.port` keeps its port
14
+ * and still gets `host` and `open`.
15
+ * - **Arrays are always contributed.** Vite concatenates them, which is what we
16
+ * want: a project adding to `resolve.dedupe` must not silently drop pixi from
17
+ * it, or two copies of pixi split its global registries.
18
+ * - **Everything else fills only when absent**, including functions like
19
+ * `manualChunks`.
20
+ */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { APP_ENTRY_FILE, CAPER_CONFIG_FILE, SOURCE_DIRS } from './internal/paths.mjs';
24
+
25
+ /**
26
+ * App identity for the `__CAPER_APP_*` defines and the PWA manifest skeleton.
27
+ *
28
+ * `npm_package_*` is only set when a package manager runs the script, so a bare
29
+ * `vite build` from a shell used to bake `undefined` into the bundle. Fall back
30
+ * to reading package.json, which is where the env vars came from anyway.
31
+ */
32
+ export function readAppIdentity(root = process.cwd()) {
33
+ const fromEnv = {
34
+ name: process.env.npm_package_name,
35
+ version: process.env.npm_package_version,
36
+ description: process.env.npm_package_description,
37
+ };
38
+ if (fromEnv.name && fromEnv.version && fromEnv.description) return fromEnv;
39
+
40
+ try {
41
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
42
+ return {
43
+ name: fromEnv.name ?? pkg.name,
44
+ version: fromEnv.version ?? pkg.version,
45
+ description: fromEnv.description ?? pkg.description,
46
+ };
47
+ } catch {
48
+ return fromEnv;
49
+ }
50
+ }
51
+
52
+ const isPlainObject = (value) =>
53
+ value !== null && typeof value === 'object' && !Array.isArray(value);
54
+
55
+ /**
56
+ * The subset of `defaults` that `target` does not already specify. Returns
57
+ * `undefined` when there is nothing to contribute, so callers can omit the key
58
+ * entirely rather than merging an empty object.
59
+ */
60
+ export function fillMissing(target, defaults) {
61
+ const out = {};
62
+
63
+ for (const [key, value] of Object.entries(defaults)) {
64
+ if (value === undefined) continue;
65
+
66
+ const current = target?.[key];
67
+
68
+ if (Array.isArray(value)) {
69
+ // Vite concatenates arrays on merge — always contribute.
70
+ out[key] = value;
71
+ continue;
72
+ }
73
+
74
+ if (isPlainObject(value)) {
75
+ const nested = fillMissing(isPlainObject(current) ? current : undefined, value);
76
+ if (nested !== undefined) out[key] = nested;
77
+ continue;
78
+ }
79
+
80
+ if (current === undefined) out[key] = value;
81
+ }
82
+
83
+ return Object.keys(out).length ? out : undefined;
84
+ }
85
+
86
+ /**
87
+ * Caper's own values, before any gap-filling.
88
+ *
89
+ * `root` is the project root vite will use — taken from the project's own config
90
+ * when it sets one, so the `@` alias follows `vite --root somewhere/else`. It is
91
+ * resolved here rather than at module load, which is what a module-level
92
+ * `process.cwd()` would have baked in.
93
+ */
94
+ export function caperDefaultValues(env, userConfig = {}) {
95
+ const isServe = env.command === 'serve';
96
+ const root = userConfig.root ? path.resolve(userConfig.root) : process.cwd();
97
+ const app = readAppIdentity(root);
98
+
99
+ return {
100
+ cacheDir: '.cache',
101
+ logLevel: 'info',
102
+ publicDir: './public',
103
+ // Dev serves from root; builds emit relative URLs so a bundle can be hosted
104
+ // from a subdirectory.
105
+ base: isServe ? '/' : './',
106
+ server: {
107
+ port: 3000,
108
+ host: true,
109
+ open: true,
110
+ },
111
+ preview: {
112
+ host: true,
113
+ port: 8080,
114
+ },
115
+ build: {
116
+ sourcemap: env.mode === 'development',
117
+ rolldownOptions: {
118
+ external: ['caper-globals'],
119
+ output: {
120
+ // Rolldown requires manualChunks as a function (Rollup allowed an object).
121
+ manualChunks(id) {
122
+ if (id.includes('node_modules/gsap/')) return 'gsap';
123
+ },
124
+ chunkFileNames: 'assets/[name]-[hash].js',
125
+ entryFileNames: 'assets/[name]-[hash].js',
126
+ assetFileNames: 'assets/[name]-[hash][extname]',
127
+ },
128
+ },
129
+ },
130
+ resolve: {
131
+ alias: {
132
+ '@': path.resolve(root, './src'),
133
+ },
134
+ // Force a single instance of each singleton lib. pixi keeps global
135
+ // registries (extensions, TextureSource cache, Ticker.shared) and relies on
136
+ // instanceof; two copies (via linked/transitive installs) split its state.
137
+ dedupe: ['pixi.js', 'gsap', '@pixi/sound'],
138
+ },
139
+ // Don't prebundle @pixi/ui: esbuild inlines its own copy of pixi.js into the
140
+ // dep chunk, giving two pixi instances — every cross-boundary
141
+ // `instanceof Texture/Sprite` then fails. Served as source, its
142
+ // `import "pixi.js"` resolves to the same optimized pixi as the app. Its
143
+ // nested typed-signals dep (CJS) still needs prebundling for interop.
144
+ optimizeDeps: {
145
+ exclude: ['@pixi/ui'],
146
+ include: ['@pixi/ui > typed-signals'],
147
+ // Every root of a caper app's module graph, because none of them are
148
+ // reachable from the default scan. Vite's cold-start dep scan reads the
149
+ // RAW index.html for <script> tags, and the runtime entry is injected by
150
+ // plugins/runtime.mjs at transformIndexHtml time — so the scan starts
151
+ // from nothing and prebundles nothing. Scenes, popups and `autoLoad:
152
+ // false` plugins then arrive through the virtual lists as dynamic
153
+ // imports the scanner cannot follow either. The first scene that pulls a
154
+ // new dep (physics, filters, gsap plugins) makes vite re-optimize
155
+ // mid-session and hard-reload the page. Scanning these roots up front
156
+ // prebundles the same deps at startup instead of on a click.
157
+ //
158
+ // The paths are SOURCE_DIRS, the same contract discovery crawls, so a
159
+ // project can't have code here that discovery misses or vice versa. A
160
+ // glob that matches nothing (no src/entities, say) is simply skipped.
161
+ entries: [
162
+ // Kept first so an app with a hand-written <script> keeps its entry:
163
+ // setting `entries` replaces the default html scan.
164
+ 'index.html',
165
+ CAPER_CONFIG_FILE,
166
+ APP_ENTRY_FILE,
167
+ ...Object.values(SOURCE_DIRS).map((dir) => `${dir}/**/*.{ts,tsx,js,jsx}`),
168
+ ],
169
+ },
170
+ define: {
171
+ __CAPER_APP_NAME: JSON.stringify(app.name),
172
+ __CAPER_APP_VERSION: JSON.stringify(app.version),
173
+ },
174
+ };
175
+ }
176
+
177
+ /** The partial config caper contributes for this project and command. */
178
+ export function caperDefaults(userConfig, env) {
179
+ return fillMissing(userConfig, caperDefaultValues(env, userConfig)) ?? {};
180
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `caper()` — the Vite preset. This is caper's whole build-time surface:
3
+ *
4
+ * import { defineConfig } from 'vite';
5
+ * import { caper } from '@caperjs/core/vite';
6
+ *
7
+ * export default defineConfig({ plugins: [caper()] });
8
+ *
9
+ * and then plain `vite` / `vite build`. Caper contributes nothing except through
10
+ * Vite's own mechanisms, so plugin ordering, config merging and precedence are
11
+ * Vite's rules rather than caper's. The project's config is the only config.
12
+ *
13
+ * See `plan/vite-preset-rework.md` for why this replaced the old
14
+ * `defaultConfig` + `caper build` arrangement.
15
+ */
16
+ import { viteStaticCopy } from 'vite-plugin-static-copy';
17
+ import wasm from 'vite-plugin-wasm';
18
+ import { assetpackPlugin } from './assetpack.mjs';
19
+ import { caperDefaults } from './defaults.mjs';
20
+ import { readCaperBuildFlags } from './internal/buildFlags.mjs';
21
+ import { assetTypesPlugin } from './plugins/assetTypes.mjs';
22
+ import { caperConfigPlugin } from './plugins/caperConfig.mjs';
23
+ import { caperDevHelperPlugin } from './plugins/devHelper.mjs';
24
+ import { entityListPlugin, pluginListPlugin, popupListPlugin, sceneListPlugin, uiListPlugin } from './plugins/lists.mjs';
25
+ import { pngFallbackPrunePlugin } from './plugins/pruneFallbacks.mjs';
26
+ import { caperPwaPlugins } from './plugins/pwa.mjs';
27
+ import { createCaperRuntimePlugin } from './plugins/runtime.mjs';
28
+
29
+ const buildFlags = readCaperBuildFlags();
30
+
31
+ function caperPluginList({ assets = {}, pwa } = {}) {
32
+ // `assets: false` opts out of the asset pipeline entirely — no assetpack run,
33
+ // no generated asset types. Replaces the old `noAssetpackConfig` export.
34
+ const { manifestUrl = 'assets.json', pngFallback = false, ...pipes } = assets === false ? {} : assets;
35
+ const assetPlugins =
36
+ assets === false
37
+ ? []
38
+ : [
39
+ assetpackPlugin(manifestUrl, pipes),
40
+ assetTypesPlugin(manifestUrl),
41
+ // Production ships webp only unless the project asks for the fallback.
42
+ ...(pngFallback ? [] : [pngFallbackPrunePlugin({ manifestUrl })]),
43
+ ];
44
+
45
+ return [
46
+ ...(buildFlags.useWasm ? [wasm()] : []),
47
+ createCaperRuntimePlugin({ pwa }),
48
+ viteStaticCopy({
49
+ // The captions plugin's bitmap font. `silent` matters: without it,
50
+ // vite-plugin-static-copy *throws* when the glob matches nothing, so any
51
+ // install layout that doesn't put caper's source at exactly this path
52
+ // fails the whole build over an optional font.
53
+ silent: true,
54
+ targets: [
55
+ {
56
+ src: './node_modules/@caperjs/core/src/plugins/captions/font/*.*',
57
+ dest: './assets/caper/font',
58
+ },
59
+ ],
60
+ }),
61
+ pluginListPlugin(),
62
+ sceneListPlugin(),
63
+ popupListPlugin(),
64
+ entityListPlugin(),
65
+ uiListPlugin(),
66
+ ...assetPlugins,
67
+ caperConfigPlugin(),
68
+ caperDevHelperPlugin(),
69
+ ...(pwa ? caperPwaPlugins(pwa) : []),
70
+ ];
71
+ }
72
+
73
+ /**
74
+ * @typedef {object} CaperOptions
75
+ * @property {object|false} [assets] AssetPack pixi-pipes overrides, deep-merged
76
+ * over caper's defaults. `false` omits the asset plugins entirely. Set
77
+ * `pngFallback: true` to keep the png twins a production build otherwise prunes.
78
+ * @property {object} [pwa] vite-plugin-pwa options, merged over caper's PWA
79
+ * defaults. Absent means no service worker and no web manifest.
80
+ */
81
+
82
+ /**
83
+ * @param {CaperOptions} [options]
84
+ * @returns {import('vite').PluginOption[]}
85
+ */
86
+ export function caper(options = {}) {
87
+ return [
88
+ {
89
+ name: 'caper:defaults',
90
+ config: (userConfig, env) => caperDefaults(userConfig, env),
91
+ },
92
+ ...caperPluginList(options),
93
+ ];
94
+ }
95
+
96
+ export default caper;