UNPKG

@dhiwise/component-tagger

Version:

A plugin that automatically tags JSX components with data attributes for debugging and analytics

577 lines (569 loc) 19.3 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var parser = require('@babel/parser'); var core = require('@babel/core'); var path = require('path'); var MagicString = require('magic-string'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path); const REACT_STRUCTURAL_COMPONENTS = new Set([ 'Fragment', 'Suspense', 'StrictMode', 'Profiler', 'React.Fragment', ]); const THREE_JS_IMPORT_PATTERNS = [ 'three', '@react-three/fiber', '@react-three/drei', '@react-three/rapier', '@react-three/a11y', '@react-three/csg', '@react-three/flex', '@react-three/gpu-pathtracer', '@react-three/postprocessing', '@react-three/cannon', '@react-three/xr', '@react-three/test-renderer', 'three/addons', 'three/examples', ]; // Three.js Fiber elements const THREE_FIBER_ELEMENTS = [ 'object3D', 'audioListener', 'positionalAudio', 'mesh', 'batchedMesh', 'instancedMesh', 'scene', 'sprite', 'lOD', 'skinnedMesh', 'skeleton', 'bone', 'lineSegments', 'lineLoop', 'points', 'group', 'camera', 'perspectiveCamera', 'orthographicCamera', 'cubeCamera', 'arrayCamera', 'instancedBufferGeometry', 'bufferGeometry', 'boxBufferGeometry', 'circleBufferGeometry', 'coneBufferGeometry', 'cylinderBufferGeometry', 'dodecahedronBufferGeometry', 'extrudeBufferGeometry', 'icosahedronBufferGeometry', 'latheBufferGeometry', 'octahedronBufferGeometry', 'planeBufferGeometry', 'polyhedronBufferGeometry', 'ringBufferGeometry', 'shapeBufferGeometry', 'sphereBufferGeometry', 'tetrahedronBufferGeometry', 'torusBufferGeometry', 'torusKnotBufferGeometry', 'tubeBufferGeometry', 'wireframeGeometry', 'tetrahedronGeometry', 'octahedronGeometry', 'icosahedronGeometry', 'dodecahedronGeometry', 'polyhedronGeometry', 'tubeGeometry', 'torusKnotGeometry', 'torusGeometry', 'sphereGeometry', 'ringGeometry', 'planeGeometry', 'latheGeometry', 'shapeGeometry', 'extrudeGeometry', 'edgesGeometry', 'coneGeometry', 'cylinderGeometry', 'circleGeometry', 'boxGeometry', 'capsuleGeometry', 'material', 'shadowMaterial', 'spriteMaterial', 'rawShaderMaterial', 'shaderMaterial', 'pointsMaterial', 'meshPhysicalMaterial', 'meshStandardMaterial', 'meshPhongMaterial', 'meshToonMaterial', 'meshNormalMaterial', 'meshLambertMaterial', 'meshDepthMaterial', 'meshDistanceMaterial', 'meshBasicMaterial', 'meshMatcapMaterial', 'lineDashedMaterial', 'lineBasicMaterial', 'primitive', 'light', 'spotLightShadow', 'spotLight', 'pointLight', 'rectAreaLight', 'hemisphereLight', 'directionalLightShadow', 'directionalLight', 'ambientLight', 'lightShadow', 'ambientLightProbe', 'hemisphereLightProbe', 'lightProbe', 'spotLightHelper', 'skeletonHelper', 'pointLightHelper', 'hemisphereLightHelper', 'gridHelper', 'polarGridHelper', 'directionalLightHelper', 'cameraHelper', 'boxHelper', 'box3Helper', 'planeHelper', 'arrowHelper', 'axesHelper', 'texture', 'videoTexture', 'dataTexture', 'dataTexture3D', 'compressedTexture', 'cubeTexture', 'canvasTexture', 'depthTexture', 'raycaster', 'vector2', 'vector3', 'vector4', 'euler', 'matrix3', 'matrix4', 'quaternion', 'bufferAttribute', 'float16BufferAttribute', 'float32BufferAttribute', 'float64BufferAttribute', 'int8BufferAttribute', 'int16BufferAttribute', 'int32BufferAttribute', 'uint8BufferAttribute', 'uint16BufferAttribute', 'uint32BufferAttribute', 'instancedBufferAttribute', 'color', 'fog', 'fogExp2', 'shape', 'colorShiftMaterial', ]; /** * Extracts all identifiers imported from Three.js packages in the given AST * @param ast The parsed AST of the code * @returns Set of imported Three.js identifiers */ function getThreeJSImports(ast) { const threeJSImports = new Set(); for (const node of ast.program.body) { if (node.type === 'ImportDeclaration') { const importDeclaration = node; const source = importDeclaration.source.value; // Check if the import source matches any Three.js pattern const isThreeJSImport = THREE_JS_IMPORT_PATTERNS.some((pattern) => source === pattern || source.startsWith(pattern + '/')); if (isThreeJSImport) { for (const specifier of importDeclaration.specifiers) { if (specifier.type === 'ImportSpecifier') { // Named import: import { Box, Mesh } from '@react-three/drei' threeJSImports.add(specifier.local.name); } else if (specifier.type === 'ImportDefaultSpecifier') { // Default import: import Canvas from '@react-three/fiber' threeJSImports.add(specifier.local.name); } else if (specifier.type === 'ImportNamespaceSpecifier') { // Namespace import: import * as THREE from 'three' // We'll handle this as a special case where we need to check for THREE.* usage threeJSImports.add(`${specifier.local.name}.*`); } } } } } return threeJSImports; } /** * Determines if a file should be processed based on its extension and path * @param filePath The path of the file to check * @param options Plugin options * @returns Boolean indicating if the file should be processed */ function shouldProcessFile(filePath, options) { const extensions = options.extensions || ['.jsx', '.tsx', '.js', '.ts']; const ext = path__namespace.extname(filePath); const skipPatterns = ['.test.', '.spec.', '__tests__', '__mocks__']; const shouldSkip = skipPatterns.some((pattern) => filePath.includes(pattern)); return extensions.includes(ext) && !shouldSkip; } /** * Determines if an element should be tagged based on its name and options * @param elementName The name of the JSX element * @param options Plugin options * @param threeJSImports Set of imported Three.js identifiers * @returns Boolean indicating if the element should be tagged */ function shouldTagElement(elementName, options, threeJSImports) { if (options.includeElements && options.includeElements.length > 0) { return options.includeElements.includes(elementName); } if (options.shouldTag) { return options.shouldTag(elementName); } if (options.excludeElements && options.excludeElements.includes(elementName)) { return false; } if (REACT_STRUCTURAL_COMPONENTS.has(elementName)) { return false; } // Skip Three.js elements that are actually imported from Three.js packages if (threeJSImports && isThreeJSElement(elementName, threeJSImports)) { return false; } return true; } /** * Checks if an element name matches any imported Three.js identifier * @param elementName The JSX element name to check * @param threeJSImports Set of imported Three.js identifiers * @returns Boolean indicating if the element is from Three.js imports */ function isThreeJSElement(elementName, threeJSImports) { // Direct match for named/default imports if (threeJSImports.has(elementName)) { return true; } // Skip Three.js Fiber elements if (THREE_FIBER_ELEMENTS.includes(elementName)) { return true; } // Check for namespace imports (e.g., THREE.Box where THREE.* is imported) for (const importedIdentifier of threeJSImports) { if (importedIdentifier.endsWith('.*')) { const namespace = importedIdentifier.slice(0, -2); // Remove ".*" if (elementName.startsWith(`${namespace}.`)) { return true; } } } return false; } /** * Safely extracts a string value from a JSX attribute value * @param value The attribute value node * @returns The extracted string value or undefined */ function extractStringValue(value) { if (!value) return undefined; if (value.type === 'StringLiteral') { return value.value; } if (value.type === 'JSXExpressionContainer') { const expr = value.expression; if (expr.type === 'StringLiteral') { return expr.value; } if (expr.type === 'TemplateLiteral' && expr.quasis.length === 1) { return expr.quasis[0].value.raw; } if (expr.type === 'BinaryExpression' && expr.operator === '+') { const left = expr.left.type === 'StringLiteral' ? expr.left.value : ''; const right = expr.right.type === 'StringLiteral' ? expr.right.value : ''; return left + right; } if (expr.type === 'Identifier') { return `[var:${expr.name}]`; } } return undefined; } /** * Extract attributes from JSX opening element * @param node The JSX opening element node * @param options Plugin options * @returns Record of attribute names and values */ function extractAttributes(node, options, currentElement) { const attributes = {}; const defaultAttrs = [ 'className', 'id', 'src', 'alt', 'href', 'type', 'name', 'value', ]; const attrsToExtract = new Set([ ...defaultAttrs, ...(options.extractAttributes || []), ]); for (const attr of node.attributes) { if (attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier') { const name = attr.name.name; if (!attrsToExtract.has(name)) continue; if (attr.value) { const stringValue = extractStringValue(attr.value); if (stringValue !== undefined) { attributes[name] = stringValue; } } else { attributes[name] = 'true'; } } else if (attr.type === 'JSXSpreadAttribute') { attributes['[spread]'] = 'true'; } } let textContent = ''; if (currentElement && currentElement.children) { textContent = currentElement.children .map((child) => { if (child.type === 'JSXText') { return child.value.trim(); } else if (child.type === 'JSXExpressionContainer') { if (child.expression.type === 'StringLiteral') { return child.expression.value; } } return ''; }) .filter(Boolean) .join(' ') .trim(); } if (textContent) { attributes['textContent'] = textContent; } return attributes; } /** * Get element name from JSX opening element * @param jsxNode The JSX opening element node * @returns The element name as a string */ function getElementName(jsxNode) { const name = jsxNode.name; if (name.type === 'JSXIdentifier') { return name.name; } if (name.type === 'JSXMemberExpression') { // Handle nested expressions like Namespace.Component return getMemberExpressionName(name); } // if (name.type === 'JSXNamespacedName') { // return `${name.namespace.name}:${name.name.name}`; // } return 'Unknown'; } /** * Recursively builds the full name of a JSX member expression * @param expr The JSX member expression * @returns The full dotted name */ function getMemberExpressionName(expr) { // const propertyName = expr.property.name; /* else if (jsxNode.name.type === "JSXMemberExpression") { const memberExpr = jsxNode.name; elementName = `${(memberExpr.object as JSXIdentifier).name}.${(memberExpr.property as JSXIdentifier).name}`; } */ // if (expr.object.type === 'JSXMemberExpression') { // return `${expr.object as JSXIdentifier}` // return `${getMemberExpressionName(expr.object)}.${propertyName}`; // } // if (expr.object.type === 'JSXIdentifier') { // return `${expr.object.name}.${propertyName}`; // } const propertyName = `${expr.object.name}.${expr.property.name}`; return propertyName; } /** * Sanitizes a string for use in HTML attributes * @param str The string to sanitize * @returns The sanitized string */ function sanitizeAttributeValue(str) { return str .replace(/&/g, '&amp;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;'); } /** * Creates a unique component ID based on file path and location * @param filePath The relative file path * @param line The line number * @param column The column number * @returns A unique component ID string */ function createComponentId(filePath, line, column) { return `${filePath}:${line}:${column}`; } /** * Logs a message if verbose mode is enabled * @param message The message to log * @param options Plugin options */ function verboseLog(message, options) { if (options.verbose) { // eslint-disable-next-line no-console console.log(`[component-tagger] ${message}`); } } /** * Custom JSX Loader that adds tracking attributes to JSX elements * @param this - The webpack loader context * @param source - The source code to transform * @returns The transformed source code */ function webPackLoader(source) { // Get the current file path const filePath = this.resourcePath; const projectRoot = process.cwd(); const relativePath = path__namespace.relative(projectRoot, filePath); const fileName = path__namespace.basename(filePath); // Get options from loader query or use defaults const options = { extensions: ['.jsx', '.tsx', '.js', '.ts'], verbose: false, attributePrefix: 'data-component', includeContentAttribute: true, maxContentLength: 1000, includeLegacyAttributes: true, sourceMaps: true, excludeDirectories: [], processNodeModules: false, ...this.query, }; // Skip files that shouldn't be processed if (!shouldProcessFile(filePath, options)) { return source; } if (filePath.includes('node_modules') && !options.processNodeModules) { return source; } if (options.excludeDirectories && options.excludeDirectories.some((dir) => filePath.includes(dir))) { return source; } verboseLog(`Processing file: ${relativePath}`, options); try { const ast = parser.parse(source, { sourceType: 'module', plugins: ['jsx', 'typescript'], }); // Collect Three.js imports for this file const threeJSImports = getThreeJSImports(ast); const magicString = new MagicString(source); let changedElementsCount = 0; let currentElement = null; core.traverse(ast, { JSXElement(nodePath) { currentElement = nodePath.node; }, JSXOpeningElement(nodePath) { var _a, _b, _c, _d, _e, _f, _g; if (!currentElement) return; const jsxNode = nodePath.node; const elementName = getElementName(jsxNode); if (!shouldTagElement(elementName, options, threeJSImports)) { return; } const jsxAttributes = extractAttributes(jsxNode, options, currentElement); const content = { elementName }; Object.entries(jsxAttributes).forEach(([key, value]) => { Object.assign(content, { [key]: value }); }); const line = (_c = (_b = (_a = jsxNode.loc) === null || _a === void 0 ? void 0 : _a.start) === null || _b === void 0 ? void 0 : _b.line) !== null && _c !== void 0 ? _c : 0; const col = (_f = (_e = (_d = jsxNode.loc) === null || _d === void 0 ? void 0 : _d.start) === null || _e === void 0 ? void 0 : _e.column) !== null && _f !== void 0 ? _f : 0; const dataComponentId = options.generateComponentId ? options.generateComponentId(relativePath, line, col) : createComponentId(relativePath, line, col); let attributesToAdd = ` ${options.attributePrefix}-id="${sanitizeAttributeValue(dataComponentId)}"`; if (options.includeLegacyAttributes) { attributesToAdd += ` ${options.attributePrefix}-path="${sanitizeAttributeValue(relativePath)}"`; attributesToAdd += ` ${options.attributePrefix}-line="${line}"`; attributesToAdd += ` ${options.attributePrefix}-file="${sanitizeAttributeValue(fileName)}"`; attributesToAdd += ` ${options.attributePrefix}-name="${sanitizeAttributeValue(elementName)}"`; } if (options.includeContentAttribute) { const contentJson = JSON.stringify(content); let encodedContent = encodeURIComponent(contentJson); if (options.maxContentLength && encodedContent.length > options.maxContentLength) { encodedContent = encodedContent.substring(0, options.maxContentLength) + '...'; } attributesToAdd += ` ${options.attributePrefix}-content="${encodedContent}"`; } // Add attributes to the node const nameEnd = (_g = jsxNode.name.end) !== null && _g !== void 0 ? _g : 0; magicString.appendLeft(nameEnd, attributesToAdd); verboseLog(attributesToAdd.toString(), options); changedElementsCount++; }, }); if (changedElementsCount > 0) { verboseLog(`Tagged ${changedElementsCount} components in ${relativePath}`, options); return magicString.toString(); } return source; } catch (error) { verboseLog('Error in component-tagger loader:', options); verboseLog('File path:', options); if (error instanceof Error) { verboseLog('Error details:', options); } // Return original source on error to prevent build failures return source; } } exports.default = webPackLoader; //# sourceMappingURL=nextLoader.js.map