@volter/editor-sdk 0.5.57
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +19 -0
- package/package.json +90 -0
- package/src/account.ts +210 -0
- package/src/chrome.ts +83 -0
- package/src/client.ts +1547 -0
- package/src/commands.ts +66 -0
- package/src/contributions.ts +985 -0
- package/src/document-probe.ts +237 -0
- package/src/editor-view.ts +220 -0
- package/src/extension.ts +40 -0
- package/src/generations.ts +178 -0
- package/src/host.ts +1167 -0
- package/src/http-transport.browser.ts +14 -0
- package/src/http-transport.node.ts +19 -0
- package/src/index.ts +128 -0
- package/src/layout-arrangements.ts +5 -0
- package/src/layouts.tsx +108 -0
- package/src/looks.ts +14 -0
- package/src/project/output-roots.ts +73 -0
- package/src/project/tab-census.ts +149 -0
- package/src/project-tool-catalog.ts +96 -0
- package/src/selection.tsx +108 -0
- package/src/services.ts +18 -0
- package/src/session/build-report.ts +19 -0
- package/src/session/collaboration-types.ts +262 -0
- package/src/session/command-table.ts +333 -0
- package/src/session/discovery.ts +90 -0
- package/src/session/editor-brand.ts +73 -0
- package/src/session/editor-compatibility.ts +248 -0
- package/src/session/editor-control-lifecycle.ts +68 -0
- package/src/session/editor-control-protocol.ts +5 -0
- package/src/session/entrypoint-selection-readers.ts +66 -0
- package/src/session/entrypoint-selection-source.ts +120 -0
- package/src/session/game-css-scope.ts +30 -0
- package/src/session/product-create.ts +24 -0
- package/src/session/product-locator.ts +389 -0
- package/src/session/project-module-url.ts +245 -0
- package/src/session/registry-format.ts +203 -0
- package/src/session/relative-path-guard.ts +56 -0
- package/src/session/source-glob.ts +15 -0
- package/src/session/tool-contribution-convention.ts +116 -0
- package/src/session/workbench-locator.ts +650 -0
- package/src/session.ts +41 -0
- package/src/share.ts +160 -0
- package/src/tools/errors.ts +91 -0
- package/src/tools/provider-execution.ts +70 -0
- package/src/tools/registry.ts +341 -0
- package/src/tools/types.ts +159 -0
- package/src/transport.ts +97 -0
- package/src/types.ts +1581 -0
- package/src/views.ts +164 -0
- package/src/widgets/design-system.ts +93 -0
- package/src/widgets/editor-appearance.ts +149 -0
- package/src/widgets/editor-material.ts +83 -0
- package/src/widgets/icon-set-registry.ts +105 -0
- package/src/widgets/index.ts +71 -0
- package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
- package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
- package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
- package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
- package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
- package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
- package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
- package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
- package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
- package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
- package/src/widgets/inspector-widgets/color-utils.ts +201 -0
- package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
- package/src/widgets/inspector-widgets/index.ts +24 -0
- package/src/widgets/inspector-widgets/shared.tsx +140 -0
- package/src/widgets/interactive-edit-scope.ts +33 -0
- package/src/widgets/patterns/Dialog.tsx +129 -0
- package/src/widgets/patterns/Fields.tsx +44 -0
- package/src/widgets/patterns/List.tsx +25 -0
- package/src/widgets/patterns/StateSurface.tsx +40 -0
- package/src/widgets/patterns/Surfaces.tsx +122 -0
- package/src/widgets/patterns/Tabs.tsx +80 -0
- package/src/widgets/patterns/Toolbar.tsx +72 -0
- package/src/widgets/patterns/Tree.tsx +72 -0
- package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
- package/src/widgets/primitives/Button.tsx +62 -0
- package/src/widgets/primitives/ColorInput.tsx +78 -0
- package/src/widgets/primitives/DraftTextInput.tsx +63 -0
- package/src/widgets/primitives/EditorIcon.tsx +157 -0
- package/src/widgets/primitives/FormControls.tsx +88 -0
- package/src/widgets/primitives/HoverPreview.tsx +96 -0
- package/src/widgets/primitives/JsonInput.tsx +113 -0
- package/src/widgets/primitives/Layout.tsx +100 -0
- package/src/widgets/primitives/Menu.tsx +140 -0
- package/src/widgets/primitives/NumberInput.tsx +169 -0
- package/src/widgets/primitives/Panel.tsx +80 -0
- package/src/widgets/primitives/SectionHeader.tsx +77 -0
- package/src/widgets/primitives/Text.tsx +54 -0
- package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
- package/src/widgets/primitives/Tooltip.tsx +204 -0
- package/src/widgets/primitives/Vec3Input.tsx +70 -0
- package/src/widgets/primitives/banner-tones.ts +32 -0
- package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
- package/src/widgets/primitives/editor-icons.ts +245 -0
- package/src/widgets/primitives/panel-header-styles.ts +42 -0
- package/src/widgets/theme.ts +2633 -0
- package/src/widgets/z-index.ts +25 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH PRODUCT OPENS THIS PROJECT — one declaration, one resolver.
|
|
3
|
+
*
|
|
4
|
+
* A PRODUCT is the running program (ARCHITECTURE-CORE §The target shape, rule
|
|
5
|
+
* 4): `@vgai/game-editor` and `@vgai/model-editor` each stitch packages onto
|
|
6
|
+
* the editor kit in code and each carry a `bin`. **Which product runs is never
|
|
7
|
+
* a switch** — there is no `--product` flag, no id in the manifest, no setting,
|
|
8
|
+
* the same way a folder never names which VS Code opens it and a `.blend` never
|
|
9
|
+
* names Blender.
|
|
10
|
+
*
|
|
11
|
+
* So the resolution is a DEPENDENCY resolution, exactly as
|
|
12
|
+
* `assertEditorProjectInstalled` resolves `@vgai/editor`: the project's
|
|
13
|
+
* `dependencies` ∪ `devDependencies` are read, each `@vgai/*` among them is
|
|
14
|
+
* resolved from the project, and the one whose `package.json` carries
|
|
15
|
+
* `vgai.product` is the product. A project's `package.json` naming a product as
|
|
16
|
+
* a devDependency is the person's convenience — what `npm run dev` launches —
|
|
17
|
+
* and not a rule about the project.
|
|
18
|
+
*
|
|
19
|
+
* NONE and TWO are both refusals by name, because either answer would otherwise
|
|
20
|
+
* be guessed. None prints the `npm install --save-dev` line for both products;
|
|
21
|
+
* two names both and says the project must resolve it, because a page runs one
|
|
22
|
+
* product and nothing here can pick.
|
|
23
|
+
*
|
|
24
|
+
* WHO CALLS THIS, and why twice. The CLI resolves to REFUSE EARLY and to hand
|
|
25
|
+
* the directory to the session; the session resolves to SERVE the product's
|
|
26
|
+
* entry and to REPORT it (`EditorState.product`), because what it is serving is
|
|
27
|
+
* its own fact. One function, one set of refusal texts.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
import { dirname, join } from 'node:path';
|
|
33
|
+
import { pathToFileURL } from 'node:url';
|
|
34
|
+
|
|
35
|
+
/** The manifest key a product declares. A DECLARATION, not a config: it says
|
|
36
|
+
* what this package IS so the CLI and the session can find its entry without
|
|
37
|
+
* running it (ARCHITECTURE-CORE §The target shape, rule 8). */
|
|
38
|
+
export const PRODUCT_DECLARATION_KEY = 'vgai.product';
|
|
39
|
+
|
|
40
|
+
/** Light or dark — the two the workbench's own `ColorScheme` has plain names
|
|
41
|
+
* for, and the only two a product needs to state (a high-contrast scheme is a
|
|
42
|
+
* person's accessibility choice, never a product's declaration). */
|
|
43
|
+
export type ProductColorTheme = 'dark' | 'light';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* THE WORKBENCH THIS PRODUCT IS, named as a published release.
|
|
47
|
+
*
|
|
48
|
+
* A product is the running program, and the program is the Code-OSS workbench
|
|
49
|
+
* its kit and its half were compiled INTO (ARCHITECTURE-CORE §The target
|
|
50
|
+
* shape: "one product number per release, pinning the fork, the kit, the API
|
|
51
|
+
* and its media"). So which release those bytes are is not a machine's setting
|
|
52
|
+
* and not a person's choice — it is what the product IS, declared beside
|
|
53
|
+
* `entry` and `colorTheme` and read without running anything (rule 8).
|
|
54
|
+
*
|
|
55
|
+
* `release` is the tag `scripts/workbench/build-release.mjs --publish` cuts on
|
|
56
|
+
* the fork's own repository, `<product>-<fork sha 12>-<platform>`;
|
|
57
|
+
* `tarballSha256` is the pin, verified against the downloaded bytes before a
|
|
58
|
+
* single one of them is extracted. Both come out of that script's own
|
|
59
|
+
* `BUILD.json`, and it prints this block to paste.
|
|
60
|
+
*
|
|
61
|
+
* A machine that has already fetched it keeps it in `~/.vgai/workbenches/<tag>`
|
|
62
|
+
* and a project that has one names it in `.vgai/workbench.json`, so this is
|
|
63
|
+
* consulted exactly once per machine per release — see
|
|
64
|
+
* `workbench-locator.ts`'s `resolveWorkbenchForProject`.
|
|
65
|
+
*/
|
|
66
|
+
export interface ProductWorkbench {
|
|
67
|
+
/** The GitHub Release tag on `volter-ai/code-oss`. */
|
|
68
|
+
readonly release: string;
|
|
69
|
+
/** The sha256 of that release's tarball asset, hex. */
|
|
70
|
+
readonly tarballSha256: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A product package, as everything outside it needs to know it. */
|
|
74
|
+
export interface ProductIdentity {
|
|
75
|
+
/** The package name — `@vgai/game-editor`. */
|
|
76
|
+
readonly name: string;
|
|
77
|
+
/** Its version, reported beside the workbench by `vgai status`. */
|
|
78
|
+
readonly version: string;
|
|
79
|
+
/** Its package root, absolute. */
|
|
80
|
+
readonly dir: string;
|
|
81
|
+
/**
|
|
82
|
+
* Its ONE source entry, package-relative and POSIX (`./src/index.ts`). That
|
|
83
|
+
* entry IS the product's frame entry: it composes and re-exports `mountVgai`,
|
|
84
|
+
* which is the name the fork's contribution reads off the served module.
|
|
85
|
+
*/
|
|
86
|
+
readonly entry: string;
|
|
87
|
+
/**
|
|
88
|
+
* WHICH WAY ROUND THIS PRODUCT PAINTS, before one line of its code runs.
|
|
89
|
+
*
|
|
90
|
+
* Measured 2026-09-21 on both releases: the web workbench's FIRST PAINT is
|
|
91
|
+
* LIGHT (`workbenchThemeService.ts`, `this.settings.getPreferredColorScheme()
|
|
92
|
+
* ?? (isWeb ? ColorScheme.LIGHT : ColorScheme.DARK)`), so the product's
|
|
93
|
+
* opening cover — which paints in `--vscode-*` variables, as it must — was
|
|
94
|
+
* white for its whole life and flipped dark the instant the real theme
|
|
95
|
+
* landed. The supported door is `IWorkbenchConstructionOptions
|
|
96
|
+
* .initialColorTheme`, which is read out of the PAGE CONFIG, before any
|
|
97
|
+
* extension or setting is available; the session's proxy injects it
|
|
98
|
+
* (`packages/editor/server/frame-proxy.ts`).
|
|
99
|
+
*
|
|
100
|
+
* It is a DECLARATION and not a setting (ARCHITECTURE-CORE §The target
|
|
101
|
+
* shape, rule 8): it says what the product IS, the same way `entry` does, and
|
|
102
|
+
* it has to be readable without running the product because the proxy needs
|
|
103
|
+
* it before the product exists. A person's own `workbench.colorTheme` still
|
|
104
|
+
* wins on every open after the first — the workbench prefers its stored theme
|
|
105
|
+
* over this and only falls back here when it has none.
|
|
106
|
+
*/
|
|
107
|
+
readonly colorTheme: ProductColorTheme;
|
|
108
|
+
/** WHICH WORKBENCH THIS PRODUCT IS — see {@link ProductWorkbench}. */
|
|
109
|
+
readonly workbench: ProductWorkbench | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The install line a refusal quotes, for a project that already EXISTS. Both
|
|
113
|
+
* products, because a person choosing between them is choosing what they are
|
|
114
|
+
* building, not a flag. */
|
|
115
|
+
export const PRODUCT_INSTALL_LINES = [' npm install --save-dev @volter/editor'] as const;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The create line a refusal or a help screen quotes, for a project that does
|
|
119
|
+
* not exist yet. CREATION IS THE PRODUCT'S (ARCHITECTURE-CORE §The target
|
|
120
|
+
* shape, rule 1): a product scaffolds the project it opens, declaring its own
|
|
121
|
+
* presets, and there is no create verb in the kit's CLI for either of them to
|
|
122
|
+
* be an alias of.
|
|
123
|
+
*
|
|
124
|
+
* This is the ONE place the two product names are spelled outside the products
|
|
125
|
+
* themselves; the CLI and the session quote it rather than keeping a copy.
|
|
126
|
+
*/
|
|
127
|
+
export const PRODUCT_CREATE_LINES = [' npx @volter/editor create <name>'] as const;
|
|
128
|
+
|
|
129
|
+
interface ProductManifestShape {
|
|
130
|
+
private?: unknown;
|
|
131
|
+
name?: unknown;
|
|
132
|
+
version?: unknown;
|
|
133
|
+
vgai?: { product?: { entry?: unknown; colorTheme?: unknown; workbench?: unknown } };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const PRODUCT_COLOR_THEMES: readonly ProductColorTheme[] = ['dark', 'light'];
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read one product package's declaration, or `null` when the manifest is not a
|
|
140
|
+
* product's. Throws when it declares `vgai.product` and the declaration is
|
|
141
|
+
* unusable — a half-written product must be named, never skipped.
|
|
142
|
+
*/
|
|
143
|
+
export function readProductManifest(packageDir: string): ProductIdentity | null {
|
|
144
|
+
const manifestPath = join(packageDir, 'package.json');
|
|
145
|
+
let manifest: ProductManifestShape;
|
|
146
|
+
try {
|
|
147
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ProductManifestShape;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const declared = manifest.vgai?.product;
|
|
152
|
+
if (declared === undefined) return null;
|
|
153
|
+
const entry = declared.entry;
|
|
154
|
+
if (typeof entry !== 'string' || !entry.startsWith('./'))
|
|
155
|
+
throw new Error(
|
|
156
|
+
`${manifestPath} declares ${PRODUCT_DECLARATION_KEY} but its "entry" is ` +
|
|
157
|
+
`${JSON.stringify(entry)}. It must be a ./-relative module — the product's ONE source ` +
|
|
158
|
+
'entry, which is also its frame entry (e.g. "./src/index.ts").',
|
|
159
|
+
);
|
|
160
|
+
const resolved = join(packageDir, entry);
|
|
161
|
+
if (!existsSync(resolved))
|
|
162
|
+
throw new Error(
|
|
163
|
+
`${manifestPath} declares ${PRODUCT_DECLARATION_KEY}.entry ${JSON.stringify(entry)}, ` +
|
|
164
|
+
`which does not exist (${resolved}).`,
|
|
165
|
+
);
|
|
166
|
+
const colorTheme = declared.colorTheme;
|
|
167
|
+
// REQUIRED, like `entry`, and for the same reason: a product that does not
|
|
168
|
+
// say which way round it paints gets the workbench's web default, which is
|
|
169
|
+
// LIGHT, and its cover is white until its real theme loads. There is no
|
|
170
|
+
// defensible default to invent here — a dark product and a light product are
|
|
171
|
+
// both ordinary — so an unstated one is named rather than guessed.
|
|
172
|
+
if (typeof colorTheme !== 'string' || !PRODUCT_COLOR_THEMES.includes(colorTheme as never))
|
|
173
|
+
throw new Error(
|
|
174
|
+
`${manifestPath} declares ${PRODUCT_DECLARATION_KEY} but its "colorTheme" is ` +
|
|
175
|
+
`${JSON.stringify(colorTheme)}. It must be ${PRODUCT_COLOR_THEMES.map((one) =>
|
|
176
|
+
JSON.stringify(one),
|
|
177
|
+
).join(' or ')} — which way round this product paints, read before any of its code ` +
|
|
178
|
+
"runs so the workbench's first frame is already the product's own background.",
|
|
179
|
+
);
|
|
180
|
+
return {
|
|
181
|
+
name: typeof manifest.name === 'string' ? manifest.name : packageDir,
|
|
182
|
+
version: typeof manifest.version === 'string' ? manifest.version : 'unknown',
|
|
183
|
+
dir: packageDir,
|
|
184
|
+
entry,
|
|
185
|
+
colorTheme: colorTheme as ProductColorTheme,
|
|
186
|
+
// A private source product may use an explicitly selected matching checkout.
|
|
187
|
+
// Public packages must always carry a downloadable, checksummed workbench.
|
|
188
|
+
workbench: manifest.private === true && declared.workbench === undefined
|
|
189
|
+
? null : readProductWorkbench(manifestPath, declared.workbench),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The sha256 a release pins its tarball with, as `BUILD.json` writes it. */
|
|
194
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* REQUIRED, like `entry` and `colorTheme`, and for the sharpest reason of the
|
|
198
|
+
* three: a product that does not say which workbench it is cannot be INSTALLED
|
|
199
|
+
* — `npx @vgai/<product> create <name>` on a machine with nothing else on it
|
|
200
|
+
* has no other way to find the bytes it runs in, and the person is left writing
|
|
201
|
+
* a path to a directory they would have to build first. There is no default to
|
|
202
|
+
* invent (the tag names a fork commit nobody can guess), so an unstated one is
|
|
203
|
+
* named rather than guessed.
|
|
204
|
+
*/
|
|
205
|
+
function readProductWorkbench(manifestPath: string, declared: unknown): ProductWorkbench {
|
|
206
|
+
const example =
|
|
207
|
+
' "vgai": { "product": { "workbench": { "release": "<product>-<fork sha 12>-<platform>", ' +
|
|
208
|
+
'"tarballSha256": "<64 hex>" } } }\n' +
|
|
209
|
+
'`node scripts/workbench/build-release.mjs --publish --out <release dir>` cuts the release ' +
|
|
210
|
+
'and prints exactly that block.';
|
|
211
|
+
if (typeof declared !== 'object' || declared === null || Array.isArray(declared))
|
|
212
|
+
throw new Error(
|
|
213
|
+
`${manifestPath} declares ${PRODUCT_DECLARATION_KEY} but its "workbench" is ` +
|
|
214
|
+
`${JSON.stringify(declared)}. A product IS the Code-OSS workbench it was compiled into, ` +
|
|
215
|
+
`so it names the published release those bytes are:\n${example}`,
|
|
216
|
+
);
|
|
217
|
+
const record = declared as Record<string, unknown>;
|
|
218
|
+
const extra = Object.keys(record).filter((key) => key !== 'release' && key !== 'tarballSha256');
|
|
219
|
+
if (extra.length > 0)
|
|
220
|
+
throw new Error(
|
|
221
|
+
`${manifestPath}'s ${PRODUCT_DECLARATION_KEY}.workbench carries ` +
|
|
222
|
+
`${extra.map((key) => `"${key}"`).join(', ')}, which this declaration has no meaning for. ` +
|
|
223
|
+
`It names the release and the pin, and nothing else:\n${example}`,
|
|
224
|
+
);
|
|
225
|
+
const release = record['release'];
|
|
226
|
+
if (typeof release !== 'string' || release.trim() === '')
|
|
227
|
+
throw new Error(
|
|
228
|
+
`${manifestPath}'s ${PRODUCT_DECLARATION_KEY}.workbench names no "release" — the GitHub ` +
|
|
229
|
+
`Release tag its workbench was published under:\n${example}`,
|
|
230
|
+
);
|
|
231
|
+
const tarballSha256 = record['tarballSha256'];
|
|
232
|
+
if (typeof tarballSha256 !== 'string' || !SHA256_HEX.test(tarballSha256))
|
|
233
|
+
throw new Error(
|
|
234
|
+
`${manifestPath}'s ${PRODUCT_DECLARATION_KEY}.workbench has tarballSha256 ` +
|
|
235
|
+
`${JSON.stringify(tarballSha256)}, which is not 64 hex characters. It is the PIN — the ` +
|
|
236
|
+
'hash the downloaded tarball is checked against before a byte of it is extracted, so it ' +
|
|
237
|
+
`is never approximate:\n${example}`,
|
|
238
|
+
);
|
|
239
|
+
return { release, tarballSha256 };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A product's SHORT id — `@vgai/model-editor` → `model-editor`.
|
|
244
|
+
*
|
|
245
|
+
* It is the product's own `product({ id })`, the directory its workbench half
|
|
246
|
+
* lives in (`packages/<id>/workbench`), the `--product` flag the overlay and the
|
|
247
|
+
* release build take, and what a release records in `BUILD.json`. One derivation
|
|
248
|
+
* rather than four literals: the package name is the only place the product is
|
|
249
|
+
* NAMED, and everything else is that name's last segment.
|
|
250
|
+
*/
|
|
251
|
+
export function workbenchProductId(packageName: string): string {
|
|
252
|
+
return packageName.split('/').pop() ?? packageName;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A product's entry as a path relative to `fromDir`, POSIX — the spelling a
|
|
256
|
+
* Vite manifest key and a dev-server URL both use. */
|
|
257
|
+
export function productEntryPath(product: ProductIdentity, fromDir: string): string {
|
|
258
|
+
const absolute = join(product.dir, product.entry);
|
|
259
|
+
const prefix = fromDir.endsWith('/') ? fromDir : `${fromDir}/`;
|
|
260
|
+
const relative = absolute.startsWith(prefix) ? absolute.slice(prefix.length) : absolute;
|
|
261
|
+
return relative.split('\\').join('/');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Every `@vgai/*` this project declares, in either section, sorted. */
|
|
265
|
+
function declaredVgaiPackages(projectRoot: string): string[] {
|
|
266
|
+
let manifest: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
267
|
+
try {
|
|
268
|
+
manifest = JSON.parse(
|
|
269
|
+
readFileSync(join(projectRoot, 'package.json'), 'utf8'),
|
|
270
|
+
) as typeof manifest;
|
|
271
|
+
} catch {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
return Object.keys({ ...(manifest.dependencies ?? {}), ...(manifest.devDependencies ?? {}) })
|
|
275
|
+
.filter((name) => name.startsWith('@volter/'))
|
|
276
|
+
.sort();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Where a package resolves FROM THE PROJECT, or null when it is not installed. */
|
|
280
|
+
function locatePackage(projectRoot: string, name: string): string | null {
|
|
281
|
+
const require = createRequire(pathToFileURL(join(projectRoot, 'package.json')));
|
|
282
|
+
try {
|
|
283
|
+
return dirname(require.resolve(`${name}/package.json`));
|
|
284
|
+
} catch {
|
|
285
|
+
// No `./package.json` export: walk up from whatever the bare name resolves
|
|
286
|
+
// to, which is what a package with a strict export map leaves reachable.
|
|
287
|
+
try {
|
|
288
|
+
let dir = dirname(require.resolve(name));
|
|
289
|
+
for (;;) {
|
|
290
|
+
const candidate = join(dir, 'package.json');
|
|
291
|
+
try {
|
|
292
|
+
if ((JSON.parse(readFileSync(candidate, 'utf8')) as { name?: string }).name === name)
|
|
293
|
+
return dir;
|
|
294
|
+
} catch {
|
|
295
|
+
/* keep walking */
|
|
296
|
+
}
|
|
297
|
+
const parent = dirname(dir);
|
|
298
|
+
if (parent === dir) return null;
|
|
299
|
+
dir = parent;
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* THE PACKAGES A PRODUCT COMPOSES — its `dependencies` that declare
|
|
309
|
+
* `package.json#vgai.contributions`, resolved from the product's own install.
|
|
310
|
+
*
|
|
311
|
+
* COMPOSITIONS ARE CODE (ARCHITECTURE-CORE §The target shape, rule 8): the
|
|
312
|
+
* product's entry names each of them as `vgai:contributions/<name>`, and that
|
|
313
|
+
* import IS the composition. This function does not read the entry — it reads
|
|
314
|
+
* the MIRROR of it in the manifest, which `scripts/validate-package-estate.mjs`
|
|
315
|
+
* keeps equal to the source by scanning those same specifiers and writing the
|
|
316
|
+
* dependency line. Two readings of one fact, one of them checked, and neither
|
|
317
|
+
* of them a second list somebody maintains: a package the entry imports and the
|
|
318
|
+
* manifest omits fails the gate, and a dependency the entry does not import is
|
|
319
|
+
* a library the product uses rather than a composition it mounts (that is what
|
|
320
|
+
* "declares contributions" tests).
|
|
321
|
+
*
|
|
322
|
+
* The runtime reads the manifest rather than the source because it is the cheap
|
|
323
|
+
* and exact half: a `package.json` is JSON this process already parses, and a
|
|
324
|
+
* source scan in the session would be a second implementation of the gate's.
|
|
325
|
+
*/
|
|
326
|
+
export function productComposedPackages(product: ProductIdentity): string[] {
|
|
327
|
+
let manifest: { dependencies?: Record<string, string> };
|
|
328
|
+
try {
|
|
329
|
+
manifest = JSON.parse(
|
|
330
|
+
readFileSync(join(product.dir, 'package.json'), 'utf8'),
|
|
331
|
+
) as typeof manifest;
|
|
332
|
+
} catch {
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
const found: string[] = [];
|
|
336
|
+
for (const name of Object.keys(manifest.dependencies ?? {}).sort()) {
|
|
337
|
+
const dir = locatePackage(product.dir, name);
|
|
338
|
+
if (dir === null) continue;
|
|
339
|
+
let dependency: { vgai?: { contributions?: unknown } };
|
|
340
|
+
try {
|
|
341
|
+
dependency = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as typeof dependency;
|
|
342
|
+
} catch {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (Array.isArray(dependency.vgai?.contributions)) found.push(name);
|
|
346
|
+
}
|
|
347
|
+
return found;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The products this project declares AND resolves, in declaration order. */
|
|
351
|
+
export function declaredProducts(projectRoot: string): ProductIdentity[] {
|
|
352
|
+
const found: ProductIdentity[] = [];
|
|
353
|
+
for (const name of declaredVgaiPackages(projectRoot)) {
|
|
354
|
+
const dir = locatePackage(projectRoot, name);
|
|
355
|
+
if (dir === null) continue;
|
|
356
|
+
const product = readProductManifest(dir);
|
|
357
|
+
if (product !== null) found.push(product);
|
|
358
|
+
}
|
|
359
|
+
return found;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* THE product this project opens in, or a throw naming what is wrong.
|
|
364
|
+
*
|
|
365
|
+
* The refusals are the whole point: a project with none and a project with two
|
|
366
|
+
* are both states a person can be in, and neither has an answer this code may
|
|
367
|
+
* invent.
|
|
368
|
+
*/
|
|
369
|
+
export function resolveProductForProject(projectRoot: string): ProductIdentity {
|
|
370
|
+
const products = declaredProducts(projectRoot);
|
|
371
|
+
if (products.length === 1) return products[0] as ProductIdentity;
|
|
372
|
+
if (products.length === 0)
|
|
373
|
+
throw new Error(
|
|
374
|
+
`${projectRoot} declares no editor product, so there is no editor to open it in.\n` +
|
|
375
|
+
'The installed product supplies the editor,\n' +
|
|
376
|
+
'and which one runs is what the project installed, never a flag.\n' +
|
|
377
|
+
'For THIS folder, install the one you are building with:\n' +
|
|
378
|
+
`${PRODUCT_INSTALL_LINES.join('\n')}\n` +
|
|
379
|
+
'then run `volter-editor edit` again. For a NEW project, the product creates it:\n' +
|
|
380
|
+
`${PRODUCT_CREATE_LINES.join('\n')}`,
|
|
381
|
+
);
|
|
382
|
+
throw new Error(
|
|
383
|
+
`${projectRoot} declares ${products.length} editor products — ` +
|
|
384
|
+
`${products.map((product) => product.name).join(' and ')}.\n` +
|
|
385
|
+
'One page runs one product, and nothing here may choose between them. Remove the one you\n' +
|
|
386
|
+
'are not building with:\n' +
|
|
387
|
+
products.map((product) => ` npm uninstall ${product.name}`).join('\n'),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE owner of every dev-server import URL the editor builds for a
|
|
3
|
+
* PROJECT-OWNED module (a root's `entry`, a `{ module }` adapter's module).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS MODULE EXISTS: PD-3, a SILENT CROSS-ROOT MODULE SPLIT
|
|
6
|
+
* -------------------------------------------------------------
|
|
7
|
+
* A vgai project declares several adapter roots, and two of them routinely
|
|
8
|
+
* import the SAME project module (the worked reference is a three root
|
|
9
|
+
* writing `arena-state.ts`'s module-level `let snapshot` each frame while a
|
|
10
|
+
* `dom` root's HUD reads it). Browser ES-module identity is per-URL, so the
|
|
11
|
+
* whole model only works while every root resolves that shared module to the
|
|
12
|
+
* BYTE-IDENTICAL url. Measured mechanism (vite 6.4.1, reproduced with a real
|
|
13
|
+
* dev server — see the PR body's probe):
|
|
14
|
+
*
|
|
15
|
+
* 1. The three root's entry was imported with a monotonic cache-buster
|
|
16
|
+
* (`?vgai-play=<n>`) so a remount re-runs edited code. Every mount is
|
|
17
|
+
* therefore a url the browser has never fetched → a fresh transform.
|
|
18
|
+
* 2. The `dom` root's entry was imported at a BARE url with no buster at
|
|
19
|
+
* all. A remount re-issues the identical string, so `import()` resolves
|
|
20
|
+
* straight out of the browser's module registry — the dom root keeps the
|
|
21
|
+
* module graph of whatever mount FIRST evaluated it.
|
|
22
|
+
* 3. Editing a shared module HMR-invalidates it, and vite's import-analysis
|
|
23
|
+
* then appends `?t=<ts>` to it for every importer it re-transforms. So
|
|
24
|
+
* after the very first edit, the freshly-transformed three entry imports
|
|
25
|
+
* `…/arena-state.ts?t=T` while the cached dom entry still holds
|
|
26
|
+
* `…/arena-state.ts` — two urls, two evaluated instances, two copies of
|
|
27
|
+
* `let snapshot`. Nothing errors: the HUD reads its own copy's defaults
|
|
28
|
+
* forever while `game.state()` reports the three root's live one.
|
|
29
|
+
*
|
|
30
|
+
* THE GUARANTEE this module exists to make: every root entry of ONE mount is
|
|
31
|
+
* imported with the SAME mount-scoped buster, so all roots are transformed
|
|
32
|
+
* together and vite hands every one of them the same current `?t=` for every
|
|
33
|
+
* shared dep. `beginProjectMountEpoch()` is called once per composition
|
|
34
|
+
* resolve (`binding-resolver.ts`'s `resolveAllRootAdapters`); every entry url
|
|
35
|
+
* built for that mount reads the same epoch.
|
|
36
|
+
*
|
|
37
|
+
* Do not hand-build a `/@fs/…` url for a project entry anywhere else —
|
|
38
|
+
* `packages/editor/test/project-module-url-single-owner.test.ts` is the
|
|
39
|
+
* tripwire. The runtime backstop for whatever this misses is
|
|
40
|
+
* `project-module-split.ts`, which reports a split LOUDLY instead of letting
|
|
41
|
+
* the next one be silent again.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a `/@fs/<absolute path>` dev-server import URL WITHOUT a doubled
|
|
46
|
+
* leading slash (T6.2 slice 3 finding — see the "Report back" of that
|
|
47
|
+
* slice's dispatch). `projectRoot` is always an absolute path (it starts
|
|
48
|
+
* with `/`), so the naive `` `/@fs/${projectRoot}/...` `` template produces
|
|
49
|
+
* `/@fs//abs/olute/path/...` — cosmetically harmless on its own (Vite's
|
|
50
|
+
* `/@fs/` middleware tolerates it), UNTIL the SAME file is also reached via
|
|
51
|
+
* a DIFFERENT route: a react world's entry module's own STATIC relative
|
|
52
|
+
* import (e.g. `import { useWorldState } from './src/ui/game-
|
|
53
|
+
* state'`) is rewritten by Vite's normal resolver into a SINGLE-slash,
|
|
54
|
+
* symlink-canonicalized `/@fs/` URL — a textually different string than
|
|
55
|
+
* this file's hand-built double-slash one. Browser ES module identity is
|
|
56
|
+
* per-URL, so the two builds of `/@fs/` string for the SAME file produced
|
|
57
|
+
* TWO SEPARATE module instances (two `createContext()` calls for
|
|
58
|
+
* `game-state.tsx`), and `useWorldState`'s `useGame()` threw "no Game in
|
|
59
|
+
* context" even though `<WorldProvider>` genuinely wrapped the entry — this
|
|
60
|
+
* is what `resolveDefaultReactAdapter`'s AC e2e (T6.2 slice 3) caught. The
|
|
61
|
+
* server-side half of this fix is `canonical-path.ts` (canonicalizes
|
|
62
|
+
* `projectRoot` itself so it agrees with Vite's own symlink-resolved form);
|
|
63
|
+
* this half removes the remaining single/double-slash divergence so the
|
|
64
|
+
* two strings are BYTE-IDENTICAL once `projectRoot` is canonical.
|
|
65
|
+
*
|
|
66
|
+
* Lives in a module of its own rather than beside the root resolver so every
|
|
67
|
+
* lane that needs a project import url — the realm's `/@fs` loader, the
|
|
68
|
+
* `{ module }` route, the ingest descriptors — reaches ONE owner without an
|
|
69
|
+
* import cycle. Before that, the three/pixi entry route hand-built a
|
|
70
|
+
* doubled-slash form of its own, which is precisely how one shared module came
|
|
71
|
+
* to be served twice under two urls.
|
|
72
|
+
*/
|
|
73
|
+
export function fsImportPath(projectRoot: string, relativePath: string): string {
|
|
74
|
+
const root = projectRoot.endsWith('/') ? projectRoot.slice(0, -1) : projectRoot;
|
|
75
|
+
const rel = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
|
|
76
|
+
// Windows canonical roots ('C:/…') carry no leading slash of their own —
|
|
77
|
+
// without inserting one the URL degenerates to '/@fsC:/…', which Vite
|
|
78
|
+
// serves as a 404. POSIX roots keep the concat form (root already starts
|
|
79
|
+
// with '/'), preserving the byte-identical contract described above.
|
|
80
|
+
return root.startsWith('/') ? `/@fs${root}/${rel}` : `/@fs/${root}/${rel}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Reuse Vite's exact update URL to verify evaluation without a second module
|
|
84
|
+
* instance. Vite emits afterUpdate even when importing the update failed.
|
|
85
|
+
*/
|
|
86
|
+
export function viteUpdateImportPath(
|
|
87
|
+
update: {
|
|
88
|
+
acceptedPath: string;
|
|
89
|
+
timestamp: number;
|
|
90
|
+
explicitImportRequired?: boolean;
|
|
91
|
+
},
|
|
92
|
+
base = '/',
|
|
93
|
+
): string {
|
|
94
|
+
const [file, query] = update.acceptedPath.split('?');
|
|
95
|
+
return (
|
|
96
|
+
base +
|
|
97
|
+
file!.slice(1) +
|
|
98
|
+
`?${update.explicitImportRequired ? 'import&' : ''}t=${update.timestamp}${query ? `&${query}` : ''}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The query key every project ENTRY url carries. One name for every root's
|
|
103
|
+
* entry (it replaced the per-caller `?vgai-play=` / `?vgai-reload=` names,
|
|
104
|
+
* whose divergence was never meaningful and whose ABSENCE on the react route
|
|
105
|
+
* was PD-3 itself). */
|
|
106
|
+
export const PROJECT_MOUNT_QUERY = 'vgai-mount';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The INGEST remount buster — deliberately NOT {@link PROJECT_MOUNT_QUERY}.
|
|
110
|
+
*
|
|
111
|
+
* `vgai-mount` is the whole mount-instance machinery: the server rewrites the
|
|
112
|
+
* module's relative specifiers to carry the same stamp (a per-mount subgraph)
|
|
113
|
+
* and the gated-globals prelude keys the GAME REALM by its value. An ingest
|
|
114
|
+
* remount wants exactly one of those effects — a fresh evaluation of the
|
|
115
|
+
* entry, so its top-level boot re-runs — and none of the others: the ingest
|
|
116
|
+
* lane's readers (the contract shim's `window.vgaiGame`, the live plane, the
|
|
117
|
+
* evidence surface) all read the DEFAULT realm. Busting the ingest entry with
|
|
118
|
+
* `vgai-mount` moved the shim's assignment into a per-epoch realm nobody
|
|
119
|
+
* reads: the game played while `vgai status` said "idle — commands/providers
|
|
120
|
+
* enumerate while playing" and the doctor read "declares no game-state
|
|
121
|
+
* providers" — measured on racing-game, 2026-08-22. A key the server does not
|
|
122
|
+
* interpret busts the URL and changes nothing else.
|
|
123
|
+
*/
|
|
124
|
+
export const INGEST_REMOUNT_QUERY = 'vgai-ingest-remount';
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Optional play remount: serve this entry with its swap-slot const rewritten
|
|
128
|
+
* to `key`. Only the targeted region's entry carries these — siblings keep
|
|
129
|
+
* the shared `?vgai-mount=` so PD-3 still holds. Kept in sync with the
|
|
130
|
+
* server plugin's literals by `project-module-instance.test.ts`.
|
|
131
|
+
*/
|
|
132
|
+
export const PROJECT_SELECTION_QUERY = 'vgai-selection';
|
|
133
|
+
export const PROJECT_SCENE_QUERY = 'vgai-scene';
|
|
134
|
+
|
|
135
|
+
/** Isolation imports a screen CLASS. The matching server key is
|
|
136
|
+
* `ISOLATE_QUERY_KEY` — tripwired in `project-module-instance.test.ts`. */
|
|
137
|
+
export const PROJECT_ISOLATE_QUERY = 'vgai-isolate';
|
|
138
|
+
|
|
139
|
+
/** Host-owned mount parameter: remount the entrypoint at a declared table key. */
|
|
140
|
+
export interface EntrypointSelectionOverride {
|
|
141
|
+
readonly selection: string;
|
|
142
|
+
readonly key: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let lastMountEpoch = 0;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A mount generation. Opaque on purpose: callers thread it, never construct or
|
|
149
|
+
* arithmetic it.
|
|
150
|
+
*
|
|
151
|
+
* It is also the INSTANCE IDENTITY for multi-instance authoring — two mounts
|
|
152
|
+
* at different epochs import every project module at different urls, and
|
|
153
|
+
* browser module identity is per-url, so their module graphs are genuinely
|
|
154
|
+
* separate (separate `let`s, separate class identities). See
|
|
155
|
+
* ARCHITECTURE-CORE §Observability "N INSTANCES of one project". That is the
|
|
156
|
+
* PD-3 guarantee read the other way round: same epoch ⇒ shared modules,
|
|
157
|
+
* different epoch ⇒ nothing shared.
|
|
158
|
+
*/
|
|
159
|
+
export type ProjectMountEpoch = number;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Open a new mount generation. Called ONCE per composition resolve, before any
|
|
163
|
+
* root's entry is imported, so every root of that mount shares one buster (the
|
|
164
|
+
* guarantee in this module's header). Monotonic, so a remount always
|
|
165
|
+
* re-transforms every entry and therefore re-runs edited code.
|
|
166
|
+
*
|
|
167
|
+
* The returned epoch is THREADED to every url built for this mount. It is
|
|
168
|
+
* deliberately not readable from ambient state: a module-level "current epoch"
|
|
169
|
+
* makes isolation SEQUENTIAL — two concurrent resolves interleave their
|
|
170
|
+
* `begin` calls and each then builds urls with the other's generation, so the
|
|
171
|
+
* mounts silently share module instances. Passing the value is what makes N
|
|
172
|
+
* concurrent instances expressible at all.
|
|
173
|
+
*/
|
|
174
|
+
export function beginProjectMountEpoch(): ProjectMountEpoch {
|
|
175
|
+
lastMountEpoch += 1;
|
|
176
|
+
return lastMountEpoch;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The dev-server import url for one root's `entry` (or a `{ module }`
|
|
181
|
+
* adapter's module) — `fsImportPath` plus the mount's own epoch. Two roots
|
|
182
|
+
* resolved in the SAME mount pass the same epoch and therefore get the same
|
|
183
|
+
* query, which is the whole point.
|
|
184
|
+
*/
|
|
185
|
+
/**
|
|
186
|
+
* Import a scene CLASS as an Edit piece. The isolate query keys a module
|
|
187
|
+
* graph whose vendor `init();` is silenced — Play still boots the unstamped
|
|
188
|
+
* entry. Cache-busted per mount, same always-fresh discipline as design-time
|
|
189
|
+
* CSF (`stories/story-discovery.ts`).
|
|
190
|
+
*/
|
|
191
|
+
export function isolationImportUrl(projectRoot: string, relativePath: string): string {
|
|
192
|
+
return `${fsImportPath(projectRoot, relativePath)}?${PROJECT_ISOLATE_QUERY}=1&t=${Date.now()}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The LIVE MODULE document's import url — a project `.ts`/`.tsx` module opened
|
|
197
|
+
* as an Asset Lab document that re-executes on every save
|
|
198
|
+
* (`components/asset-viewers/LiveModuleDocument.tsx`).
|
|
199
|
+
*
|
|
200
|
+
* Deliberately NOT {@link PROJECT_MOUNT_QUERY}, for the reason
|
|
201
|
+
* {@link INGEST_REMOUNT_QUERY} states: `vgai-mount` is the whole
|
|
202
|
+
* mount-instance machinery (per-mount specifier rewriting, gated-globals
|
|
203
|
+
* realm keying). A model module is EDITOR-side content — it builds an
|
|
204
|
+
* `Object3D` for a document, it does not mount a game — so it wants exactly
|
|
205
|
+
* one of those effects, a fresh evaluation, and none of the others. A key the
|
|
206
|
+
* server does not interpret busts the url and changes nothing else.
|
|
207
|
+
*
|
|
208
|
+
* `revision` is the document's own rebuild counter, so consecutive saves each
|
|
209
|
+
* get a url the browser has never fetched. That is only half the freshness
|
|
210
|
+
* story and the smaller half: the dev server's `hotUpdate` hook has already
|
|
211
|
+
* stamped the changed file HMR-invalid across the project's subgraph
|
|
212
|
+
* (`server/project-script-hmr.ts`), so re-transforming this module also
|
|
213
|
+
* re-points every dependency of it at a fresh `?t=`. Busting the leaf alone
|
|
214
|
+
* would re-run the leaf against stale imports.
|
|
215
|
+
*/
|
|
216
|
+
export function liveModuleImportUrl(
|
|
217
|
+
projectRoot: string,
|
|
218
|
+
relativePath: string,
|
|
219
|
+
revision: number,
|
|
220
|
+
): string {
|
|
221
|
+
return `${fsImportPath(projectRoot, relativePath)}?vgai-live-module=${revision}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let liveModuleRevision = Date.now();
|
|
225
|
+
|
|
226
|
+
/** A rebuild's import identity must outlive a component's local counter.
|
|
227
|
+
* Closing/reopening a document must never reuse its first cached module. */
|
|
228
|
+
export function beginLiveModuleRevision(): number {
|
|
229
|
+
liveModuleRevision = Math.max(liveModuleRevision + 1, Date.now());
|
|
230
|
+
return liveModuleRevision;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function projectEntryImportUrl(
|
|
234
|
+
projectRoot: string,
|
|
235
|
+
entry: string,
|
|
236
|
+
epoch: ProjectMountEpoch,
|
|
237
|
+
selectionOverride?: EntrypointSelectionOverride,
|
|
238
|
+
): string {
|
|
239
|
+
const base = `${fsImportPath(projectRoot, entry)}?${PROJECT_MOUNT_QUERY}=${epoch}`;
|
|
240
|
+
if (!selectionOverride) return base;
|
|
241
|
+
return (
|
|
242
|
+
`${base}&${PROJECT_SELECTION_QUERY}=${encodeURIComponent(selectionOverride.selection)}` +
|
|
243
|
+
`&${PROJECT_SCENE_QUERY}=${encodeURIComponent(selectionOverride.key)}`
|
|
244
|
+
);
|
|
245
|
+
}
|