Skip to content

Commit e0d9927

Browse files
committed
feat: STRF-13396 Stencil Context Attributes Usage Analyzer
1 parent a32a36b commit e0d9927

3 files changed

Lines changed: 762 additions & 0 deletions

File tree

lib/StencilContextAnalyzer.js

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import Paper from '@bigcommerce/stencil-paper';
4+
5+
class StencilContextAnalyzer {
6+
constructor(templatesDir) {
7+
this.templatesDir = templatesDir;
8+
this.variableUsage = {};
9+
this.templateExtensions = ['.html'];
10+
this.paper = new Paper();
11+
}
12+
13+
/**
14+
* Analyzes all templates in the directory and returns variable usage statistics
15+
* @returns {Promise<Object>} Object with variable paths as keys and usage counts as values
16+
*/
17+
async analyzeTemplates() {
18+
const templateFiles = await this.findTemplateFiles();
19+
console.log(`Found ${templateFiles.length} template files to analyze`);
20+
21+
if (templateFiles.length === 0) {
22+
console.warn('No template files found in:', this.templatesDir);
23+
}
24+
25+
await Promise.all(templateFiles.map((templateFile) => this.analyzeTemplate(templateFile)));
26+
27+
console.log(
28+
`Analysis complete. Found ${Object.keys(this.variableUsage).length} unique variables`,
29+
);
30+
return this.variableUsage;
31+
}
32+
33+
/**
34+
* @param {string} templatePath - Path to the template file
35+
*/
36+
async analyzeTemplate(templatePath) {
37+
try {
38+
const content = await fs.promises.readFile(templatePath, 'utf-8');
39+
40+
// Skip empty files
41+
if (!content.trim()) {
42+
return;
43+
}
44+
45+
const ast = this.paper.renderer.handlebars.parse(content);
46+
// Get relative path from templates directory
47+
const relativePath = path.relative(this.templatesDir, templatePath);
48+
this.processASTNode(ast, [], relativePath);
49+
} catch (error) {
50+
console.warn(`Warning: Could not parse template ${templatePath}: ${error.message}`);
51+
}
52+
}
53+
54+
/**
55+
* Recursively processes AST nodes to extract variable usage
56+
* @param {Object|Array} node - AST node or array of nodes
57+
* @param {Array} contextPath - Current context path for nested variables
58+
* @param {string} templatePath - Relative path of current template
59+
*/
60+
processASTNode(node, contextPath = [], templatePath) {
61+
if (Array.isArray(node)) {
62+
node.forEach((child) => this.processASTNode(child, contextPath, templatePath));
63+
return;
64+
}
65+
66+
if (!node || typeof node !== 'object') {
67+
return;
68+
}
69+
70+
switch (node.type) {
71+
case 'Program':
72+
this.processASTNode(node.body, contextPath, templatePath);
73+
break;
74+
75+
case 'MustacheStatement':
76+
case 'SubExpression':
77+
this.processExpression(node, contextPath, templatePath);
78+
break;
79+
80+
case 'BlockStatement':
81+
this.processBlockStatement(node, contextPath, templatePath);
82+
break;
83+
84+
case 'PartialStatement':
85+
this.processASTNode(node.params, contextPath, templatePath);
86+
break;
87+
88+
case 'ContentStatement':
89+
// Text content, no variables to extract
90+
break;
91+
92+
default:
93+
// Process any child nodes
94+
Object.keys(node).forEach((key) => {
95+
if (key !== 'type' && key !== 'loc') {
96+
this.processASTNode(node[key], contextPath, templatePath);
97+
}
98+
});
99+
}
100+
}
101+
102+
/**
103+
* Processes expression statements to extract variable paths
104+
* @param {Object} node - Expression AST node
105+
* @param {Array} contextPath - Current context path
106+
* @param {string} templatePath - Relative path of current template
107+
*/
108+
processExpression(node, contextPath, templatePath) {
109+
// For regular expressions (not SubExpressions), process the main path only if it's not a helper
110+
if (node.type === 'MustacheStatement' && node.path) {
111+
// Only count as variable if it's not a helper and has no parameters
112+
// If it has parameters, it's likely a helper call
113+
if (!node.params || node.params.length === 0) {
114+
const variablePath = this.extractVariablePath(node.path, contextPath);
115+
if (variablePath) {
116+
this.recordVariableUsage(variablePath, templatePath);
117+
}
118+
}
119+
}
120+
121+
// For SubExpressions, don't process the main path (it's a helper name)
122+
// but do process the parameters
123+
124+
// Process parameters (for both regular expressions and SubExpressions)
125+
if (node.params) {
126+
node.params.forEach((param) => {
127+
if (param.type === 'PathExpression') {
128+
const variablePath = this.extractVariablePath(param, contextPath);
129+
if (variablePath) {
130+
this.recordVariableUsage(variablePath, templatePath);
131+
}
132+
} else if (param.type === 'SubExpression') {
133+
// Recursively process nested SubExpressions
134+
this.processExpression(param, contextPath, templatePath);
135+
}
136+
});
137+
}
138+
139+
// Process hash parameters
140+
if (node.hash && node.hash.pairs) {
141+
node.hash.pairs.forEach((pair) => {
142+
if (pair.value && pair.value.type === 'PathExpression') {
143+
const variablePath = this.extractVariablePath(pair.value, contextPath);
144+
if (variablePath) {
145+
this.recordVariableUsage(variablePath, templatePath);
146+
}
147+
} else if (pair.value && pair.value.type === 'SubExpression') {
148+
// Recursively process SubExpressions in hash values
149+
this.processExpression(pair.value, contextPath, templatePath);
150+
}
151+
});
152+
}
153+
}
154+
155+
/**
156+
* Processes block statements (like #each, #if, #with)
157+
* @param {Object} node - Block statement AST node
158+
* @param {Array} contextPath - Current context path
159+
* @param {string} templatePath - Relative path of current template
160+
*/
161+
processBlockStatement(node, contextPath, templatePath) {
162+
this.processExpression(node, contextPath, templatePath);
163+
164+
let newContextPath = [...contextPath];
165+
166+
if (node.path && node.path.original) {
167+
const { original: helperName } = node.path;
168+
169+
if (helperName === 'each' && node.params && node.params[0]) {
170+
// For #each loops, add array index to context
171+
const arrayPath = this.extractVariablePath(node.params[0], contextPath);
172+
if (arrayPath) {
173+
newContextPath = [arrayPath + '[0]']; // Represent as array access
174+
}
175+
} else if (helperName === 'with' && node.params && node.params[0]) {
176+
// For #with blocks, change context to the specified object
177+
const withPath = this.extractVariablePath(node.params[0], contextPath);
178+
if (withPath) {
179+
newContextPath = [withPath];
180+
}
181+
}
182+
}
183+
184+
// Process the block body with new context
185+
this.processASTNode(node.program, newContextPath, templatePath);
186+
187+
// Process the inverse block (else clause) with original context
188+
if (node.inverse) {
189+
this.processASTNode(node.inverse, contextPath, templatePath);
190+
}
191+
}
192+
193+
/**
194+
* Extracts variable path from a PathExpression node
195+
* @param {Object} pathNode - PathExpression AST node
196+
* @param {Array} contextPath - Current context path
197+
* @returns {string|null} Full variable path or null if not a data variable
198+
*/
199+
extractVariablePath(pathNode, contextPath) {
200+
if (!pathNode || pathNode.type !== 'PathExpression') {
201+
return null;
202+
}
203+
204+
const { original, parts } = pathNode;
205+
206+
// Skip Handlebars helpers and built-in variables
207+
if (this.isHandlebarsHelper(original)) {
208+
return null;
209+
}
210+
211+
if (!parts || parts.length === 0) {
212+
return null;
213+
}
214+
215+
// Build the full path
216+
let fullPath;
217+
if (contextPath.length > 0 && !original.startsWith('../')) {
218+
// Relative to current context
219+
fullPath = contextPath[contextPath.length - 1] + '.' + parts.join('.');
220+
} else if (original.startsWith('../')) {
221+
// Parent context reference - keep as is since it's meaningful in loop contexts
222+
// e.g., {{#each products}}{{../customer}}{{/each}} should remain as ../customer
223+
fullPath = original;
224+
} else {
225+
// Absolute path from root context
226+
fullPath = parts.join('.');
227+
}
228+
229+
return fullPath;
230+
}
231+
232+
/**
233+
* Records variable usage in the statistics
234+
* @param {string} variablePath - Full path of the variable
235+
* @param {string} templatePath - Relative path of current template
236+
*/
237+
recordVariableUsage(variablePath, templatePath) {
238+
if (!this.variableUsage[variablePath]) {
239+
this.variableUsage[variablePath] = {
240+
count: 0,
241+
paths: [],
242+
};
243+
}
244+
245+
this.variableUsage[variablePath].count += 1;
246+
247+
// Only add path if it's not already in the array
248+
if (!this.variableUsage[variablePath].paths.includes(templatePath)) {
249+
this.variableUsage[variablePath].paths.push(templatePath);
250+
}
251+
}
252+
253+
/**
254+
* Checks if a path is a Handlebars helper by reading from Paper's registered helpers
255+
* @param {string} pathOriginal - Original path string
256+
* @returns {boolean} True if it's a helper
257+
*/
258+
isHandlebarsHelper(pathOriginal) {
259+
try {
260+
const registeredHelpers = this.paper.renderer.handlebars.helpers || {};
261+
return pathOriginal in registeredHelpers;
262+
} catch (error) {
263+
// Fallback to basic helpers if Paper fails
264+
const builtInHelpers = ['if', 'unless', 'each', 'with', 'lookup', 'log'];
265+
return builtInHelpers.includes(pathOriginal);
266+
}
267+
}
268+
269+
/**
270+
* Recursively finds all template files in the directory
271+
* @returns {Promise<string[]>} Array of template file paths
272+
*/
273+
async findTemplateFiles() {
274+
const templateFiles = [];
275+
276+
// Check if templates directory exists
277+
try {
278+
await fs.promises.access(this.templatesDir);
279+
} catch (error) {
280+
console.warn(`Templates directory not accessible: ${this.templatesDir}`);
281+
return [];
282+
}
283+
284+
const walkDir = async (dir) => {
285+
try {
286+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
287+
288+
const promises = entries.map(async (entry) => {
289+
const fullPath = path.join(dir, entry.name);
290+
291+
if (entry.isDirectory()) {
292+
return walkDir(fullPath);
293+
}
294+
if (entry.isFile()) {
295+
const ext = path.extname(entry.name);
296+
if (this.templateExtensions.includes(ext)) {
297+
templateFiles.push(fullPath);
298+
}
299+
}
300+
return null;
301+
});
302+
303+
await Promise.all(promises);
304+
} catch (error) {
305+
console.warn(`Error reading directory ${dir}: ${error.message}`);
306+
}
307+
};
308+
309+
await walkDir(this.templatesDir);
310+
return templateFiles;
311+
}
312+
313+
/**
314+
* Exports variable usage statistics to a JSON file
315+
* @param {string} outputPath - Path to output JSON file
316+
*/
317+
async exportToJson(outputPath) {
318+
const sortedUsage = Object.keys(this.variableUsage)
319+
.sort()
320+
.reduce(
321+
(result, key) => ({
322+
...result,
323+
[key]: {
324+
count: this.variableUsage[key].count,
325+
paths: this.variableUsage[key].paths.sort(),
326+
},
327+
}),
328+
{},
329+
);
330+
331+
await fs.promises.writeFile(outputPath, JSON.stringify(sortedUsage, null, 2), 'utf-8');
332+
}
333+
334+
/**
335+
* @param {string} outputPath - Path to output JSON file
336+
* @returns {Promise<Object>} Variable usage statistics
337+
*/
338+
async analyzeAndExport(outputPath) {
339+
const results = await this.analyzeTemplates();
340+
await this.exportToJson(outputPath);
341+
return results;
342+
}
343+
}
344+
345+
export default StencilContextAnalyzer;

0 commit comments

Comments
 (0)