@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
JavaScript
;
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, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>');
}
/**
* 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