@dhiwise/component-tagger
Version:
A plugin that automatically tags JSX components with data attributes for debugging and analytics
382 lines (374 loc) • 14.4 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
var path = require('path');
var parser = require('@babel/parser');
var core = require('@babel/core');
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',
]);
/**
* 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
* @returns Boolean indicating if the element should be tagged
*/
function shouldTagElement(elementName, options) {
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;
}
return true;
}
/**
* 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}`);
}
}
/**
* Generates a summary of the tagging operation
* @param stats The statistics object
* @returns A formatted string with the summary
*/
function generateSummary(stats) {
return `Component Tagger Summary:
- Total files scanned: ${stats.totalFiles}
- Files processed: ${stats.processedFiles}
- Elements tagged: ${stats.totalElements}`;
}
/**
* Creates a Vite plugin that tags JSX components with data attributes
* @param options Configuration options for the component tagger
* @returns A Vite plugin
*/
function createVitePlugin(options = {}) {
const pluginOptions = {
extensions: ['.jsx', '.tsx', '.js', '.ts'],
verbose: false,
attributePrefix: 'data-component',
includeContentAttribute: true,
maxContentLength: 1000,
includeLegacyAttributes: true,
sourceMaps: true,
excludeDirectories: [],
processNodeModules: false,
...options,
};
const cwd = process.cwd();
const stats = {
totalFiles: 0,
processedFiles: 0,
totalElements: 0,
};
return {
name: 'vite-plugin-component-tagger',
enforce: 'pre',
transform: async (code, id) => {
if (!shouldProcessFile(id, pluginOptions)) {
return null;
}
if (id.includes('node_modules') && !pluginOptions.processNodeModules) {
return null;
}
if (pluginOptions.excludeDirectories &&
pluginOptions.excludeDirectories.some((dir) => id.includes(dir))) {
return null;
}
stats.totalFiles++;
const relativePath = path__namespace.relative(cwd, id);
verboseLog(`Processing file: ${relativePath}`, pluginOptions);
try {
const parserOptions = {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
};
const ast = parser.parse(code, parserOptions);
const magicString = new MagicString(code);
let changedElementsCount = 0;
const fileName = path__namespace.basename(id);
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, pluginOptions)) {
return;
}
const jsxAttributes = extractAttributes(jsxNode, pluginOptions, currentElement);
const content = { elementName };
Object.entries(jsxAttributes).forEach(([key, value]) => {
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 = pluginOptions.generateComponentId
? pluginOptions.generateComponentId(relativePath, line, col)
: createComponentId(relativePath, line, col);
let attributesToAdd = ` ${pluginOptions.attributePrefix}-id="${sanitizeAttributeValue(dataComponentId)}"`;
if (pluginOptions.includeLegacyAttributes) {
attributesToAdd += ` ${pluginOptions.attributePrefix}-path="${sanitizeAttributeValue(relativePath)}"`;
attributesToAdd += ` ${pluginOptions.attributePrefix}-line="${line}"`;
attributesToAdd += ` ${pluginOptions.attributePrefix}-file="${sanitizeAttributeValue(fileName)}"`;
attributesToAdd += ` ${pluginOptions.attributePrefix}-name="${sanitizeAttributeValue(elementName)}"`;
}
if (pluginOptions.includeContentAttribute) {
const contentJson = JSON.stringify(content);
let encodedContent = encodeURIComponent(contentJson);
if (pluginOptions.maxContentLength &&
encodedContent.length > pluginOptions.maxContentLength) {
encodedContent =
encodedContent.substring(0, pluginOptions.maxContentLength) +
'...';
}
attributesToAdd += ` ${pluginOptions.attributePrefix}-content="${encodedContent}"`;
}
magicString.appendLeft((_g = jsxNode.name.end) !== null && _g !== void 0 ? _g : 0, attributesToAdd);
verboseLog(attributesToAdd.toString(), pluginOptions);
changedElementsCount++;
},
});
stats.processedFiles++;
stats.totalElements += changedElementsCount;
if (changedElementsCount > 0) {
verboseLog(`Tagged ${changedElementsCount} components in ${relativePath}`, pluginOptions);
}
if (changedElementsCount > 0) {
return {
code: magicString.toString(),
map: pluginOptions.sourceMaps
? magicString.generateMap({ hires: true })
: null,
};
}
return null;
}
catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing file ${relativePath}:`, error);
stats.processedFiles++;
return null;
}
},
buildStart: () => {
verboseLog('Component tagger plugin started', pluginOptions);
},
buildEnd: () => {
verboseLog(generateSummary(stats), pluginOptions);
},
};
}
exports.default = createVitePlugin;
//# sourceMappingURL=index.js.map