pixifact 0.4.0 → 0.6.0

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.
Files changed (51) hide show
  1. package/dist/compiler/builtinScenes.d.ts +0 -5
  2. package/dist/compiler/builtinScenes.js +0 -26
  3. package/dist/compiler/commands/index.js +8 -5
  4. package/dist/compiler/compileScenes.d.ts +3 -1
  5. package/dist/compiler/compileScenes.js +32 -2
  6. package/dist/compiler/index.d.ts +0 -1
  7. package/dist/compiler/index.js +0 -1
  8. package/dist/compiler/pixiNodeSchema.d.ts +5 -3
  9. package/dist/compiler/pixiNodeSchema.js +61 -7
  10. package/dist/compiler/sceneValidation.d.ts +3 -1
  11. package/dist/compiler/sceneValidation.js +105 -6
  12. package/dist/compiler/spec.d.ts +18 -7
  13. package/dist/compiler/spec.js +3 -1
  14. package/dist/compiler/templateParser.js +19 -0
  15. package/dist/compiler/templateSerializer.js +7 -1
  16. package/dist/compiler/typescriptCompiler.js +66 -8
  17. package/dist/compiler-node/builtinSceneInterfaces.d.ts +6 -0
  18. package/dist/compiler-node/builtinSceneInterfaces.js +27 -0
  19. package/dist/compiler-node/index.d.ts +3 -0
  20. package/dist/compiler-node/index.js +3 -0
  21. package/dist/compiler-node/runtimeVite.d.ts +89 -0
  22. package/dist/compiler-node/runtimeVite.js +420 -0
  23. package/dist/{compiler → compiler-node}/scriptInterfaceExtractor.d.ts +1 -1
  24. package/dist/{compiler → compiler-node}/scriptInterfaceExtractor.js +157 -25
  25. package/dist/errors.d.ts +1 -1
  26. package/dist/errors.js +4 -1
  27. package/dist/runtime/BitmapLabel.d.ts +9 -0
  28. package/dist/runtime/BitmapLabel.js +7 -0
  29. package/dist/runtime/Label.d.ts +9 -0
  30. package/dist/runtime/Label.js +7 -0
  31. package/dist/runtime/ScrollContainer.d.ts +2 -0
  32. package/dist/runtime/ScrollContainer.js +12 -2
  33. package/dist/runtime/TextBoxControl.d.ts +48 -0
  34. package/dist/runtime/TextBoxControl.js +162 -0
  35. package/dist/runtime/index.d.ts +2 -0
  36. package/dist/runtime/index.js +2 -0
  37. package/dist/runtime/runtimeBehavior.d.ts +3 -0
  38. package/dist/runtime/runtimeBehavior.js +11 -0
  39. package/dist/runtime-dev/index.d.ts +5 -0
  40. package/dist/runtime-dev/index.js +22 -0
  41. package/dist/runtime-dev/protocol.d.ts +58 -0
  42. package/dist/runtime-dev/protocol.js +1 -0
  43. package/dist/runtime-dev/runtimeClient.d.ts +22 -0
  44. package/dist/runtime-dev/runtimeClient.js +382 -0
  45. package/dist/scene/decorators.d.ts +2 -1
  46. package/dist/scene/decorators.js +12 -7
  47. package/dist/scene/index.d.ts +1 -0
  48. package/dist/scene/index.js +1 -0
  49. package/dist/scene/sceneBindingRuntime.d.ts +10 -0
  50. package/dist/scene/sceneBindingRuntime.js +124 -0
  51. package/package.json +6 -1
@@ -0,0 +1,420 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { pixifactRuntimeProtocolVersion, } from '../runtime-dev/protocol.js';
7
+ export const runtimeSessionProtocolVersion = pixifactRuntimeProtocolVersion;
8
+ const runtimeAnnounceEvent = 'pixifact:runtime:announce';
9
+ const runtimeRequestEvent = 'pixifact:runtime:request';
10
+ const runtimeResponseEvent = 'pixifact:runtime:response';
11
+ const runtimeApiRoot = '/__pixifact_runtime__';
12
+ const virtualRuntimeClientId = 'virtual:pixifact-runtime-client';
13
+ const resolvedVirtualRuntimeClientId = `\0${virtualRuntimeClientId}`;
14
+ function isRecord(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+ function jsonResponse(value, status = 200) {
18
+ return Response.json(value, { status });
19
+ }
20
+ function authorizationMatches(request, token) {
21
+ return request.headers.get('authorization') === `Bearer ${token}`;
22
+ }
23
+ function isRuntimePageDescriptor(value) {
24
+ return isRecord(value)
25
+ && typeof value.runtimeId === 'string'
26
+ && value.runtimeId.length > 0
27
+ && typeof value.url === 'string'
28
+ && typeof value.title === 'string'
29
+ && typeof value.ready === 'boolean';
30
+ }
31
+ function isRuntimeHmrResponse(value) {
32
+ if (!isRecord(value)
33
+ || typeof value.requestId !== 'string'
34
+ || typeof value.runtimeId !== 'string'
35
+ || typeof value.ok !== 'boolean') {
36
+ return false;
37
+ }
38
+ return value.ok ? 'result' in value : typeof value.error === 'string';
39
+ }
40
+ function isRuntimeRequest(value) {
41
+ if (!isRecord(value) || typeof value.type !== 'string')
42
+ return false;
43
+ if (value.type === 'tree' || value.type === 'state')
44
+ return true;
45
+ if (value.type === 'node')
46
+ return Number.isInteger(value.uid) && Number(value.uid) >= 0;
47
+ if (value.type === 'logs') {
48
+ return (value.after === undefined || (Number.isInteger(value.after) && Number(value.after) >= 0))
49
+ && (value.level === undefined || ['debug', 'log', 'info', 'warn', 'error'].includes(String(value.level)));
50
+ }
51
+ if (value.type !== 'input' || typeof value.action !== 'string')
52
+ return false;
53
+ if (value.action === 'click' || value.action === 'move') {
54
+ return typeof value.x === 'number' && typeof value.y === 'number';
55
+ }
56
+ return ['key', 'keydown', 'keyup'].includes(value.action)
57
+ && typeof value.key === 'string'
58
+ && value.key.length > 0;
59
+ }
60
+ export function createRuntimeHostSession(options) {
61
+ const requestTimeoutMs = options.requestTimeoutMs ?? 5_000;
62
+ const now = options.now ?? (() => new Date());
63
+ const sockets = new Set();
64
+ const runtimeIdBySocket = new Map();
65
+ const runtimes = new Map();
66
+ const pending = new Map();
67
+ let requestSequence = 0;
68
+ function settle(requestId, response) {
69
+ const current = pending.get(requestId);
70
+ if (!current)
71
+ return;
72
+ pending.delete(requestId);
73
+ clearTimeout(current.timeout);
74
+ current.resolve(response);
75
+ }
76
+ function removeSocketRuntime(socket) {
77
+ const runtimeId = runtimeIdBySocket.get(socket);
78
+ if (!runtimeId)
79
+ return;
80
+ runtimeIdBySocket.delete(socket);
81
+ if (runtimes.get(runtimeId)?.socket === socket)
82
+ runtimes.delete(runtimeId);
83
+ }
84
+ function open(socket) {
85
+ sockets.add(socket);
86
+ }
87
+ function close(socket) {
88
+ sockets.delete(socket);
89
+ removeSocketRuntime(socket);
90
+ for (const [requestId, current] of pending) {
91
+ if (current.socket === socket) {
92
+ settle(requestId, jsonResponse({
93
+ ok: false,
94
+ error: 'Pixifact Runtime page disconnected during the request.',
95
+ }, 409));
96
+ }
97
+ }
98
+ }
99
+ function message(socket, event, data) {
100
+ if (!sockets.has(socket))
101
+ return;
102
+ if (event === runtimeAnnounceEvent) {
103
+ if (!isRuntimePageDescriptor(data)) {
104
+ throw new Error('Pixifact Runtime page descriptor is invalid.');
105
+ }
106
+ const previousId = runtimeIdBySocket.get(socket);
107
+ const previous = runtimes.get(data.runtimeId);
108
+ if (previousId && previousId !== data.runtimeId)
109
+ runtimes.delete(previousId);
110
+ if (previous && previous.socket !== socket)
111
+ runtimeIdBySocket.delete(previous.socket);
112
+ runtimeIdBySocket.set(socket, data.runtimeId);
113
+ runtimes.set(data.runtimeId, {
114
+ ...data,
115
+ connectedAt: previous?.connectedAt ?? now().toISOString(),
116
+ socket,
117
+ });
118
+ return;
119
+ }
120
+ if (event !== runtimeResponseEvent || !isRuntimeHmrResponse(data))
121
+ return;
122
+ const current = pending.get(data.requestId);
123
+ if (!current || current.socket !== socket || current.runtimeId !== data.runtimeId)
124
+ return;
125
+ settle(data.requestId, data.ok
126
+ ? jsonResponse(data.result)
127
+ : jsonResponse({ ok: false, error: data.error }, 422));
128
+ }
129
+ async function fetchRequest(request) {
130
+ const pathname = new URL(request.url).pathname;
131
+ if (!pathname.startsWith(`${runtimeApiRoot}/`))
132
+ return undefined;
133
+ if (!authorizationMatches(request, options.token)) {
134
+ return jsonResponse({
135
+ ok: false,
136
+ error: 'Pixifact Runtime session token is invalid.',
137
+ }, 401);
138
+ }
139
+ if (pathname === `${runtimeApiRoot}/health`) {
140
+ return jsonResponse({
141
+ protocolVersion: runtimeSessionProtocolVersion,
142
+ projectRoot: options.projectRoot,
143
+ });
144
+ }
145
+ if (pathname === `${runtimeApiRoot}/list`) {
146
+ return jsonResponse({
147
+ ok: true,
148
+ runtimes: [...runtimes.values()]
149
+ .map(({ socket: _socket, ...runtime }) => runtime)
150
+ .sort((left, right) => left.connectedAt.localeCompare(right.connectedAt)),
151
+ });
152
+ }
153
+ if (pathname !== `${runtimeApiRoot}/request`)
154
+ return undefined;
155
+ if (request.method !== 'POST') {
156
+ return jsonResponse({ ok: false, error: 'Pixifact Runtime requests require POST.' }, 405);
157
+ }
158
+ const body = await request.json();
159
+ if (!isRecord(body) || typeof body.runtimeId !== 'string' || !isRuntimeRequest(body.request)) {
160
+ return jsonResponse({ ok: false, error: 'Pixifact Runtime request is invalid.' }, 400);
161
+ }
162
+ const runtime = runtimes.get(body.runtimeId);
163
+ if (!runtime) {
164
+ return jsonResponse({
165
+ ok: false,
166
+ error: `Pixifact Runtime "${body.runtimeId}" is not connected.`,
167
+ }, 404);
168
+ }
169
+ const requestId = `runtime-${++requestSequence}`;
170
+ return new Promise((resolve) => {
171
+ const timeout = setTimeout(() => {
172
+ settle(requestId, jsonResponse({
173
+ ok: false,
174
+ error: 'Pixifact Runtime request timed out.',
175
+ }, 504));
176
+ }, requestTimeoutMs);
177
+ pending.set(requestId, {
178
+ resolve,
179
+ runtimeId: runtime.runtimeId,
180
+ socket: runtime.socket,
181
+ timeout,
182
+ });
183
+ runtime.socket.send(runtimeRequestEvent, {
184
+ requestId,
185
+ runtimeId: runtime.runtimeId,
186
+ request: body.request,
187
+ });
188
+ });
189
+ }
190
+ return {
191
+ open,
192
+ close,
193
+ message,
194
+ fetch: fetchRequest,
195
+ };
196
+ }
197
+ export function defaultRuntimeSessionsRoot() {
198
+ return path.join(os.tmpdir(), 'pixifact', 'runtime-sessions');
199
+ }
200
+ export function runtimeSessionDescriptorPath(projectRoot, sessionsRoot = defaultRuntimeSessionsRoot()) {
201
+ const canonicalRoot = fs.realpathSync(projectRoot);
202
+ const projectHash = createHash('sha256').update(canonicalRoot).digest('hex');
203
+ return path.join(sessionsRoot, `${projectHash}.json`);
204
+ }
205
+ function isLoopbackOrigin(origin) {
206
+ const hostname = new URL(origin).hostname;
207
+ return hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.');
208
+ }
209
+ function validateRuntimeSessionDescriptor(value) {
210
+ if (!isRecord(value)
211
+ || value.protocolVersion !== runtimeSessionProtocolVersion
212
+ || typeof value.projectRoot !== 'string'
213
+ || typeof value.pid !== 'number'
214
+ || typeof value.origin !== 'string'
215
+ || typeof value.token !== 'string'
216
+ || !isLoopbackOrigin(value.origin)) {
217
+ throw new Error('Pixifact Runtime session descriptor is invalid.');
218
+ }
219
+ return value;
220
+ }
221
+ export function readRuntimeSessionDescriptor(projectRoot, sessionsRoot = defaultRuntimeSessionsRoot()) {
222
+ const descriptorPath = runtimeSessionDescriptorPath(projectRoot, sessionsRoot);
223
+ if (!fs.existsSync(descriptorPath))
224
+ return undefined;
225
+ return validateRuntimeSessionDescriptor(JSON.parse(fs.readFileSync(descriptorPath, 'utf8')));
226
+ }
227
+ export function writeRuntimeSessionDescriptor(descriptor, sessionsRoot = defaultRuntimeSessionsRoot()) {
228
+ const canonicalRoot = fs.realpathSync(descriptor.projectRoot);
229
+ const stored = validateRuntimeSessionDescriptor({ ...descriptor, projectRoot: canonicalRoot });
230
+ fs.mkdirSync(sessionsRoot, { recursive: true, mode: 0o700 });
231
+ fs.writeFileSync(runtimeSessionDescriptorPath(canonicalRoot, sessionsRoot), `${JSON.stringify(stored, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
232
+ }
233
+ export function removeRuntimeSessionDescriptor(descriptor, sessionsRoot = defaultRuntimeSessionsRoot()) {
234
+ const descriptorPath = runtimeSessionDescriptorPath(descriptor.projectRoot, sessionsRoot);
235
+ if (!fs.existsSync(descriptorPath))
236
+ return;
237
+ const current = readRuntimeSessionDescriptor(descriptor.projectRoot, sessionsRoot);
238
+ if (current?.token === descriptor.token)
239
+ fs.unlinkSync(descriptorPath);
240
+ }
241
+ function validateRuntimeHealth(value) {
242
+ if (!isRecord(value)
243
+ || value.protocolVersion !== runtimeSessionProtocolVersion
244
+ || typeof value.projectRoot !== 'string') {
245
+ throw new Error('Pixifact Runtime Host health response is invalid.');
246
+ }
247
+ return value;
248
+ }
249
+ export async function findActiveRuntimeSession(options) {
250
+ const projectRoot = fs.realpathSync(options.projectRoot);
251
+ const descriptor = readRuntimeSessionDescriptor(projectRoot, options.sessionsRoot);
252
+ if (!descriptor)
253
+ return undefined;
254
+ if (descriptor.projectRoot !== projectRoot) {
255
+ throw new Error('Pixifact Runtime session belongs to a different project.');
256
+ }
257
+ const fetcher = options.fetch ?? fetch;
258
+ try {
259
+ const response = await fetcher(`${descriptor.origin}${runtimeApiRoot}/health`, {
260
+ headers: { authorization: `Bearer ${descriptor.token}` },
261
+ });
262
+ if (!response.ok)
263
+ return undefined;
264
+ const health = validateRuntimeHealth(await response.json());
265
+ if (health.projectRoot !== projectRoot) {
266
+ throw new Error('Pixifact Runtime Host health check returned a different project.');
267
+ }
268
+ return descriptor;
269
+ }
270
+ catch (error) {
271
+ if (error instanceof Error && error.message.includes('different project'))
272
+ throw error;
273
+ return undefined;
274
+ }
275
+ }
276
+ export async function claimRuntimeSession(options) {
277
+ for (let attempt = 0; attempt < 2; attempt += 1) {
278
+ const current = readRuntimeSessionDescriptor(options.projectRoot, options.sessionsRoot);
279
+ const active = await findActiveRuntimeSession(options);
280
+ if (active)
281
+ return { descriptor: active, owned: false };
282
+ if (current)
283
+ removeRuntimeSessionDescriptor(current, options.sessionsRoot);
284
+ try {
285
+ writeRuntimeSessionDescriptor(options.descriptor, options.sessionsRoot);
286
+ return { descriptor: options.descriptor, owned: true };
287
+ }
288
+ catch (error) {
289
+ if (!isRecord(error) || error.code !== 'EEXIST')
290
+ throw error;
291
+ }
292
+ }
293
+ throw new Error('Pixifact Runtime session could not be registered.');
294
+ }
295
+ function projectRootPath(projectRoot, viteRoot) {
296
+ if (typeof projectRoot === 'string')
297
+ return projectRoot;
298
+ if (projectRoot)
299
+ return fileURLToPath(projectRoot);
300
+ return viteRoot;
301
+ }
302
+ function serverOrigin(server) {
303
+ const address = server.httpServer?.address();
304
+ if (!address || typeof address === 'string') {
305
+ throw new Error('Pixifact Runtime requires a listening Vite HTTP server.');
306
+ }
307
+ if (!(address.address === '::1' || address.address.startsWith('127.'))) {
308
+ throw new Error('Pixifact Runtime requires the Vite server to bind to a loopback address.');
309
+ }
310
+ const hostname = address.address === '::1' ? '[::1]' : address.address;
311
+ const protocol = server.config.server.https ? 'https' : 'http';
312
+ return `${protocol}://${hostname}:${address.port}`;
313
+ }
314
+ async function webRequestFromNode(request, origin) {
315
+ const chunks = [];
316
+ for await (const chunk of request) {
317
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
318
+ }
319
+ const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined;
320
+ return new Request(new URL(request.url ?? '/', origin), {
321
+ method: request.method,
322
+ headers: request.headers,
323
+ ...(body ? { body } : {}),
324
+ });
325
+ }
326
+ async function writeNodeResponse(response, result) {
327
+ response.statusCode = result.status;
328
+ result.headers.forEach((value, name) => response.setHeader(name, value));
329
+ response.end(Buffer.from(await result.arrayBuffer()));
330
+ }
331
+ export function pixifactRuntimePlugin(options = {}) {
332
+ let projectRoot = '';
333
+ const token = randomBytes(32).toString('hex');
334
+ return {
335
+ name: 'pixifact-runtime',
336
+ apply: 'serve',
337
+ configResolved(config) {
338
+ projectRoot = fs.realpathSync(projectRootPath(options.projectRoot, config.root));
339
+ },
340
+ resolveId(id) {
341
+ return id === virtualRuntimeClientId ? resolvedVirtualRuntimeClientId : undefined;
342
+ },
343
+ load(id) {
344
+ if (id !== resolvedVirtualRuntimeClientId)
345
+ return undefined;
346
+ return [
347
+ "const runtimeHotKey = Symbol.for('pixifact.runtime.hot');",
348
+ 'globalThis[runtimeHotKey] = import.meta.hot;',
349
+ "await import('pixifact/runtime-dev');",
350
+ 'delete globalThis[runtimeHotKey];',
351
+ ].join('\n');
352
+ },
353
+ transformIndexHtml() {
354
+ return [{
355
+ tag: 'script',
356
+ attrs: {
357
+ type: 'module',
358
+ src: `/@id/${virtualRuntimeClientId}`,
359
+ },
360
+ injectTo: 'head-prepend',
361
+ }];
362
+ },
363
+ configureServer(server) {
364
+ const host = createRuntimeHostSession({ projectRoot, token });
365
+ const openedClients = new WeakSet();
366
+ function openClient(client) {
367
+ if (openedClients.has(client))
368
+ return;
369
+ openedClients.add(client);
370
+ host.open(client);
371
+ client.socket.once('close', () => host.close(client));
372
+ }
373
+ server.ws.on(runtimeAnnounceEvent, (data, client) => {
374
+ openClient(client);
375
+ host.message(client, runtimeAnnounceEvent, data);
376
+ });
377
+ server.ws.on(runtimeResponseEvent, (data, client) => {
378
+ openClient(client);
379
+ host.message(client, runtimeResponseEvent, data);
380
+ });
381
+ server.middlewares.use((request, response, next) => {
382
+ void (async () => {
383
+ const webRequest = await webRequestFromNode(request, serverOrigin(server));
384
+ const result = await host.fetch(webRequest);
385
+ if (!result) {
386
+ next();
387
+ return;
388
+ }
389
+ await writeNodeResponse(response, result);
390
+ })().catch(next);
391
+ });
392
+ const httpServer = server.httpServer;
393
+ if (!httpServer)
394
+ throw new Error('Pixifact Runtime requires the Vite HTTP server.');
395
+ let descriptor;
396
+ httpServer.once('listening', () => {
397
+ void (async () => {
398
+ const candidate = {
399
+ protocolVersion: runtimeSessionProtocolVersion,
400
+ projectRoot,
401
+ pid: process.pid,
402
+ origin: serverOrigin(server),
403
+ token,
404
+ };
405
+ const claim = await claimRuntimeSession({ projectRoot, descriptor: candidate });
406
+ if (!claim.owned) {
407
+ throw new Error('Another Pixifact Runtime Vite server is already running for this project.');
408
+ }
409
+ descriptor = candidate;
410
+ })().catch((error) => {
411
+ server.config.logger.error(error instanceof Error ? error.message : String(error));
412
+ });
413
+ });
414
+ httpServer.once('close', () => {
415
+ if (descriptor)
416
+ removeRuntimeSessionDescriptor(descriptor);
417
+ });
418
+ },
419
+ };
420
+ }
@@ -1,4 +1,4 @@
1
- import type { SceneScriptInterface } from './spec.js';
1
+ import type { SceneScriptInterface } from '../compiler/spec.js';
2
2
  export interface ExtractSceneScriptInterfaceOptions {
3
3
  scene: string;
4
4
  }
@@ -15,6 +15,7 @@ function extractSceneScriptClasses(source, fileName, options) {
15
15
  const sourceId = normalizeScriptSourceId(fileName);
16
16
  const imports = collectNamedImports(sourceFile, sourceId);
17
17
  const structClasses = collectStructClasses(sourceFile);
18
+ const variantDeclarations = collectVariantDeclarations(sourceFile);
18
19
  const classes = [];
19
20
  for (const statement of sourceFile.statements) {
20
21
  if (!ts.isClassDeclaration(statement)) {
@@ -42,7 +43,8 @@ function extractSceneScriptClasses(source, fileName, options) {
42
43
  }
43
44
  const prop = memberDecoratorOptions(member, 'prop');
44
45
  if (prop) {
45
- props[name] = propContract(prop, structClasses);
46
+ assertStaticPropMember(member, name);
47
+ props[name] = propContract(prop, member, structClasses, variantDeclarations);
46
48
  }
47
49
  const event = memberDecoratorOptions(member, 'event');
48
50
  if (event) {
@@ -318,6 +320,88 @@ function memberName(name) {
318
320
  }
319
321
  return undefined;
320
322
  }
323
+ function collectVariantDeclarations(sourceFile) {
324
+ const variants = new Map();
325
+ for (const statement of sourceFile.statements) {
326
+ if (!ts.isVariableStatement(statement)) {
327
+ continue;
328
+ }
329
+ for (const declaration of statement.declarationList.declarations) {
330
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) {
331
+ continue;
332
+ }
333
+ const initializer = unwrapExpression(declaration.initializer);
334
+ if (!ts.isCallExpression(initializer)
335
+ || !ts.isIdentifier(initializer.expression)
336
+ || initializer.expression.text !== 'defineVariants') {
337
+ continue;
338
+ }
339
+ if (initializer.arguments.length !== 1) {
340
+ throw new Error('defineVariants accepts exactly one argument.');
341
+ }
342
+ variants.set(declaration.name.text, variantObjectValue(initializer.arguments[0], 'defineVariants argument'));
343
+ }
344
+ }
345
+ return variants;
346
+ }
347
+ function variantObjectValue(expression, label) {
348
+ const object = unwrapExpression(expression);
349
+ if (!ts.isObjectLiteralExpression(object)) {
350
+ throw new Error(`${label} must be an object literal.`);
351
+ }
352
+ const variants = {};
353
+ let expectedFields;
354
+ for (const property of object.properties) {
355
+ if (!ts.isPropertyAssignment(property)) {
356
+ throw new Error(`${label} only supports property assignments.`);
357
+ }
358
+ const name = memberName(property.name);
359
+ if (!name) {
360
+ throw new Error(`${label} only supports literal case names.`);
361
+ }
362
+ const caseExpression = unwrapExpression(property.initializer);
363
+ if (!ts.isObjectLiteralExpression(caseExpression)) {
364
+ throw new Error(`${label}.${name} must be an object literal.`);
365
+ }
366
+ const fields = {};
367
+ for (const field of caseExpression.properties) {
368
+ if (!ts.isPropertyAssignment(field)) {
369
+ throw new Error(`${label}.${name} only supports property assignments.`);
370
+ }
371
+ const fieldName = memberName(field.name);
372
+ if (!fieldName) {
373
+ throw new Error(`${label}.${name} only supports literal field names.`);
374
+ }
375
+ fields[fieldName] = literalValue(field.initializer, `${label}.${name}.${fieldName}`);
376
+ }
377
+ const fieldTypes = Object.fromEntries(Object.entries(fields).map(([field, value]) => [field, typeof value]));
378
+ if (!expectedFields) {
379
+ expectedFields = fieldTypes;
380
+ }
381
+ else if (!sameFieldTypes(expectedFields, fieldTypes)) {
382
+ throw new Error(`defineVariants case "${name}" must contain the same fields and field types as the first case.`);
383
+ }
384
+ variants[name] = fields;
385
+ }
386
+ if (Object.keys(variants).length === 0) {
387
+ throw new Error('defineVariants requires at least one case.');
388
+ }
389
+ return variants;
390
+ }
391
+ function sameFieldTypes(left, right) {
392
+ const leftEntries = Object.entries(left);
393
+ return leftEntries.length === Object.keys(right).length
394
+ && leftEntries.every(([name, type]) => right[name] === type);
395
+ }
396
+ function unwrapExpression(expression) {
397
+ if (ts.isAsExpression(expression)
398
+ || ts.isTypeAssertionExpression(expression)
399
+ || ts.isSatisfiesExpression(expression)
400
+ || ts.isParenthesizedExpression(expression)) {
401
+ return unwrapExpression(expression.expression);
402
+ }
403
+ return expression;
404
+ }
321
405
  function collectStructClasses(sourceFile) {
322
406
  const classes = new Map();
323
407
  for (const statement of sourceFile.statements) {
@@ -355,65 +439,113 @@ function structFields(node) {
355
439
  }
356
440
  return fields;
357
441
  }
358
- function propContract(prop, structClasses) {
359
- const type = prop.type;
360
- const defaultValue = prop.default;
361
- if (type === 'string' || type === 'number' || type === 'boolean') {
362
- throw new Error('@prop type must be String, Number, Boolean, or a struct class.');
442
+ function assertStaticPropMember(member, name) {
443
+ const isDeclareProperty = ts.isPropertyDeclaration(member)
444
+ && member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword)
445
+ && !member.modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.AccessorKeyword)
446
+ && !member.initializer;
447
+ if (!isDeclareProperty) {
448
+ throw new Error(`@prop "${name}" must decorate a declare property without an initializer.`);
449
+ }
450
+ if (!member.type) {
451
+ throw new Error(`@prop "${name}" requires an explicit TypeScript property type.`);
452
+ }
453
+ }
454
+ function propContract(prop, member, structClasses, variantDeclarations) {
455
+ if (!ts.isPropertyDeclaration(member) || !member.type) {
456
+ throw new Error('@prop requires a typed declare property.');
363
457
  }
364
- if (!isIdentifierValue(type)) {
365
- throw new Error('@prop type must be String, Number, Boolean, or a struct class.');
458
+ if (prop.type !== undefined) {
459
+ throw new Error('@prop type is inferred from the TypeScript property declaration; remove the type option.');
460
+ }
461
+ const defaultValue = prop.default;
462
+ const variantsReference = prop.variants;
463
+ if (variantsReference !== undefined) {
464
+ if (!isIdentifierValue(variantsReference)) {
465
+ throw new Error('@prop variants must reference a local defineVariants declaration.');
466
+ }
467
+ const variants = variantDeclarations.get(variantsReference.text);
468
+ if (!variants) {
469
+ throw new Error(`@prop variants "${variantsReference.text}" must reference a local defineVariants declaration.`);
470
+ }
471
+ if (typeof defaultValue !== 'string' || !variants[defaultValue]) {
472
+ throw new Error('@prop Variant default must name an existing defineVariants case.');
473
+ }
474
+ assertVariantPropertyType(member.type, variantsReference.text);
475
+ return {
476
+ type: 'variant',
477
+ default: defaultValue,
478
+ variants,
479
+ };
366
480
  }
367
- const primitiveType = primitiveConstructorType(type.text);
481
+ const primitiveType = primitivePropertyType(member.type);
368
482
  if (primitiveType) {
369
483
  if (defaultValue !== undefined && (!isSceneTemplateScalarValue(defaultValue) || sceneTemplateScalarType(defaultValue) !== primitiveType)) {
370
- throw new Error(`@prop default for ${type.text} must be a ${primitiveType} literal.`);
484
+ throw new Error(`@prop default must be a ${primitiveType} literal.`);
371
485
  }
372
486
  return {
373
487
  type: primitiveType,
374
488
  ...(defaultValue !== undefined ? { default: defaultValue } : {}),
375
489
  };
376
490
  }
377
- const structClass = structClasses.get(type.text);
491
+ const structName = typeReferenceName(member.type);
492
+ if (!structName) {
493
+ throw new Error('@prop type must be string, number, boolean, a local struct class, or keyof a local defineVariants declaration.');
494
+ }
495
+ const structClass = structClasses.get(structName);
378
496
  if (!structClass) {
379
- throw new Error(`Struct prop type ${type.text} was not found.`);
497
+ throw new Error(`Struct prop type ${structName} was not found.`);
380
498
  }
381
499
  if (!structClass.exported) {
382
- throw new Error(`Struct prop type ${type.text} must be exported.`);
500
+ throw new Error(`Struct prop type ${structName} must be exported.`);
383
501
  }
384
502
  if (defaultValue !== undefined) {
385
503
  throw new Error('@prop default is only supported for primitive props.');
386
504
  }
387
505
  const struct = structContractInfo(structClass.node);
388
506
  if (struct.hasRequiredConstructorParameters) {
389
- throw new Error(`Struct prop type ${type.text} must be constructable with no required parameters.`);
507
+ throw new Error(`Struct prop type ${structName} must be constructable with no required parameters.`);
390
508
  }
391
509
  return {
392
510
  type: 'struct',
393
- struct: type.text,
511
+ struct: structName,
394
512
  fields: struct.fields,
395
513
  };
396
514
  }
397
- function structContractInfo(node) {
398
- return {
399
- fields: structFields(node),
400
- hasRequiredConstructorParameters: node.members.some((member) => ts.isConstructorDeclaration(member) && member.parameters.some((parameter) => !parameter.questionToken && !parameter.initializer)),
401
- };
515
+ function assertVariantPropertyType(type, variantsName) {
516
+ if (!ts.isTypeOperatorNode(type)
517
+ || type.operator !== ts.SyntaxKind.KeyOfKeyword
518
+ || !ts.isTypeQueryNode(type.type)
519
+ || !ts.isIdentifier(type.type.exprName)
520
+ || type.type.exprName.text !== variantsName) {
521
+ throw new Error(`@prop Variant type must be "keyof typeof ${variantsName}".`);
522
+ }
402
523
  }
403
- function primitiveConstructorType(name) {
404
- if (name === 'String') {
524
+ function primitivePropertyType(type) {
525
+ if (type.kind === ts.SyntaxKind.StringKeyword) {
405
526
  return 'string';
406
527
  }
407
- if (name === 'Number') {
528
+ if (type.kind === ts.SyntaxKind.NumberKeyword) {
408
529
  return 'number';
409
530
  }
410
- if (name === 'Boolean') {
531
+ if (type.kind === ts.SyntaxKind.BooleanKeyword) {
411
532
  return 'boolean';
412
533
  }
413
534
  return undefined;
414
535
  }
536
+ function typeReferenceName(type) {
537
+ return ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)
538
+ ? type.typeName.text
539
+ : undefined;
540
+ }
541
+ function structContractInfo(node) {
542
+ return {
543
+ fields: structFields(node),
544
+ hasRequiredConstructorParameters: node.members.some((member) => ts.isConstructorDeclaration(member) && member.parameters.some((parameter) => !parameter.questionToken && !parameter.initializer)),
545
+ };
546
+ }
415
547
  function isIdentifierValue(value) {
416
- return ts.isIdentifier(value);
548
+ return typeof value === 'object' && ts.isIdentifier(value);
417
549
  }
418
550
  function isSceneTemplateScalarValue(value) {
419
551
  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
package/dist/errors.d.ts CHANGED
@@ -1 +1 @@
1
- export declare function hintForCommandError(error: string): "Use a project-relative scene path that stays inside projectRoot." | "Choose a different scene path or inspect the existing file before writing." | "Use one of: button, progressBar, scrollView, loginForm." | "Re-run scene get or node inspect to refresh locators before regenerating the command." | "Choose a container node as the parent, or create a container template before adding children." | "Verify that the field belongs to the target node or component schema before retrying." | "Check the target schema and send a value with the expected type." | undefined;
1
+ export declare function hintForCommandError(error: string): "Use a project-relative scene path that stays inside projectRoot." | "Choose a different scene path or inspect the existing file before writing." | "Use one of: button, progressBar, scrollView, loginForm." | "Run scene inspect --scene <scene-path> and pass one of the returned node locator values." | "Inspect the current project state again before regenerating the command." | "Choose a container node as the parent, or create a container template before adding children." | "Verify that the field belongs to the target node or component schema before retrying." | "Check the target schema and send a value with the expected type." | undefined;