castle-web-cli 0.4.121 → 0.4.123

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,446 @@
1
+ // save-version / list-versions / restore-version.
2
+ //
3
+ // A version is a manual commit of the deck's files. Storage details are in
4
+ // versionStore.ts; this file is the behavior:
5
+ //
6
+ // save-version hashes the whole tree and records what moved
7
+ // save-version <paths...> records ONLY those paths -- a curated state, not
8
+ // the working tree at that moment
9
+ // list-versions [<id>] walks back from head (or from the given version)
10
+ // restore-version <id> puts the working tree back to that version,
11
+ // touching only paths some later version touched
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import { customAlphabet } from 'nanoid';
15
+ import { looksBinary, unifiedDiff } from './diffText.js';
16
+ import { COVER_FILE } from './localPaths.js';
17
+ import * as store from './versionStore.js';
18
+ // Version ids are alphanumeric on purpose: they are filenames, command-line
19
+ // positionals and (later) url segments, and nanoid's default alphabet includes
20
+ // `-`, which reads as a flag wherever an id is typed.
21
+ const newVersionId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 12);
22
+ function resolveProjectDir(dir) {
23
+ const projectDir = path.resolve(dir);
24
+ if (!fs.existsSync(projectDir))
25
+ throw new Error(`No such directory: ${projectDir}`);
26
+ return projectDir;
27
+ }
28
+ // A path on the command line may name a directory, which stands for every
29
+ // tracked file under it -- on disk AND in the parent manifest, so deleting a
30
+ // whole directory and saving it still records the deletions.
31
+ function expandSelection(projectDir, given, parentFiles) {
32
+ const workingFiles = store.walkDeckFiles(projectDir);
33
+ const selected = new Set();
34
+ for (const raw of given) {
35
+ const rel = store.toDeckRelative(projectDir, raw);
36
+ if (!rel)
37
+ throw new Error(`Path is outside the deck: ${raw}`);
38
+ if (store.isIgnoredPath(rel))
39
+ throw new Error(`Path is not tracked: ${rel}`);
40
+ const isDir = fs.existsSync(path.join(projectDir, rel)) && fs.statSync(path.join(projectDir, rel)).isDirectory();
41
+ if (isDir) {
42
+ const prefix = `${rel}/`;
43
+ for (const p of workingFiles)
44
+ if (p.startsWith(prefix))
45
+ selected.add(p);
46
+ for (const p of Object.keys(parentFiles))
47
+ if (p.startsWith(prefix))
48
+ selected.add(p);
49
+ continue;
50
+ }
51
+ if (!fs.existsSync(path.join(projectDir, rel)) && !(rel in parentFiles)) {
52
+ throw new Error(`No such file: ${rel}`);
53
+ }
54
+ selected.add(rel);
55
+ }
56
+ return [...selected].sort();
57
+ }
58
+ // The whole tree becomes the manifest. Content is stored as we go, so an
59
+ // unchanged file costs a read and a hash and writes nothing new.
60
+ function manifestFromTree(projectDir) {
61
+ const files = {};
62
+ for (const rel of store.walkDeckFiles(projectDir)) {
63
+ const hash = store.storeWorkingFile(projectDir, rel);
64
+ if (hash)
65
+ files[rel] = hash;
66
+ }
67
+ return files;
68
+ }
69
+ // The parent's manifest with only the selected paths overridden. Everything
70
+ // else keeps the parent's hash -- so the version describes a curated state, and
71
+ // the paths left out stay dirty on disk for the next save to pick up.
72
+ function manifestFromSelection(projectDir, parentFiles, selected) {
73
+ const files = { ...parentFiles };
74
+ for (const rel of selected) {
75
+ const hash = store.storeWorkingFile(projectDir, rel);
76
+ if (hash)
77
+ files[rel] = hash;
78
+ else
79
+ delete files[rel];
80
+ }
81
+ return files;
82
+ }
83
+ // The save itself, with no output -- what the CLI command and the editor's
84
+ // Save version button both run.
85
+ export function createVersion(projectDir, opts = {}) {
86
+ const parentId = store.readHead(projectDir);
87
+ const parentFiles = store.parentManifest(projectDir, parentId);
88
+ const given = opts.paths ?? [];
89
+ const selected = given.length > 0 ? expandSelection(projectDir, given, parentFiles) : null;
90
+ const files = selected
91
+ ? manifestFromSelection(projectDir, parentFiles, selected)
92
+ : manifestFromTree(projectDir);
93
+ const changes = store.diffManifests(parentFiles, files, selected ?? undefined);
94
+ if (Object.keys(changes).length === 0)
95
+ return { version: null, partial: selected !== null };
96
+ const version = {
97
+ id: newVersionId(),
98
+ parentId,
99
+ createdAt: new Date().toISOString(),
100
+ message: opts.message ?? '',
101
+ files,
102
+ changes,
103
+ };
104
+ store.writeVersion(projectDir, version);
105
+ store.writeHead(projectDir, version.id);
106
+ return { version, partial: selected !== null };
107
+ }
108
+ export function saveVersion(dir, opts = {}) {
109
+ const projectDir = resolveProjectDir(dir);
110
+ const { version, partial } = createVersion(projectDir, opts);
111
+ if (!version) {
112
+ console.log(partial ? 'No changes in the selected paths. Nothing saved.' : 'No changes. Nothing saved.');
113
+ return;
114
+ }
115
+ const changes = version.changes;
116
+ const count = Object.keys(changes).length;
117
+ console.log(`Saved version ${version.id} (${count} file${count === 1 ? '' : 's'} changed).`);
118
+ for (const [p, change] of Object.entries(changes)) {
119
+ const mark = change.before === null ? 'added' : change.after === null ? 'deleted' : 'changed';
120
+ console.log(` ${mark} ${p}`);
121
+ }
122
+ if (partial) {
123
+ console.log('Partial save: unselected files are still dirty on disk.');
124
+ }
125
+ }
126
+ // The deck cover is rewritten every time the deck is played, so it is
127
+ // permanently dirty. It is versioned and restored like any other file, but it
128
+ // never counts as a change worth reporting and never blocks a restore -- the
129
+ // old cover it writes back regenerates on the next play. Without this, every
130
+ // restore refused on a file nobody edited.
131
+ function isReportableChange(path) {
132
+ return path !== COVER_FILE;
133
+ }
134
+ function changeKind(change) {
135
+ if (change.before === null)
136
+ return 'added';
137
+ if (change.after === null)
138
+ return 'deleted';
139
+ return 'changed';
140
+ }
141
+ /**
142
+ * What has changed on disk since head, in the same shape a version's `changes`
143
+ * takes. Hashes the tree WITHOUT storing anything -- asking what is outstanding
144
+ * must not write to the store.
145
+ */
146
+ export function pendingChanges(projectDir) {
147
+ const headFiles = store.parentManifest(projectDir, store.readHead(projectDir));
148
+ const current = {};
149
+ for (const rel of store.walkDeckFiles(projectDir)) {
150
+ const hash = store.hashWorkingFile(projectDir, rel);
151
+ if (hash)
152
+ current[rel] = hash;
153
+ }
154
+ return Object.entries(store.diffManifests(headFiles, current))
155
+ .filter(([path]) => isReportableChange(path))
156
+ .map(([path, change]) => ({ path, kind: changeKind(change) }));
157
+ }
158
+ function toSummary(version, later) {
159
+ return {
160
+ id: version.id,
161
+ parentId: version.parentId,
162
+ createdAt: version.createdAt,
163
+ message: version.message,
164
+ changes: Object.entries(version.changes)
165
+ .filter(([path]) => isReportableChange(path))
166
+ .map(([path, change]) => ({ path, kind: changeKind(change) })),
167
+ later,
168
+ };
169
+ }
170
+ /**
171
+ * The deck's history newest first. From head (no `fromId`): the chain forward
172
+ * from head -- marked `later` -- then head, then its ancestors. From a named
173
+ * version: that version and its ancestors, as it has always been.
174
+ *
175
+ * The forward walk is why a restore no longer makes the versions after it
176
+ * vanish from the panel and from `list-versions`. It follows one chain of
177
+ * children, newest child at each step. After a save there is nothing to follow,
178
+ * because the new version is a leaf, so the forward rows drop away on their own
179
+ * -- the abandoned versions are still on disk, just no longer reachable forward
180
+ * from head.
181
+ *
182
+ * Naming a version asks a different question -- how did THIS version come to be
183
+ * -- so that form stays a walk back and is unchanged.
184
+ */
185
+ export function versionSummaries(projectDir, fromId) {
186
+ const startId = fromId ?? store.readHead(projectDir);
187
+ if (!startId)
188
+ return [];
189
+ if (!store.versionExists(projectDir, startId))
190
+ throw new Error(`No such version: ${startId}`);
191
+ const forward = fromId ? [] : store.forwardChain(projectDir, startId);
192
+ return [
193
+ // Nearest child first out of the walk, so reverse it: the furthest
194
+ // descendant is the newest thing there is.
195
+ ...[...forward].reverse().map((v) => toSummary(v, true)),
196
+ ...store.versionChain(projectDir, startId).map((v) => toSummary(v, false)),
197
+ ];
198
+ }
199
+ export function listVersions(dir, version) {
200
+ const summaries = versionSummaries(resolveProjectDir(dir), version);
201
+ if (summaries.length === 0) {
202
+ console.log('No versions yet.');
203
+ return;
204
+ }
205
+ for (const v of summaries) {
206
+ const count = v.changes.length;
207
+ const message = v.message ? ` ${v.message}` : '';
208
+ // The same history the panel shows, marked the same way, so the two never
209
+ // disagree about what happened.
210
+ const later = v.later ? ' (later)' : '';
211
+ console.log(`${v.id} ${v.createdAt} ${count} file${count === 1 ? '' : 's'}${later}${message}`);
212
+ }
213
+ }
214
+ // One file's side of a change, as text -- or null when it wasn't there (an add
215
+ // or a delete) and `binary: true` when it must not be rendered as a patch.
216
+ function blobText(projectDir, hash) {
217
+ if (hash === null)
218
+ return null;
219
+ const content = store.getBlob(projectDir, hash);
220
+ return looksBinary(content) ? { text: '', binary: true } : { text: content.toString('utf8'), binary: false };
221
+ }
222
+ function showFileChange(projectDir, filePath, change) {
223
+ const kind = changeKind(change);
224
+ const before = blobText(projectDir, change.before);
225
+ const after = blobText(projectDir, change.after);
226
+ // A sprite or an image must never come out as a garbled patch; a line saying
227
+ // what happened to it is the whole useful answer.
228
+ if (before?.binary || after?.binary) {
229
+ console.log(`${filePath} ${kind}, binary`);
230
+ return;
231
+ }
232
+ console.log(`${filePath} ${kind}`);
233
+ const hunks = unifiedDiff(before?.text ?? '', after?.text ?? '');
234
+ if (!hunks) {
235
+ console.log(' (too large to show as a diff)');
236
+ return;
237
+ }
238
+ for (const line of hunks)
239
+ console.log(line);
240
+ }
241
+ /**
242
+ * What one version changed, as diffs of the CHANGED FILES ONLY -- built from the
243
+ * two blobs each entry in `changes` already names, so nothing new is stored and
244
+ * the whole tree is never read. The CLI counterpart of the panel's row
245
+ * expansion, for an agent working out what a change did.
246
+ */
247
+ export function showVersion(dir, versionId) {
248
+ const projectDir = resolveProjectDir(dir);
249
+ if (!store.versionExists(projectDir, versionId))
250
+ throw new Error(`No such version: ${versionId}`);
251
+ const version = store.readVersion(projectDir, versionId);
252
+ const paths = Object.keys(version.changes).sort();
253
+ console.log(`${version.id} ${version.createdAt} ${paths.length} file${paths.length === 1 ? '' : 's'}`);
254
+ if (version.message)
255
+ console.log(version.message);
256
+ console.log('');
257
+ for (const filePath of paths) {
258
+ showFileChange(projectDir, filePath, version.changes[filePath]);
259
+ console.log('');
260
+ }
261
+ }
262
+ /**
263
+ * The versions from `from` back to `to`, exclusive of `to`, newest first -- or
264
+ * null when `to` is not an ancestor of `from`. Only `parentId` is stored, so
265
+ * this is the only direction the history can be walked directly.
266
+ */
267
+ function chainDownTo(projectDir, from, to) {
268
+ const chain = [];
269
+ const seen = new Set();
270
+ let cursor = from;
271
+ while (cursor && cursor !== to) {
272
+ if (seen.has(cursor))
273
+ throw new Error(`Version history loops at ${cursor}`);
274
+ seen.add(cursor);
275
+ const version = store.readVersion(projectDir, cursor);
276
+ chain.push(version);
277
+ cursor = version.parentId;
278
+ }
279
+ return cursor === to ? chain : null;
280
+ }
281
+ /**
282
+ * What a restore should write, and what it expects to find on disk first.
283
+ *
284
+ * The two directions are mirrors of each other over the SAME set of versions --
285
+ * the ones between head and the target -- read from opposite ends:
286
+ *
287
+ * backward (the target is an ancestor of head): put back the EARLIEST
288
+ * `before`, the state closest to the target; expect the LATEST `after`, the
289
+ * state closest to head.
290
+ * forward (the target is a descendant of head): put back the LATEST `after`,
291
+ * again the state closest to the target; expect the EARLIEST `before`,
292
+ * again the state closest to head.
293
+ *
294
+ * Either way a path no version between the two touched never enters the plan,
295
+ * so unsaved work on it survives.
296
+ */
297
+ function planRestore(projectDir, targetId) {
298
+ const head = store.readHead(projectDir);
299
+ if (!head)
300
+ throw new Error('No versions yet.');
301
+ const backward = chainDownTo(projectDir, head, targetId);
302
+ if (backward)
303
+ return planFromChain(backward, 'backward');
304
+ const forward = chainDownTo(projectDir, targetId, head);
305
+ if (forward)
306
+ return planFromChain(forward, 'forward');
307
+ throw new NotOnThisLine(targetId, head);
308
+ }
309
+ // `chain` is always newest first, so "first seen wins" reads the end nearest the
310
+ // newer version and "last write wins" reads the end nearest the older one.
311
+ function planFromChain(chain, direction) {
312
+ const plan = {
313
+ wanted: {},
314
+ expected: {},
315
+ offChain: direction === 'backward' ? chain.length : 0,
316
+ };
317
+ for (const version of chain) {
318
+ for (const [p, change] of Object.entries(version.changes)) {
319
+ if (direction === 'backward') {
320
+ plan.wanted[p] = change.before;
321
+ if (!(p in plan.expected))
322
+ plan.expected[p] = change.after;
323
+ }
324
+ else {
325
+ if (!(p in plan.wanted))
326
+ plan.wanted[p] = change.after;
327
+ plan.expected[p] = change.before;
328
+ }
329
+ }
330
+ }
331
+ return plan;
332
+ }
333
+ function removeEmptyParents(projectDir, relPath) {
334
+ let dir = path.dirname(path.join(projectDir, relPath));
335
+ while (dir.startsWith(projectDir) && dir !== projectDir) {
336
+ try {
337
+ if (fs.readdirSync(dir).length > 0)
338
+ return;
339
+ fs.rmdirSync(dir);
340
+ }
341
+ catch {
342
+ return;
343
+ }
344
+ dir = path.dirname(dir);
345
+ }
346
+ }
347
+ function applyRestore(projectDir, actions) {
348
+ for (const [rel, hash] of actions) {
349
+ const abs = path.join(projectDir, rel);
350
+ if (hash === null) {
351
+ fs.rmSync(abs, { force: true });
352
+ removeEmptyParents(projectDir, rel);
353
+ continue;
354
+ }
355
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
356
+ fs.writeFileSync(abs, store.getBlob(projectDir, hash));
357
+ }
358
+ }
359
+ // Work no version has recorded, which a restore would overwrite. The paths ARE
360
+ // the answer to "what would I lose", so they ride on the error and each caller
361
+ // says what to do about it -- the CLI offers `--force`, the panel has no such
362
+ // thing and says to save a version first.
363
+ export class UnsavedChanges extends Error {
364
+ paths;
365
+ constructor(paths) {
366
+ const count = paths.length;
367
+ super(`Unsaved changes would be lost in ${count} file${count === 1 ? '' : 's'}:\n` +
368
+ paths.map((p) => ` ${p}`).join('\n'));
369
+ this.name = 'UnsavedChanges';
370
+ this.paths = paths;
371
+ }
372
+ }
373
+ // A version on a different branch: neither an ancestor of head nor a descendant
374
+ // of it. Restore moves along one line in either direction; getting from here to
375
+ // there would mean merging, which is not something this build does silently.
376
+ export class NotOnThisLine extends Error {
377
+ constructor(targetId, head) {
378
+ super(`Version ${targetId} is on a different branch than ${head}. ` +
379
+ 'Restore moves back or forward along one line, not across a fork.');
380
+ this.name = 'NotOnThisLine';
381
+ }
382
+ }
383
+ // The restore itself, with no output. Throws on unsaved changes unless forced --
384
+ // the message names every conflicting path, since that list IS the answer to
385
+ // "what would I lose".
386
+ export function applyVersionRestore(projectDir, targetId, opts = {}) {
387
+ if (!store.versionExists(projectDir, targetId))
388
+ throw new Error(`No such version: ${targetId}`);
389
+ if (store.readHead(projectDir) === targetId) {
390
+ return { alreadyThere: true, applied: [], offChain: 0 };
391
+ }
392
+ const plan = planRestore(projectDir, targetId);
393
+ // Only paths whose content would actually move are checked and applied: a
394
+ // path already holding what the restore wants is neither a conflict nor work.
395
+ const actions = [];
396
+ const conflicts = [];
397
+ for (const [rel, wanted] of Object.entries(plan.wanted)) {
398
+ const current = store.hashWorkingFile(projectDir, rel);
399
+ if (current === wanted)
400
+ continue;
401
+ // The cover is exempt: it is dirty by construction, so refusing on it would
402
+ // refuse every restore. It still gets written back.
403
+ if (current !== plan.expected[rel] && isReportableChange(rel))
404
+ conflicts.push(rel);
405
+ actions.push([rel, wanted]);
406
+ }
407
+ if (conflicts.length > 0 && !opts.force)
408
+ throw new UnsavedChanges(conflicts.sort());
409
+ applyRestore(projectDir, actions.sort((a, b) => a[0].localeCompare(b[0])));
410
+ // Head moves to the target, so the next save continues from there and the
411
+ // versions we walked past fork off. They stay on disk and stay loadable by
412
+ // id -- they are just no longer on head's chain.
413
+ store.writeHead(projectDir, targetId);
414
+ return { alreadyThere: false, applied: actions, offChain: plan.offChain };
415
+ }
416
+ export function restoreVersion(dir, targetId, opts = {}) {
417
+ const projectDir = resolveProjectDir(dir);
418
+ let outcome;
419
+ try {
420
+ outcome = applyVersionRestore(projectDir, targetId, opts);
421
+ }
422
+ catch (e) {
423
+ // Only the command line has a --force, so only the command line mentions it.
424
+ if (e instanceof UnsavedChanges) {
425
+ throw new Error(`${e.message}\nSave a version first, or pass --force to restore anyway.`);
426
+ }
427
+ throw e;
428
+ }
429
+ if (outcome.alreadyThere) {
430
+ console.log(`Already at version ${targetId}. Nothing to restore.`);
431
+ return;
432
+ }
433
+ const applied = outcome.applied;
434
+ if (applied.length === 0) {
435
+ console.log(`The working tree already matched version ${targetId}.`);
436
+ }
437
+ else {
438
+ console.log(`Restored ${applied.length} file${applied.length === 1 ? '' : 's'} to version ${targetId}.`);
439
+ for (const [rel, hash] of applied)
440
+ console.log(` ${hash === null ? 'deleted' : 'restored'} ${rel}`);
441
+ }
442
+ // A silent head move is the thing people discover a week later. What the move
443
+ // left behind needs no announcement: `list-versions` shows those versions,
444
+ // marked `(later)`.
445
+ console.log(`head is now ${targetId}.`);
446
+ }
@@ -1,7 +1,7 @@
1
1
  import * as path from 'path';
2
2
  import * as fs from 'fs';
3
3
  import { IMPORTS_DIR } from './imports.js';
4
- import { resolveFileTypes } from './editorConfig.js';
4
+ import { dataModeOf, resolveFileTypes } from './editorConfig.js';
5
5
  // Mirrors castle-web-sdk's IMPORTS_PREFIX, which is the definition deck code
6
6
  // uses. Duplicated rather than imported: the CLI compiles under node16 module
7
7
  // resolution and the SDK's published types don't, and one short string is not
@@ -25,13 +25,22 @@ const IMPORTS_PREFIX = '@imports/';
25
25
  // declares anything, kept so decks written before the declaration existed behave
26
26
  // as they did.
27
27
  const FALLBACK_DATA_FILE_EXTS = ['.scene', '.drawing', '.pxart', '.sprite'];
28
- function dataFileExts(root) {
28
+ // Extension -> how to hand it back when a module imports it. "json" parses it,
29
+ // "text" returns the raw string; a format that is neither (a `.md` an editor
30
+ // owns, say) simply isn't in here and stays an ordinary module.
31
+ function dataFileModes(root) {
29
32
  const resolved = resolveFileTypes(root);
30
33
  // Declared file types with no data entries mean exactly that: a deck whose
31
34
  // files are all modules. Only "nobody said anything" falls back.
32
35
  if (!resolved)
33
- return FALLBACK_DATA_FILE_EXTS;
34
- return resolved.filter((t) => t.data === true).map((t) => t.ext);
36
+ return new Map(FALLBACK_DATA_FILE_EXTS.map((ext) => [ext, 'json']));
37
+ const modes = new Map();
38
+ for (const type of resolved) {
39
+ const mode = dataModeOf(type);
40
+ if (mode)
41
+ modes.set(type.ext, mode);
42
+ }
43
+ return modes;
35
44
  }
36
45
  // `@imports/<alias>/...` is how a deck names a file of another deck it imports,
37
46
  // in JS as in everything else (see castle-web-sdk's resolveDeckFile). Imports are
@@ -62,19 +71,23 @@ export function importsAliasPlugin() {
62
71
  export function sceneFilesPlugin() {
63
72
  // Resolved once per plugin instance, from the root vite is configured with --
64
73
  // the deck being served or bundled.
65
- let exts = FALLBACK_DATA_FILE_EXTS;
74
+ let modes = new Map(FALLBACK_DATA_FILE_EXTS.map((ext) => [ext, 'json']));
66
75
  return {
67
76
  name: 'castle-scene-files',
68
77
  configResolved(config) {
69
- exts = dataFileExts(config.root);
78
+ modes = dataFileModes(config.root);
70
79
  },
71
80
  load(id) {
72
81
  const [filePath, query] = id.split('?');
73
82
  if (query !== undefined)
74
83
  return null;
75
- if (exts.some((ext) => filePath.endsWith(ext))) {
84
+ for (const [ext, mode] of modes) {
85
+ if (!filePath.endsWith(ext))
86
+ continue;
76
87
  const text = fs.readFileSync(filePath, 'utf-8');
77
- return `export default JSON.parse(${JSON.stringify(text)});\n`;
88
+ return mode === 'text'
89
+ ? `export default ${JSON.stringify(text)};\n`
90
+ : `export default JSON.parse(${JSON.stringify(text)});\n`;
78
91
  }
79
92
  return null;
80
93
  },
@@ -123,5 +123,5 @@
123
123
  "main": "main.jsx",
124
124
  "autoUpdateWhenImported": true,
125
125
  "title": "physics-2d",
126
- "publishedVersion": "2026-08-14T21:20:31.152Z"
126
+ "publishedVersion": "2026-08-16T04:02:06.732Z"
127
127
  }
@@ -7,6 +7,7 @@
7
7
  import React, { useEffect, useRef, useState } from 'react';
8
8
  import { onBeforeRestart, onSaveReloadState, takeReloadState, writeFile } from 'castle-web-sdk';
9
9
  import { getFileKind } from '../engine/files';
10
+ import { hasEditorModule, loadEditorModule } from './editorRegistry';
10
11
  import { useLiveDeckFiles } from '../engine/liveReload';
11
12
  import { collectAssets } from '../engine/assets';
12
13
  import { MainEditor, styles } from '../engine/ui';
@@ -65,7 +66,33 @@ function useFileSaver() {
65
66
  return { schedule, hasPending };
66
67
  }
67
68
 
68
- export function SingleEditor({ path, editor }) {
69
+ // A file type whose declaring deck named an editor module: load that module and
70
+ // render it, rather than matching the path against this kit's own list. This is
71
+ // what lets a deck hold two kits -- each type reaches its own declarer's editor,
72
+ // and neither kit has to know the other exists.
73
+ function DeclaredEditor({ path, module: modulePath, text, onChange, files, onChangeFile }) {
74
+ const [Editor, setEditor] = useState(null);
75
+ const [error, setError] = useState(null);
76
+ useEffect(() => {
77
+ let alive = true;
78
+ setEditor(null);
79
+ setError(null);
80
+ loadEditorModule(modulePath).then(
81
+ (loaded) => alive && setEditor(() => loaded),
82
+ (e) => alive && setError(e instanceof Error ? e.message : String(e)),
83
+ );
84
+ return () => {
85
+ alive = false;
86
+ };
87
+ }, [modulePath]);
88
+ if (error) return <div className={styles.editorBody}>{error}</div>;
89
+ if (!Editor) return <div className={styles.editorBody}>loading editor…</div>;
90
+ return (
91
+ <Editor path={path} text={text} onChange={onChange} files={files} onChangeFile={onChangeFile} />
92
+ );
93
+ }
94
+
95
+ export function SingleEditor({ path, editor, editorModule }) {
69
96
  // Selection survives a code-change reload: stashed via the SDK's save-state
70
97
  // hook right before the reload, picked back up here on boot.
71
98
  const stashKey = `single-editor:${path}`;
@@ -96,7 +123,21 @@ export function SingleEditor({ path, editor }) {
96
123
  const kind = editor || getFileKind(path);
97
124
  const text = files[path] ?? '';
98
125
  let body = null;
99
- if (kind === 'scene') {
126
+ // A declared module wins over this kit's own extension matching -- including
127
+ // over its own types, so the kit's editors are reachable the same way anyone
128
+ // else's are once physics-2d names them in castle.json.
129
+ if (editorModule && hasEditorModule(editorModule)) {
130
+ body = (
131
+ <DeclaredEditor
132
+ path={path}
133
+ module={editorModule}
134
+ text={text}
135
+ onChange={onChange}
136
+ files={files}
137
+ onChangeFile={onChangeFile}
138
+ />
139
+ );
140
+ } else if (kind === 'scene') {
100
141
  body = (
101
142
  <SceneEditor
102
143
  path={path}
@@ -0,0 +1,34 @@
1
+ // Every editor module any deck here declares -- this deck's own and each
2
+ // import's -- keyed by its path from the deck root, which is exactly what
3
+ // `castle.json` `editor` resolves to (see resolveFileTypes in the CLI).
4
+ //
5
+ // A glob rather than a dynamic `import(path)`: the specifier has to be static
6
+ // for the module to survive bundling into a deck's single published file, the
7
+ // same reason behaviorRegistry and files.js glob. NOT eager, so an editor is a
8
+ // separate chunk that the play route never evaluates.
9
+ //
10
+ // Editors live under `editors/` by convention; that is what makes a third
11
+ // party's editor reachable from here without this kit knowing it exists.
12
+ const modules = {
13
+ ...import.meta.glob('/imports/*/editors/**/*.{jsx,js}'),
14
+ ...import.meta.glob('/editors/**/*.{jsx,js}'),
15
+ };
16
+
17
+ export function hasEditorModule(path) {
18
+ return Boolean(path && modules[`/${path}`]);
19
+ }
20
+
21
+ // Load one, returning its default export. Throws a legible error rather than
22
+ // letting the caller see `undefined is not a function` three frames later.
23
+ export async function loadEditorModule(path) {
24
+ const load = modules[`/${path}`];
25
+ if (!load) {
26
+ throw new Error(
27
+ `No editor module at "${path}". Editors must live under editors/ so the registry glob can reach them.`,
28
+ );
29
+ }
30
+ const mod = await load();
31
+ const Editor = mod.default ?? mod.Editor;
32
+ if (!Editor) throw new Error(`"${path}" has no default export to render as an editor.`);
33
+ return Editor;
34
+ }
@@ -12,13 +12,22 @@ if (!root) throw new Error('Missing root element');
12
12
  // File browsing and the code/text editor are now builtin shell panels, so the
13
13
  // kit only renders the deck (play) and its rich per-file editors (scene/pxart):
14
14
  // (no edit / ?edit=0) -> play the deck (playtest panel)
15
- // ?file=<path>[&editor=<id>] -> a single rich editor for that file
15
+ // ?file=<path>[&editorModule=<path>] -> a single rich editor for that file
16
16
  const params = new URLSearchParams(window.location.search);
17
17
  function pick() {
18
18
  const initialScene = params.get('scene') ?? undefined;
19
19
  if (!isEdit()) return <PlayOnly initialScene={initialScene} />;
20
20
  const file = params.get('file');
21
- if (file) return <SingleEditor path={file} editor={params.get('editor') ?? undefined} />;
21
+ if (file)
22
+ return (
23
+ <SingleEditor
24
+ path={file}
25
+ editor={params.get('editor') ?? undefined}
26
+ // Which module edits this file, resolved by the serve from whichever
27
+ // deck declared the type -- see `editor` in castle.json.
28
+ editorModule={params.get('editorModule') ?? undefined}
29
+ />
30
+ );
22
31
  return <PlayOnly initialScene={initialScene} />;
23
32
  }
24
33
  createRoot(root).render(<ErrorBoundary>{pick()}</ErrorBoundary>);
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "../../sdk": {
30
30
  "name": "castle-web-sdk",
31
- "version": "0.4.13",
31
+ "version": "0.4.12",
32
32
  "devDependencies": {
33
33
  "eslint": "^9.0.0",
34
34
  "jscpd": "^4.0.5",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.121",
3
+ "version": "0.4.123",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"