@wix/origami-lint 1.34.5 → 1.34.7

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
  }
@@ -2,7 +2,7 @@
2
2
  // 1227, 1231, 1235, 1255, 1268, 1289, 1697; projectData.ts 214, 225, 281, 435, 443; enrichImage.ts 94, 105.
3
3
  // Converter 0.81 (checked at 0.84, 2c0e21c): the tag's import, not a `type` attr, decides if a component takes data attributes; `type` is data.
4
4
  import { isDeepStrictEqual } from 'node:util';
5
- import { DATA_KEY, GLOBAL_SECTIONS, RESERVED_ATTRIBUTES, RESERVED_ENTRY_KEYS, SHARED_COMP_ID, bare, getAttribute, getExpression, getKey, getName, getProperties, getProperty, isOneEditAway, isStructure, ownerOf, } from "../rules-api/index.js";
5
+ import { DATA_KEY, EVENT_ATTRIBUTE, RESERVED_ATTRIBUTES, RESERVED_ENTRY_KEYS, bare, getAttribute, getExpression, getKey, getName, getObject, getProperties, getCallbackKind, getProperty, isBound, isOneEditAway, isReservedAttribute, isStructure, ownerOf, } from "../rules-api/index.js";
6
6
  import { defineRule } from "./define-rule.js";
7
7
  /** The names the import never reads as data (dataKeys.ts). The React name is joined as the converter does: Wix Standards fails the build on it in any file. */
8
8
  const EXCLUDED_DATA_KEYS = [
@@ -66,9 +66,9 @@ function checkValuesAreLiterals(input, skip) {
66
66
  continue;
67
67
  }
68
68
  const value = getExpression(attribute);
69
- if (!value || skip.includes(name) || (isReserved(name) && !READ_AS_DATA.includes(name)))
69
+ if (!value || skip.includes(name) || (isReservedAttribute(name) && !READ_AS_DATA.includes(name)))
70
70
  continue;
71
- if (!isReserved(name) && (isExcluded(name) || nearMissOf(name) || value.type === 'CallExpression'))
71
+ if (!isReservedAttribute(name) && (isExcluded(name) || nearMissOf(name) || isBound(value) || getCallbackKind(value) === 'template'))
72
72
  continue;
73
73
  const mode = name === 'props' ? PROPS : FULL;
74
74
  const found = name === 'elementProps' ? nonLiteralInElementProps(value, mode, input.rulesApi, textOf(input)) : nonLiteralIn(value, mode, input.rulesApi, textOf(input));
@@ -107,7 +107,7 @@ function checkElementPropsEntries(input, map, path) {
107
107
  for (const property of getProperties(map)) {
108
108
  const name = getKey(property);
109
109
  const at = [...path, name].join('.');
110
- if (!checkEntryName(input, property, name) || builderOf(bare(property.value), input.rulesApi))
110
+ if (!checkEntryName(input, property, name) || isRuntimeCall(bare(property.value), input.rulesApi))
111
111
  continue;
112
112
  // an object written out: a media call decodes to an object, but not to an inner part's settings
113
113
  const entry = getObject(property.value);
@@ -138,10 +138,10 @@ function checkEntryName(input, property, name) {
138
138
  function checkEntryKeysAreKnown(input, entry, at) {
139
139
  for (const property of getProperties(entry)) {
140
140
  const key = getKey(property);
141
- if (key === null || key === '_id' || RESERVED_ENTRY_KEYS.includes(key) || bare(property.value).type === 'CallExpression')
141
+ if (key === null || key === '_id' || RESERVED_ENTRY_KEYS.includes(key) || isBound(bare(property.value)))
142
142
  continue;
143
143
  const typo = nearMissOf(key);
144
- if (!isReserved(key) && !isExcluded(key) && !typo)
144
+ if (!isReservedAttribute(key) && !isExcluded(key) && !typo)
145
145
  continue;
146
146
  say(input, property.key, `${owner(input)}'s elementProps entry \`${at}\` has the key \`${key}\`, which an inner part doesn't take. Use its data keys, or \`data: {…}\`${typo ? ` (did you mean \`${typo}\`?)` : ''}.`);
147
147
  }
@@ -151,7 +151,7 @@ function checkDataCarrier(input) {
151
151
  const value = getExpression(data);
152
152
  if (!value)
153
153
  return null;
154
- const merges = dataAttributes(input).length > 0 || (takesData(input) && getAttribute(input.node, '_id') !== undefined);
154
+ const merges = dataAttributes(input).length > 0 || (input.rulesApi.hasDataAttributes(input.node) && getAttribute(input.node, '_id') !== undefined);
155
155
  if (!merges)
156
156
  return null;
157
157
  if (!isObjectValue(value, input.rulesApi)) {
@@ -286,34 +286,23 @@ function imageCallsIn(input, dataValuesRead, read) {
286
286
  if (value.type === 'ArrayExpression')
287
287
  return value.elements.flatMap((element) => (element && element.type !== 'SpreadElement' ? calls(element) : []));
288
288
  if (value.type === 'ObjectExpression')
289
- return getProperties(value).flatMap((property) => (builderOf(bare(property.value), input.rulesApi) ? [] : calls(property.value)));
289
+ return getProperties(value).flatMap((property) => (isRuntimeCall(bare(property.value), input.rulesApi) ? [] : calls(property.value)));
290
290
  return [];
291
291
  };
292
292
  return values.flatMap(calls);
293
293
  }
294
294
  // ── reading the code ──
295
295
  const getAttributes = (element) => element.openingElement.attributes.filter((attribute) => attribute.type === 'JSXAttribute');
296
- const getObject = (node) => (node && bare(node).type === 'ObjectExpression' ? bare(node) : null);
297
- /** The tags the import reads as the DM's own components ('document') or a global component's reference (identity.ts identityOfTag): no data attributes. */
298
- const NO_DATA_TYPES = ['page', 'section', 'header', 'footer', 'headerLandmark', 'footerLandmark', ...GLOBAL_SECTIONS];
299
- /** Converter 0.81: a builder component (its import gives a componentType) takes data attributes; the `type` attr is gone. */
300
- function takesData({ node, rulesApi }) {
301
- const found = rulesApi.getImport(node);
302
- const type = rulesApi.getType(node);
303
- return found !== null && found.kind !== 'core' && !found.source.startsWith('.') && !(type && NO_DATA_TYPES.includes(type));
304
- }
305
296
  /** The name the import gives the tag's component (parseJsx.ts namedImportOf): the imported name, else the tag's own (`<Box>`, `<ui.Box>`). */
306
297
  function componentNameOf({ node, rulesApi }) {
307
298
  const imported = rulesApi.getImport(node)?.imported;
308
299
  return imported && imported !== 'default' && imported !== '*' ? imported : rulesApi.getTagName(node).split('.').at(-1);
309
300
  }
310
- /** Not the component's data: the converter's reserved attributes, and `sharedCompId`. */
311
- const isReserved = (name) => RESERVED_ATTRIBUTES.includes(name) || name === SHARED_COMP_ID;
312
- const isDataName = (name) => !isReserved(name) && !isExcluded(name) && nearMissOf(name) === undefined;
313
- const isExcluded = (name) => EXCLUDED_DATA_KEYS.includes(name) || name.startsWith('data-') || name.startsWith('aria-') || /^on/i.test(name) || !DATA_KEY.test(name);
301
+ const isDataName = (name) => !isReservedAttribute(name) && !isExcluded(name) && nearMissOf(name) === undefined;
302
+ const isExcluded = (name) => EXCLUDED_DATA_KEYS.includes(name) || name.startsWith('data-') || name.startsWith('aria-') || EVENT_ATTRIBUTE.test(name) || !DATA_KEY.test(name);
314
303
  const nearMissOf = (name) => RESERVED_ATTRIBUTES.find((target) => isOneEditAway(name, target));
315
- const dataAttributes = (input) => !takesData(input) ? [] : getAttributes(input.node).filter((attribute) => isDataName(getName(attribute)) && getExpression(attribute)?.type !== 'CallExpression');
316
- const entryDataKeys = (entry) => getProperties(entry).filter((property) => getKey(property) !== null && !RESERVED_ENTRY_KEYS.includes(getKey(property)) && bare(property.value).type !== 'CallExpression');
304
+ const dataAttributes = (input) => !input.rulesApi.hasDataAttributes(input.node) ? [] : getAttributes(input.node).filter((attribute) => isDataName(getName(attribute)) && !isBound(getExpression(attribute)) && getCallbackKind(getExpression(attribute) ?? attribute) !== 'template');
305
+ const entryDataKeys = (entry) => getProperties(entry).filter((property) => getKey(property) !== null && !RESERVED_ENTRY_KEYS.includes(getKey(property)) && !isBound(bare(property.value)));
317
306
  /** The attribute a data value's path starts at: `data.x` → `data`, `elementProps.nav.label` → `elementProps`. */
318
307
  const attributeOf = (path) => path.split('.')[0];
319
308
  function getEntries(map, path = []) {
@@ -357,7 +346,8 @@ function documentIdentifierOf(node, rulesApi) {
357
346
  }
358
347
  /** What `x` decodes to is an object: an object written out, or a media call (the import keeps it as an object). */
359
348
  const isObjectValue = (node, rulesApi) => bare(node).type === 'ObjectExpression' || isImageCall(bare(node), rulesApi) || documentIdentifierOf(bare(node), rulesApi) !== null;
360
- function builderOf(node, rulesApi) {
349
+ /** A call of any name from the runtime, a builder or not: the bindings rule reports one that isn't a builder. */
350
+ function isRuntimeCall(node, rulesApi) {
361
351
  return node.type === 'CallExpression' && node.callee.type === 'Identifier' && rulesApi.getImport(node.callee)?.kind === 'runtime';
362
352
  }
363
353
  function nonLiteralIn(node, mode, rulesApi, getText, path = '') {
@@ -433,7 +423,7 @@ function nonLiteralInElementProps(node, mode, rulesApi, getText, path = '') {
433
423
  const readEntry = (property, at) => {
434
424
  const key = getKey(property);
435
425
  const value = bare(property.value);
436
- if (key === 'bindings' || key === 'slots' || key === 'className' || (!RESERVED_ENTRY_KEYS.includes(key) && value.type === 'CallExpression'))
426
+ if (key === 'bindings' || key === 'slots' || key === 'className' || (!RESERVED_ENTRY_KEYS.includes(key) && isBound(value)))
437
427
  return null;
438
428
  return key === 'elementProps' ? nonLiteralInElementProps(value, mode, rulesApi, getText, at) : nonLiteralIn(value, mode, rulesApi, getText, at);
439
429
  };
@@ -499,10 +489,11 @@ function dataValues(input) {
499
489
  }
500
490
  return values;
501
491
  }
492
+ /** In Structure's own JSX, or in a renderItem template, which the import reads in the row's scope (parseJsx.ts claimSlotChild). */
502
493
  function isWrittenOut(element) {
503
494
  for (let up = element.parent; up.type !== 'JSXElement' && up.type !== 'Program'; up = up.parent) {
504
495
  if (up.type === 'ArrowFunctionExpression')
505
- return isStructure(up);
496
+ return isStructure(up) || getCallbackKind(up) === 'template';
506
497
  if (NOT_READ_IN.includes(up.type) || (up.type === 'JSXExpressionContainer' && up.parent.type !== 'JSXAttribute'))
507
498
  return false;
508
499
  }
@@ -1,6 +1,6 @@
1
1
  // Part 3 — rules API: one linted file's page and the functions over it, and how a rule reports.
2
2
  export { createRulesApi, getOrCreateRulesApi } from "./rules-api.js";
3
3
  export { createCssFileReporter, describeAtRule, reportCssProblem } from "./report.js";
4
- export { bare, getAttribute, getExpression, getKey, getName, getProperties, getProperty, isOneEditAway, isStructure, ownerOf, } from "../shared/utils.js";
5
- export { BUILDERS, DATA_KEY, GLOBAL_SECTIONS, INSTANCE_PROPS, LAYER_ATTRIBUTES, RESERVED_ATTRIBUTES, RESERVED_ENTRY_KEYS, RUNTIME_ONLY_BUILDERS, RUNTIME_PACKAGE, SECTIONS, SHARED_COMP_ID, STRUCTURE, SYSTEM_PACKAGE, WRAPPERS, } from "../shared/constants.js";
4
+ export { bare, getAttribute, getExpression, getKey, getName, getObject, getProperties, getCallbackKind, getParamReadKind, getProperty, isBound, isOneEditAway, isReservedAttribute, isStructure, ownerOf, } from "../shared/utils.js";
5
+ export { BUILDERS, CALLBACK_PROP, DATA_KEY, EVENT_ATTRIBUTE, GLOBAL_SECTIONS, INSTANCE_PROPS, LAYER_ATTRIBUTES, RESERVED_ATTRIBUTES, RESERVED_ENTRY_KEYS, RUNTIME_ONLY_BUILDERS, RUNTIME_PACKAGE, SECTIONS, SHARED_COMP_ID, STRUCTURE, SYSTEM_PACKAGE, TRANSFORM_BUILDERS, TYPED_BUILDERS, WRAPPERS, } from "../shared/constants.js";
6
6
  export { BREAKPOINT_AT_RULES, CONTENT_TARGET, getCssChildren, getDashedId, getSegmentClass, getSelectorSegments, isCssIdentifier, isPresetProperty, isSafeCssIdentifier, ORIGAMI_TARGETS, OVERFLOW_TARGET, PREAMBLE_LAYER, readSegment } from "../shared/css.js";
@@ -3,7 +3,7 @@
3
3
  // One per file, shared by every rule on it. Only building its page reads from disk, once per file.
4
4
  import path from 'node:path';
5
5
  import { readPage } from "../page-model/index.js";
6
- import { getAttributeElements as getHeldElements, getChildren, getId, getImport, getImportKind, getImportedName, getPropNames, getSlot, getTagName, getType, isTypeScript } from "../shared/utils.js";
6
+ import { getAttributeElements as getHeldElements, getChildren, getId, getImport, getImportKind, getImportedName, getPropNames, getSlot, getTagName, getType, hasDataAttributes, isTypeScript } from "../shared/utils.js";
7
7
  import { readBreakpointName, readCustomMedia, readThemeTokens } from "../shared/css.js";
8
8
  import { WRAPPERS } from "../shared/constants.js";
9
9
  import { getEnclosingAtRules, isBaseDeclaration } from "./utils.js";
@@ -44,6 +44,7 @@ export function createRulesApi(page) {
44
44
  getAttributeElements,
45
45
  getSlot,
46
46
  getImport: (node) => getImport(node.type === 'JSXElement' ? getTagName(node).split('.')[0] : node.name, getProgram(node)),
47
+ hasDataAttributes: (node) => hasDataAttributes(node, getProgram(node)),
47
48
  getImportKind,
48
49
  getImportedName,
49
50
  isTypeScript,
@@ -12,10 +12,14 @@ export const INSTANCE_PROPS = ['id', 'className'];
12
12
  export const SYSTEM_PACKAGE = '@wix/origami-system-components';
13
13
  /** The runtime package: the builders (`ctx`, `fn`…) and the `<Remove>` wrapper. */
14
14
  export const RUNTIME_PACKAGE = '@wix/origami-runtime';
15
- /** The runtime's builders: the converter's BINDINGS_BUILDER_ORDER without `Remove`, which is a tag (reservedAttributes.ts). */
15
+ /** The typed builders (converter 0.76, PRIMITIVE_DATA_TYPES): `email('a@b.c')` is a fixed value of that type, like a literal. */
16
+ export const TYPED_BUILDERS = ['textEnum', 'phone', 'email', 'webUrl', 'hostname', 'regex', 'guid', 'direction', 'localDate', 'localTime', 'localDateTime', 'dateTime'];
17
+ /** The builders whose second argument is a callback, `map(source, (item) => ({…}))` (astValue.ts TRANSFORM_CALLBACKS). */
18
+ export const TRANSFORM_BUILDERS = ['map', 'shape'];
19
+ /** The runtime's builders: the converter's BINDINGS_BUILDER_ORDER without `Remove`, which is a tag (reservedAttributes.ts); `map`, `shape` and `callback` since converter 0.82–0.86. */
16
20
  export const BUILDERS = [
17
- 'all', 'ctx', 'fn', 'is', 'raw', 'state',
18
- 'textEnum', 'phone', 'email', 'webUrl', 'hostname', 'regex', 'guid', 'direction', 'localDate', 'localTime', 'localDateTime', 'dateTime',
21
+ 'all', 'ctx', 'fn', 'is', 'raw', 'state', 'map', 'shape', 'callback',
22
+ ...TYPED_BUILDERS,
19
23
  ];
20
24
  /** The six builders no other package exports under these names; `email`, `regex`… may well come from a library for fn(). */
21
25
  export const RUNTIME_ONLY_BUILDERS = ['all', 'ctx', 'fn', 'is', 'raw', 'state'];
@@ -29,5 +33,9 @@ export const SHARED_COMP_ID = 'sharedCompId';
29
33
  export const RESERVED_ENTRY_KEYS = ['data', 'slots', 'elementProps', 'bindings', 'className'];
30
34
  /** A data key is a JavaScript name (dataKeys.ts). */
31
35
  export const DATA_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
36
+ /** An attribute the import reads as a React event, never as data: `on` in any case (dataKeys.ts REACT_EVENT_ATTR_PATTERN, converter 0.87). */
37
+ export const EVENT_ATTRIBUTE = /^on/i;
38
+ /** A callback prop, which the runtime hands a function: `on` and a capital letter, `onClick` but not `onclick` (the runtime's withOrigami.tsx). */
39
+ export const CALLBACK_PROP = /^on[A-Z]/;
32
40
  /** By import source; the wrappers also by the imported name, since their packages export other things (`Show`, `Interact`). */
33
41
  export const LAYER_ATTRIBUTES = ['backgroundLayer', 'pinnedLayer'];
@@ -1,4 +1,4 @@
1
- import { RUNTIME_PACKAGE, STRUCTURE, SYSTEM_PACKAGE } from "./constants.js";
1
+ import { GLOBAL_SECTIONS, RESERVED_ATTRIBUTES, RUNTIME_PACKAGE, SHARED_COMP_ID, STRUCTURE, SYSTEM_PACKAGE, TRANSFORM_BUILDERS } from "./constants.js";
2
2
  import { GLOBAL_COMPONENTS, GLOBAL_MENUS } from "./site-files-tree-mapping.js";
3
3
  const TYPE_BY_IMPORT_SOURCE = [
4
4
  [new RegExp(`^${SYSTEM_PACKAGE}/(?:layout/)?Page$`), 'page'],
@@ -24,6 +24,8 @@ const KIND_BY_IMPORT_SOURCE = [
24
24
  [new RegExp(`^(\\.\\.?/)*${GLOBAL_MENUS}(/|$)`), 'menu'],
25
25
  [/^document$/, 'core'],
26
26
  [/^document\//, 'document'],
27
+ [/^@origami-wrappers\//, 'wrapper'],
28
+ [/^@site\/site-code\/[^/]+$/, 'siteCode'],
27
29
  ];
28
30
  /** <Band> with `import { Section as Band } from '@wix/origami-system-components/Section'` → 'section'. `<Band.Item>` is Band's. A wrapper is the named export: `<Show>` from the runtime is null. */
29
31
  export function getType(element, program) {
@@ -34,6 +36,15 @@ export function getType(element, program) {
34
36
  const [, type, imported] = entry;
35
37
  return imported === undefined || found.imported === imported ? type : null;
36
38
  }
39
+ /** The tags the import reads as the DM's own components ('document') or a global component's reference (identity.ts identityOfTag): no data attributes. */
40
+ const NO_DATA_TYPES = ['page', 'section', 'header', 'footer', 'headerLandmark', 'footerLandmark', ...GLOBAL_SECTIONS];
41
+ /** Converter 0.81: are the tag's attributes saved as its component's data? Yes on a builder component (its import gives a componentType):
42
+ * imported, not from `document` and not from a local file. No on the DM's own tags (`Page`, the sections) and a global component's reference. */
43
+ export function hasDataAttributes(element, program) {
44
+ const found = getImport(getTagName(element).split('.')[0], program);
45
+ const type = getType(element, program);
46
+ return found !== null && found.kind !== 'core' && !found.source.startsWith('.') && !(type && NO_DATA_TYPES.includes(type));
47
+ }
37
48
  /** What an import's source is: 'runtime' for @wix/origami-runtime, 'interact' for @wix/interact/…; null for anything else. */
38
49
  export const getImportKind = (source) => KIND_BY_IMPORT_SOURCE.find(([pattern]) => pattern.test(source))?.[1] ?? null;
39
50
  /** Where a name of the file comes from; null when no import brings it in. */
@@ -111,6 +122,15 @@ export function getExpression(attribute) {
111
122
  return null;
112
123
  return bare(value.expression);
113
124
  }
125
+ /** The object written out in a value, in parentheses or in an attribute's `{…}`: `x={{ a: 1 }}` → `{ a: 1 }`; null for any other value. */
126
+ export function getObject(node) {
127
+ if (!node)
128
+ return null;
129
+ const value = node.type === 'JSXAttribute' ? getExpression(node) : bare(node);
130
+ return value?.type === 'ObjectExpression' ? value : null;
131
+ }
132
+ /** Not the component's data: the converter's reserved attributes, and `sharedCompId` (reservedAttributes.ts). */
133
+ export const isReservedAttribute = (name) => RESERVED_ATTRIBUTES.includes(name) || name === SHARED_COMP_ID;
114
134
  /** The node inside its parentheses: `((x))` → `x`. */
115
135
  export function bare(node) {
116
136
  return node.type === 'ParenthesizedExpression' ? bare(node.expression) : node;
@@ -131,6 +151,52 @@ export function isStructure(node) {
131
151
  return declarator?.type === 'VariableDeclarator' && declarator.id?.type === 'Identifier' && declarator.id.name === STRUCTURE
132
152
  && declarator.parent?.parent?.type === 'ExportNamedDeclaration' && declarator.parent.parent.parent?.type === 'Program';
133
153
  }
154
+ /** The node's parent, past parentheses, and its child on the way up: the node itself, or the parentheses around it. */
155
+ function getOuter(node) {
156
+ let child = node;
157
+ while (child.parent?.type === 'ParenthesizedExpression')
158
+ child = child.parent;
159
+ return [child.parent, child];
160
+ }
161
+ /** The callbacks whose parameters the import reads as bindings: 'template' for a slot's template, `renderItem={(item, index, length) => <X />}`, an attribute's
162
+ * arrow that returns one element (parseJsx.ts templateCallbackOf); 'transform' for `map(source, (item, index) => ({…}))` and `shape(source, (obj) => ({…}))`
163
+ * (astValue.ts transformCallbackOf). null for any other node. */
164
+ export function getCallbackKind(syntax) {
165
+ const node = syntax;
166
+ if (node.type !== 'ArrowFunctionExpression')
167
+ return null;
168
+ let body = node.body;
169
+ while (body?.type === 'ParenthesizedExpression')
170
+ body = body.expression;
171
+ const [outer, child] = getOuter(node);
172
+ if (body?.type === 'JSXElement' && outer?.type === 'JSXExpressionContainer' && outer.parent?.type === 'JSXAttribute')
173
+ return 'template';
174
+ const isTransform = outer?.type === 'CallExpression' && outer.callee?.type === 'Identifier' && TRANSFORM_BUILDERS.includes(outer.callee.name) && outer.arguments?.[1] === child;
175
+ return isTransform ? 'transform' : null;
176
+ }
177
+ /** A read of a callback's parameter, `item`, `item.name`, `item['link-1']`, which the import reads as a binding (astValue.ts templateReadOf):
178
+ * 'template' off a renderItem's parameter, 'transform' off a map() or shape() callback's. null for any other value, or inside any other function. */
179
+ export function getParamReadKind(syntax) {
180
+ const node = syntax;
181
+ let root = node;
182
+ while (root.type === 'MemberExpression' && root.object)
183
+ root = root.object;
184
+ if (root.type !== 'Identifier')
185
+ return null;
186
+ for (let up = node.parent; up && up.type !== 'Program'; up = up.parent) {
187
+ if (up.type !== 'ArrowFunctionExpression' && up.type !== 'FunctionExpression')
188
+ continue;
189
+ const kind = getCallbackKind(up);
190
+ if (!kind)
191
+ return null;
192
+ if (up.params?.some((param) => param.type === 'Identifier' && param.name === root.name))
193
+ return kind;
194
+ }
195
+ return null;
196
+ }
197
+ /** A call, or a read of a renderItem / map() parameter (`item.name`): the import reads it as a binding, not as data (parseJsx.ts takeBoundProp).
198
+ * Any call: the bindings rule reports one that isn't a builder. */
199
+ export const isBound = (value) => !!value && (value.type === 'CallExpression' || getParamReadKind(value) !== null);
134
200
  /** A component in a message: `<Image>` img, or `<Image>` when it has no id. */
135
201
  export function ownerOf(element) {
136
202
  const id = getId(element);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/origami-lint",
3
- "version": "1.34.5",
3
+ "version": "1.34.7",
4
4
  "description": "Offline lint for an exported Origami site: lints the pages that changed since the last passing run",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "test": "node --test 'test/*.test.ts'"
22
22
  },
23
23
  "dependencies": {
24
- "@wix/origami-fonts": "1.0.1",
24
+ "@wix/origami-fonts": "1.0.2",
25
25
  "oxc-parser": "^0.151.0",
26
26
  "oxlint": "^1.39.0",
27
27
  "postcss": "^8.5.3"
@@ -51,5 +51,5 @@
51
51
  ]
52
52
  }
53
53
  },
54
- "falconPackageHash": "7e83272a42478bff6871d3e1a9841d447d7f2446e79f08856a87192d"
54
+ "falconPackageHash": "0f6cfa38e9a6a4139df372764355a226553e82d331beca19c870ed7b"
55
55
  }