Files
2026-06-24 09:48:54 +02:00

177 lines
7.5 KiB
JavaScript

import { DATA_COLLECTION_ITEM_ID } from "../consts.js";
import { JSXUtils } from "../jsx-utils.js";
import { JSXAttributeUtils, ExpressionAnalysisUtils, PathNavigationUtils, } from "./utils/shared-utils.js";
const FORWARDING_PARAM_NAME = "__dataCollectionItemId";
export class DataItemIdProcessor {
types;
attributeUtils;
expressionUtils;
pathUtils;
constructor(types) {
this.types = types;
this.attributeUtils = new JSXAttributeUtils(types);
this.expressionUtils = new ExpressionAnalysisUtils(types);
this.pathUtils = new PathNavigationUtils(types);
}
process(path) {
if (this.attributeUtils.hasAttribute(path, DATA_COLLECTION_ITEM_ID)) {
return;
}
const keyAttr = this.findKeyAttribute(path);
if (!keyAttr)
return;
const expression = this.extractKeyExpression(keyAttr);
if (!expression)
return;
if (!this.expressionUtils.isIdAccess(expression))
return;
const optionalExpr = this.types.isMemberExpression(expression)
? this.expressionUtils.createOptionalChainExpression(expression)
: expression;
this.attributeUtils.addExpressionAttribute(path, DATA_COLLECTION_ITEM_ID, optionalExpr);
}
/**
* Called for every root DOM element of a component. Adds a `data-collection-item-id`
* forwarding attribute so that when the component is used as a collection item
* (with `data-collection-item-id` passed as a prop from a parent `.map()`), the
* attribute flows through to the actual DOM element.
*
* Skipped when:
* - The root element already spreads props (e.g. `{...props}`) — forwarding is automatic
* - The enclosing function maps over a collection — it's a container, not an item
*/
processRootElement(path) {
if (this.attributeUtils.hasAttribute(path, DATA_COLLECTION_ITEM_ID))
return;
// Only DOM or allowed custom elements (e.g. motion.div)
const name = JSXUtils.getElementName(path.node);
if (!name || !JSXUtils.isDOMOrAllowed(name))
return;
// Only the root return element of a component function
if (!this.pathUtils.isRootReturnElement(path))
return;
// Skip if root element already spreads all props — forwarding is handled naturally
if (this.hasSpreadAttribute(path))
return;
const fn = this.pathUtils.findEnclosingFunction(path);
if (!fn)
return;
// Skip if this function IS a .map()/.flatMap() callback — it's an iteration
// function, not a component (e.g. [...Array(3)].map((_, i) => <div />))
if (this.isMapCallbackFunction(fn))
return;
// Skip if this function maps over a collection — it's a container, not an item
if (this.functionContainsMapCall(fn))
return;
const expr = this.buildCollectionItemIdExpression(fn);
if (!expr)
return;
this.attributeUtils.addExpressionAttribute(path, DATA_COLLECTION_ITEM_ID, expr);
}
hasSpreadAttribute(path) {
return path.node.attributes.some((attr) => this.types.isJSXSpreadAttribute(attr));
}
/**
* Returns true when `fn` is a direct callback argument to a `.map()` or
* `.flatMap()` call — i.e. the function is an iteration callback, not a
* component definition. This prevents injecting prop-forwarding into
* patterns like `[...Array(3)].map((_, i) => <div />)` where the first
* param may be `undefined`.
*/
isMapCallbackFunction(fn) {
const parent = fn.parentPath;
if (!parent?.isCallExpression())
return false;
const callee = parent.node.callee;
return (this.types.isMemberExpression(callee) &&
this.types.isIdentifier(callee.property) &&
(callee.property.name === "map" || callee.property.name === "flatMap"));
}
functionContainsMapCall(fn) {
let found = false;
const types = this.types;
fn.traverse({
// Stop at nested functions — event handlers / callbacks defined inside the
// component should not cause it to be treated as a collection container.
Function(innerFnPath) {
innerFnPath.skip();
},
CallExpression(callPath) {
if (found) {
callPath.stop();
return;
}
const callee = callPath.node.callee;
if (types.isMemberExpression(callee) &&
types.isIdentifier(callee.property) &&
(callee.property.name === "map" || callee.property.name === "flatMap")) {
found = true;
}
},
});
return found;
}
/**
* Returns an expression for reading `data-collection-item-id` from the component's
* props, modifying the function's params destructuring if necessary.
*/
buildCollectionItemIdExpression(fn) {
const params = fn.get("params");
const firstParam = Array.isArray(params) ? params[0] : params;
if (!firstParam)
return null;
if (firstParam.isObjectPattern()) {
// Check if already destructured (key must be a string literal — hyphens in the
// attribute name rule out a plain identifier)
for (const prop of firstParam.get("properties")) {
if (!prop.isObjectProperty())
continue;
const key = prop.node.key;
const val = prop.node.value;
if (this.types.isStringLiteral(key) &&
key.value === DATA_COLLECTION_ITEM_ID &&
this.types.isIdentifier(val)) {
return this.types.identifier(val.name);
}
}
// Add "data-collection-item-id": __dataCollectionItemId to destructuring
const newProp = this.types.objectProperty(this.types.stringLiteral(DATA_COLLECTION_ITEM_ID), this.types.identifier(FORWARDING_PARAM_NAME));
const restIdx = firstParam.node.properties.findIndex((p) => this.types.isRestElement(p));
if (restIdx === -1) {
firstParam.node.properties.push(newProp);
}
else {
firstParam.node.properties.splice(restIdx, 0, newProp);
}
return this.types.identifier(FORWARDING_PARAM_NAME);
}
if (firstParam.isIdentifier()) {
// props?.["data-collection-item-id"] — optional access in case param is nullish
return this.types.optionalMemberExpression(this.types.identifier(firstParam.node.name), this.types.stringLiteral(DATA_COLLECTION_ITEM_ID), true, // computed
true // optional
);
}
return null;
}
findKeyAttribute(path) {
for (const attr of path.node.attributes) {
if (this.types.isJSXAttribute(attr) &&
this.types.isJSXIdentifier(attr.name) &&
attr.name.name === "key") {
return attr;
}
}
return null;
}
extractKeyExpression(attr) {
if (!attr.value)
return null;
if (this.types.isJSXExpressionContainer(attr.value)) {
const expr = attr.value.expression;
if (this.types.isExpression(expr))
return expr;
}
return null;
}
}
//# sourceMappingURL=collection-item-id-processor.js.map