@volter/editor-react 0.5.210 → 0.5.212
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/contributions/component-contracts.service.ts +1 -1
- package/dist-node/serving.mjs +14770 -1629
- package/package.json +20 -9
- package/serving/component-prop-worker.ts +3 -19
- package/serving/index.ts +20 -8
- package/serving/source-diagnostics.ts +311 -0
- package/serving/ui-oid-plugin.ts +154 -775
- package/src/source/component-prop-types.ts +6 -62
- package/src/source/drei-detailed-names.ts +24 -0
- package/src/source/oid-transform.ts +166 -376
- package/src/source/plan-csf-story.ts +3 -26
- package/src/source/plan-extract-component.ts +15 -60
- package/src/source/plan-fork-component.ts +25 -92
- package/src/source/plan-named-style.ts +3 -17
- package/src/source/plan-source-edit.ts +9 -40
- package/src/source/r3f-contract-resolver.ts +9 -103
- package/src/source/r3f-physics-binding.ts +28 -186
- package/src/source/r3f-project-contracts.ts +13 -103
- package/src/source/relative-import-specifier.ts +3 -18
- package/src/source/reparent-guard.ts +16 -61
- package/src/source/source-edit-request.ts +2 -20
- package/src/source/ts-ast.ts +5 -95
- package/src/source/utility-class-support.ts +4 -32
- package/src/source/writer.ts +74 -326
- package/src/source/r3f-diagnostic-index.ts +0 -165
- package/src/source/r3f-environment-binding.ts +0 -106
- package/src/source/r3f-joint-binding.ts +0 -228
- package/src/source/r3f-lod-binding.ts +0 -81
- package/src/source/r3f-particle-binding.ts +0 -213
- package/src/source/syntactic-prop-specs.ts +0 -296
package/serving/ui-oid-plugin.ts
CHANGED
|
@@ -1,74 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vite plugin: live OID instrumentation + source-write endpoints (C1/C2/C7/C8).
|
|
3
|
-
*
|
|
4
|
-
* The visual-edit "trick" made live for hand-authored React UI components: at dev
|
|
5
|
-
* time it stamps `data-oid` on every JSX element in files matched by `include` (a
|
|
6
|
-
* predicate, so the rest of the editor build is untouched), builds an OID→source
|
|
7
|
-
* index, and serves:
|
|
8
|
-
* GET /__ui-source/index → the whole OID index
|
|
9
|
-
* POST /__ui-source/write {oid,prop,value} → surgical inline-style edit to source
|
|
10
|
-
* POST /__ui-source/struct {oid,op} → structural source edit (delete)
|
|
11
|
-
* Edits run the SAME `writer.ts` used in unit tests, server-side, then Vite HMR
|
|
12
|
-
* reloads. `data-oid` exists only in transform output, never on disk.
|
|
13
|
-
*
|
|
14
|
-
* `include` widening (T6.2 slice 2): the original
|
|
15
|
-
* scope was ONLY `ui-editor/editable-components/*.tsx` (the visual-edit fixture dir,
|
|
16
|
-
* for `UIAuthoringAdapter`/`SourceEditPanel`). A react WORLD's entry graph is a real
|
|
17
|
-
* project's `.tsx` source — the mounted template project (`packages/game-editor/template/
|
|
18
|
-
* src/**`) or an externally-scaffolded project directory reached via the SAME
|
|
19
|
-
* dev-server `fs.allow` pipeline `server/dev.ts` already builds for project scripts
|
|
20
|
-
* (T3.3) — so `ReactRootAuthoringAdapter` needs those files instrumented too.
|
|
21
|
-
* `defaultProjectScopeInclude` widens to PROJECT scope, not repo-global: any `.tsx`
|
|
22
|
-
* file outside `node_modules`, EXCEPT the Volter tooling/engine source trees this repo
|
|
23
|
-
* itself is built from (`packages/engine/src`, `packages/create-volter-project`,
|
|
24
|
-
* `packages/volter-cli`, `packages/sdk`, and `packages/editor/src` generally —
|
|
25
|
-
* carving OUT `ui-editor/editable-components` so the existing UI-edit-mode surface
|
|
26
|
-
* keeps working unchanged). This does NOT depend on which project happens to be
|
|
27
|
-
* open — `fs.allow` already bounds what Vite can even reach, and this predicate
|
|
28
|
-
* additionally keeps the Volter app's OWN react source un-instrumented.
|
|
29
|
-
*
|
|
30
|
-
* Vendored trees: a vendored GAME's source is STAMPED; every other `/vendor/`
|
|
31
|
-
* tree is not. `data-oid` stamping is what makes a `.tsx` file addressable —
|
|
32
|
-
* and, through `/__ui-source/write`, writable — so this predicate is where the
|
|
33
|
-
* question "may a vendored game be authored?" is actually decided.
|
|
34
|
-
*
|
|
35
|
-
* It used to answer no, for every path under a `/vendor/` segment, under the
|
|
36
|
-
* never-modify-vendored-source rule. The owner's code-or-data ruling
|
|
37
|
-
* superseded that rule (ARCHITECTURE-CORE §Editor "Every edit edits the game's
|
|
38
|
-
* own CODE or DATA", §Rules "there is no unwritable base"): ownership decides
|
|
39
|
-
* the RECORDER, not whether the edit may happen. A user's folder records in
|
|
40
|
-
* their git; a repo-vendored copy records in its own `UPSTREAM.lock`, written
|
|
41
|
-
* in the SAME gesture as the file by `server/vendored-lock-recorder.ts` and
|
|
42
|
-
* routed through {@link writeEditableSource} below. `verify-unaltered.mjs`'s
|
|
43
|
-
* bar was never zero diff — it is zero UNRECORDED diff — so it stays green
|
|
44
|
-
* with an authored edit present, as a patch that reverse-applies to upstream.
|
|
45
|
-
*
|
|
46
|
-
* What stays excluded is everything under `/vendor/` that is a DEPENDENCY
|
|
47
|
-
* rather than a game: `packages/threejs-runtime/vendor/realism-effects` (a vendored
|
|
48
|
-
* library) and the import analyzers' `vendor/` API dumps. See
|
|
49
|
-
* {@link VENDORED_GAME_SRC_RE} for the measured characterization behind that
|
|
50
|
-
* split.
|
|
51
|
-
*
|
|
52
|
-
* D-Y4 (wave 2, slice S3) —
|
|
53
|
-
* the write-back ban becomes IDENTITY-scoped, not just location-scoped: the
|
|
54
|
-
* `/vendor/` regex above only protects the repo's OWN vendored trees, but
|
|
55
|
-
* wave 2 lets an `ingest-react` world live in ANY external, user-owned
|
|
56
|
-
* folder (`volter-game-editor edit <folder>`) — an ingested-as-is foreign game there would
|
|
57
|
-
* otherwise be silently stamped/writable, reopening the never-modify-game-
|
|
58
|
-
* source rule by location rather than by identity. `nearestManifestExcludesIngestReact`
|
|
59
|
-
* walks up from a candidate `.tsx`'s own directory to the NEAREST
|
|
60
|
-
* `volter.project.json`; if that manifest declares an `{ ingest }` react world,
|
|
61
|
-
* the whole manifest folder's subtree is excluded — exact, not coarse:
|
|
62
|
-
* `ingest-react` is single-world by construction (`binding-resolver.ts`'s
|
|
63
|
-
* `MULTI_WORLD_ALLOWED_IDENTITIES` exclusion), so "the manifest folder" IS
|
|
64
|
-
* the foreign game's whole tree, and D-N8 ships no authoring for it anyway —
|
|
65
|
-
* excluding it from stamping loses nothing. Cached per directory (hit AND
|
|
66
|
-
* miss, R-Y3) so a project with no manifest anywhere up the tree pays the
|
|
67
|
-
* walk cost once, ever. Malformed JSON at the manifest path is treated as
|
|
68
|
-
* "no exclusion" (never crash the transform hook over a broken file); a
|
|
69
|
-
* genuine fs error OTHER than "not found" (e.g. a permission error) is not
|
|
70
|
-
* swallowed — it propagates, same honesty bar the rest of this file holds.
|
|
71
|
-
*/
|
|
1
|
+
/** Vite source authoring for project React components. JSX receives data-oid attributes only in transform output; the source index and write routes apply recorded edits to the project files. Tooling and dependency source stay outside this scope. */
|
|
72
2
|
|
|
73
3
|
import { createHash } from 'node:crypto';
|
|
74
4
|
import { createRequire } from 'node:module';
|
|
@@ -82,7 +12,6 @@ import {
|
|
|
82
12
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
83
13
|
import { Worker } from 'node:worker_threads';
|
|
84
14
|
import type { Plugin, ViteDevServer } from 'vite';
|
|
85
|
-
import { resolveManifestPath } from '@volter/project/manifest/locate';
|
|
86
15
|
import {
|
|
87
16
|
type ProjectServingServices,
|
|
88
17
|
SOURCE_WRITE_ROUTES_PLUGIN,
|
|
@@ -94,7 +23,6 @@ import {
|
|
|
94
23
|
type OidEntry,
|
|
95
24
|
OidStore,
|
|
96
25
|
oidAttributeForSurface,
|
|
97
|
-
type R3fComponentContract,
|
|
98
26
|
transformSource,
|
|
99
27
|
} from '../src/source/oid-transform';
|
|
100
28
|
import { planDeleteStory, planRenameStory, planSaveStory } from '../src/source/plan-csf-story';
|
|
@@ -107,15 +35,6 @@ import {
|
|
|
107
35
|
planSourceEdit,
|
|
108
36
|
sourceEditFiles,
|
|
109
37
|
} from '../src/source/plan-source-edit';
|
|
110
|
-
import {
|
|
111
|
-
type EntryDiagnosticSelector,
|
|
112
|
-
fileDiagnosticJoin,
|
|
113
|
-
} from '../src/source/r3f-diagnostic-index';
|
|
114
|
-
import {
|
|
115
|
-
importedR3fContracts,
|
|
116
|
-
r3fAuthoringDiagnostics,
|
|
117
|
-
visibleR3fContracts,
|
|
118
|
-
} from '../src/source/r3f-project-contracts';
|
|
119
38
|
import type { SourceEditRequest } from '../src/source/source-edit-request';
|
|
120
39
|
import {
|
|
121
40
|
detectUtilityClassSupport,
|
|
@@ -127,11 +46,8 @@ import {
|
|
|
127
46
|
addClassNameToken,
|
|
128
47
|
collectLiteralInlineStyles,
|
|
129
48
|
cssCamelToKebab,
|
|
130
|
-
deleteElements,
|
|
131
49
|
editTextContent,
|
|
132
50
|
findTagEnd,
|
|
133
|
-
getEditableText,
|
|
134
|
-
groupSiblingElements,
|
|
135
51
|
removeClassNameToken,
|
|
136
52
|
removeInlineStyle,
|
|
137
53
|
removePropAttribute,
|
|
@@ -140,14 +56,8 @@ import {
|
|
|
140
56
|
writePropChange,
|
|
141
57
|
} from '../src/source/writer';
|
|
142
58
|
|
|
143
|
-
// `applyStyleWriteRequest` and `oidAttributeForSurface` live in browser-safe
|
|
144
|
-
// modules (`plan-source-edit.ts` / `oid-transform.ts`), because a PAGE reaches
|
|
145
|
-
// the same transform; re-exported here so this file stays the stable import
|
|
146
|
-
// site for both.
|
|
147
|
-
export { applyStyleWriteRequest, oidAttributeForSurface };
|
|
148
|
-
|
|
149
59
|
const NODE_MODULES_RE = /\/node_modules\//;
|
|
150
|
-
const TOOLING_SRC_RE = /\/packages\/(engine\/src|
|
|
60
|
+
const TOOLING_SRC_RE = /\/packages\/(blender-engine|cyclotron|live|play|project)\/(src|node)\//;
|
|
151
61
|
/** The SDK's own folder, where a workspace keeps its source: tooling, never a game's UI. Named by
|
|
152
62
|
* where `@volter/sdk` resolves from here (its entry module is `src/index.ts`), with forward
|
|
153
63
|
* slashes; `null` when it does not resolve. A project's own `packages/sdk` is not this folder. */
|
|
@@ -171,101 +81,22 @@ const foldCase = (file: string): string => (process.platform === 'win32' ? file.
|
|
|
171
81
|
function isToolingSource(clean: string): boolean {
|
|
172
82
|
return TOOLING_SRC_RE.test(clean) || (SDK_ROOT !== null && foldCase(clean).startsWith(foldCase(SDK_ROOT)));
|
|
173
83
|
}
|
|
174
|
-
const EDITOR_SRC_RE = /\/packages\/editor\/src\//;
|
|
175
|
-
|
|
176
|
-
/** Track N, D-N4 item 2 — see the module doc comment's "Vendored-tree exclusion". */
|
|
84
|
+
const EDITOR_SRC_RE = /\/packages\/editor-(core|blender|threejs|react|ui)\/(src|contributions)\//;
|
|
85
|
+
/** Dependency source inside vendor trees is excluded from authoring. */
|
|
177
86
|
const VENDOR_RE = /\/vendor\//;
|
|
178
|
-
/**
|
|
179
|
-
* A vendored GAME's own source (`…/vendor/games/<id>/…`) — the ONE tree under
|
|
180
|
-
* a `/vendor/` segment that is a game rather than a dependency.
|
|
181
|
-
*
|
|
182
|
-
* The distinction is what {@link defaultProjectScopeInclude} needs and the
|
|
183
|
-
* blanket `VENDOR_RE` could not express. Characterized before it was carved,
|
|
184
|
-
* because "what else does this regex protect?" is the question a scope change
|
|
185
|
-
* has to answer: under every `/vendor/` root in this repo, the only `.tsx` at
|
|
186
|
-
* all is `vendor/games/racing-game`'s 35 files. The rest is
|
|
187
|
-
* `packages/threejs-runtime/vendor/realism-effects` (a vendored LIBRARY — built `dist`
|
|
188
|
-
* JS and shaders, a dependency we happen to ship in-tree) and the two import
|
|
189
|
-
* analyzers' `vendor/` API dumps (`packages/gd-analyze`,
|
|
190
|
-
* `packages/rbx-analyze` — JSON only). None of those is a game, none is
|
|
191
|
-
* authorable, and all of them stay excluded here.
|
|
192
|
-
*
|
|
193
|
-
* The Godot lane is ARCHIVED off main — `git fetch origin archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
|
|
194
|
-
*/
|
|
87
|
+
/** Project game source may be authored inside a vendor tree; dependency source is excluded. */
|
|
195
88
|
const VENDORED_GAME_SRC_RE = /\/vendor\/games\/[^/]+\//;
|
|
196
89
|
|
|
197
|
-
/** True if a parsed `volter.project.json` body declares at least one
|
|
198
|
-
* React root with an `{ ingest }` adapter — the `{ ingest }` shape alone is
|
|
199
|
-
* the answer, because every ingest-react root is D-N8 no-authoring.
|
|
200
|
-
* Defensive against
|
|
201
|
-
* any shape (never trusts the manifest is even an object) — this is a
|
|
202
|
-
* best-effort identity probe for a WRITE-BACK BAN, not manifest validation
|
|
203
|
-
* (the real Zod schema/loader is `@volter/project/manifest/load`, not reachable
|
|
204
|
-
* from this vite-config-time, dependency-light file by design). */
|
|
205
|
-
function manifestDeclaresIngestReactWorld(parsed: unknown): boolean {
|
|
206
|
-
if (!parsed || typeof parsed !== 'object') return false;
|
|
207
|
-
const roots = (parsed as { roots?: unknown }).roots;
|
|
208
|
-
if (!Array.isArray(roots)) return false;
|
|
209
|
-
return roots.some((entry) => {
|
|
210
|
-
if (!entry || typeof entry !== 'object') return false;
|
|
211
|
-
const root = entry as { adapter?: unknown };
|
|
212
|
-
const { adapter } = root;
|
|
213
|
-
return (
|
|
214
|
-
!!adapter &&
|
|
215
|
-
typeof adapter === 'object' &&
|
|
216
|
-
(adapter as { surface?: unknown }).surface === 'dom' &&
|
|
217
|
-
'ingest' in adapter
|
|
218
|
-
);
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Cache: absolute directory -> whether ITS OWN ancestor walk (starting AT
|
|
223
|
-
* this directory) hit an ingest-react-declaring manifest. Cached both ways
|
|
224
|
-
* (hit and miss, R-Y3) — see the module doc comment's D-Y4 section. */
|
|
225
|
-
const manifestAncestorExclusionCache = new Map<string, boolean>();
|
|
226
|
-
|
|
227
|
-
function nearestManifestExcludesIngestReact(dir: string): boolean {
|
|
228
|
-
const cached = manifestAncestorExclusionCache.get(dir);
|
|
229
|
-
if (cached !== undefined) return cached;
|
|
230
|
-
|
|
231
|
-
const manifestPath = resolveManifestPath(dir);
|
|
232
|
-
let raw: string | undefined;
|
|
233
|
-
try {
|
|
234
|
-
raw = readFileSync(manifestPath, 'utf8');
|
|
235
|
-
} catch (err) {
|
|
236
|
-
// ENOENT (no manifest at this level) -> keep walking up. Any OTHER fs
|
|
237
|
-
// error (e.g. EACCES) is a real environment problem, not "no manifest
|
|
238
|
-
// here" — never silently swallowed.
|
|
239
|
-
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
let result: boolean;
|
|
243
|
-
if (raw === undefined) {
|
|
244
|
-
const parent = dirname(dir);
|
|
245
|
-
result = parent === dir ? false : nearestManifestExcludesIngestReact(parent);
|
|
246
|
-
} else {
|
|
247
|
-
try {
|
|
248
|
-
result = manifestDeclaresIngestReactWorld(JSON.parse(raw));
|
|
249
|
-
} catch {
|
|
250
|
-
// Malformed JSON: honest "no exclusion" (stamping stays on) — never
|
|
251
|
-
// crash the transform hook over a broken manifest file.
|
|
252
|
-
result = false;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
manifestAncestorExclusionCache.set(dir, result);
|
|
256
|
-
return result;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
90
|
/**
|
|
260
|
-
*
|
|
91
|
+
* Scope guard for the `/__ui-source/css` endpoint.
|
|
261
92
|
* A CSS file path (from a stylesheet's `data-vite-dev-id`) is editable iff it is a
|
|
262
|
-
* first-party PROJECT `.css` file
|
|
263
|
-
*
|
|
264
|
-
*
|
|
93
|
+
* first-party PROJECT `.css` file outside node_modules, every vendor tree, the Volter
|
|
94
|
+
* tooling/engine source and the editor's own source. JSX stamping separately permits project
|
|
95
|
+
* game source in vendor trees. Paths are normalized to forward slashes so a Windows dev-id matches. This
|
|
265
96
|
* is the CSS analogue of the `.tsx` stamping scope: an out-of-scope path must not become
|
|
266
97
|
* writable through a client-supplied `file`.
|
|
267
98
|
*/
|
|
268
|
-
|
|
99
|
+
function isEditableCssFile(id: string): boolean {
|
|
269
100
|
const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
|
|
270
101
|
if (!clean.endsWith('.css')) return false;
|
|
271
102
|
if (NODE_MODULES_RE.test(clean)) return false;
|
|
@@ -275,11 +106,7 @@ export function isEditableCssFile(id: string): boolean {
|
|
|
275
106
|
return true;
|
|
276
107
|
}
|
|
277
108
|
|
|
278
|
-
/**
|
|
279
|
-
* `/__ui-source/restore` endpoint (below) uses to refuse a stale write. Only
|
|
280
|
-
* ever computed SERVER-SIDE: the client (`ReactRootAuthoringAdapter`) never
|
|
281
|
-
* hashes anything itself, it only echoes back a digest this server already
|
|
282
|
-
* handed it in a prior `writeStruct`/`restoreSource` response. */
|
|
109
|
+
/** Server-computed SHA-256 for guarded whole-file source writes. */
|
|
283
110
|
function sha256(text: string): string {
|
|
284
111
|
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
285
112
|
}
|
|
@@ -294,17 +121,9 @@ function sourceResourcePath(file: string, projectRoot: string): string {
|
|
|
294
121
|
return `.volter/external-source/${identity}/${basename(file)}`;
|
|
295
122
|
}
|
|
296
123
|
|
|
297
|
-
/**
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* tree, with its own `package.json` and its own build, and it is THAT build
|
|
301
|
-
* that would have to compile a `bg-[#059669]`. So the probe walks up from the
|
|
302
|
-
* file to the nearest enclosing `package.json` (falling back to the served
|
|
303
|
-
* project root) and reads the evidence `utility-class-support.ts` names.
|
|
304
|
-
*
|
|
305
|
-
* Cached per resolved package directory, like `propResolvers` above: the walk
|
|
306
|
-
* and the reads happen once per package per dev-server lifetime.
|
|
307
|
-
*/
|
|
124
|
+
/** Cache utility-class evidence by the package owning the edited file. The
|
|
125
|
+
* nearest package.json, or the served project root, determines which build
|
|
126
|
+
* must compile its authored classes. */
|
|
308
127
|
const utilityClassSupport = new Map<string, UtilityClassSupport>();
|
|
309
128
|
|
|
310
129
|
/** The nearest ancestor of `file` holding a `package.json`, else `fallback`. */
|
|
@@ -383,109 +202,28 @@ function declaredPropsOf(
|
|
|
383
202
|
return propsByTag.get(key);
|
|
384
203
|
}
|
|
385
204
|
|
|
386
|
-
/**
|
|
387
|
-
*
|
|
388
|
-
|
|
389
|
-
/** Component contracts visible in this module — `null` for a file this
|
|
390
|
-
* server does not stamp as R3F (or could not read). */
|
|
391
|
-
readonly contracts: ReadonlyMap<string, R3fComponentContract> | null;
|
|
392
|
-
/** H5 — which of this file's authorability diagnostics pertain to a given
|
|
393
|
-
* entry of this file. Inert for a non-R3F/unreadable file. */
|
|
394
|
-
readonly selectDiagnostics: EntryDiagnosticSelector;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const INERT_FILE_ANALYSIS: IndexFileAnalysis = {
|
|
398
|
-
contracts: null,
|
|
399
|
-
selectDiagnostics: () => undefined,
|
|
400
|
-
};
|
|
401
|
-
|
|
402
|
-
/** The index grouped by file, each bucket keyed by oid — the shape
|
|
403
|
-
* `fileDiagnosticJoin` needs so an entry's `parentOid` resolves. */
|
|
404
|
-
function indexEntriesByFile(store: OidStore): Map<string, Map<string, OidEntry>> {
|
|
405
|
-
const byFile = new Map<string, Map<string, OidEntry>>();
|
|
406
|
-
for (const [oid, entry] of store.index) {
|
|
407
|
-
const bucket = byFile.get(entry.file);
|
|
408
|
-
if (bucket) bucket.set(oid, entry);
|
|
409
|
-
else byFile.set(entry.file, new Map([[oid, entry]]));
|
|
410
|
-
}
|
|
411
|
-
return byFile;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Read one file's current bytes and derive everything an index read wants from
|
|
416
|
-
* it. Unreadable (deleted/renamed since it was transformed) or not stamped as
|
|
417
|
-
* R3F ⇒ the inert analysis: its entries ship exactly as recorded, which is a
|
|
418
|
-
* degradation, never a failure of the whole index read.
|
|
419
|
-
*
|
|
420
|
-
* COST, and why it went DOWN rather than up when H5 landed: this runs at most
|
|
421
|
-
* once per file per index read (the caller memoizes), whereas the previous
|
|
422
|
-
* shape re-read and re-surface-resolved a file for EVERY capitalized-tag entry
|
|
423
|
-
* whose surface turned out not to be `userData-oid`. H5 adds one
|
|
424
|
-
* `r3fAuthoringDiagnostics` pass per R3F file — the same order of work as the
|
|
425
|
-
* `visibleR3fContracts` pass beside it, and it early-returns for any file
|
|
426
|
-
* without an R3F reconciler import.
|
|
427
|
-
*/
|
|
428
|
-
function analyzeIndexFile(
|
|
429
|
-
file: string,
|
|
430
|
-
fileEntries: ReadonlyMap<string, OidEntry>,
|
|
431
|
-
moduleGraph: unknown,
|
|
432
|
-
): IndexFileAnalysis {
|
|
205
|
+
/** Whether the current source belongs to a stage region. Unreadable files
|
|
206
|
+
* keep their recorded index entries without declared-prop enrichment. */
|
|
207
|
+
function isStageIndexFile(file: string, moduleGraph: unknown): boolean {
|
|
433
208
|
let source: string;
|
|
434
209
|
try {
|
|
435
210
|
source = readFileSync(file, 'utf8');
|
|
436
211
|
} catch {
|
|
437
|
-
return
|
|
438
|
-
}
|
|
439
|
-
// The SAME decision the transform hook stamped with (task #45) — not a
|
|
440
|
-
// second derivation that could disagree with it.
|
|
441
|
-
if (serving().surfaceOf(file, source, moduleGraph).attribute !== 'userData-oid') {
|
|
442
|
-
return INERT_FILE_ANALYSIS;
|
|
212
|
+
return false;
|
|
443
213
|
}
|
|
444
|
-
return
|
|
445
|
-
contracts: visibleR3fContracts(source, file),
|
|
446
|
-
// The surface check above already established this file renders through
|
|
447
|
-
// the R3F reconciler, which is a stronger answer than the source-text
|
|
448
|
-
// sniff the analyzer falls back on — and the one a project that imports
|
|
449
|
-
// its prop shape from a shared module can actually pass.
|
|
450
|
-
selectDiagnostics: fileDiagnosticJoin(
|
|
451
|
-
fileEntries,
|
|
452
|
-
r3fAuthoringDiagnostics(source, file, { knownThreeSurface: true }),
|
|
453
|
-
),
|
|
454
|
-
};
|
|
214
|
+
return oidAttributeForSurface(serving().surfaceOf(file, source, moduleGraph).surface) === 'userData-oid';
|
|
455
215
|
}
|
|
456
216
|
|
|
457
|
-
/**
|
|
458
|
-
* One recorded entry plus everything the current bytes add to it: its
|
|
459
|
-
* component's authoring contract, its declared props, and (H5) the
|
|
460
|
-
* authorability diagnostics that pertain to it. Returns the entry ITSELF when
|
|
461
|
-
* there is nothing to add, so an unenriched index read allocates nothing.
|
|
462
|
-
*
|
|
463
|
-
* Note that the diagnostic lookup runs for EVERY entry, including a lowercase
|
|
464
|
-
* host tag: R3F002/3/5 are ABOUT a component definition, and a definition's
|
|
465
|
-
* root is usually an ordinary `<group>`/`<mesh>` — that entry is precisely the
|
|
466
|
-
* one an instance row reaches through its boundary object's own `userData.oid`.
|
|
467
|
-
*/
|
|
217
|
+
/** Add declared props to a source index entry when the resolver has them. */
|
|
468
218
|
function enrichIndexEntry(
|
|
469
219
|
entry: OidEntry,
|
|
470
|
-
|
|
220
|
+
stage: boolean,
|
|
471
221
|
propsByTag: ReadonlyMap<string, ComponentPropSpec[]>,
|
|
472
222
|
): OidEntry {
|
|
473
|
-
const
|
|
474
|
-
const custom = analysis.contracts !== null && /^[A-Z]/.test(entry.tag);
|
|
475
|
-
const contract = custom ? analysis.contracts?.get(entry.tag) : undefined;
|
|
223
|
+
const custom = stage && /^[A-Z]/.test(entry.tag);
|
|
476
224
|
const props = custom ? declaredPropsOf(propsByTag, entry.file, entry.tag) : undefined;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
// pending cold resolver from a genuine absence (see OidEntry.propsResolved).
|
|
480
|
-
const propsResolved = props !== undefined;
|
|
481
|
-
if (!contract && !props?.length && !diagnostics && !propsResolved) return entry;
|
|
482
|
-
return {
|
|
483
|
-
...entry,
|
|
484
|
-
...(contract ? { r3fAuthoring: contract } : {}),
|
|
485
|
-
...(props?.length ? { props } : {}),
|
|
486
|
-
...(propsResolved ? { propsResolved: true as const } : {}),
|
|
487
|
-
...(diagnostics ? { diagnostics } : {}),
|
|
488
|
-
};
|
|
225
|
+
if (!props?.length) return entry;
|
|
226
|
+
return { ...entry, props };
|
|
489
227
|
}
|
|
490
228
|
|
|
491
229
|
function snapshotRawOidIndex(store: OidStore): Record<string, unknown> {
|
|
@@ -496,38 +234,31 @@ function snapshotRawOidIndex(store: OidStore): Record<string, unknown> {
|
|
|
496
234
|
|
|
497
235
|
function buildEnrichedOidIndex(
|
|
498
236
|
store: OidStore,
|
|
499
|
-
devServer
|
|
237
|
+
devServer: ViteDevServer,
|
|
500
238
|
propsByTag: ReadonlyMap<string, ComponentPropSpec[]> = new Map(),
|
|
501
239
|
): Record<string, unknown> {
|
|
502
|
-
const moduleGraph = devServer
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
return analysis;
|
|
240
|
+
const moduleGraph = devServer.moduleGraph;
|
|
241
|
+
|
|
242
|
+
const stageFiles = new Map<string, boolean>();
|
|
243
|
+
const stageFor = (file: string): boolean => {
|
|
244
|
+
const cached = stageFiles.get(file);
|
|
245
|
+
if (cached !== undefined) return cached;
|
|
246
|
+
const stage = isStageIndexFile(file, moduleGraph);
|
|
247
|
+
stageFiles.set(file, stage);
|
|
248
|
+
return stage;
|
|
512
249
|
};
|
|
513
250
|
|
|
514
251
|
const current: Record<string, unknown> = {};
|
|
515
252
|
for (const [oid, entry] of store.index) {
|
|
516
|
-
current[oid] = enrichIndexEntry(entry,
|
|
253
|
+
current[oid] = enrichIndexEntry(entry, stageFor(entry.file), propsByTag);
|
|
517
254
|
}
|
|
518
255
|
return current;
|
|
519
256
|
}
|
|
520
257
|
|
|
521
|
-
/**
|
|
522
|
-
*
|
|
523
|
-
* of first design attach: `ComponentPropResolver` builds a `ts.Program`. The Scene
|
|
524
|
-
* can render without it — oids, file/line, and tags are already on the raw
|
|
525
|
-
* stamp. Blocking GET /__ui-source/index on enrich froze the Vite thread
|
|
526
|
-
* (and every module load) for those seconds. Serve enrichment only for an
|
|
527
|
-
* unchanged source index; otherwise serve current stamps while it refreshes.
|
|
528
|
-
*/
|
|
258
|
+
/** Resolve declared props outside the index request. Return current raw
|
|
259
|
+
* stamps while enrichment runs; cached enrichment must match the source index. */
|
|
529
260
|
let lastEnrichedIndex: {
|
|
530
|
-
root: string
|
|
261
|
+
root: string;
|
|
531
262
|
sourceIndex: ReadonlyMap<string, OidEntry>;
|
|
532
263
|
index: Record<string, unknown>;
|
|
533
264
|
} | null = null;
|
|
@@ -543,7 +274,7 @@ const pendingPropRequests = new Map<
|
|
|
543
274
|
>();
|
|
544
275
|
|
|
545
276
|
function componentPropWorkerUrl(): URL {
|
|
546
|
-
// The serving module runs built
|
|
277
|
+
// The serving module runs built, beside the worker built with it.
|
|
547
278
|
return new URL('./component-prop-worker.mjs', import.meta.url);
|
|
548
279
|
}
|
|
549
280
|
|
|
@@ -557,16 +288,15 @@ function declaredPropWorker(): Worker {
|
|
|
557
288
|
const worker = new Worker(componentPropWorkerUrl(), { name: 'volter-component-props' });
|
|
558
289
|
worker.on(
|
|
559
290
|
'message',
|
|
560
|
-
(message: { id
|
|
561
|
-
if (typeof message.id !== 'number') return;
|
|
291
|
+
(message: { id: number; props: Record<string, ComponentPropSpec[]> } | { id: number; error: string }) => {
|
|
562
292
|
const pending = pendingPropRequests.get(message.id);
|
|
563
293
|
if (!pending) return;
|
|
564
294
|
pendingPropRequests.delete(message.id);
|
|
565
|
-
if (
|
|
295
|
+
if ('error' in message) {
|
|
566
296
|
pending.reject(new Error(message.error));
|
|
567
297
|
return;
|
|
568
298
|
}
|
|
569
|
-
pending.resolve(new Map(Object.entries(message.props
|
|
299
|
+
pending.resolve(new Map(Object.entries(message.props)));
|
|
570
300
|
},
|
|
571
301
|
);
|
|
572
302
|
worker.on('error', (error: unknown) => {
|
|
@@ -596,10 +326,10 @@ function propRequestsFor(store: OidStore): Array<{ key: string; file: string; ta
|
|
|
596
326
|
}
|
|
597
327
|
|
|
598
328
|
function resolveDeclaredProps(
|
|
599
|
-
projectRoot: string
|
|
329
|
+
projectRoot: string,
|
|
600
330
|
requests: Array<{ key: string; file: string; tag: string; definition?: true }>,
|
|
601
331
|
): Promise<ReadonlyMap<string, ComponentPropSpec[]>> {
|
|
602
|
-
if (
|
|
332
|
+
if (requests.length === 0) {
|
|
603
333
|
return new Promise((resolveRequest) => setImmediate(() => resolveRequest(new Map())));
|
|
604
334
|
}
|
|
605
335
|
const id = ++propRequestId;
|
|
@@ -611,8 +341,8 @@ function resolveDeclaredProps(
|
|
|
611
341
|
|
|
612
342
|
function scheduleOidIndexEnrich(
|
|
613
343
|
store: OidStore,
|
|
614
|
-
projectRoot
|
|
615
|
-
devServer
|
|
344
|
+
projectRoot: string,
|
|
345
|
+
devServer: ViteDevServer,
|
|
616
346
|
): void {
|
|
617
347
|
if (enrichQueued) return;
|
|
618
348
|
enrichQueued = true;
|
|
@@ -627,9 +357,7 @@ function scheduleOidIndexEnrich(
|
|
|
627
357
|
};
|
|
628
358
|
})
|
|
629
359
|
.catch((error) => {
|
|
630
|
-
//
|
|
631
|
-
// and diagnostics still enrich on the server thread; declared props
|
|
632
|
-
// degrade to the attributes physically present in source.
|
|
360
|
+
// Keep source-attribute inspection available when semantic props fail.
|
|
633
361
|
process.stderr.write(
|
|
634
362
|
`[ui-oid] Declared-prop analysis unavailable; using source attributes: ${String(error)}\n`,
|
|
635
363
|
);
|
|
@@ -646,8 +374,8 @@ function scheduleOidIndexEnrich(
|
|
|
646
374
|
|
|
647
375
|
function currentOidIndex(
|
|
648
376
|
store: OidStore,
|
|
649
|
-
projectRoot
|
|
650
|
-
devServer
|
|
377
|
+
projectRoot: string,
|
|
378
|
+
devServer: ViteDevServer,
|
|
651
379
|
): Record<string, unknown> {
|
|
652
380
|
scheduleOidIndexEnrich(store, projectRoot, devServer);
|
|
653
381
|
const cached = lastEnrichedIndex;
|
|
@@ -664,58 +392,6 @@ function currentOidIndex(
|
|
|
664
392
|
return snapshotRawOidIndex(store);
|
|
665
393
|
}
|
|
666
394
|
|
|
667
|
-
/** Test-only: wait for the in-flight background enrich, if any. */
|
|
668
|
-
export function __flushOidIndexEnrichForTest(): Promise<void> {
|
|
669
|
-
return new Promise((resolve) => {
|
|
670
|
-
const finish = (): void => {
|
|
671
|
-
if (!enrichQueued) {
|
|
672
|
-
resolve();
|
|
673
|
-
return;
|
|
674
|
-
}
|
|
675
|
-
setImmediate(finish);
|
|
676
|
-
};
|
|
677
|
-
setImmediate(finish);
|
|
678
|
-
});
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
/** Test-only reset. */
|
|
682
|
-
export function __resetOidIndexEnrichForTest(): void {
|
|
683
|
-
lastEnrichedIndex = null;
|
|
684
|
-
enrichQueued = false;
|
|
685
|
-
if (propWorker) {
|
|
686
|
-
void propWorker.terminate();
|
|
687
|
-
propWorker = null;
|
|
688
|
-
}
|
|
689
|
-
rejectPendingPropRequests(new Error('Declared-prop worker reset.'));
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
/** Test-only: whether the last GET returned without waiting for enrich. */
|
|
693
|
-
export function __oidIndexEnrichStateForTest(): { queued: boolean; hasEnriched: boolean } {
|
|
694
|
-
return { queued: enrichQueued, hasEnriched: lastEnrichedIndex !== null };
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/**
|
|
698
|
-
* THE write point for every `/__ui-source/*` handler — and the reason a
|
|
699
|
-
* vendored game's source can be stamped without reopening silent drift.
|
|
700
|
-
*
|
|
701
|
-
* A source write lands in one of two recorders, decided by WHERE the file is,
|
|
702
|
-
* never by what the handler was doing:
|
|
703
|
-
*
|
|
704
|
-
* - inside a repo-vendored game → `writeRecordedVendoredFile`, which moves the
|
|
705
|
-
* file AND its `UPSTREAM.lock` entry as ONE journalled operation, so
|
|
706
|
-
* `verify-unaltered.mjs` is green with the edit present (the edit becomes a
|
|
707
|
-
* recorded patch that reverse-applies to upstream). Undo needs no special
|
|
708
|
-
* case: it comes back through here with the previous bytes, the lock
|
|
709
|
-
* reconciles from the bytes alone, and the edit that restores upstream's own
|
|
710
|
-
* content deletes the patch entry again.
|
|
711
|
-
* - anywhere else → the plain `writeFileSync` this file always did, because a
|
|
712
|
-
* user's own project records in the user's own git.
|
|
713
|
-
*
|
|
714
|
-
* Centralised deliberately. Nine handlers in this file write source; routing
|
|
715
|
-
* per-handler would mean nine chances to add a tenth that forgets, and a
|
|
716
|
-
* forgotten one is not a visible bug — it is a vendored tree that quietly
|
|
717
|
-
* stops matching its lock.
|
|
718
|
-
*/
|
|
719
395
|
/** The kit's server capabilities this plugin was constructed with
|
|
720
396
|
* (`@volter/sdk/session/project-serving`). */
|
|
721
397
|
let servingServices: ProjectServingServices | null = null;
|
|
@@ -725,46 +401,29 @@ function serving(): ProjectServingServices {
|
|
|
725
401
|
return servingServices;
|
|
726
402
|
}
|
|
727
403
|
|
|
728
|
-
/**
|
|
729
|
-
|
|
730
|
-
* before its file is read, so no handler plans against bytes the recorder is about to replace.
|
|
731
|
-
* Welded to the read, the way the recorder is welded to the write, so no handler can forget it.
|
|
732
|
-
*/
|
|
733
|
-
function readSettledSource(file: string): string {
|
|
734
|
-
serving().settleSource(file);
|
|
404
|
+
/** THE read point for every `/__ui-source/*` handler. */
|
|
405
|
+
function readSource(file: string): string {
|
|
735
406
|
return readFileSync(file, 'utf8');
|
|
736
407
|
}
|
|
737
408
|
|
|
738
409
|
/**
|
|
739
|
-
* THE write point for every `/__ui-source/*` handler: the kit's own write (
|
|
740
|
-
*
|
|
741
|
-
*
|
|
410
|
+
* THE write point for every `/__ui-source/*` handler: the kit's own write (the served modules
|
|
411
|
+
* it replaces are invalidated, so the next mount reads the new bytes rather than a transform
|
|
412
|
+
* the dev server still holds).
|
|
742
413
|
*/
|
|
743
414
|
function writeEditableSource(file: string, code: string): void {
|
|
744
415
|
serving().writeSource(file, code);
|
|
745
416
|
}
|
|
746
417
|
|
|
747
|
-
/**
|
|
748
|
-
|
|
418
|
+
/** Project source scope for JSX stamping. */
|
|
419
|
+
function defaultProjectScopeInclude(id: string): boolean {
|
|
749
420
|
const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
|
|
750
421
|
if (!clean.endsWith('.tsx')) return false;
|
|
751
422
|
if (NODE_MODULES_RE.test(clean)) return false;
|
|
752
|
-
// A vendored
|
|
753
|
-
// The old blanket exclusion was written under the never-modify-vendored-
|
|
754
|
-
// source rule, which the owner's code-or-data ruling superseded
|
|
755
|
-
// (ARCHITECTURE-CORE §Editor "Every edit edits the game's own CODE or DATA",
|
|
756
|
-
// §Rules "there is no unwritable base"): ownership no longer decides WHETHER
|
|
757
|
-
// an edit may be written, only WHO RECORDS it. For a repo-vendored copy the
|
|
758
|
-
// recorder is its own `UPSTREAM.lock`, written in the same gesture as the
|
|
759
|
-
// file (`server/vendored-lock-recorder.ts`), so the zero-UNRECORDED-diff bar
|
|
760
|
-
// holds WITH the edit present rather than by forbidding the edit. Stamping is
|
|
761
|
-
// what makes the game's own TSX literals addressable by the gizmo and the
|
|
762
|
-
// inspector; refusing it here is what used to make a vendored game
|
|
763
|
-
// permanently unauthorable.
|
|
423
|
+
// A vendored project records edits with its own source; dependencies remain excluded.
|
|
764
424
|
if (VENDOR_RE.test(clean) && !VENDORED_GAME_SRC_RE.test(clean)) return false;
|
|
765
425
|
if (isToolingSource(clean)) return false;
|
|
766
|
-
if (EDITOR_SRC_RE.test(clean)
|
|
767
|
-
if (nearestManifestExcludesIngestReact(dirname(clean))) return false;
|
|
426
|
+
if (EDITOR_SRC_RE.test(clean)) return false;
|
|
768
427
|
return true;
|
|
769
428
|
}
|
|
770
429
|
|
|
@@ -775,13 +434,12 @@ function resolveEditableSourceFile(id: string, projectRoot: string): string | nu
|
|
|
775
434
|
|
|
776
435
|
// Whole-file transactions also serve project-owned model source. This is
|
|
777
436
|
// deliberately narrower than a general project-file API: TypeScript below
|
|
778
|
-
// this project's src/ tree,
|
|
437
|
+
// this project's src/ tree, with vendored dependencies excluded.
|
|
779
438
|
const projectPath = relative(projectRoot, candidate).replace(/\\/g, '/');
|
|
780
439
|
if (
|
|
781
440
|
projectPath.startsWith('src/') &&
|
|
782
441
|
(projectPath.endsWith('.ts') || projectPath.endsWith('.tsx')) &&
|
|
783
|
-
!VENDOR_RE.test(candidate)
|
|
784
|
-
!nearestManifestExcludesIngestReact(dirname(candidate))
|
|
442
|
+
!VENDOR_RE.test(candidate)
|
|
785
443
|
) {
|
|
786
444
|
return candidate;
|
|
787
445
|
}
|
|
@@ -806,7 +464,7 @@ function isCanonicalProjectSourcePath(projectRoot: string, file: string): boolea
|
|
|
806
464
|
}
|
|
807
465
|
|
|
808
466
|
/** The JSON response a source-endpoint handler produces (status defaults to 200). */
|
|
809
|
-
|
|
467
|
+
interface HandlerResult {
|
|
810
468
|
status?: number;
|
|
811
469
|
body: unknown;
|
|
812
470
|
}
|
|
@@ -837,20 +495,12 @@ function preparedSource(
|
|
|
837
495
|
};
|
|
838
496
|
}
|
|
839
497
|
|
|
840
|
-
/**
|
|
841
|
-
*
|
|
842
|
-
|
|
843
|
-
* The edit itself is planned by the tier-agnostic `planSourceEdit`
|
|
844
|
-
* (`src/ui-source/plan-source-edit.ts`) — this handler only supplies what is
|
|
845
|
-
* genuinely server-side: the `node:fs` reads, the css scope guard, sha256, and
|
|
846
|
-
* the project-relative resource path. The hosted browser backend supplies its
|
|
847
|
-
* own equivalents around the SAME planner, which is what keeps the two tiers
|
|
848
|
-
* from drifting into different edit semantics.
|
|
849
|
-
*/
|
|
850
|
-
export function handlePrepare(
|
|
498
|
+
/** Prepare source bytes and hashes with ../src/source/plan-source-edit.ts.
|
|
499
|
+
* This handler owns file reads, CSS scope validation and project resource paths. */
|
|
500
|
+
function handlePrepare(
|
|
851
501
|
store: OidStore,
|
|
852
502
|
body: Record<string, unknown>,
|
|
853
|
-
projectRoot
|
|
503
|
+
projectRoot: string,
|
|
854
504
|
): HandlerResult {
|
|
855
505
|
const request = body as unknown as SourceEditRequest;
|
|
856
506
|
if (request.kind === 'css' && (!request.file || !isEditableCssFile(request.file))) {
|
|
@@ -863,7 +513,7 @@ export function handlePrepare(
|
|
|
863
513
|
const sources = new Map<string, string>();
|
|
864
514
|
const files = sourceEditFiles(request, resolveOid);
|
|
865
515
|
for (const file of files) {
|
|
866
|
-
sources.set(file,
|
|
516
|
+
sources.set(file, readSource(file));
|
|
867
517
|
}
|
|
868
518
|
// The class-vs-inline gate is a question about the EDITED FILE's own project,
|
|
869
519
|
// so it is answered here (the tier that has `node:fs`) and handed to the pure
|
|
@@ -878,16 +528,16 @@ export function handlePrepare(
|
|
|
878
528
|
return preparedSource(plan.file, plan.prevSource, plan.newSource, plan.result, projectRoot);
|
|
879
529
|
}
|
|
880
530
|
|
|
881
|
-
|
|
531
|
+
function handleSourceRead(
|
|
882
532
|
body: Record<string, unknown>,
|
|
883
|
-
projectRoot
|
|
533
|
+
projectRoot: string,
|
|
884
534
|
): HandlerResult {
|
|
885
535
|
const file = body['file'];
|
|
886
536
|
const resolved = typeof file === 'string' ? resolveEditableSourceFile(file, projectRoot) : null;
|
|
887
537
|
if (!resolved) {
|
|
888
538
|
return { status: 403, body: { error: 'file out of editable scope' } };
|
|
889
539
|
}
|
|
890
|
-
const source =
|
|
540
|
+
const source = readSource(resolved);
|
|
891
541
|
return {
|
|
892
542
|
body: {
|
|
893
543
|
source,
|
|
@@ -897,9 +547,9 @@ export function handleSourceRead(
|
|
|
897
547
|
};
|
|
898
548
|
}
|
|
899
549
|
|
|
900
|
-
|
|
550
|
+
function handleSourceApply(
|
|
901
551
|
body: Record<string, unknown>,
|
|
902
|
-
projectRoot
|
|
552
|
+
projectRoot: string,
|
|
903
553
|
): HandlerResult {
|
|
904
554
|
const restored = handleRestore(body, projectRoot);
|
|
905
555
|
const value = restored.body as { restored: boolean; sha?: string; error?: string };
|
|
@@ -909,14 +559,11 @@ export function handleSourceApply(
|
|
|
909
559
|
};
|
|
910
560
|
}
|
|
911
561
|
|
|
912
|
-
/**
|
|
913
|
-
* D-A3 (wave 13): a `value: null` body is the REMOVAL sentinel — routed via
|
|
914
|
-
* `applyStyleWriteRequest` to `removeInlineStyle` (append-aware undo); `appended`
|
|
915
|
-
* is forwarded so the client's undo can remove vs. restore. */
|
|
562
|
+
/** Surgical style/className write. A null value removes an inline property. */
|
|
916
563
|
function handleWrite(
|
|
917
564
|
store: OidStore,
|
|
918
565
|
body: Record<string, unknown>,
|
|
919
|
-
projectRoot
|
|
566
|
+
projectRoot: string,
|
|
920
567
|
): HandlerResult {
|
|
921
568
|
// A numeric `value` stays a NUMBER all the way to the writer — the JSX then
|
|
922
569
|
// carries a bare `12` (React px-ifies it) instead of `'12'`, which React
|
|
@@ -928,7 +575,7 @@ function handleWrite(
|
|
|
928
575
|
};
|
|
929
576
|
const entry = store.index.get(oid);
|
|
930
577
|
if (!entry) return { body: { changed: false, error: 'unknown oid' } };
|
|
931
|
-
const src =
|
|
578
|
+
const src = readSource(entry.file);
|
|
932
579
|
const off = lineColToOffset(src, entry.line, entry.col);
|
|
933
580
|
const result = applyStyleWriteRequest(
|
|
934
581
|
src,
|
|
@@ -943,7 +590,6 @@ function handleWrite(
|
|
|
943
590
|
changed: result.changed,
|
|
944
591
|
dynamic: result.dynamic,
|
|
945
592
|
route: result.route,
|
|
946
|
-
appended: result.appended,
|
|
947
593
|
...(result.tokenRef ? { tokenRef: result.tokenRef } : {}),
|
|
948
594
|
...(result.error ? { error: result.error } : {}),
|
|
949
595
|
file: entry.file,
|
|
@@ -951,20 +597,9 @@ function handleWrite(
|
|
|
951
597
|
};
|
|
952
598
|
}
|
|
953
599
|
|
|
954
|
-
/**
|
|
955
|
-
*
|
|
956
|
-
|
|
957
|
-
* the rule's source file + selector; this edits that file in place. The file path is
|
|
958
|
-
* client-supplied, so it MUST pass the first-party CSS scope guard before any read/write
|
|
959
|
-
* — an out-of-scope path is rejected, not touched. A selector not present in source is
|
|
960
|
-
* generated CSS (`generated: true`); the client re-routes to class/inline.
|
|
961
|
-
*
|
|
962
|
-
* Exported (unlike its sibling handlers above, which close over the live
|
|
963
|
-
* `OidStore`) so it's directly unit-testable without a dev server — it's pure
|
|
964
|
-
* body-in/HandlerResult-out plus real fs I/O on a client-supplied, scope-
|
|
965
|
-
* guarded path, no store/session state involved.
|
|
966
|
-
*/
|
|
967
|
-
export function handleCss(
|
|
600
|
+
/** Edit a client-selected first-party CSS rule after the scope guard.
|
|
601
|
+
* Selectors absent from source report generated CSS for caller rerouting. */
|
|
602
|
+
function handleCss(
|
|
968
603
|
body: Record<string, unknown>,
|
|
969
604
|
): HandlerResult {
|
|
970
605
|
const { file, selector, prop, value, media } = body as {
|
|
@@ -978,7 +613,7 @@ export function handleCss(
|
|
|
978
613
|
return { body: { changed: false, error: 'css file out of editable scope' } };
|
|
979
614
|
}
|
|
980
615
|
const cleanFile = file.split('?')[0] ?? file;
|
|
981
|
-
const src =
|
|
616
|
+
const src = readSource(cleanFile);
|
|
982
617
|
// `media` scopes the edit to a breakpoint: the rule inside `@media <media>`
|
|
983
618
|
// (block and rule both created on demand — a breakpoint's first edit IS
|
|
984
619
|
// what mints it). The base-path `generated` probe does not apply: the
|
|
@@ -993,9 +628,8 @@ export function handleCss(
|
|
|
993
628
|
}
|
|
994
629
|
|
|
995
630
|
/**
|
|
996
|
-
* POST `/__ui-source/text` —
|
|
997
|
-
* body in place
|
|
998
|
-
* `prevText` (the editable text before the edit) for the client's undo inverse.
|
|
631
|
+
* POST `/__ui-source/text` — text-content edit. Replaces a leaf element's pure-text
|
|
632
|
+
* body in place, refusing a body with expressions or child elements.
|
|
999
633
|
*/
|
|
1000
634
|
function handleText(
|
|
1001
635
|
store: OidStore,
|
|
@@ -1004,18 +638,17 @@ function handleText(
|
|
|
1004
638
|
const { oid, text } = body as { oid: string; text: string };
|
|
1005
639
|
const entry = store.index.get(oid);
|
|
1006
640
|
if (!entry) return { body: { changed: false, error: 'unknown oid' } };
|
|
1007
|
-
const src =
|
|
641
|
+
const src = readSource(entry.file);
|
|
1008
642
|
const off = lineColToOffset(src, entry.line, entry.col);
|
|
1009
|
-
const prevText = getEditableText(src, off);
|
|
1010
643
|
const result = editTextContent(src, off, text);
|
|
1011
644
|
if (result.changed) writeEditableSource(entry.file, result.code);
|
|
1012
645
|
return {
|
|
1013
|
-
body: { changed: result.changed, dynamic: result.dynamic,
|
|
646
|
+
body: { changed: result.changed, dynamic: result.dynamic, file: entry.file },
|
|
1014
647
|
};
|
|
1015
648
|
}
|
|
1016
649
|
|
|
1017
650
|
/**
|
|
1018
|
-
* POST `/__ui-source/prop` —
|
|
651
|
+
* POST `/__ui-source/prop` — component prop / attribute edit at the call site. The
|
|
1019
652
|
* client sends the CALL-SITE oid (resolved from the fiber, `inspect.ts` getCallSiteOid)
|
|
1020
653
|
* whose index entry points at the `<Component …>` tag. Guards a dynamic (non-literal) prop
|
|
1021
654
|
* and the event-handler / structural props.
|
|
@@ -1027,7 +660,7 @@ function handleProp(
|
|
|
1027
660
|
const { oid, prop, value } = body as { oid: string; prop: string; value: string | null };
|
|
1028
661
|
const entry = store.index.get(oid);
|
|
1029
662
|
if (!entry) return { body: { changed: false, error: 'unknown oid' } };
|
|
1030
|
-
const src =
|
|
663
|
+
const src = readSource(entry.file);
|
|
1031
664
|
const off = lineColToOffset(src, entry.line, entry.col);
|
|
1032
665
|
// A dotted prop is one member of an object-literal prop (`params.threshold`): the index names
|
|
1033
666
|
// members that way (`authoredProps`), and the member writer rewrites that literal alone.
|
|
@@ -1036,9 +669,7 @@ function handleProp(
|
|
|
1036
669
|
if (member.changed) writeEditableSource(entry.file, member.code);
|
|
1037
670
|
return { body: { changed: member.changed, dynamic: member.dynamic, file: entry.file } };
|
|
1038
671
|
}
|
|
1039
|
-
//
|
|
1040
|
-
// uses for `removeStyle` (D-A3). Removing a prop attribute is how the
|
|
1041
|
-
// inspector reverts a prop to the component's declared default.
|
|
672
|
+
// A null value removes the prop so its component default can take effect.
|
|
1042
673
|
if (value === null) {
|
|
1043
674
|
const removed = removePropAttribute(src, off, prop);
|
|
1044
675
|
if (removed.changed) writeEditableSource(entry.file, removed.code);
|
|
@@ -1046,53 +677,39 @@ function handleProp(
|
|
|
1046
677
|
}
|
|
1047
678
|
const result = writePropChange(src, off, prop, value, {
|
|
1048
679
|
addIfMissing: body['addIfMissing'] === true,
|
|
1049
|
-
//
|
|
1050
|
-
// upgrade — see writePropChange's own doc comment. Absent/false on every
|
|
1051
|
-
// pre-existing caller, so react-dom prop writes are byte-identical.
|
|
680
|
+
// Forward explicit scalar-to-tuple permission to the source writer.
|
|
1052
681
|
allowShapeUpgrade: body['allowShapeUpgrade'] === true,
|
|
1053
682
|
});
|
|
1054
683
|
if (result.changed) writeEditableSource(entry.file, result.code);
|
|
1055
684
|
return { body: { changed: result.changed, dynamic: result.dynamic, file: entry.file } };
|
|
1056
685
|
}
|
|
1057
686
|
|
|
1058
|
-
/**
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
|
|
1062
|
-
* is added to a world whose root JSX is an OID-less fragment), `reorder`
|
|
1063
|
-
* (move `oid` before `targetOid`, or to the end of `parentOid`), and `reparent` (move `oid`
|
|
1064
|
-
* into `parentOid`). An insert may carry `ensureImport` when its snippet names a component.
|
|
1065
|
-
* Multi-oid ops resolve every oid to an offset in the SAME file (a
|
|
1066
|
-
* cross-file move is refused) — OID-resolved, immune to index races.
|
|
1067
|
-
*
|
|
1068
|
-
* D-1 (Phase D, spec27 §2 "known limitation to schedule, not hide"): when the op actually
|
|
1069
|
-
* changes the file, the response also carries the WHOLE file's `prevSource`/`newSource`
|
|
1070
|
-
* (before/after this write) and their `prevSha`/`newSha` (sha256 digests, computed here —
|
|
1071
|
-
* the client never hashes anything itself). `ReactRootAuthoringAdapter.structOp` uses
|
|
1072
|
-
* these to push a checksum-guarded whole-file-snapshot undo/redo entry — the sound inverse
|
|
1073
|
-
* for a structural op (a per-OID inverse is unsound: OIDs are content-signature keyed,
|
|
1074
|
-
* reorder/delete reassign occurrence indices, and delete has no inverse payload). Exported
|
|
1075
|
-
* (like `handleCss`) so it's directly unit-testable without a running dev server.
|
|
1076
|
-
*
|
|
1077
|
-
* The op switch itself lives in `planSourceEdit` and NOWHERE else. It used to be written
|
|
1078
|
-
* out a second time here, and the two copies drifted the moment `reparent` grew rules of
|
|
1079
|
-
* its own (R1–R4): one caller routed through the planner and got them, this
|
|
1080
|
-
* one did not. Everything that remains below is what is genuinely server-side — the
|
|
1081
|
-
* `node:fs` read/write, sha256, and the response envelope.
|
|
1082
|
-
*/
|
|
1083
|
-
export function handleStruct(
|
|
687
|
+
/** Plan a structural source edit, then apply its complete next-file bytes.
|
|
688
|
+
* The response includes before/after source and SHA values for guarded history.
|
|
689
|
+
* Scope, transform, and destination rules live in the shared source planner. */
|
|
690
|
+
function handleStruct(
|
|
1084
691
|
store: OidStore,
|
|
1085
692
|
body: Record<string, unknown>,
|
|
693
|
+
kind: 'struct' | 'struct-many',
|
|
1086
694
|
): HandlerResult {
|
|
1087
|
-
const { oid, op } = body
|
|
1088
|
-
if (
|
|
1089
|
-
|
|
695
|
+
const { oid, oids, op } = body;
|
|
696
|
+
if (kind === 'struct') {
|
|
697
|
+
if (typeof oid !== 'string' || typeof op !== 'string') {
|
|
698
|
+
return { body: { changed: false, error: 'struct requests need an oid and an op' } };
|
|
699
|
+
}
|
|
700
|
+
} else {
|
|
701
|
+
if (op !== 'delete' && op !== 'group') {
|
|
702
|
+
return { body: { changed: false, error: `unsupported batch op: ${op}` } };
|
|
703
|
+
}
|
|
704
|
+
if (!Array.isArray(oids) || oids.length === 0) {
|
|
705
|
+
return { body: { changed: false, error: 'oids must be a non-empty array' } };
|
|
706
|
+
}
|
|
1090
707
|
}
|
|
1091
|
-
const request = { ...body, kind
|
|
708
|
+
const request = { ...body, kind } as unknown as SourceEditRequest;
|
|
1092
709
|
const resolveOid = (id: string): OidEntry | undefined => store.index.get(id);
|
|
1093
710
|
const sources = new Map<string, string>();
|
|
1094
711
|
for (const file of sourceEditFiles(request, resolveOid)) {
|
|
1095
|
-
sources.set(file,
|
|
712
|
+
sources.set(file, readSource(file));
|
|
1096
713
|
}
|
|
1097
714
|
const plan = planSourceEdit(request, sources, resolveOid);
|
|
1098
715
|
if (plan.file === null) return { body: { changed: false, ...plan.result } };
|
|
@@ -1112,96 +729,13 @@ export function handleStruct(
|
|
|
1112
729
|
};
|
|
1113
730
|
}
|
|
1114
731
|
|
|
1115
|
-
/**
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
|
|
1119
|
-
* (50f90a6d): it resolves EVERY `oid` in `oids` to an offset against ONE shared
|
|
1120
|
-
* `readFileSync` snapshot, then applies them via `deleteElements` (`writer.ts`) —
|
|
1121
|
-
* highest offset first, so
|
|
1122
|
-
* no still-queued target's offset is ever shifted out from under it, REGARDLESS of
|
|
1123
|
-
* what order the caller listed `oids` in. This is what makes the batch immune to the
|
|
1124
|
-
* delete-order-residual: `deleteSelection` (`editor-hotkeys.ts`)'s per-id fallback
|
|
1125
|
-
* loop derives its delete order from the CALLER's own hierarchy walk (DOM order for
|
|
1126
|
-
* `ReactRootAuthoringAdapter` — see `walkOidTree`), which can diverge from true
|
|
1127
|
-
* SOURCE order when a component's live DOM child order is a runtime permutation of
|
|
1128
|
-
* its JSX (e.g. `{[...els].reverse()}`); this endpoint never consults that walk order
|
|
1129
|
-
* at all, only each oid's own registered `{line, col}` and ONE shared file read, so
|
|
1130
|
-
* caller order is irrelevant to correctness. Explicitly NOT a re-scan/re-transform of
|
|
1131
|
-
* the file per oid (see `deleteElements`'s doc comment for why that would be UNSOUND
|
|
1132
|
-
* here — a live re-transform mid-batch reassigns occurrence-index-based oid identity
|
|
1133
|
-
* out from under the very oids this batch is still trying to resolve). One write, one
|
|
1134
|
-
* `prevSource`/`newSource` pair — one undo entry for the whole batch (a bonus over
|
|
1135
|
-
* the N-entry per-id fallback, not the point of this fix, but free from reusing
|
|
1136
|
-
* `pushStructUndo`'s existing whole-file-snapshot inverse unchanged).
|
|
1137
|
-
*/
|
|
1138
|
-
export function handleStructMany(
|
|
732
|
+
/** Plan a component fork, collision-check the new file, and write it.
|
|
733
|
+
* Return the callsite edit unwritten for project history; undo retains the
|
|
734
|
+
* new module and restores the previous callsite. */
|
|
735
|
+
function handleForkComponent(
|
|
1139
736
|
store: OidStore,
|
|
1140
737
|
body: Record<string, unknown>,
|
|
1141
|
-
|
|
1142
|
-
const { oids, op, wrapperTag } = body as {
|
|
1143
|
-
oids?: unknown;
|
|
1144
|
-
op?: string;
|
|
1145
|
-
wrapperTag?: string;
|
|
1146
|
-
};
|
|
1147
|
-
if (op !== 'delete' && op !== 'group') {
|
|
1148
|
-
return { body: { changed: false, error: `unsupported batch op: ${op}` } };
|
|
1149
|
-
}
|
|
1150
|
-
if (!Array.isArray(oids) || oids.length === 0) {
|
|
1151
|
-
return { body: { changed: false, error: 'oids must be a non-empty array' } };
|
|
1152
|
-
}
|
|
1153
|
-
const uniqueOids = [...new Set(oids as string[])];
|
|
1154
|
-
const entries = uniqueOids.map((oid) => ({ oid, entry: store.index.get(oid) }));
|
|
1155
|
-
const missing = entries.find((e) => !e.entry);
|
|
1156
|
-
if (missing) return { body: { changed: false, error: `unknown oid: ${missing.oid}` } };
|
|
1157
|
-
const file = entries[0]!.entry!.file;
|
|
1158
|
-
const crossFile = entries.find((e) => e.entry!.file !== file);
|
|
1159
|
-
if (crossFile) return { body: { changed: false, error: 'cannot batch-edit across files' } };
|
|
1160
|
-
|
|
1161
|
-
const src = readSettledSource(file); // ONE snapshot every offset below resolves against
|
|
1162
|
-
const offsets = entries.map((e) => lineColToOffset(src, e.entry!.line, e.entry!.col));
|
|
1163
|
-
const result =
|
|
1164
|
-
op === 'group' ? groupSiblingElements(src, offsets, wrapperTag) : deleteElements(src, offsets);
|
|
1165
|
-
if (!result.changed) return { body: { changed: false, file } };
|
|
1166
|
-
const prevSource = src;
|
|
1167
|
-
const newSource = result.code;
|
|
1168
|
-
writeEditableSource(file, newSource);
|
|
1169
|
-
return {
|
|
1170
|
-
body: {
|
|
1171
|
-
changed: true,
|
|
1172
|
-
file,
|
|
1173
|
-
prevSource,
|
|
1174
|
-
newSource,
|
|
1175
|
-
prevSha: sha256(prevSource),
|
|
1176
|
-
newSha: sha256(newSource),
|
|
1177
|
-
},
|
|
1178
|
-
};
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
/**
|
|
1182
|
-
* POST `/__ui-source/fork-component` — H7 "Fork Component…" (wave 6).
|
|
1183
|
-
*
|
|
1184
|
-
* The ONE thing this endpoint does that the client cannot: create a file. Every
|
|
1185
|
-
* other `/__ui-source/*` write targets a file the OID index already knows, and
|
|
1186
|
-
* the whole-file `apply`/`restore` pair is checksum-guarded against a file that
|
|
1187
|
-
* exists — there is no create path through the client write seam at all. So the
|
|
1188
|
-
* new module is written HERE with `node:fs`, and the CALLSITE edit is handed
|
|
1189
|
-
* back to the client unwritten, so it can go through project history like every
|
|
1190
|
-
* other source write (the fork is undoable at the callsite; the new file, being
|
|
1191
|
-
* outside history, survives an undo as an inert module nothing imports).
|
|
1192
|
-
*
|
|
1193
|
-
* ORDER AND ATOMICITY. `planComponentFork` computes BOTH edits from both files'
|
|
1194
|
-
* current bytes before anything is written, and refuses — with a sentence — on
|
|
1195
|
-
* every failure it can see (definition not found, tag no longer matches, name
|
|
1196
|
-
* or file collision). Only then is the new file written, and only if nothing is
|
|
1197
|
-
* at that path already: `existsSync` is checked here as well as in the planner,
|
|
1198
|
-
* because the planner reads a directory LISTING the caller supplied and this is
|
|
1199
|
-
* the last moment before the write.
|
|
1200
|
-
*/
|
|
1201
|
-
export function handleForkComponent(
|
|
1202
|
-
store: OidStore,
|
|
1203
|
-
body: Record<string, unknown>,
|
|
1204
|
-
projectRoot = process.cwd(),
|
|
738
|
+
projectRoot: string,
|
|
1205
739
|
): HandlerResult {
|
|
1206
740
|
const { oid, definitionOid, name } = body as {
|
|
1207
741
|
oid?: string;
|
|
@@ -1223,11 +757,11 @@ export function handleForkComponent(
|
|
|
1223
757
|
}
|
|
1224
758
|
const planned = planComponentFork({
|
|
1225
759
|
callsiteFile: callsite.file,
|
|
1226
|
-
callsiteSource:
|
|
760
|
+
callsiteSource: readSource(callsite.file),
|
|
1227
761
|
callsiteLine: callsite.line,
|
|
1228
762
|
callsiteCol: callsite.col,
|
|
1229
763
|
definitionFile: definition.file,
|
|
1230
|
-
definitionSource:
|
|
764
|
+
definitionSource: readSource(definition.file),
|
|
1231
765
|
tag: callsite.tag,
|
|
1232
766
|
nameSeed: typeof name === 'string' ? name : undefined,
|
|
1233
767
|
siblingFiles,
|
|
@@ -1251,39 +785,23 @@ export function handleForkComponent(
|
|
|
1251
785
|
newName: plan.newName,
|
|
1252
786
|
tag: plan.tag,
|
|
1253
787
|
newFile,
|
|
1254
|
-
newResourcePath: sourceResourcePath(newFile, projectRoot),
|
|
1255
788
|
importSpecifier: plan.importSpecifier,
|
|
1256
789
|
label: plan.historyLabel,
|
|
1257
790
|
callsiteFile: plan.callsiteFile,
|
|
1258
791
|
callsiteResourcePath: sourceResourcePath(plan.callsiteFile, projectRoot),
|
|
1259
792
|
callsitePrevSource: plan.callsitePrevSource,
|
|
1260
793
|
callsiteNewSource: plan.callsiteNewSource,
|
|
1261
|
-
callsitePrevSha: sha256(plan.callsitePrevSource),
|
|
1262
|
-
callsiteNewSha: sha256(plan.callsiteNewSource),
|
|
1263
794
|
},
|
|
1264
795
|
};
|
|
1265
796
|
}
|
|
1266
797
|
|
|
1267
|
-
/**
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
|
|
1271
|
-
* middleware (the header on `/__ui-source/fork-component` records why file
|
|
1272
|
-
* creation lives server-side at all). `planComponentExtraction` computes the
|
|
1273
|
-
* two new modules (component + portable CSF story under `src/prefabs/`) and
|
|
1274
|
-
* the callsite edit from current bytes before anything is written, refusing
|
|
1275
|
-
* with a sentence on every failure it can see (subtree gone, lexical capture,
|
|
1276
|
-
* module-local reference, name/file collision). Only then are the new files
|
|
1277
|
-
* written — `existsSync`-guarded here as well as in the planner, because the
|
|
1278
|
-
* planner reads a directory LISTING and this is the last moment before the
|
|
1279
|
-
* write — and the CALLSITE edit is handed back unwritten so the client pushes
|
|
1280
|
-
* it through project history: undo restores the callsite and leaves the two
|
|
1281
|
-
* new files behind as an inert module + story nothing imports.
|
|
1282
|
-
*/
|
|
1283
|
-
export function handleExtractComponent(
|
|
798
|
+
/** Plan extraction into a component and portable story, then collision-check
|
|
799
|
+
* and write both new files. Return the callsite change for project history;
|
|
800
|
+
* undo restores that callsite while retaining the created files. */
|
|
801
|
+
function handleExtractComponent(
|
|
1284
802
|
store: OidStore,
|
|
1285
803
|
body: Record<string, unknown>,
|
|
1286
|
-
projectRoot
|
|
804
|
+
projectRoot: string,
|
|
1287
805
|
): HandlerResult {
|
|
1288
806
|
const { oid, name } = body as { oid?: string; name?: string };
|
|
1289
807
|
const entry = typeof oid === 'string' ? store.index.get(oid) : undefined;
|
|
@@ -1298,7 +816,7 @@ export function handleExtractComponent(
|
|
|
1298
816
|
}
|
|
1299
817
|
const planned = planComponentExtraction({
|
|
1300
818
|
sourceFile: entry.file,
|
|
1301
|
-
source:
|
|
819
|
+
source: readSource(entry.file),
|
|
1302
820
|
line: entry.line,
|
|
1303
821
|
col: entry.col,
|
|
1304
822
|
nameSeed: typeof name === 'string' ? name : undefined,
|
|
@@ -1327,31 +845,21 @@ export function handleExtractComponent(
|
|
|
1327
845
|
newName: plan.newName,
|
|
1328
846
|
tag: plan.tag,
|
|
1329
847
|
componentFile: plan.componentFile,
|
|
1330
|
-
componentResourcePath: sourceResourcePath(plan.componentFile, projectRoot),
|
|
1331
848
|
storyFile: plan.storyFile,
|
|
1332
|
-
storyResourcePath: sourceResourcePath(plan.storyFile, projectRoot),
|
|
1333
849
|
importSpecifier: plan.importSpecifier,
|
|
1334
850
|
label: plan.historyLabel,
|
|
1335
851
|
callsiteFile: plan.callsiteFile,
|
|
1336
852
|
callsiteResourcePath: sourceResourcePath(plan.callsiteFile, projectRoot),
|
|
1337
853
|
callsitePrevSource: plan.callsitePrevSource,
|
|
1338
854
|
callsiteNewSource: plan.callsiteNewSource,
|
|
1339
|
-
callsitePrevSha: sha256(plan.callsitePrevSource),
|
|
1340
|
-
callsiteNewSha: sha256(plan.callsiteNewSource),
|
|
1341
855
|
},
|
|
1342
856
|
};
|
|
1343
857
|
}
|
|
1344
858
|
|
|
1345
|
-
/**
|
|
1346
|
-
|
|
1347
|
-
* stories composed and framed everywhere, written nowhere): save an arg
|
|
1348
|
-
* set as a new story export, rename one, or delete one. The grammar and
|
|
1349
|
-
* every refusal live in the pure planner (`ui-source/plan-csf-story.ts`);
|
|
1350
|
-
* this handler is scope-guard + read + plan + write.
|
|
1351
|
-
*/
|
|
1352
|
-
export function handleCsfStory(
|
|
859
|
+
/** Save, rename, or delete a CSF story through the scoped source planner. */
|
|
860
|
+
function handleCsfStory(
|
|
1353
861
|
body: Record<string, unknown>,
|
|
1354
|
-
projectRoot
|
|
862
|
+
projectRoot: string,
|
|
1355
863
|
): HandlerResult {
|
|
1356
864
|
const { op, file, name, newName, args } = body as {
|
|
1357
865
|
op?: 'save' | 'rename' | 'delete';
|
|
@@ -1367,7 +875,7 @@ export function handleCsfStory(
|
|
|
1367
875
|
if (!resolved) {
|
|
1368
876
|
return { status: 403, body: { changed: false, error: 'file out of editable scope' } };
|
|
1369
877
|
}
|
|
1370
|
-
const source =
|
|
878
|
+
const source = readSource(resolved);
|
|
1371
879
|
const plan =
|
|
1372
880
|
op === 'save'
|
|
1373
881
|
? planSaveStory(source, name, args ?? {})
|
|
@@ -1390,22 +898,10 @@ function cssValueFromJsxLiteral(name: string, raw: string): string | null {
|
|
|
1390
898
|
return null;
|
|
1391
899
|
}
|
|
1392
900
|
|
|
1393
|
-
/**
|
|
1394
|
-
*
|
|
1395
|
-
*
|
|
1396
|
-
|
|
1397
|
-
*
|
|
1398
|
-
* - `create` — mint `.className` in the given first-party stylesheet
|
|
1399
|
-
* (`file`, guarded by {@link isEditableCssFile}), MOVING the element's
|
|
1400
|
-
* literal inline declarations into it (dynamic members stay inline — they
|
|
1401
|
-
* are not the writer's to move), and adding the class token to the
|
|
1402
|
-
* element. Both files are planned before either is written, so a refusal
|
|
1403
|
-
* (duplicate class, dynamic className) leaves nothing half-done.
|
|
1404
|
-
* - `apply` / `remove` — add/remove the bare class token on the element.
|
|
1405
|
-
*
|
|
1406
|
-
* Every refusal names its gate; a `className={expr}` is never rewritten.
|
|
1407
|
-
*/
|
|
1408
|
-
export function handleNamedStyle(
|
|
901
|
+
/** Create a first-party CSS class from literal inline declarations, or
|
|
902
|
+
* apply/remove a class token. Creation plans both files before writing;
|
|
903
|
+
* dynamic members remain inline and dynamic className expressions refuse. */
|
|
904
|
+
function handleNamedStyle(
|
|
1409
905
|
store: OidStore,
|
|
1410
906
|
body: Record<string, unknown>,
|
|
1411
907
|
): HandlerResult {
|
|
@@ -1427,7 +923,7 @@ export function handleNamedStyle(
|
|
|
1427
923
|
}
|
|
1428
924
|
const entry = store.index.get(oid);
|
|
1429
925
|
if (!entry) return { body: { changed: false, error: 'unknown oid' } };
|
|
1430
|
-
const src =
|
|
926
|
+
const src = readSource(entry.file);
|
|
1431
927
|
const off = lineColToOffset(src, entry.line, entry.col);
|
|
1432
928
|
|
|
1433
929
|
if (op === 'apply' || op === 'remove') {
|
|
@@ -1444,11 +940,7 @@ export function handleNamedStyle(
|
|
|
1444
940
|
return { body: { changed: r.changed, file: entry.file } };
|
|
1445
941
|
}
|
|
1446
942
|
|
|
1447
|
-
//
|
|
1448
|
-
// the module import it — an unimported stylesheet never paints, and a bare
|
|
1449
|
-
// `import './styles.css'` is exactly the ecosystem-native form Vite and the
|
|
1450
|
-
// game's own build both honor). No example or fresh scaffold ships project
|
|
1451
|
-
// CSS today, so without this the whole feature would open on a refusal.
|
|
943
|
+
// Without an explicit stylesheet, create one beside the element module and import it.
|
|
1452
944
|
const cleanCss =
|
|
1453
945
|
typeof file === 'string'
|
|
1454
946
|
? (file.split('?')[0] ?? file)
|
|
@@ -1456,7 +948,7 @@ export function handleNamedStyle(
|
|
|
1456
948
|
if (!isEditableCssFile(cleanCss)) {
|
|
1457
949
|
return { body: { changed: false, error: 'css file out of editable scope' } };
|
|
1458
950
|
}
|
|
1459
|
-
const cssSrc = existsSync(cleanCss) ?
|
|
951
|
+
const cssSrc = existsSync(cleanCss) ? readSource(cleanCss) : '';
|
|
1460
952
|
const decls: { prop: string; value: string }[] = [];
|
|
1461
953
|
const moved: string[] = [];
|
|
1462
954
|
for (const member of collectLiteralInlineStyles(src, off)) {
|
|
@@ -1503,23 +995,11 @@ export function handleNamedStyle(
|
|
|
1503
995
|
};
|
|
1504
996
|
}
|
|
1505
997
|
|
|
1506
|
-
/**
|
|
1507
|
-
*
|
|
1508
|
-
|
|
1509
|
-
* create) undoable. `ReactRootAuthoringAdapter.structOp` captures `{prevSource, newSource,
|
|
1510
|
-
* prevSha, newSha}` from a struct write's response (`handleStruct`, above) and, on undo/
|
|
1511
|
-
* redo, posts the INVERSE `{file, source, ifMatchSha}` here: undo sends `source: prevSource,
|
|
1512
|
-
* ifMatchSha: newSha` (the hash of what SHOULD be on disk right now); redo sends the mirror
|
|
1513
|
-
* image. Refuses — WITHOUT writing — when the file's CURRENT sha256 doesn't match
|
|
1514
|
-
* `ifMatchSha` (an out-of-band change since: a hand-edit, a since-diverged reload, …);
|
|
1515
|
-
* `restored: false` is the caller's cue to surface a loud refusal instead of silently
|
|
1516
|
-
* corrupting the file. Scope-guarded the same way `handleCss`'s client-supplied `file` is
|
|
1517
|
-
* (`defaultProjectScopeInclude` — restore targets the SAME `.tsx` project files struct ops
|
|
1518
|
-
* do). Exported (like `handleCss`) so it's directly unit-testable without a dev server.
|
|
1519
|
-
*/
|
|
1520
|
-
export function handleRestore(
|
|
998
|
+
/** Restore a complete source file only when its current SHA matches
|
|
999
|
+
* ifMatchSha. The same project scope guard applies to reads and writes. */
|
|
1000
|
+
function handleRestore(
|
|
1521
1001
|
body: Record<string, unknown>,
|
|
1522
|
-
projectRoot
|
|
1002
|
+
projectRoot: string,
|
|
1523
1003
|
): HandlerResult {
|
|
1524
1004
|
const { file, source, ifMatchSha } = body as {
|
|
1525
1005
|
file: string;
|
|
@@ -1532,7 +1012,7 @@ export function handleRestore(
|
|
|
1532
1012
|
}
|
|
1533
1013
|
let current: string;
|
|
1534
1014
|
try {
|
|
1535
|
-
current =
|
|
1015
|
+
current = readSource(resolved);
|
|
1536
1016
|
} catch (e) {
|
|
1537
1017
|
return { status: 404, body: { restored: false, error: String(e) } };
|
|
1538
1018
|
}
|
|
@@ -1551,67 +1031,39 @@ export function handleRestore(
|
|
|
1551
1031
|
return { body: { restored: true, sha: sha256(source) } };
|
|
1552
1032
|
}
|
|
1553
1033
|
|
|
1554
|
-
export function uiOidPlugin(
|
|
1555
|
-
services: ProjectServingServices,
|
|
1556
|
-
include: RegExp | ((id: string) => boolean) = defaultProjectScopeInclude,
|
|
1557
|
-
): Plugin {
|
|
1034
|
+
export function uiOidPlugin(services: ProjectServingServices): Plugin {
|
|
1558
1035
|
servingServices = services;
|
|
1559
1036
|
const projectRoot = services.currentProjectRoot;
|
|
1560
|
-
const test = typeof include === 'function' ? include : (id: string) => include.test(id);
|
|
1561
1037
|
const store = new OidStore();
|
|
1562
1038
|
// Captured by `configureServer` below, read by `transform` — the dev
|
|
1563
|
-
// server's own `ModuleGraph` is what lets
|
|
1039
|
+
// server's own `ModuleGraph` is what lets the surface resolver
|
|
1564
1040
|
// reach a root entry's surface through a NON-entry file's importer chain
|
|
1565
1041
|
// (a child component with no direct fiber import of its
|
|
1566
1042
|
// own). `undefined` in a build/SSR context without `configureServer`
|
|
1567
1043
|
// (e.g. a one-off transform in a test) degrades to the direct-entry-only
|
|
1568
|
-
// answer
|
|
1044
|
+
// answer.
|
|
1569
1045
|
let devServer: ViteDevServer | undefined;
|
|
1570
1046
|
return {
|
|
1571
1047
|
name: SOURCE_WRITE_ROUTES_PLUGIN,
|
|
1572
1048
|
enforce: 'pre',
|
|
1573
1049
|
transform(code, id) {
|
|
1574
|
-
//
|
|
1575
|
-
// `clean` as one — `resolveOidSurface` walks up from it looking for the
|
|
1576
|
-
// owning `volter.project.json`, and `transformSource` records it as the
|
|
1577
|
-
// stamped file's path. Vite's convention is that a `\0`-prefixed id was
|
|
1578
|
-
// synthesized by a plugin and has no path behind it, so a path plugin
|
|
1579
|
-
// skips it.
|
|
1580
|
-
//
|
|
1581
|
-
// The `test(clean)` below happens to reject today's virtual ids for the
|
|
1582
|
-
// wrong reason (they carry no extension); a synthesized id may legally
|
|
1583
|
-
// end in `.tsx`, and then the extension test passes and a NUL byte
|
|
1584
|
-
// reaches `readFileSync`. That exact throw was measured one layer down
|
|
1585
|
-
// on 2026-09-21 — see `importerFiles` in `server/project-root-surface.ts`
|
|
1586
|
-
// for the failure and what it looked like from the editor.
|
|
1050
|
+
// Virtual module IDs have no filesystem path and must not reach source reads.
|
|
1587
1051
|
if (id.startsWith('\0')) return null;
|
|
1588
1052
|
const clean = id.split('?')[0] ?? id;
|
|
1589
|
-
if (!
|
|
1590
|
-
//
|
|
1591
|
-
// `object.userData.oid`); react-dom files keep ` data-oid` unchanged.
|
|
1592
|
-
// WHICH REGION owns this exact file decides: a declaration the project
|
|
1593
|
-
// made (an `include` glob or a `mounts` entry in `editor/volter.adapter.ts`), the
|
|
1594
|
-
// manifest root whose `entry` it is, or reach from one through the live
|
|
1595
|
-
// import graph. Nothing is inferred from the file's own bytes; a file
|
|
1596
|
-
// nothing places gets the documented `data-oid` default, said out loud.
|
|
1597
|
-
// The project root is threaded because an INGEST root's sources live
|
|
1598
|
-
// outside its manifest's directory (see `manifestOwning`), so the walk
|
|
1599
|
-
// alone cannot find the project that declares them.
|
|
1053
|
+
if (!defaultProjectScopeInclude(clean)) return null;
|
|
1054
|
+
// The owning region chooses the stamp; source diagnostics report any mismatch.
|
|
1600
1055
|
const decision = services.surfaceOf(clean, code, devServer?.moduleGraph, projectRoot());
|
|
1601
1056
|
const { code: out, entries } = transformSource(code, clean, store, {
|
|
1602
|
-
attribute: decision.
|
|
1057
|
+
attribute: oidAttributeForSurface(decision.surface),
|
|
1603
1058
|
canvasComponents: decision.surface === 'canvas',
|
|
1604
|
-
importedR3fContracts: importedR3fContracts(code, clean),
|
|
1605
1059
|
});
|
|
1606
|
-
//
|
|
1607
|
-
// file actually GOT stamps — a module with no JSX has no selection to
|
|
1608
|
-
// break, so its (irrelevant) attribute is not worth a terminal line.
|
|
1060
|
+
// Report stamp diagnostics only for modules that actually contain stamped JSX.
|
|
1609
1061
|
if (entries.length > 0) decision.report();
|
|
1610
1062
|
return { code: out, map: null };
|
|
1611
1063
|
},
|
|
1612
1064
|
configureServer(server) {
|
|
1613
1065
|
devServer = server;
|
|
1614
|
-
const activeProjectRoot =
|
|
1066
|
+
const activeProjectRoot = projectRoot;
|
|
1615
1067
|
// The editor's own writes invalidate the modules they replace; the kit binds the graph.
|
|
1616
1068
|
services.bindModuleGraph(server.moduleGraph, activeProjectRoot);
|
|
1617
1069
|
// The Express host may apply express.json() app-wide, consuming the body and
|
|
@@ -1660,25 +1112,8 @@ export function uiOidPlugin(
|
|
|
1660
1112
|
isAbsolute(file) ? file : resolve(activeProjectRoot(), file),
|
|
1661
1113
|
activeProjectRoot(),
|
|
1662
1114
|
);
|
|
1663
|
-
/**
|
|
1664
|
-
*
|
|
1665
|
-
* REVISION IT PRODUCED.
|
|
1666
|
-
*
|
|
1667
|
-
* The revision is what the next mutation's optimistic-concurrency guard
|
|
1668
|
-
* (`assertSourceMutation`, below) compares against, and the client only
|
|
1669
|
-
* learned it from the collaboration SSE stream — one event loop later.
|
|
1670
|
-
* A gesture that writes the same file TWICE therefore sent a revision
|
|
1671
|
-
* that its own first write had already superseded, and the second write
|
|
1672
|
-
* was rejected: measured on a canvas transform drag, whose `position`
|
|
1673
|
-
* channel is two props (`x` and `y`) by construction, so EVERY drag lost
|
|
1674
|
-
* its whole gesture to the compensation path. An R3F pivot-rotate
|
|
1675
|
-
* (position + rotation) has the same shape.
|
|
1676
|
-
*
|
|
1677
|
-
* Returning it here closes the window without a second channel: the
|
|
1678
|
-
* writer already awaits this response, so adopting the number it carries
|
|
1679
|
-
* is exactly as fresh as the write itself (`source-write-backend.ts`'s
|
|
1680
|
-
* `postJson`).
|
|
1681
|
-
*/
|
|
1115
|
+
/** Return the revision produced by this write so the next awaited mutation
|
|
1116
|
+
* can use it without waiting for collaboration SSE delivery. */
|
|
1682
1117
|
const recordEditorMutation = (
|
|
1683
1118
|
participantId: string,
|
|
1684
1119
|
files: readonly string[],
|
|
@@ -1701,7 +1136,7 @@ export function uiOidPlugin(
|
|
|
1701
1136
|
);
|
|
1702
1137
|
};
|
|
1703
1138
|
// Single middleware switching on the URL — avoids connect prefix-matching subtleties.
|
|
1704
|
-
// Each POST endpoint delegates to a small module-level handler
|
|
1139
|
+
// Each POST endpoint delegates to a small module-level handler so this
|
|
1705
1140
|
// dispatcher stays flat; the try/catch + JSON plumbing lives here once.
|
|
1706
1141
|
const post = async (
|
|
1707
1142
|
req: import('node:http').IncomingMessage,
|
|
@@ -1711,42 +1146,15 @@ export function uiOidPlugin(
|
|
|
1711
1146
|
): Promise<void> => {
|
|
1712
1147
|
try {
|
|
1713
1148
|
const body = await readJson(req);
|
|
1714
|
-
const
|
|
1715
|
-
const trustedParticipantId = req.headers['x-volter-share-participant-id'];
|
|
1716
|
-
const trustedRole = req.headers['x-volter-share-role'];
|
|
1717
|
-
if (
|
|
1718
|
-
typeof trustedParticipantId === 'string' &&
|
|
1719
|
-
typeof claimedParticipantId === 'string' &&
|
|
1720
|
-
trustedParticipantId !== claimedParticipantId
|
|
1721
|
-
) {
|
|
1722
|
-
throw new Error(
|
|
1723
|
-
'The authenticated share participant does not match the source mutation author.',
|
|
1724
|
-
);
|
|
1725
|
-
}
|
|
1726
|
-
const participantId =
|
|
1727
|
-
typeof trustedParticipantId === 'string' ? trustedParticipantId : claimedParticipantId;
|
|
1149
|
+
const participantId = body['participantId'];
|
|
1728
1150
|
const expectedRevision = body['expectedRevision'];
|
|
1729
1151
|
const readOnlySourceRequest =
|
|
1730
1152
|
url === '/__ui-source/prepare' || url === '/__ui-source/read';
|
|
1731
1153
|
const beforeFiles = mutationFiles(body);
|
|
1732
|
-
if (
|
|
1733
|
-
typeof trustedParticipantId === 'string' &&
|
|
1734
|
-
beforeFiles.some((file) => !isCanonicalProjectSourcePath(activeProjectRoot(), file))
|
|
1735
|
-
) {
|
|
1736
|
-
throw new Error('Shared source path leaves the project root.');
|
|
1737
|
-
}
|
|
1738
1154
|
if (!readOnlySourceRequest && beforeFiles.length > 0) {
|
|
1739
1155
|
if (typeof participantId !== 'string' || !Number.isInteger(expectedRevision)) {
|
|
1740
1156
|
throw new Error('Source mutations require participantId and expectedRevision.');
|
|
1741
1157
|
}
|
|
1742
|
-
if (
|
|
1743
|
-
typeof trustedRole === 'string' &&
|
|
1744
|
-
trustedRole !== 'editor' &&
|
|
1745
|
-
trustedRole !== 'terminal' &&
|
|
1746
|
-
trustedRole !== 'maintainer'
|
|
1747
|
-
) {
|
|
1748
|
-
throw new Error(`The ${trustedRole} share role cannot edit source.`);
|
|
1749
|
-
}
|
|
1750
1158
|
services.collaboration(activeProjectRoot()).assertSourceMutation(
|
|
1751
1159
|
participantId,
|
|
1752
1160
|
expectedRevision as number,
|
|
@@ -1788,8 +1196,8 @@ export function uiOidPlugin(
|
|
|
1788
1196
|
'/__ui-source/css': (b) => handleCss(b),
|
|
1789
1197
|
'/__ui-source/text': (b) => handleText(store, b),
|
|
1790
1198
|
'/__ui-source/prop': (b) => handleProp(store, b),
|
|
1791
|
-
'/__ui-source/struct': (b) => handleStruct(store, b),
|
|
1792
|
-
'/__ui-source/struct-many': (b) =>
|
|
1199
|
+
'/__ui-source/struct': (b) => handleStruct(store, b, 'struct'),
|
|
1200
|
+
'/__ui-source/struct-many': (b) => handleStruct(store, b, 'struct-many'),
|
|
1793
1201
|
'/__ui-source/csf-story': (b) => handleCsfStory(b, activeProjectRoot()),
|
|
1794
1202
|
'/__ui-source/named-style': (b) => handleNamedStyle(store, b),
|
|
1795
1203
|
'/__ui-source/fork-component': (b) =>
|
|
@@ -1800,17 +1208,7 @@ export function uiOidPlugin(
|
|
|
1800
1208
|
server.middlewares.use(async (req, res, next) => {
|
|
1801
1209
|
const url = (req.url ?? '').split('?')[0] ?? '';
|
|
1802
1210
|
if (url === '/__ui-source/index') {
|
|
1803
|
-
//
|
|
1804
|
-
// read — but OFF the request: a cold enrich is a 2.6 s ts.Program
|
|
1805
|
-
// and must not freeze first paint. The handler returns the last
|
|
1806
|
-
// enriched map (or the raw stamp) immediately; see currentOidIndex.
|
|
1807
|
-
//
|
|
1808
|
-
// H5 (wave 4): the R3F
|
|
1809
|
-
// authorability DIAGNOSTICS ride this same response, per entry.
|
|
1810
|
-
// Deliberately not a channel of their own — attaching them to the
|
|
1811
|
-
// payload the R3F adapter's `refreshSourceState()` already fetches is
|
|
1812
|
-
// what makes their freshness structurally identical to the index's,
|
|
1813
|
-
// with no second fetch, no polling loop and no cache to invalidate.
|
|
1211
|
+
// Serve current stamps or matching cached enrichment while props resolve off-request.
|
|
1814
1212
|
|
|
1815
1213
|
const indexStarted = Date.now();
|
|
1816
1214
|
const index = currentOidIndex(store, activeProjectRoot(), server);
|
|
@@ -1819,34 +1217,15 @@ export function uiOidPlugin(
|
|
|
1819
1217
|
'X-Volter-Oid-Index-Enriched',
|
|
1820
1218
|
index === lastEnrichedIndex?.index ? '1' : '0',
|
|
1821
1219
|
);
|
|
1822
|
-
|
|
1823
|
-
return json(res, index);
|
|
1824
|
-
}
|
|
1825
|
-
return json(
|
|
1826
|
-
res,
|
|
1827
|
-
Object.fromEntries(
|
|
1828
|
-
Object.entries(index).filter(([, value]) => {
|
|
1829
|
-
const file = (value as { file?: unknown } | null)?.file;
|
|
1830
|
-
return (
|
|
1831
|
-
typeof file === 'string' &&
|
|
1832
|
-
isCanonicalProjectSourcePath(activeProjectRoot(), file)
|
|
1833
|
-
);
|
|
1834
|
-
}),
|
|
1835
|
-
),
|
|
1836
|
-
);
|
|
1220
|
+
return json(res, index);
|
|
1837
1221
|
}
|
|
1838
1222
|
if (url === '/__ui-source/component-props' && req.method === 'GET') {
|
|
1839
|
-
//
|
|
1840
|
-
// any callsite exists. The index above only knows tags already used
|
|
1841
|
-
// in a file; a drop needs the declaration itself so a REQUIRED enum
|
|
1842
|
-
// prop can be written with its first option instead of nothing
|
|
1843
|
-
// (`<WeaponPickup>` rendered NOTHING until `kind` was set — runhuman
|
|
1844
|
-
// pass 123). Same worker, same checker, one request.
|
|
1223
|
+
// Resolve declared props before a callsite exists, for component insertion defaults.
|
|
1845
1224
|
const params = new URL(req.url ?? '', 'http://localhost').searchParams;
|
|
1846
1225
|
const file = params.get('file') ?? '';
|
|
1847
1226
|
const name = params.get('name') ?? '';
|
|
1848
1227
|
const root = activeProjectRoot();
|
|
1849
|
-
if (!
|
|
1228
|
+
if (!file || !name) return json(res, { props: [] });
|
|
1850
1229
|
const absolute = isAbsolute(file) ? resolve(file) : resolve(root, file);
|
|
1851
1230
|
if (
|
|
1852
1231
|
!isCanonicalProjectSourcePath(root, absolute) ||
|
|
@@ -1859,7 +1238,7 @@ export function uiOidPlugin(
|
|
|
1859
1238
|
const props = await resolveDeclaredProps(root, [
|
|
1860
1239
|
{ key, file: absolute, tag: name, definition: true },
|
|
1861
1240
|
]);
|
|
1862
|
-
return json(res, { props: props.get(key)
|
|
1241
|
+
return json(res, { props: props.get(key)! });
|
|
1863
1242
|
} catch (error) {
|
|
1864
1243
|
return json(res, {
|
|
1865
1244
|
props: [],
|