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

300 lines
13 KiB
JavaScript

import { DATA_COLLECTION_ITEM_FIELD, DATA_COLLECTION_ITEM_ID, EXCLUDED_FIELDS, } from "../consts.js";
import { JSXUtils } from "../jsx-utils.js";
import { JSXAttributeUtils, ExpressionAnalysisUtils, StaticValueUtils, } from "./utils/shared-utils.js";
export class DataItemFieldProcessor {
types;
attributeUtils;
expressionUtils;
staticUtils;
constructor(types) {
this.types = types;
this.attributeUtils = new JSXAttributeUtils(types);
this.expressionUtils = new ExpressionAnalysisUtils(types);
this.staticUtils = new StaticValueUtils(types);
}
process(path) {
if (this.attributeUtils.hasAttribute(path, DATA_COLLECTION_ITEM_FIELD)) {
return;
}
const jsxElement = path.parentPath;
if (!jsxElement?.isJSXElement())
return;
const result = this.extractFieldFromChildren(jsxElement);
if (!result)
return;
this.attributeUtils.addStringAttribute(path, DATA_COLLECTION_ITEM_FIELD, result.fieldPath);
this.tryAddItemId(path, result.rootIdentifier, result.fieldPath);
}
extractFieldFromChildren(jsxElement) {
const children = jsxElement.get("children");
for (const child of children) {
const result = this.extractFieldFromChild(child);
if (result)
return result;
}
return null;
}
extractFieldFromChild(child) {
if (child.isJSXExpressionContainer()) {
return this.extractFromExpressionContainer(child);
}
if (child.isJSXElement()) {
return this.extractFromChildElement(child);
}
return null;
}
extractFromExpressionContainer(container) {
const expression = container.get("expression");
if (expression.isJSXEmptyExpression())
return null;
const expr = expression;
if (expr.isMemberExpression() || expr.isOptionalMemberExpression()) {
return this.extractFromMemberExpression(expr);
}
if (expr.isLogicalExpression() && expr.node.operator === "&&") {
const left = expr.get("left");
if (left.isMemberExpression() ||
left.isOptionalMemberExpression()) {
return this.extractFromMemberExpression(left);
}
}
if (expr.isCallExpression()) {
const callee = expr.get("callee");
if (callee.isMemberExpression()) {
const obj = callee.get("object");
if (obj.isMemberExpression() || obj.isOptionalMemberExpression()) {
return this.extractFromMemberExpression(obj);
}
}
}
if (expr.isIdentifier()) {
return this.extractFromSimpleIdentifier(expr);
}
return null;
}
extractFromMemberExpression(expr) {
const rootId = this.expressionUtils.extractRootIdentifier(expr.node);
if (!rootId)
return null;
if (this.staticUtils.isDerivedFromStaticData(rootId.name, expr)) {
return null;
}
const parts = this.expressionUtils.collectMemberExpressionPath(expr.node);
if (parts.length < 2)
return null;
const fieldParts = parts.slice(1);
const fieldPath = fieldParts.join(".");
if (EXCLUDED_FIELDS.includes(fieldPath))
return null;
if (fieldParts.some((p) => EXCLUDED_FIELDS.includes(p)))
return null;
return { fieldPath, rootIdentifier: rootId };
}
extractFromSimpleIdentifier(expr) {
const name = expr.node.name;
if (EXCLUDED_FIELDS.includes(name))
return null;
if (this.staticUtils.isDerivedFromStaticData(name, expr))
return null;
return { fieldPath: name, rootIdentifier: expr.node };
}
extractFromChildElement(child) {
const elementName = JSXUtils.getElementName(child.node.openingElement);
if (!elementName)
return null;
if (elementName === "Image" || elementName === "img") {
return this.extractFromImageElement(child);
}
const logicalParent = child.parentPath;
if (logicalParent?.isJSXExpressionContainer()) {
const logicalExpr = logicalParent.parentPath;
if (logicalExpr?.isLogicalExpression()) {
return this.extractFromConditionalImage(child);
}
}
return null;
}
extractFromImageElement(imageElement) {
const openingElement = imageElement.get("openingElement");
const attrs = openingElement.node.attributes;
for (const attr of attrs) {
if (this.types.isJSXAttribute(attr) &&
this.types.isJSXIdentifier(attr.name) &&
attr.name.name === "src") {
if (attr.value &&
this.types.isJSXExpressionContainer(attr.value) &&
this.types.isExpression(attr.value.expression) &&
!this.types.isJSXEmptyExpression(attr.value.expression)) {
const expr = attr.value.expression;
if (this.types.isMemberExpression(expr) ||
this.types.isOptionalMemberExpression(expr)) {
const rootId = this.expressionUtils.extractRootIdentifier(expr);
if (!rootId)
return null;
const parts = this.expressionUtils.collectMemberExpressionPath(expr);
if (parts.length < 2)
return null;
const fieldPath = parts.slice(1).join(".");
if (EXCLUDED_FIELDS.includes(fieldPath))
return null;
return { fieldPath, rootIdentifier: rootId };
}
}
}
}
return null;
}
extractFromConditionalImage(child) {
const elementName = JSXUtils.getElementName(child.node.openingElement);
if (elementName === "Image" || elementName === "img") {
return this.extractFromImageElement(child);
}
return null;
}
tryAddItemId(path, rootIdentifier, fieldPath) {
if (this.attributeUtils.hasAttribute(path, DATA_COLLECTION_ITEM_ID)) {
return;
}
const idField = this.resolveIdFieldName(path);
if (rootIdentifier.name === fieldPath) {
this.tryAddItemIdFromDestructuredParams(path, idField);
return;
}
const idExpr = this.buildIdExpression(rootIdentifier.name, idField);
const binding = path.scope.getBinding(rootIdentifier.name);
if (!binding) {
this.tryAddItemIdFromParentComponent(path, rootIdentifier, idField);
return;
}
if (binding.path.isVariableDeclarator() ||
binding.kind === "param") {
this.attributeUtils.addExpressionAttribute(path, DATA_COLLECTION_ITEM_ID, idExpr);
}
}
/**
* Build the ID expression for a given root object name.
* If a specific idField was resolved from a key attribute, generates root?.id (or root?._id).
* If unknown (cross-file component), generates root?.id || root?._id to cover both conventions.
*/
buildIdExpression(rootName, idField) {
if (idField) {
return this.types.optionalMemberExpression(this.types.identifier(rootName), this.types.identifier(idField), false, true);
}
return this.types.logicalExpression("||", this.types.optionalMemberExpression(this.types.identifier(rootName), this.types.identifier("id"), false, true), this.types.optionalMemberExpression(this.types.identifier(rootName), this.types.identifier("_id"), false, true));
}
/**
* Walk up the JSX tree to find a key attribute that accesses .id or ._id.
* Returns null when no key is found (e.g. cross-file component receiving props).
*/
resolveIdFieldName(path) {
let current = path.parentPath;
while (current) {
if (current.isJSXElement()) {
const opening = current.get("openingElement");
const field = this.extractIdFieldFromKey(opening);
if (field)
return field;
}
current = current.parentPath;
}
return null;
}
extractIdFieldFromKey(path) {
for (const attr of path.node.attributes) {
if (this.types.isJSXAttribute(attr) &&
this.types.isJSXIdentifier(attr.name) &&
attr.name.name === "key" &&
attr.value &&
this.types.isJSXExpressionContainer(attr.value)) {
const expr = attr.value.expression;
if (this.types.isMemberExpression(expr) &&
this.types.isIdentifier(expr.property)) {
const name = expr.property.name;
if (name === "id" || name === "_id")
return name;
}
}
}
return null;
}
/**
* For simple identifiers from destructured component props like ({ name, price }),
* find or inject the id field in the ObjectPattern so we can reference it directly.
*/
tryAddItemIdFromDestructuredParams(path, idField) {
const fn = path.getFunctionParent();
if (!fn)
return;
const params = fn.get("params");
for (const param of Array.isArray(params) ? params : [params]) {
if (!param.isObjectPattern())
continue;
for (const prop of param.get("properties")) {
if (!prop.isObjectProperty())
continue;
const key = prop.node.key;
const val = prop.node.value;
// Reuse a forwarding param already injected by processRootElement
if (this.types.isStringLiteral(key) &&
key.value === DATA_COLLECTION_ITEM_ID &&
this.types.isIdentifier(val)) {
this.attributeUtils.addExpressionAttribute(path, DATA_COLLECTION_ITEM_ID, this.types.identifier(val.name));
return;
}
// Use an existing id / _id destructured prop
if (this.types.isIdentifier(key) &&
(key.name === "id" || key.name === "_id") &&
this.types.isIdentifier(val)) {
this.attributeUtils.addExpressionAttribute(path, DATA_COLLECTION_ITEM_ID, this.types.identifier(val.name));
return;
}
}
const fieldToInject = idField ?? "id";
const newProp = this.types.objectProperty(this.types.identifier(fieldToInject), this.types.identifier(fieldToInject), false, true);
const props = param.node.properties;
const lastProp = props[props.length - 1];
if (lastProp && this.types.isRestElement(lastProp)) {
props.splice(props.length - 1, 0, newProp);
}
else {
props.push(newProp);
}
this.attributeUtils.addExpressionAttribute(path, DATA_COLLECTION_ITEM_ID, this.types.identifier(fieldToInject));
return;
}
}
tryAddItemIdFromParentComponent(path, rootIdentifier, idField) {
let current = path.parentPath;
while (current) {
if (current.isJSXElement()) {
const opening = current.get("openingElement");
const name = JSXUtils.getElementName(opening.node);
if (name && JSXUtils.isCustomComponent(name)) {
const keyAttr = this.findKeyWithId(opening);
if (keyAttr) {
const idExpr = this.buildIdExpression(rootIdentifier.name, idField);
this.attributeUtils.addExpressionAttribute(path, DATA_COLLECTION_ITEM_ID, idExpr);
return;
}
}
}
current = current.parentPath;
}
}
findKeyWithId(path) {
for (const attr of path.node.attributes) {
if (this.types.isJSXAttribute(attr) &&
this.types.isJSXIdentifier(attr.name) &&
attr.name.name === "key" &&
attr.value &&
this.types.isJSXExpressionContainer(attr.value)) {
const expr = attr.value.expression;
if (this.types.isExpression(expr)) {
return this.expressionUtils.isIdAccess(expr);
}
}
}
return false;
}
}
//# sourceMappingURL=collection-item-field-processor.js.map