@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,248 @@
|
|
|
1
|
+
import { GAME_MANIFEST_VERSION } from '@volter/editor-project/manifest/schema';
|
|
2
|
+
|
|
3
|
+
export interface EditorServerCompatibility {
|
|
4
|
+
apiVersion: 1;
|
|
5
|
+
engineVersion: string | null;
|
|
6
|
+
manifestVersion: number;
|
|
7
|
+
startedAt: string;
|
|
8
|
+
source:
|
|
9
|
+
| { state: 'current' }
|
|
10
|
+
| { state: 'restart-required'; changedPath: string; changedAt: string };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type StartupRecovery =
|
|
14
|
+
| {
|
|
15
|
+
kind: 'retry-editor';
|
|
16
|
+
title: 'Editor server unavailable';
|
|
17
|
+
guidance: string;
|
|
18
|
+
command: 'volter-editor edit .';
|
|
19
|
+
}
|
|
20
|
+
| {
|
|
21
|
+
kind: 'restart-editor';
|
|
22
|
+
title: 'Restart this editor';
|
|
23
|
+
guidance: string;
|
|
24
|
+
command: 'volter-editor edit .' | 'volter-editor close && volter-editor edit .';
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
kind: 'use-compatible-editor';
|
|
28
|
+
title: 'Use a compatible editor';
|
|
29
|
+
guidance: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class ProjectCompatibilityError extends Error {
|
|
33
|
+
readonly recovery: StartupRecovery;
|
|
34
|
+
|
|
35
|
+
constructor(message: string, recovery: StartupRecovery) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'ProjectCompatibilityError';
|
|
38
|
+
this.recovery = recovery;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function editorServerUnavailableError(detail?: string): ProjectCompatibilityError {
|
|
43
|
+
return new ProjectCompatibilityError(
|
|
44
|
+
`Could not reach the local editor server${detail ? `: ${detail}` : '.'}`,
|
|
45
|
+
{
|
|
46
|
+
kind: 'retry-editor',
|
|
47
|
+
title: 'Editor server unavailable',
|
|
48
|
+
guidance: 'Make sure the local editor is running, then retry this operation.',
|
|
49
|
+
command: 'volter-editor edit .',
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isStartupRecovery(value: unknown): value is StartupRecovery {
|
|
55
|
+
if (!value || typeof value !== 'object') return false;
|
|
56
|
+
const candidate = value as Record<string, unknown>;
|
|
57
|
+
if (typeof candidate['title'] !== 'string' || typeof candidate['guidance'] !== 'string') {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
switch (candidate['kind']) {
|
|
61
|
+
case 'retry-editor':
|
|
62
|
+
return candidate['command'] === 'volter-editor edit .';
|
|
63
|
+
case 'restart-editor':
|
|
64
|
+
return (
|
|
65
|
+
candidate['command'] === 'volter-editor edit .' ||
|
|
66
|
+
candidate['command'] === 'volter-editor close && volter-editor edit .'
|
|
67
|
+
);
|
|
68
|
+
case 'use-compatible-editor':
|
|
69
|
+
return candidate['command'] === undefined;
|
|
70
|
+
default:
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ProjectIdentity {
|
|
76
|
+
manifestVersion?: unknown;
|
|
77
|
+
engine?: { version?: unknown } | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* The manifest's `roots`, RAW or resolved — read only by
|
|
80
|
+
* `usesNoPinnedEngineApi` below, structurally, so either shape works. Left
|
|
81
|
+
* `unknown` on purpose: this module is the pre-Zod identity check, and
|
|
82
|
+
* inventing a typed root here would duplicate the loader it runs before.
|
|
83
|
+
*/
|
|
84
|
+
roots?: unknown;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Does this project mount through the engine's PINNED API at all? (S-7.)
|
|
89
|
+
*
|
|
90
|
+
* The engine-version pin below is an identity pin on the API a project's own
|
|
91
|
+
* source compiles against. An INGEST root has no such source: the game is
|
|
92
|
+
* foreign, unmodified, and reaches the host through the adapter seam, so
|
|
93
|
+
* there is nothing in it that a `@volter/editor-project` version could break — and
|
|
94
|
+
* nothing `vgai upgrade` could rewrite if the pin did complain. Found
|
|
95
|
+
* source-mounting SimCity: the project scaffolded at an older pin, and the
|
|
96
|
+
* editor refused to open it with "run vgai upgrade", an instruction that
|
|
97
|
+
* could not be carried out because the project has no engine surface to
|
|
98
|
+
* upgrade.
|
|
99
|
+
*
|
|
100
|
+
* The discriminator is the manifest itself — a root whose adapter carries an
|
|
101
|
+
* `ingest` block (raw) / resolves to `type: 'ingest'` — never a new opt-out
|
|
102
|
+
* flag, which would be a second thing to keep true. EVERY root must be an
|
|
103
|
+
* ingest for the exemption to apply: one first-party root and the project
|
|
104
|
+
* does compile against the pinned API, so the pin means exactly what it says.
|
|
105
|
+
*
|
|
106
|
+
* Note what this does NOT exempt: the manifest FORMAT version. The editor
|
|
107
|
+
* reads `vgai.project.json` for every project, ingest or not, so a v1
|
|
108
|
+
* manifest is still an upgrade this editor genuinely requires.
|
|
109
|
+
*/
|
|
110
|
+
export function usesNoPinnedEngineApi(project: ProjectIdentity): boolean {
|
|
111
|
+
const roots = project.roots;
|
|
112
|
+
if (!Array.isArray(roots) || roots.length === 0) return false;
|
|
113
|
+
return roots.every((root) => {
|
|
114
|
+
if (!root || typeof root !== 'object') return false;
|
|
115
|
+
const adapter = (root as { adapter?: unknown }).adapter;
|
|
116
|
+
if (!adapter || typeof adapter !== 'object') return false;
|
|
117
|
+
const shape = adapter as { ingest?: unknown; type?: unknown };
|
|
118
|
+
return shape.type === 'ingest' || (shape.ingest !== undefined && shape.ingest !== null);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const EXACT_SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
123
|
+
|
|
124
|
+
function compareSemver(a: string, b: string): -1 | 0 | 1 | null {
|
|
125
|
+
const aMatch = EXACT_SEMVER.exec(a);
|
|
126
|
+
const bMatch = EXACT_SEMVER.exec(b);
|
|
127
|
+
if (!aMatch || !bMatch) return null;
|
|
128
|
+
for (let index = 1; index <= 3; index++) {
|
|
129
|
+
const aPart = Number(aMatch[index]);
|
|
130
|
+
const bPart = Number(bMatch[index]);
|
|
131
|
+
if (aPart < bPart) return -1;
|
|
132
|
+
if (aPart > bPart) return 1;
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function missingCompatibilityError(): ProjectCompatibilityError {
|
|
138
|
+
return new ProjectCompatibilityError(
|
|
139
|
+
'The editor page is newer than its local server. The running server does not expose the compatibility handshake this page expects.',
|
|
140
|
+
{
|
|
141
|
+
kind: 'restart-editor',
|
|
142
|
+
title: 'Restart this editor',
|
|
143
|
+
guidance: 'From the project folder, restart the local editor and then reopen this page.',
|
|
144
|
+
command: 'volter-editor close && volter-editor edit .',
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Verify that the browser bundle and its long-running local server still agree. */
|
|
150
|
+
export function assertEditorCompatibility(identity: EditorServerCompatibility): void {
|
|
151
|
+
if (identity.source.state === 'restart-required') {
|
|
152
|
+
throw new ProjectCompatibilityError(
|
|
153
|
+
`The local editor server is stale. ${identity.source.changedPath} changed after this server started at ${identity.startedAt}.`,
|
|
154
|
+
{
|
|
155
|
+
kind: 'restart-editor',
|
|
156
|
+
title: 'Restart this editor',
|
|
157
|
+
guidance:
|
|
158
|
+
'The browser has newer source than the running Node process. Run the editor command again from the project folder; it will replace the stale server on the same port.',
|
|
159
|
+
command: 'volter-editor edit .',
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (identity.manifestVersion !== GAME_MANIFEST_VERSION) {
|
|
165
|
+
throw new ProjectCompatibilityError(
|
|
166
|
+
`The editor page supports project format v${GAME_MANIFEST_VERSION}, but its local server supports v${identity.manifestVersion}.`,
|
|
167
|
+
{
|
|
168
|
+
kind: 'restart-editor',
|
|
169
|
+
title: 'Restart this editor',
|
|
170
|
+
guidance: 'The browser and server are from different editor builds. Restart them together.',
|
|
171
|
+
command: 'volter-editor close && volter-editor edit .',
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The manifest FORMAT half of the check below. Split out so the pin half
|
|
179
|
+
* stays readable next to its S-7 exemption; no behavior of its own beyond
|
|
180
|
+
* what it always did.
|
|
181
|
+
*/
|
|
182
|
+
function assertManifestVersionCompatibility(project: ProjectIdentity): void {
|
|
183
|
+
if (
|
|
184
|
+
typeof project.manifestVersion === 'number' &&
|
|
185
|
+
project.manifestVersion !== GAME_MANIFEST_VERSION
|
|
186
|
+
) {
|
|
187
|
+
if (project.manifestVersion < GAME_MANIFEST_VERSION) {
|
|
188
|
+
throw new ProjectCompatibilityError(
|
|
189
|
+
`This project uses manifest format v${project.manifestVersion}; this editor requires v${GAME_MANIFEST_VERSION}.`,
|
|
190
|
+
{
|
|
191
|
+
kind: 'use-compatible-editor',
|
|
192
|
+
title: 'Use a compatible editor',
|
|
193
|
+
guidance:
|
|
194
|
+
'Open this project with the editor version that created it. This product does not provide an automatic project-format upgrade.',
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
throw new ProjectCompatibilityError(
|
|
199
|
+
`This project uses manifest format v${project.manifestVersion}, which is newer than this editor's v${GAME_MANIFEST_VERSION} format.`,
|
|
200
|
+
{
|
|
201
|
+
kind: 'use-compatible-editor',
|
|
202
|
+
title: 'Use a compatible editor',
|
|
203
|
+
guidance: 'Open this project with the newer Volter Editor checkout or installation that created it.',
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Verify the lightweight identity fields before full Zod manifest parsing. */
|
|
210
|
+
export function assertProjectCompatibility(
|
|
211
|
+
project: ProjectIdentity,
|
|
212
|
+
editor: EditorServerCompatibility,
|
|
213
|
+
): void {
|
|
214
|
+
assertManifestVersionCompatibility(project);
|
|
215
|
+
|
|
216
|
+
const projectEngine = project.engine?.version;
|
|
217
|
+
const editorEngine = editor.engineVersion;
|
|
218
|
+
if (typeof projectEngine !== 'string' || !editorEngine || projectEngine === editorEngine) return;
|
|
219
|
+
// S-7: a project that mounts nothing through the pinned API is not gated by
|
|
220
|
+
// it. See `usesNoPinnedEngineApi` for why, and for why it is the manifest —
|
|
221
|
+
// not a flag — that decides.
|
|
222
|
+
if (usesNoPinnedEngineApi(project)) return;
|
|
223
|
+
|
|
224
|
+
const comparison = compareSemver(projectEngine, editorEngine);
|
|
225
|
+
if (comparison === -1) {
|
|
226
|
+
throw new ProjectCompatibilityError(
|
|
227
|
+
`This project is pinned to @volter/editor-project ${projectEngine}, but this editor is running ${editorEngine}.`,
|
|
228
|
+
{
|
|
229
|
+
kind: 'use-compatible-editor',
|
|
230
|
+
title: 'Use a compatible editor',
|
|
231
|
+
guidance:
|
|
232
|
+
'Open the project with the editor version matching its pinned project API. This product does not automatically rewrite project source.',
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
throw new ProjectCompatibilityError(
|
|
238
|
+
`This project is pinned to @volter/editor-project ${projectEngine}, but this editor is running ${editorEngine}.`,
|
|
239
|
+
{
|
|
240
|
+
kind: 'use-compatible-editor',
|
|
241
|
+
title: 'Use a compatible editor',
|
|
242
|
+
guidance:
|
|
243
|
+
comparison === 1
|
|
244
|
+
? 'This project targets a newer engine. Open it from the matching newer Volter Editor checkout or installation.'
|
|
245
|
+
: 'The engine identities differ. Open the project with the exact Volter Editor version it is pinned to.',
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity envelope shared by every editor control transport.
|
|
3
|
+
*
|
|
4
|
+
* A page has more than one useful identity: the server process it loaded
|
|
5
|
+
* from, the tab it occupies, the page load inside that tab, and the current
|
|
6
|
+
* transport connection. Treating any one of those as all four is how a
|
|
7
|
+
* duplicated tab could heartbeat as B while its copied control socket still
|
|
8
|
+
* received commands as A. Every upstream control fact now names the complete
|
|
9
|
+
* generation that made it true.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const EDITOR_CONTROL_LIFECYCLE_VERSION = 1 as const;
|
|
13
|
+
|
|
14
|
+
export interface EditorControlLifecycle {
|
|
15
|
+
readonly version: typeof EDITOR_CONTROL_LIFECYCLE_VERSION;
|
|
16
|
+
/** The editor server process (`processSessionId()`). */
|
|
17
|
+
readonly serverGeneration: string;
|
|
18
|
+
/** One accepted event-stream connection. Reconnects always mint a new one. */
|
|
19
|
+
readonly connectionGeneration: string;
|
|
20
|
+
/** The inline bootstrap's per-page id. */
|
|
21
|
+
readonly clientId: string;
|
|
22
|
+
/** The sessionStorage identity that survives reloads. */
|
|
23
|
+
readonly tabId: string;
|
|
24
|
+
/** The heartbeat worker's page-load epoch. */
|
|
25
|
+
readonly pageGeneration: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseEditorControlLifecycle(value: unknown): EditorControlLifecycle | null {
|
|
29
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
30
|
+
const record = value as Record<string, unknown>;
|
|
31
|
+
if (
|
|
32
|
+
record['version'] !== EDITOR_CONTROL_LIFECYCLE_VERSION ||
|
|
33
|
+
typeof record['serverGeneration'] !== 'string' ||
|
|
34
|
+
typeof record['connectionGeneration'] !== 'string' ||
|
|
35
|
+
typeof record['clientId'] !== 'string' ||
|
|
36
|
+
typeof record['tabId'] !== 'string' ||
|
|
37
|
+
typeof record['pageGeneration'] !== 'string'
|
|
38
|
+
) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
version: EDITOR_CONTROL_LIFECYCLE_VERSION,
|
|
43
|
+
serverGeneration: record['serverGeneration'],
|
|
44
|
+
connectionGeneration: record['connectionGeneration'],
|
|
45
|
+
clientId: record['clientId'],
|
|
46
|
+
tabId: record['tabId'],
|
|
47
|
+
pageGeneration: record['pageGeneration'],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type EditorControlLifecycleField = Exclude<keyof EditorControlLifecycle, 'version'>;
|
|
52
|
+
|
|
53
|
+
/** Which parent generation disagrees, or null when the envelope is current. */
|
|
54
|
+
export function editorControlLifecycleMismatch(
|
|
55
|
+
expected: EditorControlLifecycle,
|
|
56
|
+
reported: EditorControlLifecycle,
|
|
57
|
+
): EditorControlLifecycleField | null {
|
|
58
|
+
for (const field of [
|
|
59
|
+
'serverGeneration',
|
|
60
|
+
'connectionGeneration',
|
|
61
|
+
'clientId',
|
|
62
|
+
'tabId',
|
|
63
|
+
'pageGeneration',
|
|
64
|
+
] as const) {
|
|
65
|
+
if (expected[field] !== reported[field]) return field;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure TS-AST readers over an entrypoint's SELECTION TABLE — shared by the
|
|
3
|
+
* write side (`entrypoint-selection-source.ts`) and the read side
|
|
4
|
+
* (`finders/scenes-from-entrypoint-selection.ts`). These two must agree on
|
|
5
|
+
* what a selection table IS, or play remounts a key the finder never
|
|
6
|
+
* discovered; one spelling here is what makes that agreement structural.
|
|
7
|
+
* (The finder-import boundary bans importing from `finders/` — this sibling
|
|
8
|
+
* sits outside that directory precisely so both sides can share it.)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import ts from 'typescript';
|
|
12
|
+
|
|
13
|
+
/** The object literal a module-level `const <name> = { … }` binds. */
|
|
14
|
+
export function selectionTable(
|
|
15
|
+
sf: ts.SourceFile,
|
|
16
|
+
name: string,
|
|
17
|
+
): ts.ObjectLiteralExpression | undefined {
|
|
18
|
+
for (const statement of sf.statements) {
|
|
19
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
20
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
21
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue;
|
|
22
|
+
const initializer = declaration.initializer;
|
|
23
|
+
if (initializer && ts.isObjectLiteralExpression(initializer)) return initializer;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A property's key when it is a plain identifier or string literal. */
|
|
30
|
+
export function propertyKey(property: ts.ObjectLiteralElementLike): string | undefined {
|
|
31
|
+
const name = property.name;
|
|
32
|
+
if (!name) return undefined;
|
|
33
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The identifier the entrypoint INDEXES the selection table with — the
|
|
39
|
+
* `activeScene` in `scenes[activeScene]`.
|
|
40
|
+
*
|
|
41
|
+
* `undefined` when the module never indexes the table; `null` when it does
|
|
42
|
+
* but not readably (several identifiers, or a non-identifier index). Both
|
|
43
|
+
* are honest "cannot be read" answers the callers turn into their own notes.
|
|
44
|
+
*/
|
|
45
|
+
export function indexingIdentifier(
|
|
46
|
+
sf: ts.SourceFile,
|
|
47
|
+
selection: string,
|
|
48
|
+
): string | undefined | null {
|
|
49
|
+
const names = new Set<string>();
|
|
50
|
+
let unreadable = false;
|
|
51
|
+
const visit = (node: ts.Node): void => {
|
|
52
|
+
if (
|
|
53
|
+
ts.isElementAccessExpression(node) &&
|
|
54
|
+
ts.isIdentifier(node.expression) &&
|
|
55
|
+
node.expression.text === selection
|
|
56
|
+
) {
|
|
57
|
+
if (ts.isIdentifier(node.argumentExpression)) names.add(node.argumentExpression.text);
|
|
58
|
+
else unreadable = true;
|
|
59
|
+
}
|
|
60
|
+
ts.forEachChild(node, visit);
|
|
61
|
+
};
|
|
62
|
+
ts.forEachChild(sf, visit);
|
|
63
|
+
if (names.size === 0 && !unreadable) return undefined;
|
|
64
|
+
if (names.size !== 1 || unreadable) return null;
|
|
65
|
+
return [...names][0];
|
|
66
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serve-time rewrite of an entrypoint's swap-slot key.
|
|
3
|
+
*
|
|
4
|
+
* The scenes finder (`adapter/finders/scenes-from-entrypoint-selection.ts`)
|
|
5
|
+
* READS the same facts this module WRITES: a module-level `const <selection>
|
|
6
|
+
* = { … }` table, indexed by one identifier whose own module-level `const`
|
|
7
|
+
* is a string literal (`const activeScene = 'main'`). This is not an
|
|
8
|
+
* extraction of that finder — the finder stays the adapter-selected discovery
|
|
9
|
+
* algorithm, import-banned everywhere except the adapter loader. The rewrite
|
|
10
|
+
* is a host-owned mount parameter: play remounts the same entrypoint with a
|
|
11
|
+
* different declared key, without a new adapter slot and without mutating
|
|
12
|
+
* the file on disk.
|
|
13
|
+
*
|
|
14
|
+
* `typescript` is the same parser the finder already uses. This module is
|
|
15
|
+
* host-side only — a game's `vgai.adapter.ts` must not import it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import ts from 'typescript';
|
|
19
|
+
import { indexingIdentifier, propertyKey, selectionTable } from './entrypoint-selection-readers';
|
|
20
|
+
|
|
21
|
+
export type EntrypointSelectionRewrite =
|
|
22
|
+
| { readonly ok: true; readonly source: string; readonly changed: boolean }
|
|
23
|
+
| { readonly ok: false; readonly reason: string };
|
|
24
|
+
|
|
25
|
+
function parse(path: string, source: string): ts.SourceFile {
|
|
26
|
+
return ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tableKeys(table: ts.ObjectLiteralExpression): string[] {
|
|
30
|
+
const keys: string[] = [];
|
|
31
|
+
for (const property of table.properties) {
|
|
32
|
+
const key = propertyKey(property);
|
|
33
|
+
if (key !== undefined) keys.push(key);
|
|
34
|
+
}
|
|
35
|
+
return keys;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function moduleLevelStringLiteral(sf: ts.SourceFile, name: string): ts.StringLiteral | undefined {
|
|
39
|
+
for (const statement of sf.statements) {
|
|
40
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
41
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
|
|
42
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
43
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue;
|
|
44
|
+
const initializer = declaration.initializer;
|
|
45
|
+
if (initializer && ts.isStringLiteral(initializer)) return initializer;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rewrite the entrypoint so its swap slot mounts `key` instead of the
|
|
54
|
+
* source-declared occupant.
|
|
55
|
+
*
|
|
56
|
+
* Fails (never guesses) when the source is not the template's shape: no
|
|
57
|
+
* table, a key the table does not declare, or an index that is not a
|
|
58
|
+
* module-level `const` string. A no-op rewrite (`key` already at the slot)
|
|
59
|
+
* is still `ok` — remounting the same key is a real restart.
|
|
60
|
+
*/
|
|
61
|
+
export function rewriteEntrypointSelectionKey(
|
|
62
|
+
source: string,
|
|
63
|
+
path: string,
|
|
64
|
+
selection: string,
|
|
65
|
+
key: string,
|
|
66
|
+
): EntrypointSelectionRewrite {
|
|
67
|
+
const sf = parse(path, source);
|
|
68
|
+
const table = selectionTable(sf, selection);
|
|
69
|
+
if (!table) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason:
|
|
73
|
+
`${path} declares no module-level \`const ${selection} = { … }\` selection table, ` +
|
|
74
|
+
'so the host cannot remount it at another key.',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const keys = tableKeys(table);
|
|
78
|
+
if (!keys.includes(key)) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason:
|
|
82
|
+
`"${key}" is not a key of ${path}'s \`${selection}\` table ` +
|
|
83
|
+
`(known: ${keys.length > 0 ? keys.join(', ') : 'none'}).`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const identifier = indexingIdentifier(sf, selection);
|
|
87
|
+
if (identifier === undefined) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: `${path} never indexes \`${selection}\`, so there is no slot key to rewrite.`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (identifier === null) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
reason:
|
|
97
|
+
`${path} indexes \`${selection}\` with something other than one single identifier, ` +
|
|
98
|
+
'so the host cannot rewrite the key it mounts.',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const literal = moduleLevelStringLiteral(sf, identifier);
|
|
102
|
+
if (!literal) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason:
|
|
106
|
+
`${path} indexes \`${selection}\` with \`${identifier}\`, which is not a ` +
|
|
107
|
+
'module-level `const` bound to a string literal — the running game cannot be ' +
|
|
108
|
+
'sent to another key by rewriting the slot.',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (literal.text === key) return { ok: true, source, changed: false };
|
|
112
|
+
const start = literal.getStart(sf);
|
|
113
|
+
const end = literal.getEnd();
|
|
114
|
+
const quote = source[start] ?? "'";
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
source: `${source.slice(0, start)}${quote}${key}${quote}${source.slice(end)}`,
|
|
118
|
+
changed: true,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE spelling of the game-CSS scope marker, and the act of putting it on
|
|
3
|
+
* an element.
|
|
4
|
+
*
|
|
5
|
+
* A game's page-level stylesheet is served rewritten into
|
|
6
|
+
* `@scope ([data-vgai-game-styles]) { … }` (`server/scoped-game-css.ts`), so
|
|
7
|
+
* the attribute below is the entire contract between the two halves: the
|
|
8
|
+
* server emits the selector, and every host container that owns a game's DOM
|
|
9
|
+
* wears the attribute. Both sides import it from here rather than spelling it,
|
|
10
|
+
* because a drift between them is silent — the sheet loads, matches nothing,
|
|
11
|
+
* and the HUD is unstyled exactly as it was before the feature existed.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately dependency-free: the server imports this module, and a server
|
|
14
|
+
* import must not drag the editor's browser modules into a Node process.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Attribute a host container wears to become a game-CSS scope root. */
|
|
18
|
+
export const GAME_CSS_SCOPE_ATTRIBUTE = 'data-vgai-game-styles';
|
|
19
|
+
|
|
20
|
+
/** The `@scope (…)` selector the served stylesheet is rooted at. */
|
|
21
|
+
export const GAME_CSS_SCOPE_SELECTOR = `[${GAME_CSS_SCOPE_ATTRIBUTE}]`;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Make `element` a game-CSS scope root. Idempotent, and deliberately the only
|
|
25
|
+
* writer of the attribute — a container that stops being a scope root is torn
|
|
26
|
+
* down with its mount, never un-marked.
|
|
27
|
+
*/
|
|
28
|
+
export function markGameCssScope(element: HTMLElement): void {
|
|
29
|
+
element.setAttribute(GAME_CSS_SCOPE_ATTRIBUTE, '');
|
|
30
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Project creation is executable product code, shared by its CLI and the host. */
|
|
2
|
+
export interface ProductCreateRequest {
|
|
3
|
+
name: string;
|
|
4
|
+
targetDir: string;
|
|
5
|
+
template?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ProductCreateResult {
|
|
8
|
+
targetDir: string;
|
|
9
|
+
manifest: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface ProductCreateDeclaration {
|
|
12
|
+
readonly product: string;
|
|
13
|
+
readonly templates: readonly { readonly id: string; readonly name: string; readonly description?: string }[];
|
|
14
|
+
create(request: ProductCreateRequest): Promise<ProductCreateResult>;
|
|
15
|
+
}
|
|
16
|
+
export function assertProductCreateDeclaration(value: unknown, source: string): ProductCreateDeclaration {
|
|
17
|
+
const declaration = value as Partial<ProductCreateDeclaration> | null;
|
|
18
|
+
if (!declaration || typeof declaration.product !== 'string' ||
|
|
19
|
+
typeof declaration.create !== 'function' || !Array.isArray(declaration.templates) ||
|
|
20
|
+
declaration.templates.some(t => typeof t?.id !== 'string' || typeof t?.name !== 'string')) {
|
|
21
|
+
throw new Error(`${source}: expected a product, templates and create(request) function`);
|
|
22
|
+
}
|
|
23
|
+
return declaration as ProductCreateDeclaration;
|
|
24
|
+
}
|