castle-web-cli 0.4.114 → 0.4.115
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/dist/castleJson.d.ts +1 -0
- package/dist/editorConfig.d.ts +34 -0
- package/dist/editorConfig.js +101 -0
- package/dist/ide.js +245 -120
- package/dist/imports.d.ts +6 -0
- package/dist/imports.js +90 -7
- package/dist/init.js +15 -2
- package/dist/serve.js +16 -0
- package/dist/shell/assets/index-CUamb8rK.js +144 -0
- package/dist/shell/assets/index-Y6cJRRCX.css +1 -0
- package/dist/shell/index.html +2 -2
- package/dist/unsupportedMedia.d.ts +1 -0
- package/dist/unsupportedMedia.js +54 -0
- package/dist/vitePlugins.js +12 -19
- package/kits/basic-2d/castle.json +1 -1
- package/kits/basic-2d/editors/behaviorRegistry.js +5 -7
- package/kits/physics-2d/CLAUDE.md +42 -23
- package/kits/physics-2d/{physics/behaviors → behaviors}/AnalogStick.jsx +3 -3
- package/kits/physics-2d/behaviors/Collider.jsx +1 -1
- package/kits/physics-2d/{physics/behaviors → behaviors}/Draggable.jsx +3 -3
- package/kits/physics-2d/{physics/behaviors → behaviors}/Joints.jsx +4 -4
- package/kits/physics-2d/{physics/behaviors → behaviors}/RigidBody.jsx +2 -2
- package/kits/physics-2d/{physics/behaviors → behaviors}/Slingshot.jsx +3 -3
- package/kits/physics-2d/behaviors/Sound.jsx +152 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +98 -33
- package/kits/physics-2d/behaviors/Tone.jsx +83 -0
- package/kits/physics-2d/behaviors/Video.jsx +111 -0
- package/kits/physics-2d/behaviors/tint.js +24 -9
- package/kits/physics-2d/castle.json +64 -2
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +7 -3
- package/kits/physics-2d/editors/ImageViewer.jsx +206 -0
- package/kits/physics-2d/editors/MediaPlayer.jsx +57 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +7 -3
- package/kits/physics-2d/editors/SingleEditor.jsx +8 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +5 -7
- package/kits/physics-2d/editors/mediaFile.js +20 -0
- package/kits/physics-2d/editors/mediaViewer.module.css +128 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +1 -0
- package/kits/physics-2d/engine/art.js +105 -0
- package/kits/physics-2d/engine/assets.js +11 -3
- package/kits/physics-2d/engine/audioContext.js +34 -0
- package/kits/physics-2d/engine/collider.js +28 -17
- package/kits/physics-2d/engine/files.js +74 -0
- package/kits/physics-2d/engine/media.js +212 -0
- package/kits/physics-2d/{physics → engine/physics}/PhysicsSystem.js +1 -1
- package/kits/physics-2d/{physics → engine/physics}/jointArt.js +1 -2
- package/kits/physics-2d/{physics → engine/physics}/matterBridge.js +2 -9
- package/kits/physics-2d/engine/scene.js +12 -0
- package/kits/physics-2d/engine/tone.js +112 -0
- package/kits/physics-2d/systems/media.js +34 -0
- package/kits/physics-2d/systems/physics.js +12 -3
- package/package.json +1 -1
- package/dist/shell/assets/index-CqpY1xZR.js +0 -144
- package/dist/shell/assets/index-DCwVFL5u.css +0 -1
- package/kits/physics-2d/physics/index.js +0 -26
- /package/kits/physics-2d/{physics → engine/physics}/controls.js +0 -0
- /package/kits/physics-2d/{physics → engine/physics}/joints.js +0 -0
package/dist/castleJson.d.ts
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface EditorPanelSpec {
|
|
2
|
+
type?: unknown;
|
|
3
|
+
file?: unknown;
|
|
4
|
+
}
|
|
5
|
+
interface EditorPanelGroup {
|
|
6
|
+
tabs?: unknown;
|
|
7
|
+
}
|
|
8
|
+
interface EditorPanelColumn {
|
|
9
|
+
column?: unknown;
|
|
10
|
+
}
|
|
11
|
+
export type InitialPanel = EditorPanelSpec | EditorPanelGroup | EditorPanelColumn;
|
|
12
|
+
export interface FileTypeConfig {
|
|
13
|
+
ext: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
new?: string;
|
|
16
|
+
icon?: string;
|
|
17
|
+
editor?: string;
|
|
18
|
+
data?: boolean;
|
|
19
|
+
unsupported?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ResolvedFileType extends FileTypeConfig {
|
|
22
|
+
from?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface EditorConfig {
|
|
25
|
+
initialPanels?: InitialPanel[];
|
|
26
|
+
hiddenPaths?: string[];
|
|
27
|
+
visiblePaths?: string[];
|
|
28
|
+
fileTypes?: FileTypeConfig[];
|
|
29
|
+
extensions?: string[];
|
|
30
|
+
defaultPlayFile?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function readEditorConfig(deckDir: string): EditorConfig;
|
|
33
|
+
export declare function resolveFileTypes(deckDir: string): ResolvedFileType[] | null;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A deck's `castle.json` `editor` block, and the resolution of the one part of
|
|
2
|
+
// it that is not the deck's alone: its file types.
|
|
3
|
+
//
|
|
4
|
+
// A deck's vocabulary -- which extensions exist, what they are called, which
|
|
5
|
+
// ones a kit editor opens, which ones are data -- is declared, never built in.
|
|
6
|
+
// The declarations come from the deck itself AND from every deck it imports,
|
|
7
|
+
// because a kit is just an imported deck: importing one is what gives a deck
|
|
8
|
+
// scenes and drawings, so the import has to be able to say so. Imports are
|
|
9
|
+
// flattened into one `imports/` dir at the deck root (a transitively-pulled kit
|
|
10
|
+
// sits there like any other), so resolving is a read of each import's own
|
|
11
|
+
// castle.json -- there is no graph to walk.
|
|
12
|
+
//
|
|
13
|
+
// Everything else in the block (`initialPanels`, `visiblePaths`/`hiddenPaths`,
|
|
14
|
+
// `defaultPlayFile`) stays the deck's own, read from the deck root only.
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { readCastleJson } from './castleJson.js';
|
|
18
|
+
import { IMPORTS_DIR } from './imports.js';
|
|
19
|
+
// An array with no usable entries stays an (empty) array rather than becoming
|
|
20
|
+
// undefined: "I declare no file types" is a real answer, distinct from "I say
|
|
21
|
+
// nothing about file types", and only the second one defers to the imports.
|
|
22
|
+
function asFileTypes(value) {
|
|
23
|
+
if (!Array.isArray(value))
|
|
24
|
+
return undefined;
|
|
25
|
+
return value.filter((e) => Boolean(e) && typeof e === 'object' && typeof e.ext === 'string');
|
|
26
|
+
}
|
|
27
|
+
function asStringArray(value) {
|
|
28
|
+
if (Array.isArray(value) && value.every((e) => typeof e === 'string')) {
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
// Read one deck's `castle.json` `editor` block. Tolerates an absent /
|
|
34
|
+
// unparseable file (returns {}). Folds the legacy top-level `editorExtensions`
|
|
35
|
+
// into `editor.extensions` for decks saved before the `editor` block existed.
|
|
36
|
+
export function readEditorConfig(deckDir) {
|
|
37
|
+
let data;
|
|
38
|
+
try {
|
|
39
|
+
data = JSON.parse(fs.readFileSync(path.join(deckDir, 'castle.json'), 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
const editor = data.editor ?? {};
|
|
45
|
+
return {
|
|
46
|
+
initialPanels: Array.isArray(editor.initialPanels) ? editor.initialPanels : undefined,
|
|
47
|
+
hiddenPaths: asStringArray(editor.hiddenPaths),
|
|
48
|
+
visiblePaths: asStringArray(editor.visiblePaths),
|
|
49
|
+
fileTypes: asFileTypes(editor.fileTypes),
|
|
50
|
+
extensions: asStringArray(editor.extensions) ?? asStringArray(data.editorExtensions),
|
|
51
|
+
defaultPlayFile: typeof editor.defaultPlayFile === 'string' ? editor.defaultPlayFile : undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// The deck's imports, in the order the deck pinned them (its castle.json
|
|
55
|
+
// `imports` map), then anything on disk that isn't pinned. Pin order puts an
|
|
56
|
+
// import the deck asked for ahead of one that came in behind it, which is the
|
|
57
|
+
// order the deck itself would expect to be read in.
|
|
58
|
+
function importAliases(deckDir) {
|
|
59
|
+
let onDisk;
|
|
60
|
+
try {
|
|
61
|
+
onDisk = fs
|
|
62
|
+
.readdirSync(path.join(deckDir, IMPORTS_DIR), { withFileTypes: true })
|
|
63
|
+
.filter((entry) => entry.isDirectory())
|
|
64
|
+
.map((entry) => entry.name)
|
|
65
|
+
.sort();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const pinned = Object.keys(readCastleJson(deckDir)?.imports ?? {}).filter((alias) => onDisk.includes(alias));
|
|
71
|
+
return [...pinned, ...onDisk.filter((alias) => !pinned.includes(alias))];
|
|
72
|
+
}
|
|
73
|
+
// Every file type this deck understands: its own, plus every import's. Keyed by
|
|
74
|
+
// extension, first declaration wins -- so the deck overrides what it imports,
|
|
75
|
+
// and among imports the one it pinned first does (the same order
|
|
76
|
+
// syncImportDependencies merges dependencies in).
|
|
77
|
+
//
|
|
78
|
+
// Returns null when NOTHING anywhere declares file types, which is a different
|
|
79
|
+
// answer from an empty list: a deck that declares none has none, while a deck
|
|
80
|
+
// that says nothing is one written before file types existed, and callers with a
|
|
81
|
+
// legacy fallback use it only for that one.
|
|
82
|
+
export function resolveFileTypes(deckDir) {
|
|
83
|
+
const byExt = new Map();
|
|
84
|
+
let anyDeclared = false;
|
|
85
|
+
const add = (types, from) => {
|
|
86
|
+
anyDeclared = true;
|
|
87
|
+
for (const type of types) {
|
|
88
|
+
if (!byExt.has(type.ext))
|
|
89
|
+
byExt.set(type.ext, from ? { ...type, from } : { ...type });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const own = readEditorConfig(deckDir).fileTypes;
|
|
93
|
+
if (own)
|
|
94
|
+
add(own);
|
|
95
|
+
for (const alias of importAliases(deckDir)) {
|
|
96
|
+
const imported = readEditorConfig(path.join(deckDir, IMPORTS_DIR, alias)).fileTypes;
|
|
97
|
+
if (imported)
|
|
98
|
+
add(imported, alias);
|
|
99
|
+
}
|
|
100
|
+
return anyDeclared ? [...byExt.values()] : null;
|
|
101
|
+
}
|
package/dist/ide.js
CHANGED
|
@@ -15,6 +15,8 @@ import headlessPkg from "@xterm/headless";
|
|
|
15
15
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
16
16
|
import { WebSocketServer } from "ws";
|
|
17
17
|
import { IMPORTS_DIR, importStatuses, updateImport } from "./imports.js";
|
|
18
|
+
import { readEditorConfig, resolveFileTypes, } from "./editorConfig.js";
|
|
19
|
+
import { UNSUPPORTED_MEDIA } from "./unsupportedMedia.js";
|
|
18
20
|
import { IMPORT_API_PREFIX, handleImportApi } from "./importBrowse.js";
|
|
19
21
|
import { readRequestBody, sendJson } from "./httpJson.js";
|
|
20
22
|
import { envForUserShell, installCliShims } from "./byo-auth.js";
|
|
@@ -100,38 +102,34 @@ export const FILES_API_PREFIX = "/__castle/files/";
|
|
|
100
102
|
// Directories never surfaced in the file list / never read or written through
|
|
101
103
|
// the builtin editor: VCS, deck-private state, and dependency trees.
|
|
102
104
|
const FILES_IGNORE_DIRS = new Set([".git", ".castle", "node_modules", "dist"]);
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
exts.push(ext);
|
|
132
|
-
}
|
|
133
|
-
return exts;
|
|
134
|
-
}
|
|
105
|
+
// Ceiling on one uploaded file. A deck is saved whole (source tar + bundle), so
|
|
106
|
+
// a huge asset is a problem for the deck long before it is a problem here --
|
|
107
|
+
// refuse it at the door with a message rather than let it land and break `save`.
|
|
108
|
+
const MAX_UPLOAD_BYTES = 32 * 1024 * 1024;
|
|
109
|
+
// Content types for `files/raw`, which serves a deck file as bytes (the shell's
|
|
110
|
+
// image/audio viewers read their file through it rather than through `read`,
|
|
111
|
+
// which is text-only). Anything unlisted is served as opaque bytes -- a viewer
|
|
112
|
+
// that asked for it knows what it is.
|
|
113
|
+
const RAW_MIME = {
|
|
114
|
+
".png": "image/png",
|
|
115
|
+
".jpg": "image/jpeg",
|
|
116
|
+
".jpeg": "image/jpeg",
|
|
117
|
+
".gif": "image/gif",
|
|
118
|
+
".webp": "image/webp",
|
|
119
|
+
".avif": "image/avif",
|
|
120
|
+
".bmp": "image/bmp",
|
|
121
|
+
".svg": "image/svg+xml",
|
|
122
|
+
".mp3": "audio/mpeg",
|
|
123
|
+
".wav": "audio/wav",
|
|
124
|
+
".ogg": "audio/ogg",
|
|
125
|
+
".m4a": "audio/mp4",
|
|
126
|
+
".aac": "audio/aac",
|
|
127
|
+
".flac": "audio/flac",
|
|
128
|
+
".webm": "video/webm",
|
|
129
|
+
".mp4": "video/mp4",
|
|
130
|
+
".m4v": "video/x-m4v",
|
|
131
|
+
".mov": "video/quicktime",
|
|
132
|
+
};
|
|
135
133
|
// Files under `imports/` came from another deck. They are readable like any
|
|
136
134
|
// other deck file -- the engine, editors and pickers all reference them -- but a
|
|
137
135
|
// deck can't edit them, because editing one would mean editing somebody else's
|
|
@@ -204,86 +202,16 @@ function listDeckFiles(deckDir) {
|
|
|
204
202
|
out.sort((a, b) => a.localeCompare(b));
|
|
205
203
|
return out;
|
|
206
204
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (Array.isArray(value) && value.every((e) => typeof e === "string")) {
|
|
215
|
-
return value;
|
|
216
|
-
}
|
|
217
|
-
return undefined;
|
|
218
|
-
}
|
|
219
|
-
// Read the deck's `castle.json` `editor` block. Tolerates an absent /
|
|
220
|
-
// unparseable file (returns {}). Folds the legacy top-level `editorExtensions`
|
|
221
|
-
// into `editor.extensions` for decks saved before the `editor` block existed.
|
|
222
|
-
function readEditorConfig(deckDir) {
|
|
223
|
-
let data;
|
|
224
|
-
try {
|
|
225
|
-
data = JSON.parse(fs.readFileSync(path.join(deckDir, "castle.json"), "utf8"));
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
return {};
|
|
229
|
-
}
|
|
230
|
-
const editor = data.editor ?? {};
|
|
231
|
-
const config = {
|
|
232
|
-
initialPanels: Array.isArray(editor.initialPanels)
|
|
233
|
-
? editor.initialPanels
|
|
234
|
-
: undefined,
|
|
235
|
-
hiddenPaths: asStringArray(editor.hiddenPaths),
|
|
236
|
-
visiblePaths: asStringArray(editor.visiblePaths),
|
|
237
|
-
fileTypes: asFileTypes(editor.fileTypes),
|
|
238
|
-
extensions: asStringArray(editor.extensions) ?? asStringArray(data.editorExtensions),
|
|
239
|
-
defaultPlayFile: typeof editor.defaultPlayFile === "string" ? editor.defaultPlayFile : undefined,
|
|
240
|
-
};
|
|
241
|
-
return config;
|
|
242
|
-
}
|
|
243
|
-
// Extensions the kit owns a rich editor for. A kitless (bare) deck has no kit
|
|
244
|
-
// entry, so this is empty and the builtin editor renders everything. A deck may
|
|
245
|
-
// override the default list via `castle.json` `editor.extensions`.
|
|
246
|
-
// Where the kit lives for this deck: the deck itself when it holds the kit's
|
|
247
|
-
// files, else the import that provides one. A deck built on an imported kit has
|
|
248
|
-
// no `main.jsx` of its own -- its entry point is the kit's, at
|
|
249
|
-
// `imports/<alias>/main.jsx` -- so looking only in the deck root would read as
|
|
250
|
-
// "no kit" and hand every .scene / .pxart to the builtin text editor.
|
|
251
|
-
function findKitRoot(deckDir) {
|
|
252
|
-
if (fs.existsSync(path.join(deckDir, "main.jsx")))
|
|
253
|
-
return deckDir;
|
|
254
|
-
const importsDir = path.join(deckDir, IMPORTS_DIR);
|
|
255
|
-
let aliases;
|
|
256
|
-
try {
|
|
257
|
-
aliases = fs.readdirSync(importsDir);
|
|
258
|
-
}
|
|
259
|
-
catch {
|
|
260
|
-
return null;
|
|
205
|
+
// Extensions a kit owns a rich editor for, from the deck's resolved file types
|
|
206
|
+
// -- which say outright which extensions get a kit editor, and which kit
|
|
207
|
+
// declared each one. A deck that declares none and imports none has no kit
|
|
208
|
+
// editors, and the builtin code editor renders everything.
|
|
209
|
+
function kitEditorExtensions(config, fileTypes) {
|
|
210
|
+
if (fileTypes) {
|
|
211
|
+
return fileTypes.filter((t) => t.editor === "kit").map((t) => t.ext);
|
|
261
212
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (fs.existsSync(path.join(root, "main.jsx")))
|
|
265
|
-
return root;
|
|
266
|
-
}
|
|
267
|
-
return null;
|
|
268
|
-
}
|
|
269
|
-
function kitEditorExtensions(deckDir) {
|
|
270
|
-
const config = readEditorConfig(deckDir);
|
|
271
|
-
// Declared file types win: they say outright which extensions the kit edits.
|
|
272
|
-
if (config.fileTypes) {
|
|
273
|
-
return config.fileTypes.filter((t) => t.editor === "kit").map((t) => t.ext);
|
|
274
|
-
}
|
|
275
|
-
const configured = config.extensions;
|
|
276
|
-
if (configured)
|
|
277
|
-
return configured;
|
|
278
|
-
const kitRoot = findKitRoot(deckDir);
|
|
279
|
-
if (!kitRoot)
|
|
280
|
-
return [];
|
|
281
|
-
// Prefer the kit's own getFileKind (authoritative per deck); fall back to the
|
|
282
|
-
// known rich content types if it couldn't be read/parsed.
|
|
283
|
-
const parsed = parseKitEditorExtensions(kitRoot);
|
|
284
|
-
if (parsed && parsed.length > 0)
|
|
285
|
-
return parsed;
|
|
286
|
-
return FALLBACK_KIT_EDITOR_EXTS;
|
|
213
|
+
// A deck saved before file types existed named its kit extensions directly.
|
|
214
|
+
return config.extensions ?? [];
|
|
287
215
|
}
|
|
288
216
|
// Apply the deck's `editor.hiddenPaths` / `editor.visiblePaths` globs to a file
|
|
289
217
|
// list (deck-relative POSIX paths). Only hidden -> drop matches; only visible ->
|
|
@@ -325,6 +253,30 @@ function filterImportedFiles(deckDir, imported) {
|
|
|
325
253
|
}
|
|
326
254
|
return out;
|
|
327
255
|
}
|
|
256
|
+
// Read a raw (binary) request body, refusing anything over `limit`. Uploads are
|
|
257
|
+
// arbitrary device files, so the cap is enforced as bytes arrive rather than
|
|
258
|
+
// after buffering the whole thing.
|
|
259
|
+
function readRequestBuffer(req, limit) {
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
const chunks = [];
|
|
262
|
+
let size = 0;
|
|
263
|
+
let over = false;
|
|
264
|
+
req.on("data", (c) => {
|
|
265
|
+
size += c.length;
|
|
266
|
+
// Past the cap, keep draining but stop KEEPING the bytes. Destroying the
|
|
267
|
+
// request here would reset the connection and the client would see a
|
|
268
|
+
// network failure instead of the reason it was refused.
|
|
269
|
+
if (size > limit) {
|
|
270
|
+
over = true;
|
|
271
|
+
chunks.length = 0;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
chunks.push(c);
|
|
275
|
+
});
|
|
276
|
+
req.on("end", () => over ? reject(new RangeError("too large")) : resolve(Buffer.concat(chunks)));
|
|
277
|
+
req.on("error", reject);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
328
280
|
function withJsonBody(req, res, handler) {
|
|
329
281
|
void (async () => {
|
|
330
282
|
let body;
|
|
@@ -367,11 +319,16 @@ function handleFilesWrite(deckDir, req, res) {
|
|
|
367
319
|
});
|
|
368
320
|
}
|
|
369
321
|
// Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
|
|
370
|
-
// newly-made folder show in the curated Files tree.
|
|
322
|
+
// newly-made folder show in the curated Files tree.
|
|
323
|
+
function ensureVisiblePath(deckDir, rel) {
|
|
324
|
+
return ensureVisibleGlob(deckDir, `${rel}/**`, `${rel}/__probe__`);
|
|
325
|
+
}
|
|
326
|
+
// Add `glob` to the deck's editor.visiblePaths. Only touches a deck that is
|
|
371
327
|
// ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
|
|
372
328
|
// everything is visible, and adding a glob would wrongly start hiding things.
|
|
373
|
-
// No-op if an existing glob already covers
|
|
374
|
-
|
|
329
|
+
// No-op if an existing glob already covers `probe`, a path the new glob would
|
|
330
|
+
// match. Returns whether it wrote.
|
|
331
|
+
function ensureVisibleGlob(deckDir, glob, probe) {
|
|
375
332
|
const file = path.join(deckDir, "castle.json");
|
|
376
333
|
let data;
|
|
377
334
|
try {
|
|
@@ -385,10 +342,9 @@ function ensureVisiblePath(deckDir, rel) {
|
|
|
385
342
|
: null;
|
|
386
343
|
if (!visible || visible.length === 0)
|
|
387
344
|
return false; // not curated -> all visible
|
|
388
|
-
const glob = `${rel}/**`;
|
|
389
345
|
if (visible.includes(glob))
|
|
390
346
|
return false;
|
|
391
|
-
if (picomatch(visible)(
|
|
347
|
+
if (picomatch(visible)(probe))
|
|
392
348
|
return false; // already covered
|
|
393
349
|
visible.push(glob);
|
|
394
350
|
data.editor.visiblePaths = visible;
|
|
@@ -412,6 +368,142 @@ function handleFilesMkdir(deckDir, req, res) {
|
|
|
412
368
|
sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
|
|
413
369
|
});
|
|
414
370
|
}
|
|
371
|
+
// Upload one file from the user's device: `POST files/upload?path=<rel>` with
|
|
372
|
+
// the file's bytes as the whole body (no multipart -- the client sends one
|
|
373
|
+
// request per file, so the path is all the metadata there is to carry). Refuses
|
|
374
|
+
// to clobber: the client picks a free name first and a collision here means
|
|
375
|
+
// something else took it in between.
|
|
376
|
+
//
|
|
377
|
+
// A curated deck (non-empty visiblePaths) also gets the destination registered,
|
|
378
|
+
// the same way mkdir does -- otherwise an upload lands on disk and is invisible
|
|
379
|
+
// in the very panel that put it there.
|
|
380
|
+
function handleFilesUpload(deckDir, req, res) {
|
|
381
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
382
|
+
const target = resolveDeckPath(deckDir, url.searchParams.get("path"), {
|
|
383
|
+
mutation: true,
|
|
384
|
+
});
|
|
385
|
+
if (!target.ok)
|
|
386
|
+
return sendJson(res, 400, { error: target.error });
|
|
387
|
+
const tooBig = () => {
|
|
388
|
+
const mb = Math.round(MAX_UPLOAD_BYTES / (1024 * 1024));
|
|
389
|
+
sendJson(res, 413, {
|
|
390
|
+
error: `${path.posix.basename(target.rel)} is larger than ${mb} MB.`,
|
|
391
|
+
});
|
|
392
|
+
};
|
|
393
|
+
// An oversized body is drained and refused at its END, not the moment the cap
|
|
394
|
+
// is crossed: answering mid-upload closes the connection under a client that
|
|
395
|
+
// is still writing, and it sees a network error instead of the reason.
|
|
396
|
+
void readRequestBuffer(req, MAX_UPLOAD_BYTES).then((bytes) => {
|
|
397
|
+
if (fs.existsSync(target.abs)) {
|
|
398
|
+
return sendJson(res, 409, { error: `Already exists: ${target.rel}` });
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
fs.mkdirSync(path.dirname(target.abs), { recursive: true });
|
|
402
|
+
fs.writeFileSync(target.abs, bytes);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
return sendFailure(res, "upload", target.rel, err);
|
|
406
|
+
}
|
|
407
|
+
const dir = path.posix.dirname(target.rel);
|
|
408
|
+
const visiblePathAdded = dir === "."
|
|
409
|
+
? ensureVisibleGlob(deckDir, target.rel, target.rel)
|
|
410
|
+
: ensureVisiblePath(deckDir, dir);
|
|
411
|
+
sendJson(res, 200, { ok: true, path: target.rel, visiblePathAdded });
|
|
412
|
+
}, (err) => {
|
|
413
|
+
if (err instanceof RangeError)
|
|
414
|
+
return tooBig();
|
|
415
|
+
sendFailure(res, "upload", target.rel, err);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
// Serve a deck file as bytes. `read` returns UTF-8 text in JSON, which mangles
|
|
419
|
+
// anything binary; the shell's image/audio viewers read through this instead.
|
|
420
|
+
function handleFilesRaw(deckDir, req, res) {
|
|
421
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
422
|
+
const resolved = resolveDeckPath(deckDir, url.searchParams.get("path"));
|
|
423
|
+
if (!resolved.ok)
|
|
424
|
+
return sendJson(res, 400, { error: resolved.error });
|
|
425
|
+
let stat;
|
|
426
|
+
try {
|
|
427
|
+
stat = fs.statSync(resolved.abs);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return sendJson(res, 404, { error: `Not found: ${resolved.rel}` });
|
|
431
|
+
}
|
|
432
|
+
if (!stat.isFile()) {
|
|
433
|
+
return sendJson(res, 404, { error: `Not a file: ${resolved.rel}` });
|
|
434
|
+
}
|
|
435
|
+
const headers = {
|
|
436
|
+
"content-type": RAW_MIME[path.extname(resolved.abs).toLowerCase()] ??
|
|
437
|
+
"application/octet-stream",
|
|
438
|
+
"cache-control": "no-store",
|
|
439
|
+
// Seeking an <audio>/<video> is a range request; without this the browser
|
|
440
|
+
// won't scrub (it re-requests from 0 and the playhead snaps back).
|
|
441
|
+
"accept-ranges": "bytes",
|
|
442
|
+
};
|
|
443
|
+
const range = parseByteRange(req.headers.range, stat.size);
|
|
444
|
+
if (range === "unsatisfiable") {
|
|
445
|
+
res.writeHead(416, { "content-range": `bytes */${stat.size}` }).end();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (!range) {
|
|
449
|
+
res.writeHead(200, { ...headers, "content-length": String(stat.size) });
|
|
450
|
+
fs.createReadStream(resolved.abs).pipe(res);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
res.writeHead(206, {
|
|
454
|
+
...headers,
|
|
455
|
+
"content-length": String(range.end - range.start + 1),
|
|
456
|
+
"content-range": `bytes ${range.start}-${range.end}/${stat.size}`,
|
|
457
|
+
});
|
|
458
|
+
fs.createReadStream(resolved.abs, { start: range.start, end: range.end }).pipe(res);
|
|
459
|
+
}
|
|
460
|
+
// A single `Range: bytes=<start>-<end>` (the only form a media element sends).
|
|
461
|
+
// null = serve the whole file; 'unsatisfiable' = a range past the end.
|
|
462
|
+
function parseByteRange(header, size) {
|
|
463
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec((header ?? "").trim());
|
|
464
|
+
if (!match || size === 0)
|
|
465
|
+
return null;
|
|
466
|
+
const [, rawStart, rawEnd] = match;
|
|
467
|
+
if (rawStart === "" && rawEnd === "")
|
|
468
|
+
return null;
|
|
469
|
+
// `bytes=-N`: the last N bytes.
|
|
470
|
+
const start = rawStart === "" ? Math.max(0, size - Number(rawEnd)) : Number(rawStart);
|
|
471
|
+
const end = rawStart === "" || rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
|
|
472
|
+
if (!Number.isFinite(start) || start > end || start >= size)
|
|
473
|
+
return "unsatisfiable";
|
|
474
|
+
return { start, end };
|
|
475
|
+
}
|
|
476
|
+
// A NUL byte near the start is the practical test for "not text": no text file
|
|
477
|
+
// a deck holds has one, and every binary format this is likely to meet does.
|
|
478
|
+
function isBinary(bytes) {
|
|
479
|
+
return bytes.subarray(0, 8192).includes(0);
|
|
480
|
+
}
|
|
481
|
+
// Copy a file byte-for-byte. The Files panel's Duplicate goes through this
|
|
482
|
+
// rather than read-then-write, which would corrupt anything binary.
|
|
483
|
+
function handleFilesCopy(deckDir, req, res) {
|
|
484
|
+
withJsonBody(req, res, (body) => {
|
|
485
|
+
const from = resolveDeckPath(deckDir, body.from);
|
|
486
|
+
if (!from.ok)
|
|
487
|
+
return sendJson(res, 400, { error: from.error });
|
|
488
|
+
const to = resolveDeckPath(deckDir, body.to, { mutation: true });
|
|
489
|
+
if (!to.ok)
|
|
490
|
+
return sendJson(res, 400, { error: to.error });
|
|
491
|
+
if (!fs.existsSync(from.abs)) {
|
|
492
|
+
return sendJson(res, 404, { error: `Not found: ${from.rel}` });
|
|
493
|
+
}
|
|
494
|
+
if (fs.existsSync(to.abs)) {
|
|
495
|
+
return sendJson(res, 409, { error: `Already exists: ${to.rel}` });
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
fs.mkdirSync(path.dirname(to.abs), { recursive: true });
|
|
499
|
+
fs.copyFileSync(from.abs, to.abs);
|
|
500
|
+
sendJson(res, 200, { ok: true, path: to.rel });
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
sendFailure(res, "copy", from.rel, err);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
}
|
|
415
507
|
// True when two paths resolve to the same underlying file (same inode+device) --
|
|
416
508
|
// e.g. the source and target of a case-only rename on a case-insensitive FS.
|
|
417
509
|
function isSameFile(a, b) {
|
|
@@ -475,15 +567,23 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
475
567
|
const action = reqPath.slice(FILES_API_PREFIX.length);
|
|
476
568
|
if (action === "info") {
|
|
477
569
|
const config = readEditorConfig(deckDir);
|
|
570
|
+
// File types come from the deck AND its imports (a kit is an imported deck,
|
|
571
|
+
// so importing one is what gives a deck scenes and drawings); everything
|
|
572
|
+
// else in the block is the deck's own.
|
|
573
|
+
const fileTypes = resolveFileTypes(deckDir);
|
|
478
574
|
sendJson(res, 200, {
|
|
479
575
|
// Stable per-deck id for client-side layout persistence (localStorage key).
|
|
480
576
|
deckId: path.resolve(deckDir),
|
|
481
|
-
kitEditorExtensions: kitEditorExtensions(
|
|
482
|
-
fileTypes
|
|
577
|
+
kitEditorExtensions: kitEditorExtensions(config, fileTypes),
|
|
578
|
+
fileTypes,
|
|
483
579
|
defaultPlayFile: config.defaultPlayFile ?? null,
|
|
484
580
|
initialPanels: config.initialPanels ?? null,
|
|
485
581
|
hiddenPaths: config.hiddenPaths ?? [],
|
|
486
582
|
visiblePaths: config.visiblePaths ?? [],
|
|
583
|
+
// Formats the platform knows won't survive publishing. Sent alongside the
|
|
584
|
+
// deck's own types rather than merged into them: these are not things the
|
|
585
|
+
// deck understands, they are things this CLI refuses to let it ship.
|
|
586
|
+
unsupportedMedia: UNSUPPORTED_MEDIA,
|
|
487
587
|
});
|
|
488
588
|
return true;
|
|
489
589
|
}
|
|
@@ -549,14 +649,31 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
549
649
|
if (!resolved.ok)
|
|
550
650
|
return (sendJson(res, 400, { error: resolved.error }), true);
|
|
551
651
|
try {
|
|
552
|
-
const
|
|
553
|
-
|
|
652
|
+
const bytes = fs.readFileSync(resolved.abs);
|
|
653
|
+
// Binary files (an uploaded image, a sound) come back EMPTY and flagged.
|
|
654
|
+
// Decoding one as UTF-8 mangles it, and the code editor saves what it
|
|
655
|
+
// holds -- opening a .png would quietly destroy it on the first keystroke.
|
|
656
|
+
if (isBinary(bytes)) {
|
|
657
|
+
sendJson(res, 200, {
|
|
658
|
+
path: resolved.rel,
|
|
659
|
+
contents: "",
|
|
660
|
+
binary: true,
|
|
661
|
+
size: bytes.length,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
sendJson(res, 200, { path: resolved.rel, contents: bytes.toString("utf8") });
|
|
666
|
+
}
|
|
554
667
|
}
|
|
555
668
|
catch {
|
|
556
669
|
sendJson(res, 404, { error: `Not found: ${resolved.rel}` });
|
|
557
670
|
}
|
|
558
671
|
return true;
|
|
559
672
|
}
|
|
673
|
+
if (action === "copy") {
|
|
674
|
+
handleFilesCopy(deckDir, req, res);
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
560
677
|
if (action === "write") {
|
|
561
678
|
handleFilesWrite(deckDir, req, res);
|
|
562
679
|
return true;
|
|
@@ -573,6 +690,14 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
573
690
|
handleFilesMkdir(deckDir, req, res);
|
|
574
691
|
return true;
|
|
575
692
|
}
|
|
693
|
+
if (action === "upload") {
|
|
694
|
+
handleFilesUpload(deckDir, req, res);
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
if (action === "raw") {
|
|
698
|
+
handleFilesRaw(deckDir, req, res);
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
576
701
|
return (sendJson(res, 404, { error: `Unknown files action: ${action}` }),
|
|
577
702
|
true);
|
|
578
703
|
}
|
package/dist/imports.d.ts
CHANGED
|
@@ -35,6 +35,12 @@ export declare function addImport(dir: string, options?: {
|
|
|
35
35
|
deckRef?: string;
|
|
36
36
|
alias?: string;
|
|
37
37
|
}): Promise<void>;
|
|
38
|
+
export interface AutoUpdatedImport {
|
|
39
|
+
alias: string;
|
|
40
|
+
from: string;
|
|
41
|
+
to: string;
|
|
42
|
+
}
|
|
43
|
+
export declare function autoUpdateImports(deckDir: string): Promise<AutoUpdatedImport[]>;
|
|
38
44
|
export declare function updateImport(dir: string, options?: {
|
|
39
45
|
alias?: string;
|
|
40
46
|
check?: boolean;
|