@wix/zero-config-implementation 1.106.0 → 1.109.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.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "registry": "https://registry.npmjs.org/",
5
5
  "access": "public"
6
6
  },
7
- "version": "1.106.0",
7
+ "version": "1.109.0",
8
8
  "description": "Core library for extracting component manifests from JS and CSS files",
9
9
  "type": "module",
10
10
  "main": "dist/index.js",
@@ -39,7 +39,7 @@
39
39
  }
40
40
  },
41
41
  "dependencies": {
42
- "@wix/react-component-schema": "1.9.0",
42
+ "@wix/react-component-schema": "1.10.0",
43
43
  "@wix/seo-service": "^1.9.0",
44
44
  "@wix/services-manager": "^1.0.7",
45
45
  "@wix/services-manager-react": "^1.0.8",
@@ -106,5 +106,5 @@
106
106
  ]
107
107
  }
108
108
  },
109
- "falconPackageHash": "257681de48c644877515f4fab42d8af3418a370d31f219d70153597a"
109
+ "falconPackageHash": "9a14eb3c7154bae6dcc34db0e6a5db751a6ed10efc0e2da43f00408c"
110
110
  }
@@ -8,8 +8,8 @@
8
8
  * intentional because new type kinds are expected as the TypeScript type system
9
9
  * evolves, and a text fallback is always a safe default.
10
10
  *
11
- * The only real error is an invariant violation in `handleArrayType` when the
12
- * resolved type metadata is inconsistent (kind === 'array' but no elementType).
11
+ * Function signatures fail explicitly when their types cannot be represented.
12
+ * Arrays also fail when resolved metadata is inconsistent (no elementType).
13
13
  */
14
14
 
15
15
  import type { DataItem, DomEvent } from '@wix/react-component-schema'
@@ -21,6 +21,7 @@ import type { DOMBinding, TrackingStores } from '../information-extractors/react
21
21
  import type { PropInfo, ResolvedType } from '../information-extractors/ts/types'
22
22
  import { WIX_TYPE_TO_DATA_TYPE } from '../wix-type-to-data-type'
23
23
  import { collectUsedA11yAttributes } from './a11y-builder'
24
+ import { buildFunctionSignature } from './function-signature-builder'
24
25
  import { formatDisplayName } from './utils'
25
26
 
26
27
  const { DATA_TYPE } = DATA
@@ -57,8 +58,8 @@ function getBindingsForPath(propUsages: TrackingStores['propUsages'], path: stri
57
58
  * @param defaultValue - Optional default value for the data item.
58
59
  * @param propUsages - The full stores.propUsages map for on-demand binding lookups.
59
60
  * @param propPath - The full stores.propUsages key for this prop, e.g. "props.linkUrl".
60
- * @returns `Ok<DataItem>` on success, `Err<ParseError>` if an invariant is violated
61
- * (e.g. an array type missing its element type).
61
+ * @returns `Ok<DataItem>` on success, `Err<ParseError>` for unsupported function
62
+ * signatures or inconsistent resolved metadata.
62
63
  */
63
64
  export function buildDataItem(
64
65
  propInfo: PropInfo,
@@ -120,8 +121,16 @@ function applyResolvedTypeToDataItem(
120
121
  return handleUnionType({ dataItem, resolvedType, propInfo, propUsages, propPath })
121
122
 
122
123
  case 'function':
123
- handleFunctionType(dataItem, propInfo, bindings)
124
- return ok(undefined)
124
+ {
125
+ const signatureResult = buildFunctionSignature(resolvedType, propInfo.name)
126
+ if (signatureResult.isErr()) return err(signatureResult.error)
127
+ if (signatureResult.value) {
128
+ dataItem.dataType = DATA_TYPE.function
129
+ dataItem.function = signatureResult.value
130
+ return ok(undefined)
131
+ }
132
+ }
133
+ return handleFunctionType(dataItem, propInfo, resolvedType, bindings)
125
134
 
126
135
  case 'semantic':
127
136
  handleSemanticType({ dataItem, resolvedType, propUsages, propPath })
@@ -309,6 +318,17 @@ function handleUnionType({
309
318
  return ok(undefined)
310
319
  }
311
320
 
321
+ if (resolvedType.types.some((member) => member.kind === 'function')) {
322
+ const definedTypes = resolvedType.types.filter(
323
+ (member) => !(member.kind === 'primitive' && member.value === 'undefined'),
324
+ )
325
+ if (definedTypes.length !== 1) {
326
+ return err(
327
+ new ParseError(`Cannot represent callback union for ${propInfo.name}; use a single supported signature.`),
328
+ )
329
+ }
330
+ }
331
+
312
332
  // Filter out undefined and null types
313
333
  const validTypes = resolvedType.types.filter((t) => t.value !== 'undefined' && t.value !== 'null')
314
334
 
@@ -423,8 +443,8 @@ const EVENT_ATTR_TO_DOM_EVENT: Record<string, DomEvent> = {
423
443
  }
424
444
 
425
445
  /**
426
- * Handles function types — maps standard DOM event handlers to `dataType: 'function'`
427
- * with a `domEvent` parameter, and leaves custom functions as `function: {}`.
446
+ * Legacy fallback when signature information is missing or uninformative.
447
+ * Maps recognized DOM handlers to event parameters; unresolved custom signatures fail.
428
448
  *
429
449
  * Checks bound DOM attributes first (reflects actual DOM wiring regardless of prop name),
430
450
  * then falls back to prop name matching.
@@ -432,7 +452,12 @@ const EVENT_ATTR_TO_DOM_EVENT: Record<string, DomEvent> = {
432
452
  * The `domEvent` field exists in the proto schema but is not yet present in the installed
433
453
  * FunctionParameter typings — cast through `unknown` to bridge the version gap.
434
454
  */
435
- function handleFunctionType(dataItem: DataItem, propInfo: PropInfo, bindings?: DOMBinding[]): void {
455
+ function handleFunctionType(
456
+ dataItem: DataItem,
457
+ propInfo: PropInfo,
458
+ resolvedType: ResolvedType,
459
+ bindings?: DOMBinding[],
460
+ ): Result<void, ParseErrorInstance> {
436
461
  let domEvent: DomEvent | undefined
437
462
 
438
463
  if (bindings) {
@@ -452,10 +477,19 @@ function handleFunctionType(dataItem: DataItem, propInfo: PropInfo, bindings?: D
452
477
  dataItem.dataType = DATA_TYPE.function
453
478
 
454
479
  if (domEvent) {
455
- dataItem.function = { parameters: [{ domEvent }] } as unknown as DataItem['function']
480
+ const optional = resolvedType.functionSignature?.parameters[0]?.optional
481
+ dataItem.function = {
482
+ parameters: [{ domEvent, ...(optional ? { optional: true } : {}) }],
483
+ } as unknown as DataItem['function']
456
484
  } else {
485
+ if (resolvedType.functionSignature) {
486
+ return err(
487
+ new ParseError(`Cannot infer function parameter for ${propInfo.name}; declare a supported explicit type.`),
488
+ )
489
+ }
457
490
  dataItem.function = {}
458
491
  }
492
+ return ok(undefined)
459
493
  }
460
494
 
461
495
  /**
@@ -0,0 +1,257 @@
1
+ import path from 'node:path'
2
+ import ts from 'typescript'
3
+ import { describe, expect, it } from 'vitest'
4
+ import type { TrackingStores } from '../information-extractors/react'
5
+ import { extractAllComponentInfo } from '../information-extractors/ts/components'
6
+ import { resolveType } from '../information-extractors/ts/utils/semantic-type-resolver'
7
+ import { compileTsFile } from '../ts-compiler'
8
+ import { buildDataItem } from './data-item-builder'
9
+
10
+ const fixturePath = path.resolve(__dirname, '__signature_fixture__.ts')
11
+ const aliasPath = path.resolve(__dirname, '__signature_alias__.ts')
12
+ const fixtures = new Map([
13
+ [
14
+ fixturePath,
15
+ `
16
+ import type * as React from 'react'
17
+ import type { Email, Phone, WebUrl, Link, Image, Container } from '@wix/editor-react-types'
18
+ import type { ValueHandler, ChangeHandler, EmailHandler } from './__signature_alias__'
19
+ interface Callbacks {
20
+ onChange: (value: string) => void
21
+ onValueChange: ValueHandler
22
+ onOptionalValueChange?: ValueHandler
23
+ onUndefinedCallback: ValueHandler | undefined
24
+ onCallbackUnion: ((value: string) => void) | ((value: number) => void)
25
+ onNullableCallback: ValueHandler | null
26
+ onMixedCallback: ValueHandler | string
27
+ onOptionalNullableCallback?: ValueHandler | null
28
+ onOptionalBoolean: (value?: boolean) => void
29
+ onCheckedChange: (checked: boolean) => void
30
+ onNumberChange: (value: number) => void
31
+ onSelectionChange: (values: readonly string[]) => void
32
+ onDetails: (value: string, details?: { committed: boolean; count?: number }) => void
33
+ onTooMany: (one: string, two: string, three: string, four: string, five: string, six: string, seven: string, eight: string, nine: string, ten: string, eleven: string) => void
34
+ onClick: () => void
35
+ onEvent: ChangeHandler
36
+ onMouse: (event: React.MouseEvent) => void
37
+ onPointer: (event: React.PointerEvent) => void
38
+ onFocus: (event: React.FocusEvent) => void
39
+ onKeyboard: (event: React.KeyboardEvent) => void
40
+ onSubmit: (event: React.FormEvent) => void
41
+ onClickLegacy: (event: any) => void
42
+ calculate: (value: number) => Promise<number>
43
+ onLoad: () => Promise<void>
44
+ onUnsupported: (value: string | number) => void
45
+ onNullable: (value: string | null) => void
46
+ onRest: (...values: string[]) => void
47
+ onUnknown: (value: unknown) => void
48
+ onMode: (value: 'on' | 'off') => void
49
+ onEmail: (value: Email) => void
50
+ onEmailAlias: EmailHandler
51
+ getEmail: () => Email
52
+ loadEmail: () => Promise<Email>
53
+ onPhone: (value: Phone) => void
54
+ onUrl: (value: WebUrl) => void
55
+ onLink: (value: Link) => void
56
+ onImage: (value: Image) => void
57
+ onContainer: (value: Container) => void
58
+ overloaded: { (value: string): void; (value: number): void }
59
+ }
60
+ `,
61
+ ],
62
+ [
63
+ aliasPath,
64
+ `
65
+ import type { ChangeEvent } from 'react'
66
+ import type { Email as EmailAddress } from '@wix/editor-react-types'
67
+ type ContactEmail = EmailAddress
68
+ export type EmailHandler = (value: ContactEmail) => void
69
+ export type Name = string
70
+ export type ValueHandler = (value: Name) => void
71
+ export type InputChange = ChangeEvent<HTMLInputElement>
72
+ export type ChangeHandler = (event: InputChange) => void
73
+ `,
74
+ ],
75
+ ])
76
+ const compilerOptions: ts.CompilerOptions = {
77
+ strict: true,
78
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
79
+ module: ts.ModuleKind.ESNext,
80
+ skipLibCheck: true,
81
+ noEmit: true,
82
+ }
83
+ const compilerHost = ts.createCompilerHost(compilerOptions)
84
+ const originalReadFile = compilerHost.readFile.bind(compilerHost)
85
+ const originalFileExists = compilerHost.fileExists.bind(compilerHost)
86
+ compilerHost.readFile = (fileName) => fixtures.get(fileName) ?? originalReadFile(fileName)
87
+ compilerHost.fileExists = (fileName) => fixtures.has(fileName) || originalFileExists(fileName)
88
+ compilerHost.getSourceFile = (fileName, languageVersion) => {
89
+ const source = compilerHost.readFile(fileName)
90
+ return source === undefined ? undefined : ts.createSourceFile(fileName, source, languageVersion, true)
91
+ }
92
+ const program = ts.createProgram([fixturePath, aliasPath], compilerOptions, compilerHost)
93
+ const checker = program.getTypeChecker()
94
+ const sourceFile = program.getSourceFile(fixturePath)!
95
+ const callbacksDeclaration = sourceFile.statements.find(ts.isInterfaceDeclaration)!
96
+ const callbacks = checker.getTypeAtLocation(callbacksDeclaration)
97
+
98
+ function buildCallback(name: string, manifestName = name, propUsages?: TrackingStores['propUsages']) {
99
+ const symbol = callbacks.getProperty(name)!
100
+ const type = checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration!)
101
+ return buildDataItem(
102
+ {
103
+ name: manifestName,
104
+ type: checker.typeToString(type),
105
+ required: true,
106
+ resolvedType: resolveType({ type, checker }),
107
+ },
108
+ undefined,
109
+ propUsages,
110
+ `props.${manifestName}`,
111
+ )
112
+ }
113
+
114
+ describe('function signatures from TypeScript to manifest', () => {
115
+ it.each([
116
+ ['ForwardRefComponent', 'onChange'],
117
+ ['ComplexProps', 'onUpdate'],
118
+ ['PropBindingTypes', 'onSubmit'],
119
+ ])('retains signatures through the actual component extractor for %s', async (componentName, callbackName) => {
120
+ const componentPath = path.resolve(
121
+ __dirname,
122
+ '../../../example-components/src/components',
123
+ componentName,
124
+ 'component.tsx',
125
+ )
126
+ const program = (await compileTsFile(componentPath))._unsafeUnwrap()
127
+ const component = extractAllComponentInfo(program, componentPath).find(
128
+ (candidate) => candidate.componentName === componentName,
129
+ )!
130
+ const manifest = buildDataItem(component.props[callbackName])._unsafeUnwrap()
131
+ expect(manifest.dataType).toBe('function')
132
+ expect(manifest.function?.parameters?.length).toBeGreaterThan(0)
133
+ expect(JSON.parse(JSON.stringify(manifest)).function).toEqual(manifest.function)
134
+ if (componentName === 'ForwardRefComponent') expect(manifest.function?.parameters).toEqual([{ dataType: 'text' }])
135
+ })
136
+ it('uses a valid fixture with resolved React types and imported aliases', () => {
137
+ expect(program.getSemanticDiagnostics().map((diagnostic) => diagnostic.messageText)).toEqual([])
138
+ })
139
+ it.each([
140
+ ['onChange', 'text'],
141
+ ['onValueChange', 'text'],
142
+ ['onCheckedChange', 'booleanValue'],
143
+ ['onNumberChange', 'number'],
144
+ ])('maps %s from its signature rather than its name', (name, dataType) => {
145
+ expect(buildCallback(name)._unsafeUnwrap().function).toEqual({ parameters: [{ dataType }] })
146
+ })
147
+ it.each([
148
+ ['onEmail', 'email'],
149
+ ['onEmailAlias', 'email'],
150
+ ['onPhone', 'phone'],
151
+ ['onUrl', 'webUrl'],
152
+ ['onLink', 'link'],
153
+ ['onImage', 'image'],
154
+ ])('preserves semantic Wix parameter type for %s', (name, dataType) => {
155
+ expect(buildCallback(name)._unsafeUnwrap().function).toEqual({ parameters: [{ dataType }] })
156
+ })
157
+ it('preserves branded return values, including promised results', () => {
158
+ expect(buildCallback('getEmail')._unsafeUnwrap().function).toEqual({ returns: { dataType: 'email' } })
159
+ expect(buildCallback('loadEmail')._unsafeUnwrap().function).toEqual({ async: true, returns: { dataType: 'email' } })
160
+ expect(buildCallback('onContainer').isErr()).toBe(true)
161
+ })
162
+ it.each([
163
+ ['onEvent', 'CHANGE'],
164
+ ['onMouse', 'POINTER'],
165
+ ['onPointer', 'POINTER'],
166
+ ['onFocus', 'FOCUS'],
167
+ ['onKeyboard', 'KEYBOARD'],
168
+ ['onSubmit', 'SUBMIT'],
169
+ ])('recognizes %s including imported event aliases', (name, domEvent) => {
170
+ expect(buildCallback(name)._unsafeUnwrap().function).toEqual({ parameters: [{ domEvent }] })
171
+ })
172
+ it('maps readonly value arrays with the function-specific item schema', () => {
173
+ expect(buildCallback('onSelectionChange')._unsafeUnwrap().function).toEqual({
174
+ parameters: [{ dataType: 'arrayItems', arrayItems: { item: { dataType: 'text' } } }],
175
+ })
176
+ })
177
+ it('handles optional callback props and optional boolean parameters', () => {
178
+ expect(buildCallback('onOptionalValueChange')._unsafeUnwrap().function).toEqual({
179
+ parameters: [{ dataType: 'text' }],
180
+ })
181
+ expect(buildCallback('onUndefinedCallback')._unsafeUnwrap().function).toEqual({
182
+ parameters: [{ dataType: 'text' }],
183
+ })
184
+ expect(buildCallback('onOptionalBoolean')._unsafeUnwrap().function).toEqual({
185
+ parameters: [{ dataType: 'booleanValue', optional: true }],
186
+ })
187
+ })
188
+ it('does not override a declared value signature with its DOM wiring', () => {
189
+ const propUsages: TrackingStores['propUsages'] = new Map([
190
+ [
191
+ 'props.onValueChange',
192
+ {
193
+ elements: new Map([['input', { tag: 'input', elementId: 'input' }]]),
194
+ attributes: new Map([['input:onChange', { attr: 'onChange', concatenated: false }]]),
195
+ },
196
+ ],
197
+ ])
198
+ expect(buildCallback('onValueChange', 'onValueChange', propUsages)._unsafeUnwrap().function).toEqual({
199
+ parameters: [{ dataType: 'text' }],
200
+ })
201
+ })
202
+ it('preserves ordered parameters and optional structured fields', () => {
203
+ expect(buildCallback('onDetails')._unsafeUnwrap().function).toEqual({
204
+ parameters: [
205
+ { dataType: 'text' },
206
+ {
207
+ dataType: 'data',
208
+ optional: true,
209
+ data: {
210
+ items: {
211
+ committed: { dataType: 'booleanValue' },
212
+ count: { dataType: 'number', optional: true },
213
+ },
214
+ },
215
+ },
216
+ ],
217
+ })
218
+ })
219
+ it('does not invent an event parameter for a no-argument onClick', () => {
220
+ expect(buildCallback('onClick')._unsafeUnwrap().function).toEqual({})
221
+ })
222
+ it('keeps name inference for an uninformative legacy event signature', () => {
223
+ expect(buildCallback('onClickLegacy', 'onClick')._unsafeUnwrap().function).toEqual({
224
+ parameters: [{ domEvent: 'POINTER' }],
225
+ })
226
+ })
227
+ it('maps promised results and promised void', () => {
228
+ expect(buildCallback('calculate')._unsafeUnwrap().function).toEqual({
229
+ parameters: [{ dataType: 'number' }],
230
+ async: true,
231
+ returns: { dataType: 'number' },
232
+ })
233
+ expect(buildCallback('onLoad')._unsafeUnwrap().function).toEqual({ async: true })
234
+ })
235
+ it('preserves a string union as a text enum', () => {
236
+ expect(buildCallback('onMode')._unsafeUnwrap().function?.parameters?.[0]).toEqual({
237
+ dataType: 'textEnum',
238
+ textEnum: { options: [{ value: 'on' }, { value: 'off' }] },
239
+ })
240
+ })
241
+ it.each(['onCallbackUnion', 'onNullableCallback', 'onMixedCallback', 'onOptionalNullableCallback'])(
242
+ 'rejects unsupported callback prop union %s',
243
+ (name) => {
244
+ const result = buildCallback(name)
245
+ expect(result.isErr()).toBe(true)
246
+ expect(result._unsafeUnwrapErr().message).toContain(name)
247
+ },
248
+ )
249
+ it.each(['onUnsupported', 'onNullable', 'onRest', 'onUnknown', 'overloaded', 'onTooMany'])(
250
+ 'reports %s instead of dropping its parameters',
251
+ (name) => {
252
+ const result = buildCallback(name)
253
+ expect(result.isErr()).toBe(true)
254
+ expect(result._unsafeUnwrapErr().message).toContain(name)
255
+ },
256
+ )
257
+ })
@@ -0,0 +1,144 @@
1
+ import type { DataItem, DomEvent } from '@wix/react-component-schema'
2
+ import { DATA, DOM_EVENT } from '@wix/react-component-schema'
3
+ import { type Result, err, ok } from 'neverthrow'
4
+ import { ParseError } from '../errors'
5
+ import type { ResolvedType } from '../information-extractors/ts/types'
6
+ import { WIX_TYPE_TO_DATA_TYPE } from '../wix-type-to-data-type'
7
+
8
+ type FunctionDefinition = NonNullable<DataItem['function']>
9
+ type FunctionParameter = NonNullable<FunctionDefinition['parameters']>[number]
10
+ type ParseErrorInstance = InstanceType<typeof ParseError>
11
+
12
+ const EVENT_TYPES: Record<string, DomEvent> = {
13
+ ChangeEvent: DOM_EVENT.CHANGE,
14
+ MouseEvent: DOM_EVENT.POINTER,
15
+ PointerEvent: DOM_EVENT.POINTER,
16
+ FocusEvent: DOM_EVENT.FOCUS,
17
+ KeyboardEvent: DOM_EVENT.KEYBOARD,
18
+ }
19
+
20
+ function unsupported(path: string): Result<never, ParseErrorInstance> {
21
+ return err(
22
+ new ParseError(`Cannot represent function signature at ${path}; use a supported explicit parameter/return type.`),
23
+ )
24
+ }
25
+
26
+ function buildParameter(
27
+ type: ResolvedType,
28
+ path: string,
29
+ submitHandler = false,
30
+ ): Result<FunctionParameter, ParseErrorInstance> {
31
+ if (type.kind === 'semantic' && (type.source === '@types/react' || type.source === 'react')) {
32
+ const domEvent = type.value === 'FormEvent' && submitHandler ? DOM_EVENT.SUBMIT : EVENT_TYPES[String(type.value)]
33
+ return domEvent ? ok({ domEvent } as unknown as FunctionParameter) : unsupported(path)
34
+ }
35
+ if (type.kind === 'semantic' && type.source === '@wix/editor-react-types') {
36
+ const dataType = WIX_TYPE_TO_DATA_TYPE[String(type.value)]
37
+ if (dataType && !['container', 'schema', 'arrayItems', 'textEnum'].includes(dataType)) {
38
+ return ok({ dataType: DATA.DATA_TYPE[dataType] })
39
+ }
40
+ return unsupported(path)
41
+ }
42
+ if (type.kind === 'primitive' || type.kind === 'literal') {
43
+ const primitive = type.kind === 'literal' ? typeof type.value : type.value
44
+ switch (primitive) {
45
+ case 'string':
46
+ return ok({ dataType: DATA.DATA_TYPE.text })
47
+ case 'number':
48
+ return ok({ dataType: DATA.DATA_TYPE.number })
49
+ case 'boolean':
50
+ return ok({ dataType: DATA.DATA_TYPE.booleanValue })
51
+ default:
52
+ return unsupported(path)
53
+ }
54
+ }
55
+ if (type.kind === 'union') {
56
+ // TypeScript represents boolean as true | false in some contexts.
57
+ const members = type.types ?? []
58
+ if (members.length && members.every((member) => member.kind === 'literal' && typeof member.value === 'boolean')) {
59
+ return ok({ dataType: DATA.DATA_TYPE.booleanValue })
60
+ }
61
+ if (members.length && members.every((member) => member.kind === 'literal' && typeof member.value === 'string')) {
62
+ return ok({
63
+ dataType: DATA.DATA_TYPE.textEnum,
64
+ textEnum: { options: members.map((member) => ({ value: String(member.value) })) },
65
+ })
66
+ }
67
+ return unsupported(path)
68
+ }
69
+ if (type.kind === 'array' && type.elementType) {
70
+ return buildParameter(type.elementType, `${path}[]`).map((item) => ({
71
+ dataType: DATA.DATA_TYPE.arrayItems,
72
+ arrayItems: { item },
73
+ }))
74
+ }
75
+ if (type.kind === 'object' && type.properties) {
76
+ const items: Record<string, FunctionParameter> = {}
77
+ for (const [name, property] of Object.entries(type.properties)) {
78
+ const propertyType =
79
+ !property.required && property.resolvedType.kind === 'union'
80
+ ? property.resolvedType.types?.filter(
81
+ (member) => !(member.kind === 'primitive' && member.value === 'undefined'),
82
+ )
83
+ : undefined
84
+ const result = buildParameter(
85
+ propertyType
86
+ ? propertyType.length === 1
87
+ ? propertyType[0]
88
+ : { kind: 'union', types: propertyType }
89
+ : property.resolvedType,
90
+ `${path}.${name}`,
91
+ )
92
+ if (result.isErr()) return err(result.error)
93
+ items[name] = { ...result.value, ...(!property.required ? { optional: true } : {}) }
94
+ }
95
+ return ok({ dataType: DATA.DATA_TYPE.data, data: { items } })
96
+ }
97
+ if (type.kind === 'function') {
98
+ return buildFunctionSignature(type, path).andThen((definition) =>
99
+ definition ? ok({ dataType: DATA.DATA_TYPE.function, function: definition }) : unsupported(path),
100
+ )
101
+ }
102
+ return unsupported(path)
103
+ }
104
+
105
+ /** Undefined means legacy metadata has no signature and name/usage inference may run. */
106
+ export function buildFunctionSignature(
107
+ type: ResolvedType,
108
+ path: string,
109
+ ): Result<FunctionDefinition | undefined, ParseErrorInstance> {
110
+ const signature = type.functionSignature
111
+ if (!signature) return ok(undefined)
112
+ if (signature.overloaded || signature.parameters.length > 10) return unsupported(path)
113
+ if (
114
+ signature.parameters.length === 1 &&
115
+ !signature.parameters[0].rest &&
116
+ signature.parameters[0].type.kind === 'primitive' &&
117
+ ['any', 'unknown'].includes(String(signature.parameters[0].type.value)) &&
118
+ signature.returnType.kind === 'primitive' &&
119
+ signature.returnType.value === 'void' &&
120
+ !signature.async
121
+ ) {
122
+ return ok(undefined)
123
+ }
124
+ const parameters: FunctionParameter[] = []
125
+ let optionalParameterSeen = false
126
+ for (const parameter of signature.parameters) {
127
+ if (parameter.rest) return unsupported(`${path}.${parameter.name} (rest parameter)`)
128
+ if (optionalParameterSeen && !parameter.optional)
129
+ return unsupported(`${path}.${parameter.name} (required after optional)`)
130
+ optionalParameterSeen ||= parameter.optional
131
+ const result = buildParameter(parameter.type, `${path}.${parameter.name}`, path.toLowerCase() === 'onsubmit')
132
+ if (result.isErr()) return err(result.error)
133
+ parameters.push({ ...result.value, ...(parameter.optional ? { optional: true } : {}) })
134
+ }
135
+ const definition: FunctionDefinition = parameters.length ? { parameters } : {}
136
+ if (signature.async) definition.async = true
137
+ if (!(signature.returnType.kind === 'primitive' && signature.returnType.value === 'void')) {
138
+ const result = buildParameter(signature.returnType, `${path} return`)
139
+ if (result.isErr()) return err(result.error)
140
+ if (result.value.domEvent || result.value.function) return unsupported(`${path} return`)
141
+ definition.returns = result.value
142
+ }
143
+ return ok(definition)
144
+ }
@@ -590,91 +590,6 @@ describe('toEditorReactComponent — @property design tokens', () => {
590
590
  })
591
591
  })
592
592
 
593
- it('carries the --min / --max / --step descriptors of a number @property into number limits', () => {
594
- const component = createComponentWithCss(`
595
- @property --speed { syntax: "<number>"; inherits: true; initial-value: 4; --min: 1; --max: 10; --step: 0.5; }
596
- `)
597
-
598
- const customProps = toEditorReactComponent(component).editorElement?.cssCustomProperties
599
-
600
- expect(customProps?.speed).toEqual({
601
- displayName: 'Speed',
602
- defaultValue: '4',
603
- cssPropertyType: 'number',
604
- number: { min: '1', max: '10', multiplier: '0.5' },
605
- })
606
- })
607
-
608
- it('emits only the usable half of a partial range and omits the rest', () => {
609
- const component = createComponentWithCss(`
610
- @property --weight { syntax: "<number>"; inherits: true; initial-value: 2; --min: 0; }
611
- @property --tempo { syntax: "<number>"; inherits: true; initial-value: 3; --max: 9; --step: nonsense; }
612
- @property --offset { syntax: "<number>"; inherits: true; initial-value: 0; --min: ; --max: 12; }
613
- `)
614
-
615
- const customProps = toEditorReactComponent(component).editorElement?.cssCustomProperties
616
-
617
- expect(customProps?.weight).toEqual({
618
- displayName: 'Weight',
619
- defaultValue: '2',
620
- cssPropertyType: 'number',
621
- number: { min: '0' },
622
- })
623
- expect(customProps?.tempo).toEqual({
624
- displayName: 'Tempo',
625
- defaultValue: '3',
626
- cssPropertyType: 'number',
627
- number: { max: '9' },
628
- })
629
- expect(customProps?.offset).toEqual({
630
- displayName: 'Offset',
631
- defaultValue: '0',
632
- cssPropertyType: 'number',
633
- number: { max: '12' },
634
- })
635
- })
636
-
637
- it('drops an inverted range and a non-positive step, omitting `number` when nothing is usable', () => {
638
- const component = createComponentWithCss(`
639
- @property --span { syntax: "<number>"; inherits: true; initial-value: 5; --min: 10; --max: 2; --step: 0; }
640
- @property --frozen { syntax: "<number>"; inherits: true; initial-value: 5; --min: 5; --max: 5; --step: 1; }
641
- `)
642
-
643
- const customProps = toEditorReactComponent(component).editorElement?.cssCustomProperties
644
-
645
- expect(customProps?.span).toEqual({
646
- displayName: 'Span',
647
- defaultValue: '5',
648
- cssPropertyType: 'number',
649
- })
650
- expect(customProps?.frozen).toEqual({
651
- displayName: 'Frozen',
652
- defaultValue: '5',
653
- cssPropertyType: 'number',
654
- number: { multiplier: '1' },
655
- })
656
- })
657
-
658
- it('ignores range descriptors on a non-number @property', () => {
659
- const component = createComponentWithCss(`
660
- @property --sdf-safelight-color { syntax: "<color>"; inherits: true; initial-value: #F02011; --min: 1; --max: 10; }
661
- @property --gap { syntax: "<length>"; inherits: true; initial-value: 4px; --min: 1; --max: 10; --step: 1; }
662
- `)
663
-
664
- const customProps = toEditorReactComponent(component).editorElement?.cssCustomProperties
665
-
666
- expect(customProps?.['sdf-safelight-color']).toEqual({
667
- displayName: 'Sdf Safelight Color',
668
- defaultValue: '#F02011',
669
- cssPropertyType: 'color',
670
- })
671
- expect(customProps?.gap).toEqual({
672
- displayName: 'Gap',
673
- defaultValue: '4px',
674
- cssPropertyType: 'length',
675
- })
676
- })
677
-
678
593
  it('emits a customEnum dropdown for a pipe-separated ident-list @property', () => {
679
594
  const component = createComponentWithCss(`
680
595
  @property --sdf-palette { syntax: "warm | cool | neutral"; inherits: false; initial-value: warm; }