@wix/origami-lint 1.34.4 → 1.34.6

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.
@@ -1,24 +1,30 @@
1
- import { RUNTIME_ONLY_BUILDERS, RESERVED_ATTRIBUTES, RESERVED_ENTRY_KEYS, RUNTIME_PACKAGE, SHARED_COMP_ID, bare, getAttribute, getExpression, getKey, getName, getProperties, getProperty, isStructure, ownerOf, } from "../rules-api/index.js";
1
+ import { BUILDERS, CALLBACK_PROP, EVENT_ATTRIBUTE, RESERVED_ENTRY_KEYS, RUNTIME_PACKAGE, TRANSFORM_BUILDERS, TYPED_BUILDERS, bare, getAttribute, getExpression, getKey, getCallbackKind, getName, getObject, getParamReadKind, getProperties, getProperty, isBound, isReservedAttribute, isStructure, ownerOf, } from "../rules-api/index.js";
2
2
  import { defineRule } from "./define-rule.js";
3
3
  /** The converter's reserved sources (identity.ts `isReservedRegistry`) and the DM's own `document`: no fn()/ctx() names. */
4
4
  const RESERVED_FOR_BINDINGS = ['runtime', 'interactReact', 'interact', 'motionPresets', 'menu', 'document', 'core'];
5
- /** The keys of a `bindings` object. */
6
- const BINDINGS_KEYS = ['props', 'visibility', 'states'];
5
+ /** The key of a `bindings` object (parseJsx.ts bindingsIrOfExpression); the other two sections are written in `className` since converter 0.79. */
6
+ const BINDINGS_KEY = 'props';
7
+ const CLASS_NAME_SECTIONS = ['visibility', 'states'];
8
+ /** A `className` term for a visibility: the viewer's utility class (reservedAttributes.ts CLASS_NAME_UTILITIES). */
9
+ const VISIBILITY_PREFIX = 'wix-visibility-';
7
10
  const VISIBILITY_KEYS = ['collapsed', 'hidden'];
8
11
  const REMOVED = 'removed';
9
12
  const CTX_OPTIONS = ['from', 'placeholder'];
10
- const STATE_OPTIONS = ['kind', 'id', 'pseudoClass'];
11
13
  const BINDINGS_LIST = 'BindingsList';
12
14
  const STATIC_PREFIX = 'Static.';
15
+ /** The types of an object the import reads as an argument's data item (decode.ts decodeDataRef); an object with no type and an `href` is a link. */
16
+ const DATA_ITEM_TYPES = ['Builder.Image', 'Builder.Video', 'Builder.VectorArt', 'Builder.Audio', 'Builder.RichText', 'Builder.MenuItems', 'Builder.MenuItem', 'PageLink', 'ExternalLink',
17
+ 'EmailLink', 'AnchorLink', 'DocumentLink', 'PhoneLink', 'DynamicPageLink', 'TextLink', 'LoginToWixLink', 'WhatsAppLink', 'AddressLink', 'TpaPageLink'];
13
18
  const PLACES = {
14
- prop: { name: "a prop's binding", builders: ['ctx', 'fn', 'raw'], literals: false },
15
- fnArg: { name: 'an `fn()` argument', builders: ['ctx', 'fn', 'raw'], literals: true },
16
- isBinding: { name: "`is()`'s binding", builders: ['ctx', 'fn'], literals: false },
17
- condition: { name: 'a condition', builders: ['is', 'all', 'ctx', 'fn', 'raw'], literals: true },
18
- allItem: { name: 'an `all()` item', builders: ['is', 'ctx', 'fn', 'raw'], literals: true },
19
- statesItem: { name: 'a `states` item', builders: ['state', 'raw'], literals: false },
19
+ prop: { name: "a prop's binding", builders: ['ctx', 'fn', 'raw', 'callback', 'map', 'shape'], literals: false },
20
+ fnArg: { name: 'an `fn()` argument', builders: ['ctx', 'fn', 'raw', 'callback', 'map', 'shape'], literals: true },
21
+ isBinding: { name: "`is()`'s binding", builders: ['ctx', 'fn', 'callback'], literals: false },
22
+ condition: { name: 'a condition', builders: ['is', 'all', 'ctx', 'fn', 'raw', 'callback'], literals: true },
23
+ allItem: { name: 'an `all()` item', builders: ['is', 'ctx', 'fn', 'raw', 'callback'], literals: true },
20
24
  };
21
25
  const PLAIN = 'a string, number or true / false';
26
+ /** The builders whose value runs at render: a callback prop gets their value, not a function. */
27
+ const RENDER_BUILDERS = ['ctx', 'fn', ...TRANSFORM_BUILDERS];
22
28
  export default defineRule({
23
29
  name: 'bindings',
24
30
  editors: ['studio2'],
@@ -47,13 +53,14 @@ function checkRemoveCondition(at) {
47
53
  condition(at, when, '`when`', true);
48
54
  removeTarget(at);
49
55
  }
50
- /** A component's builders, its `states` and `bindings`, and the props it binds as data attributes. */
56
+ /** A component's builders, its callback props, its `bindings` and `className` conditions, and the props it binds as data attributes. */
51
57
  function checkComponentBindings(at) {
52
- const { node } = at.input;
58
+ const { node, rulesApi } = at.input;
53
59
  builderInPlace(at);
54
- statesAttribute(at, getAttribute(node, 'states'));
60
+ checkCallbackProps(at);
55
61
  bindingsAttribute(at, getAttribute(node, 'bindings'));
56
- if (!hasData(node))
62
+ classNameAttribute(at, getAttribute(node, 'className'));
63
+ if (!rulesApi.hasDataAttributes(node))
57
64
  return;
58
65
  boundPropOnce(at);
59
66
  for (const [name, , value] of boundAttributes(node))
@@ -69,6 +76,9 @@ function checkEntryBindings(at, entry) {
69
76
  const bindings = getProperty(object, 'bindings');
70
77
  if (bindings)
71
78
  bindingsValue(entryAt, bindings.value, true);
79
+ const className = getObject(getProperty(object, 'className')?.value);
80
+ if (className)
81
+ classNameConditions(entryAt, className, '`className`');
72
82
  entryPropBoundOnce(entryAt, object);
73
83
  for (const [key, property] of boundEntryKeys(object))
74
84
  prop(entryAt, key, property.value, true);
@@ -84,7 +94,7 @@ function checkCallExpressionImports(input) {
84
94
  ctxSourceHasName(input, ctxHook);
85
95
  }
86
96
  /** Each builder's own row: the shape of its call, a key written twice in it. */
87
- const ROW_OF_BUILDER = { all: 'CONV-decode.ts:196', ctx: 'CONV-astValue.ts:276', fn: 'CONV-astValue.ts:287', is: 'CONV-decode.ts:172', raw: 'CONV-decode.ts:28', state: 'CONV-decode.ts:145' };
97
+ const ROW_OF_BUILDER = { all: 'CONV-decode.ts:196', callback: 'CONV-astValue.ts:287', ctx: 'CONV-astValue.ts:276', fn: 'CONV-astValue.ts:287', is: 'CONV-decode.ts:172', raw: 'CONV-decode.ts:28' };
88
98
  const tracked = (message, row) => `${message.slice(0, -1)} (tracker ${row}).`;
89
99
  const say = ({ input }, node, message, row) => input.report({ loc: node.loc, message: tracked(message, row) });
90
100
  const textOf = ({ input }, node) => input.context.sourceCode.getText(node);
@@ -110,12 +120,9 @@ function removeTarget(at) {
110
120
  if (!value || builderOf(value, rulesApi) !== 'raw')
111
121
  continue;
112
122
  const item = getObject(value.arguments[0]);
113
- const target = `${at.owner} wraps ${ownerOf(component)}, whose \`bindings\` are \`raw()\``;
114
- if (!item || stringValue(getProperty(item, 'type')?.value) !== BINDINGS_LIST) {
115
- say(at, value, `${target} of an item that isn't \`{ type: '${BINDINGS_LIST}', … }\`. Write the bindings as an object, or set ${REMOVED} in a ${BINDINGS_LIST} item instead of using \`<Remove>\`.`, 'CONV-decode.ts:296');
116
- }
117
- else if (setsRemoved(item)) {
118
- say(at, value, `${target} of an item that already sets \`features.visibility.${REMOVED}\`. Keep only one of them.`, 'CONV-decode.ts:280');
123
+ // An item of another type is the bindings' own line (checkRawBindingsType).
124
+ if (item && stringValue(getProperty(item, 'type')?.value) === BINDINGS_LIST && setsRemoved(item)) {
125
+ say(at, value, `${at.owner} wraps ${ownerOf(component)}, whose \`bindings\` are \`raw()\` of an item that already sets \`features.visibility.${REMOVED}\`. Keep only one of them.`, 'CONV-decode.ts:280');
119
126
  }
120
127
  }
121
128
  }
@@ -125,6 +132,20 @@ function builderInPlace(at) {
125
132
  say(at, call, `\`${builderOf(call, rulesApi)}()\` on ${at.owner} is inside ${where}, where the import reads only plain values. Write a builder as the whole value of a data attribute (\`label={ctx(…)}\`) or of an elementProps entry key (\`navbar: { label: ctx(…) }\`), or in \`bindings\`.`, 'CONV-astValue.ts:98');
126
133
  }
127
134
  }
135
+ /** A callback prop takes a function: the import reads it only in `bindings.props`, and there the site's runtime throws on anything but `callback()` (withOrigami). */
136
+ function checkCallbackProps(at) {
137
+ const { node, rulesApi } = at.input;
138
+ for (const [name, attribute] of getCallbackAttributes(node)) {
139
+ say(at, attribute, `\`${name}\` on ${at.owner} is an attribute, which the import doesn't read for a callback prop. Bind it in \`bindings\` with \`callback()\`: \`bindings={{ props: { ${name}: callback(goHome) } }}\`.`, 'OUR-031');
140
+ }
141
+ if (!isWrappedByRuntime(node, rulesApi))
142
+ return;
143
+ for (const [name, value] of getBoundCallbacks(getAttribute(node, 'bindings'))) {
144
+ const what = runsAtRender(at, value);
145
+ if (what)
146
+ say(at, value, `\`bindings.props.${name}\` on ${at.owner} is ${what}, which runs at render and gives a value, not a function: the site's runtime throws on it. Write \`${name}: callback(goHome)\`.`, 'OUR-031');
147
+ }
148
+ }
128
149
  function boundPropOnce(at) {
129
150
  const { node, rulesApi } = at.input;
130
151
  const bindings = getAttribute(node, 'bindings');
@@ -156,7 +177,7 @@ function bindingsAttribute(at, attribute) {
156
177
  else
157
178
  say(at, attribute, notBindings(at, attribute.value ? `\`${at.input.context.sourceCode.getText(attribute.value)}\`` : 'empty'), 'CONV-parseJsx.ts:700');
158
179
  }
159
- const notBindings = (at, what) => `\`bindings\` on ${at.owner} is ${what}. Write an object in place, \`bindings={{ props: {…}, visibility: {…}, states: {…} }}\`, or \`raw({…})\`.`;
180
+ const notBindings = (at, what) => `\`bindings\` on ${at.owner} is ${what}. Write an object in place, \`bindings={{ props: {…} }}\`, or \`raw({…})\`.`;
160
181
  function bindingsValue(at, node, isEntry = false) {
161
182
  const value = bare(node);
162
183
  if (value.type === 'ObjectExpression')
@@ -167,6 +188,8 @@ function bindingsValue(at, node, isEntry = false) {
167
188
  if (hasDuplicateKey(at, value, 'raw') || !isRawWrittenOut(at, value))
168
189
  return;
169
190
  const object = bare(item);
191
+ if (!isEntry)
192
+ checkRawBindingsType(at, value, object);
170
193
  if (!isEntry && setsRemoved(object))
171
194
  say(at, value, `${at.owner} sets \`features.visibility.${REMOVED}\` in its \`raw()\` bindings. Move the condition out, and wrap the component in \`<Remove when={…}>\`.`, 'CONV-decode.ts:280');
172
195
  const propertyMap = getProperty(object, 'properties');
@@ -179,7 +202,7 @@ function bindingsValue(at, node, isEntry = false) {
179
202
  }
180
203
  }
181
204
  else if (builder) {
182
- say(at, value, `\`bindings\` on ${at.owner} is a \`${builder}()\` call. Write an object and put the call under props, visibility or states, or use \`raw({…})\`.`, 'CONV-decode.ts:263');
205
+ say(at, value, `\`bindings\` on ${at.owner} is a \`${builder}()\` call. Write an object and put the call under props, or use \`raw({…})\`.`, 'CONV-decode.ts:263');
183
206
  }
184
207
  else if (!isCallByName(value)) {
185
208
  say(at, value, notBindings(at, describeIn(at, value)), 'CONV-parseJsx.ts:700');
@@ -191,15 +214,27 @@ function bindingsObject(at, bindings, isEntry) {
191
214
  : property.computed ? 'a computed key'
192
215
  : property.key.type === 'Literal' && typeof property.key.value === 'string' ? `the quoted key \`${textOf(at, property.key)}\``
193
216
  : property.key.type !== 'Identifier' ? `the key \`${textOf(at, property.key)}\``
194
- : BINDINGS_KEYS.includes(property.key.name) ? null
217
+ : property.key.name === BINDINGS_KEY || CLASS_NAME_SECTIONS.includes(property.key.name) ? null
195
218
  : `the key \`${property.key.name}\``;
196
219
  if (what)
197
- say(at, property, `\`bindings\` on ${at.owner} has ${what}. Use only the keys props, visibility and states, written as plain names.`, 'CONV-parseJsx.ts:708');
220
+ say(at, property, `\`bindings\` on ${at.owner} has ${what}. Use only the key props, written as a plain name.`, 'CONV-parseJsx.ts:708');
221
+ else if (getKey(property) === BINDINGS_KEY)
222
+ section(at, BINDINGS_KEY, property.value);
198
223
  else
199
- section(at, property.key.name, property.value, isEntry);
224
+ classNameSection(at, property, isEntry);
200
225
  }
201
226
  }
202
- function section(at, name, node, isEntry) {
227
+ /** `bindings.visibility` and `bindings.states`: the import reads them in `className` since converter 0.79, and `removed` in `<Remove when>` (parseJsx.ts assertNoClassNameSections). */
228
+ function classNameSection(at, property, isEntry) {
229
+ const key = getKey(property);
230
+ const removed = key === 'visibility' && !isEntry && getObject(property.value) && getProperty(getObject(property.value), REMOVED);
231
+ if (removed)
232
+ return say(at, removed, `${at.owner} sets \`visibility.${REMOVED}\` in its \`bindings\`. Move the condition out, and wrap the component in \`<Remove when={…}>\`.`, 'CONV-decode.ts:280');
233
+ const term = key === 'states' ? '{ hover: is(…) }' : "{ 'wix-visibility-hidden': is(…) }";
234
+ const fix = isEntry ? `in the entry's \`className: ${term}\`` : `as a \`className\` term: \`className={['comp-x', ${term}]}\``;
235
+ say(at, property, `\`bindings.${key}\` on ${at.owner} isn't read: the import reads states and visibility in \`className\`. Write each condition ${fix}.`, 'CONV-parseJsx.ts:708');
236
+ }
237
+ function section(at, name, node) {
203
238
  const fix = `Write an object of \`key: value\` entries: \`${name}: { label: … }\`.`;
204
239
  const value = bare(node);
205
240
  if (value.type !== 'ObjectExpression')
@@ -216,23 +251,36 @@ function section(at, name, node, isEntry) {
216
251
  say(at, property, `\`bindings.${name}\` on ${at.owner} ${problem}. ${fix}`, 'CONV-parseJsx.ts:669');
217
252
  continue;
218
253
  }
254
+ prop(at, getKey(property), property.value, false);
255
+ }
256
+ }
257
+ /** `className={['comp-x', 'open', { hover: is(…) }]}`: each object term's key holds a condition, as `bindings.states` and `bindings.visibility` did (parseJsx.ts mergeClassName). */
258
+ function classNameAttribute(at, attribute) {
259
+ const value = getExpression(attribute);
260
+ if (value?.type !== 'ArrayExpression')
261
+ return;
262
+ for (const term of value.elements.slice(1)) {
263
+ if (term?.type === 'ObjectExpression')
264
+ classNameConditions(at, term, '`className`');
265
+ }
266
+ }
267
+ function classNameConditions(at, terms, where) {
268
+ for (const property of getProperties(terms)) {
219
269
  const key = getKey(property);
220
- const where = `\`bindings.${name}.${key}\``;
221
- if (name === 'props')
222
- prop(at, key, property.value, false);
223
- else if (name === 'visibility' && key === REMOVED && !isEntry)
224
- say(at, property, `${at.owner} sets \`visibility.${REMOVED}\` in its \`bindings\`. Move the condition out, and wrap the component in \`<Remove when={…}>\`.`, 'CONV-decode.ts:280');
225
- else if (name === 'visibility' && key !== REMOVED && !VISIBILITY_KEYS.includes(key))
226
- say(at, property, `\`bindings.visibility\` on ${at.owner} has the key \`${key}\`. Use \`collapsed\` or \`hidden\`.`, 'CONV-decode.ts:221');
270
+ if (key === null)
271
+ continue;
272
+ if (key.startsWith(VISIBILITY_PREFIX) && !VISIBILITY_KEYS.includes(key.slice(VISIBILITY_PREFIX.length))) {
273
+ say(at, property, `${where} on ${at.owner} has the term \`${key}\`, which isn't a visibility. Use \`${VISIBILITY_PREFIX}hidden\` or \`${VISIBILITY_PREFIX}collapsed\`.`, 'CONV-decode.ts:221');
274
+ }
227
275
  else
228
- condition(at, property.value, where);
276
+ condition(at, property.value, `${where} term \`${key}\``);
229
277
  }
230
278
  }
231
279
  function prop(at, name, node, placeholderAllowed) {
232
280
  const value = bare(node);
233
281
  if (value.type === 'ArrayExpression')
234
282
  return say(at, value, `\`bindings.props.${name}\` on ${at.owner} is an array. Bind the prop to one value: \`ctx()\`, \`fn()\` or \`raw()\`.`, 'CONV-decode.ts:209');
235
- if (isPlain(value) || rawOnly(value) || isStaticRaw(at, value))
283
+ if (isPlain(value) || rawOnly(value) || isStaticRaw(at, value) || isTyped(value, at.input.rulesApi))
236
284
  return say(at, value, fixedValue(at, name), 'CONV-decode.ts:252');
237
285
  binding(at, value, 'prop', placeholderAllowed);
238
286
  }
@@ -255,10 +303,17 @@ function condition(at, node, where, inRemove = false) {
255
303
  function binding(at, node, place, placeholderAllowed = false) {
256
304
  const value = bare(node);
257
305
  const { name } = PLACES[place];
306
+ // A read of the renderItem row or of a map() item (`item.name`) is a binding in every place (decode.ts decodeRef).
307
+ if (getParamReadKind(value))
308
+ return;
309
+ if (isInvoke(value))
310
+ return invoke(at, value);
258
311
  if (isCallByName(value)) {
259
312
  const builder = builderOf(value, at.input.rulesApi);
260
313
  if (!builder)
261
314
  return;
315
+ if (TYPED_BUILDERS.includes(builder))
316
+ return PLACES[place].literals ? undefined : wrongPlace(at, value, `\`${builder}()\``, place);
262
317
  if (!PLACES[place].builders.includes(builder))
263
318
  return wrongPlace(at, value, `\`${builder}()\``, place);
264
319
  return call(at, value, builder, place, placeholderAllowed);
@@ -269,31 +324,46 @@ function binding(at, node, place, placeholderAllowed = false) {
269
324
  return;
270
325
  }
271
326
  const raw = rawOnly(value);
272
- if (raw && place === 'statesItem')
273
- return wrongPlace(at, value, raw, place);
327
+ if (raw === 'an object' && place === 'fnArg' && isDataItem(value))
328
+ return;
329
+ if (raw === 'an object' && place === 'fnArg')
330
+ return say(at, value, `${capitalize(name)} on ${at.owner} is an object that isn't a data item. Write ${PLAIN}, a link (\`{ href: … }\`) or a typed item (\`{ type: 'Builder.Image', … }\`), or wrap it in \`raw()\`.`, 'CONV-decode.ts:86');
274
331
  if (raw)
275
332
  return say(at, value, `${capitalize(name)} on ${at.owner} is ${raw}. Write ${PLAIN}, or wrap it in \`raw()\`.`, 'CONV-decode.ts:86');
276
333
  say(at, value, `${capitalize(name)} on ${at.owner} is ${describeIn(at, value)}. Write it out. ${hintFor(place)}`, 'CONV-decode.ts:86');
277
334
  }
278
- const wrongPlace = (at, node, what, place) => say(at, node, `${capitalize(what)} can't be ${PLACES[place].name} on ${at.owner}. ${hintFor(place)}`, place === 'statesItem' ? 'CONV-decode.ts:132' : 'CONV-decode.ts:126');
335
+ const wrongPlace = (at, node, what, place) => say(at, node, `${capitalize(what)} can't be ${PLACES[place].name} on ${at.owner}. ${hintFor(place)}`, 'CONV-decode.ts:126');
336
+ /** A called ctx() or row member: `ctx('cart.add', { from: useCart })(item.id)`, `item.actions.select('quick')` (decode.ts decodeInvoke). */
337
+ function invoke(at, node) {
338
+ const { callee } = node;
339
+ if (!isMethodTarget(callee, at.input.rulesApi))
340
+ return say(at, node, `${capitalize(describeIn(at, node))} on ${at.owner} calls what isn't a context or the renderItem row. Call only \`ctx(…)\` or a member of the row: \`ctx('cart.add', { from: useCart })(item.id)\`.`, 'CONV-decode.ts:86');
341
+ if (callee.type === 'CallExpression')
342
+ ctxCall(at, callee, false);
343
+ if (node.arguments.some((argument) => argument.type === 'SpreadElement'))
344
+ return say(at, node, `A called \`ctx()\` or row member on ${at.owner} spreads its arguments. Write each argument out.`, 'CONV-astValue.ts:310');
345
+ for (const argument of node.arguments)
346
+ binding(at, argument, 'fnArg');
347
+ }
279
348
  function call(at, node, builder, place, placeholderAllowed) {
280
349
  if (node.arguments.some((argument) => argument.type === 'SpreadElement')) {
281
350
  return say(at, node, `\`${builder}()\` on ${at.owner} spreads its arguments. Write each argument out.`, 'CONV-astValue.ts:310');
282
351
  }
283
- if (hasDuplicateKey(at, node, builder))
352
+ // map() and shape() hold a callback, read in its own scope: no tracker row checks its shape yet.
353
+ if (builder === 'map' || builder === 'shape' || hasDuplicateKey(at, node, builder))
284
354
  return;
285
355
  if (builder === 'ctx')
286
356
  ctxCall(at, node, placeholderAllowed);
287
357
  else if (builder === 'fn')
288
358
  fnCall(at, node, placeholderAllowed);
359
+ else if (builder === 'callback')
360
+ callbackCall(at, node);
289
361
  else if (builder === 'is')
290
362
  isCall(at, node);
291
363
  else if (builder === 'all')
292
364
  allCall(at, node, place);
293
- else if (builder === 'raw')
294
- isRawWrittenOut(at, node);
295
365
  else
296
- stateCall(at, node);
366
+ isRawWrittenOut(at, node);
297
367
  }
298
368
  function hasDuplicateKey(at, node, builder) {
299
369
  const twice = node.arguments.map(duplicateKeyIn).find(Boolean);
@@ -334,6 +404,21 @@ function fnCall(at, node, placeholderAllowed) {
334
404
  binding(at, argument, 'fnArg');
335
405
  }
336
406
  }
407
+ /** `callback(goHome, 2)`: the function (an imported one, a `ctx()` or a member of the row), then its bound arguments (decode.ts decodeCallback). */
408
+ function callbackCall(at, node) {
409
+ const [first, ...bound] = node.arguments;
410
+ if (!first)
411
+ return say(at, node, `\`callback()\` on ${at.owner} has no arguments. Write the function first: \`callback(goHome, …)\`.`, 'CONV-astValue.ts:287');
412
+ const target = bare(first);
413
+ // A name that isn't a parameter is the imported function: bindingNameImported checks its import.
414
+ const isFunctionName = target.type === 'Identifier' && !getParamReadKind(target);
415
+ if (!isFunctionName && !isMethodTarget(target, at.input.rulesApi))
416
+ return say(at, target, `\`callback()\`'s function on ${at.owner} is ${describeIn(at, target)}. Write an imported function, a \`ctx()\` or a member of the renderItem row: \`callback(goHome, …)\`.`, 'CONV-decode.ts:86');
417
+ if (target.type === 'CallExpression')
418
+ ctxCall(at, target, false);
419
+ for (const argument of bound)
420
+ binding(at, argument, 'fnArg');
421
+ }
337
422
  function placeholderValue(at, node) {
338
423
  const value = bare(node);
339
424
  if (!isPlain(value))
@@ -344,14 +429,26 @@ function isCall(at, node) {
344
429
  if (node.arguments.length !== 2)
345
430
  return say(at, node, `\`is()\` on ${at.owner} has ${node.arguments.length} argument${node.arguments.length === 1 ? '' : 's'}. ${fix}`, 'CONV-decode.ts:172');
346
431
  const [binded, compared] = node.arguments.map((argument) => bare(argument));
347
- if (isPlain(binded) || rawOnly(binded) || builderOf(binded, at.input.rulesApi) === 'raw')
348
- say(at, binded, `\`is()\` on ${at.owner} compares ${isCallByName(binded) ? '`raw()`' : 'a literal'}, not a binding. ${fix}`, 'CONV-decode.ts:172');
432
+ if (isPlain(binded) || rawOnly(binded) || builderOf(binded, at.input.rulesApi) === 'raw' || isTyped(binded, at.input.rulesApi))
433
+ say(at, binded, `\`is()\` on ${at.owner} compares ${isCallByName(binded) ? `\`${builderOf(binded, at.input.rulesApi)}()\`` : 'a literal'}, not a binding. ${fix}`, 'CONV-decode.ts:172');
349
434
  else
350
435
  binding(at, binded, 'isBinding');
351
436
  const isStringOrBoolean = compared.type === 'Literal' && ['string', 'boolean'].includes(typeof compared.value);
352
437
  if (!isStringOrBoolean)
353
438
  say(at, compared, `\`is()\` on ${at.owner} compares to ${rawOnly(compared) ?? describeIn(at, compared)}. ${fix}`, 'CONV-decode.ts:172');
354
439
  }
440
+ /**
441
+ * A component's `raw()` bindings are one item of the bindings namespace, and its one schema is BindingsList (reservedAttributes.ts
442
+ * BINDINGS_WRAPPER_TYPE): the import refuses an item with no type, or another one ("Cannot find schema … for namespace: bindings").
443
+ * An elementProps entry's bindings are stored inline in the component's inner elements, so they have no such item.
444
+ */
445
+ function checkRawBindingsType(at, node, item) {
446
+ const type = getProperty(item, 'type');
447
+ if (stringValue(type?.value) === BINDINGS_LIST)
448
+ return;
449
+ const has = type ? `has the type \`${textOf(at, type.value)}\`` : 'has no `type`';
450
+ say(at, type?.value ?? node, `The \`raw()\` bindings item on ${at.owner} ${has}. Write \`raw({ type: '${BINDINGS_LIST}', … })\`: the import stores no other bindings item.`, 'CONV-decode.ts:28');
451
+ }
355
452
  function allCall(at, node, place) {
356
453
  const count = node.arguments.length;
357
454
  if (place === 'condition' && count < 2)
@@ -379,61 +476,30 @@ function isRawWrittenOut(at, node) {
379
476
  say(at, found, `\`raw()\` on ${at.owner} holds ${describeIn(at, found)}. Write its item out with literal values.`, 'CONV-decode.ts:28');
380
477
  return !found;
381
478
  }
382
- function stateCall(at, node) {
383
- const [nameNode, options, ...extra] = node.arguments;
384
- const name = stringValue(nameNode);
385
- const object = options && getObject(options);
386
- const keys = object ? getProperties(object).map(getKey) : [];
387
- const unknown = keys.find((key) => key !== null && !STATE_OPTIONS.includes(key));
388
- const problem = name === null ? 'has no name string first'
389
- : extra.length > 0 ? 'has more than two arguments'
390
- : !object || object.properties.length !== keys.length ? "has no options object, or one that isn't all `key: value`"
391
- : unknown !== undefined ? `has the option \`${unknown}\``
392
- : null;
393
- if (problem)
394
- return say(at, node, `\`state()\` on ${at.owner} ${problem}. Write \`state('hover', { kind: 'hover', id: 'state-1' })\` (pseudoClass is optional).`, 'CONV-decode.ts:145');
395
- const found = nonLiteralIn(object);
396
- if (found)
397
- return say(at, found, `\`state()\` on ${at.owner} holds ${describeIn(at, found)}. Write its options out with literal values.`, 'CONV-decode.ts:145');
398
- if (stringValue(getProperty(object, 'kind')?.value) === null)
399
- return say(at, node, `\`state()\` on ${at.owner} has no \`kind\` string. Write \`state('hover', { kind: 'hover', id: 'state-1' })\` (pseudoClass is optional).`, 'CONV-decode.ts:145');
400
- if (stringValue(getProperty(object, 'id')?.value) === null)
401
- say(at, node, `\`state('${name}')\` on ${at.owner} has no \`id\` string. Add \`id: '…'\` to its options.`, 'CONV-decode.ts:142');
402
- }
403
- function statesAttribute(at, attribute) {
404
- if (!attribute)
405
- return;
406
- const value = getExpression(attribute);
407
- if (value?.type === 'ArrayExpression' && !hasHoleOrSpread(value)) {
408
- for (const item of value.elements)
409
- binding(at, item, 'statesItem');
410
- return;
411
- }
412
- const what = value?.type === 'ArrayExpression' ? 'a list with a spread or an empty place' : value ? describeIn(at, value) : 'not a `{…}` value';
413
- say(at, attribute, `\`states\` on ${at.owner} is ${what}. Write a list of \`state()\` or \`raw()\` calls: \`states={[state('hover', {…})]}\`.`, 'CONV-parseJsx.ts:641');
414
- }
415
- /** Only RUNTIME_ONLY_BUILDERS: other runtime names (`Remove(…)`) pass the converter's parse, then its decode has no case for them. */
479
+ /** Only BUILDERS: other runtime names (`Remove(…)`) pass the converter's parse, then its decode has no case for them. */
416
480
  function isBuilderImported({ rulesApi, report }, callee) {
417
481
  const found = rulesApi.getImport(callee);
418
- if (found?.kind === 'runtime' && found.imported === callee.name && RUNTIME_ONLY_BUILDERS.includes(callee.name))
482
+ if (found?.kind === 'runtime' && found.imported === callee.name && BUILDERS.includes(callee.name))
419
483
  return true;
420
484
  const builder = found?.kind === 'runtime' ? found.imported : callee.name;
421
485
  report({
422
486
  loc: callee.loc,
423
- message: tracked(RUNTIME_ONLY_BUILDERS.includes(builder)
487
+ message: tracked(BUILDERS.includes(builder)
424
488
  ? `\`${callee.name}(…)\` isn't the builder \`${builder}\` from ${RUNTIME_PACKAGE}. Import it under its own name, \`import { ${builder} } from '${RUNTIME_PACKAGE}'\`, and call it as \`${builder}(…)\`.`
425
- : `\`${callee.name}(…)\` isn't a builder. In an attribute, only the builders of ${RUNTIME_PACKAGE} are called (${RUNTIME_ONLY_BUILDERS.join(', ')}); write a literal value, or one of them.`, 'CONV-astValue.ts:296'),
489
+ : `\`${callee.name}(…)\` isn't a builder. In an attribute, only the builders of ${RUNTIME_PACKAGE} are called (${BUILDERS.join(', ')}); write a literal value, or one of them.`, 'CONV-astValue.ts:296'),
426
490
  });
427
491
  return false;
428
492
  }
429
493
  function bindingNameImported({ node, rulesApi, report, context }, builder) {
430
494
  const [first, second] = node.arguments;
431
495
  const options = builder === 'ctx' ? getObject(second) : null;
432
- const named = builder === 'fn' ? first : options ? getProperty(options, 'from')?.value : undefined;
496
+ // callback()'s first argument may also be a `ctx()` or a member of the row (astValue.ts astToCallArg).
497
+ const isCallbackName = builder === 'callback' && first && bare(first).type === 'Identifier' && !getParamReadKind(bare(first));
498
+ const named = builder === 'fn' || isCallbackName ? first : options ? getProperty(options, 'from')?.value : undefined;
433
499
  if (!named)
434
500
  return null;
435
501
  const value = bare(named);
436
- const what = builder === 'fn' ? "fn()'s function" : "ctx()'s `from` hook";
502
+ const what = builder === 'fn' ? "fn()'s function" : builder === 'callback' ? "callback()'s function" : "ctx()'s `from` hook";
437
503
  if (value.type !== 'Identifier') {
438
504
  report({ loc: value.loc, message: tracked(`${what} is \`${context.sourceCode.getText(value)}\`. Write an imported name: \`fn(numberToText, …)\`, \`{ from: useProduct }\`.`, 'CONV-astValue.ts:227') });
439
505
  return null;
@@ -456,12 +522,6 @@ function ctxSourceHasName({ report }, { hook, source }) {
456
522
  report({ loc: hook.loc, message: tracked(`ctx()'s \`from\` hook \`${hook.name}\` is imported from '${source}', which has no context name after the \`/\`. Write '<componentType>/<contextName>'.`, 'CONV-decode.ts:100') });
457
523
  }
458
524
  // ── reading the code ──
459
- function getObject(source) {
460
- if (!source)
461
- return null;
462
- const value = source.type === 'JSXAttribute' ? getExpression(source) : bare(source);
463
- return value?.type === 'ObjectExpression' ? value : null;
464
- }
465
525
  function getElementPropsEntries(element) {
466
526
  return getEntries(getObject(getAttribute(element, 'elementProps')));
467
527
  }
@@ -478,9 +538,22 @@ function builderOf(node, rulesApi) {
478
538
  if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier')
479
539
  return null;
480
540
  const found = rulesApi.getImport(node.callee);
481
- return found?.kind === 'runtime' && RUNTIME_ONLY_BUILDERS.includes(found.imported) ? found.imported : null;
541
+ return found?.kind === 'runtime' && BUILDERS.includes(found.imported) ? found.imported : null;
482
542
  }
483
543
  const isCallByName = (node) => node.type === 'CallExpression' && node.callee.type === 'Identifier';
544
+ /** What the import calls as a method (decode.ts methodBindingOf): a `ctx()`, or a read of the renderItem row. A map() or shape() parameter has no methods. */
545
+ const isMethodTarget = (node, rulesApi) => builderOf(node, rulesApi) === 'ctx' || getParamReadKind(node) === 'template';
546
+ /** A call of a call or of a member: `ctx(…)(…)`, `item.actions.select()`. */
547
+ const isInvoke = (node) => node.type === 'CallExpression' && (node.callee.type === 'CallExpression' || node.callee.type === 'MemberExpression');
548
+ /** A value the import reads as a binding: a builder call, a called ctx() or row member, a read of a renderItem or map() parameter. */
549
+ const isBindingValue = (node, rulesApi) => builderOf(node, rulesApi) !== null || isInvoke(node) || getParamReadKind(node) !== null;
550
+ /** A typed builder, `email('a@b.c')`: a fixed value, like a literal (decode.ts decodeTypedStatic). */
551
+ const isTyped = (node, rulesApi) => TYPED_BUILDERS.includes(builderOf(node, rulesApi) ?? '');
552
+ /** An argument's object the import reads as a data item: a typed Builder item or link, or a link with an `href` and no type (decode.ts decodeDataRef). */
553
+ function isDataItem(item) {
554
+ const type = getProperty(item, 'type');
555
+ return type ? DATA_ITEM_TYPES.includes(stringValue(type.value) ?? '') : getProperty(item, 'href') !== undefined;
556
+ }
484
557
  function isPlain(node) {
485
558
  if (node.type === 'UnaryExpression')
486
559
  return node.operator === '-' && node.argument.type === 'Literal' && typeof node.argument.value === 'number';
@@ -547,22 +620,45 @@ const wrappedComponents = (element, rulesApi) => rulesApi.getChildren(element).f
547
620
  return wrappedComponents(child, rulesApi);
548
621
  return rulesApi.getComponent(child) ? [child] : [];
549
622
  });
550
- /** The import reads data attributes only on a component with `type=`. */
551
- const hasData = (element) => getAttribute(element, 'type') !== undefined;
552
- /** Not the component's data: the converter's reserved attributes, `sharedCompId`, and `states`, which bindings still reads. */
553
- const isReserved = (name) => RESERVED_ATTRIBUTES.includes(name) || name === SHARED_COMP_ID || name === 'states';
623
+ /** The runtime's withOrigami wraps the component wrappers and the companion components: only on them does a callback prop throw. */
624
+ function isWrappedByRuntime(element, rulesApi) {
625
+ const kind = rulesApi.getImport(element)?.kind;
626
+ return kind === 'wrapper' || kind === 'siteCode';
627
+ }
628
+ /** A callback prop written as an attribute with a call: `onClick={fn(goHome)}`. */
629
+ function getCallbackAttributes(element) {
630
+ return element.openingElement.attributes.flatMap((attribute) => attribute.type === 'JSXAttribute' && CALLBACK_PROP.test(getName(attribute)) && getExpression(attribute)?.type === 'CallExpression' ? [[getName(attribute), attribute]] : []);
631
+ }
632
+ /** The callback props in `bindings={{ props: { onClick: … } }}`. */
633
+ function getBoundCallbacks(attribute) {
634
+ const bindings = getObject(attribute);
635
+ const props = bindings && getObject(getProperty(bindings, BINDINGS_KEY)?.value);
636
+ return (props ? getProperties(props) : []).flatMap((property) => {
637
+ const key = getKey(property);
638
+ return key !== null && CALLBACK_PROP.test(key) ? [[key, bare(property.value)]] : [];
639
+ });
640
+ }
641
+ /** What a callback prop is bound with that runs at render: `fn()`, `ctx()`, a called ctx() or row member, a read of the row; null for `callback()` and `raw()`. */
642
+ function runsAtRender(at, node) {
643
+ const builder = builderOf(node, at.input.rulesApi);
644
+ if (builder)
645
+ return RENDER_BUILDERS.includes(builder) ? `\`${builder}()\`` : null;
646
+ return isInvoke(node) || getParamReadKind(node) ? describeIn(at, node) : null;
647
+ }
648
+ /** The data attributes bound to a value the import reads as a binding; not an `on…` attribute, which it doesn't read at all (OUR-031 reports it). */
554
649
  function boundAttributes(element) {
555
650
  return element.openingElement.attributes.flatMap((attribute) => {
556
- if (attribute.type !== 'JSXAttribute' || isReserved(getName(attribute)))
651
+ if (attribute.type !== 'JSXAttribute' || isReservedAttribute(getName(attribute)) || EVENT_ATTRIBUTE.test(getName(attribute)))
557
652
  return [];
558
653
  const value = getExpression(attribute);
559
- return value?.type === 'CallExpression' ? [[getName(attribute), attribute, value]] : [];
654
+ return isBound(value) ? [[getName(attribute), attribute, value]] : [];
560
655
  });
561
656
  }
562
657
  function boundEntryKeys(entry) {
563
658
  return getProperties(entry).flatMap((property) => {
564
659
  const key = getKey(property);
565
- return key !== null && !RESERVED_ENTRY_KEYS.includes(key) && bare(property.value).type === 'CallExpression' ? [[key, property]] : [];
660
+ const value = bare(property.value);
661
+ return key !== null && !RESERVED_ENTRY_KEYS.includes(key) && isBound(value) ? [[key, property]] : [];
566
662
  });
567
663
  }
568
664
  function getBindingsProps(node, rulesApi) {
@@ -617,13 +713,14 @@ function misplacedBuilders(element, rulesApi) {
617
713
  return [];
618
714
  const name = getName(attribute);
619
715
  const value = getExpression(attribute);
620
- if (!value || name === 'bindings' || name === 'states')
716
+ if (!value || name === 'bindings')
621
717
  return [];
622
718
  if (name === 'elementProps')
623
719
  return entryBuilders(value, rulesApi, '`elementProps`');
624
- if (!isReserved(name) && builderOf(value, rulesApi))
720
+ if (!isReservedAttribute(name) && isBindingValue(value, rulesApi))
625
721
  return [];
626
- return buildersIn(value, rulesApi).map((call) => [call, `\`${name}\``]);
722
+ const builders = name === 'className' && value.type === 'ArrayExpression' ? value.elements.flatMap((term) => conditionlessBuilders(term, rulesApi)) : buildersIn(value, rulesApi);
723
+ return builders.map((call) => [call, `\`${name}\``]);
627
724
  });
628
725
  }
629
726
  function entryBuilders(elementProps, rulesApi, where) {
@@ -643,12 +740,19 @@ function entryBuilders(elementProps, rulesApi, where) {
643
740
  return [];
644
741
  if (key === 'elementProps')
645
742
  return entryBuilders(property.value, rulesApi, `${name}'s \`elementProps\``);
646
- if (!RESERVED_ENTRY_KEYS.includes(key) && builderOf(property.value, rulesApi))
743
+ if (!RESERVED_ENTRY_KEYS.includes(key) && isBindingValue(bare(property.value), rulesApi))
647
744
  return [];
648
- return buildersIn(property.value, rulesApi).map((call) => [call, `${name}'s \`${key}\``]);
745
+ const builders = key === 'className' ? conditionlessBuilders(property.value, rulesApi) : buildersIn(property.value, rulesApi);
746
+ return builders.map((call) => [call, `${name}'s \`${key}\``]);
649
747
  });
650
748
  });
651
749
  }
750
+ /** The builders in a `className` term outside its keys' conditions: an object term's `key: value` holds a condition, the rest is plain (parseJsx.ts classNameEntriesOf). */
751
+ function conditionlessBuilders(term, rulesApi) {
752
+ if (term?.type !== 'ObjectExpression')
753
+ return buildersIn(term, rulesApi);
754
+ return term.properties.flatMap((property) => property.type === 'Property' && !property.computed && getKey(property) !== null ? [] : buildersIn(property, rulesApi));
755
+ }
652
756
  function buildersIn(node, rulesApi) {
653
757
  if (!node || typeof node !== 'object' || !('type' in node))
654
758
  return [];
@@ -666,6 +770,9 @@ function isInStructureAttribute(node) {
666
770
  for (let up = node.parent; up.type !== 'Program'; up = up.parent) {
667
771
  if (up.type === 'JSXAttribute')
668
772
  inAttribute = true;
773
+ // A renderItem template and a map() or shape() callback are read in the structure, in their parameters' scope.
774
+ if (up.type === 'ArrowFunctionExpression' && getCallbackKind(up))
775
+ continue;
669
776
  if (up.type === 'ArrowFunctionExpression' || up.type === 'FunctionExpression')
670
777
  return inAttribute && isStructure(up);
671
778
  }